feat(keybase): Adds PGP Encryption Plugin

commit efe58092494c8870072bdf45991c122624bb93e6
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue Jun 7 13:06:25 2016 -0700

    patching specs

commit 15a346b93efa5a21327f6bd7c14ef60ddc0f79bb
Author: Ben Gotow <bengotow@gmail.com>
Date:   Mon Jun 6 17:49:20 2016 -0700

    A few small fixes

commit d4cc62a90d2d4f101b71bc57b5521353d2175219
Merge: 80018c0 17d9c3d
Author: Ben Gotow <bengotow@gmail.com>
Date:   Mon Jun 6 17:32:00 2016 -0700

    Merge branch 'master' into wip/keybase

    Conflicts:
    	build/Gruntfile.coffee
    	internal_packages/onboarding/lib/page-authenticate.jsx

commit 80018c045527dec331118426ce67d82c44926334
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri Jun 3 18:12:39 2016 -0700

    Re-styles decryption interface, temporarily removes file decryption

    The decryption interface was ugly as hell. Now it's aligned with
    the "Message Encrypted" notice and also the ugly text input is
    gone - replaced by a much nicer-looking popover. In the course of
    this refactor, file decryption was temporarily deactivated, but
    will return soon.

commit ed1e6232803e2c6b8930f5ed2bd6ccde56542b3b
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri Jun 3 15:39:02 2016 -0700

    Adds more information to key-adder UI

    User testing indicated that the key-adder UI didn't provide
    enough feedback about incorrect inputs and the slow load time
    of key generation. This commit adds a spinning wheel while loading
    and a span describing which inputs are incorrect.

commit 4d7908635cd606a3ebb2607537127dd1c5740a35
Author: logandavis <logankeahidavis@gmail.com>
Date:   Thu Jun 2 18:40:57 2016 -0700

    Touches up search

    Fixes a bug in the search debouncing that was sometimes
    allowing users to modify search queries and not have the
    new query searched. Added messaging to stop people from
    trying to search keybase by email address.

commit 140ae6679758d8ed54d1b6b7bd5c837f84132363
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed Jun 1 17:35:39 2016 -0700

    Adds import from file and unifies paste in key-adder.cjsx

    User review feedback indicated that key import was a big pain
    point - pasting keys was a pain in the butt, private keys
    could be saved as public keys, etc. This commit is the functional
    overhaul (and refactor) for the key-adder on the preferences
    page. It adds an Import from File function and merges
    Paste Private and Paste Public into just Paste, which autodetects.

commit 297f9ed482f4e83c52e40e479a31ba1bffad3f7d
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 21:11:01 2016 -0700

    fix(es6): Converstion broke file uploads by adding body: {}

commit 7815e90c49c70339cd6d26d48e818d4f5f962b7f
Merge: af54f01 0af27ee
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed Jun 1 14:49:14 2016 -0700

    Merge branch 'wip/keybase' of github.com:nylas/N1 into wip/keybase

commit af54f0165b95447ffa04c1e324e02d892b20d047
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed Jun 1 14:47:03 2016 -0700

    Bugfixes: clientID not found (from rebase) and modal on new email

    User testing revealed two pretty noxious bugs, one when you email
    someone who's not saved as a contact and one caused by an update
    to mail merge requiring inputs to Participant-Text-Field that
    n1-keybase wasn't provided. This fix patches modal-key-recommender
    to use emails as a fallback to contacts and sets a reasonable
    default for the input to Participant-Text-Field.

commit 0af27ee10666f35f248a4191fe937cde920d6baf
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 19:10:11 2016 -0700

    fix(specs): Empty parameter list

commit 87407249c2bfa7737a4609ac89b46c3b5a074f76
Merge: e4c050b 9892473
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 18:08:46 2016 -0700

    Merge branch 'master' into wip/keybase

commit e4c050b599fbc83b6249634297483a5683fe3c56
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 18:08:42 2016 -0700

    fix

commit 6c7857890df9d277f786b87ab996feeec4089c3b
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 18:05:35 2016 -0700

    fix(linter): Minor issue

commit 36197effd6c8ca3648a5010dac874c9a990fb1b9
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 18:02:26 2016 -0700

    fix(lint)

commit 73dd4850bf675f983ecfcc5ef65e91b9aa118e16
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 17:52:18 2016 -0700

    fix(lint): Don't lint node_modules of packages

commit 15c9d42f2d896f22092465e78cb340d07e0086ac
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 17:36:24 2016 -0700

    patch(auth): Remove unused import

commit 8756b2b5314fd0366caf8b2e12a3bc5e527bc593
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 31 17:33:30 2016 -0700

    patch(auth): Temporarily remove auth flow

commit 135395e4afdfcc8ba57667e60fa73ef55e126c2f
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 31 17:25:08 2016 -0700

    linting fix

commit 1049449f211111e704b3e543d2748339bf9cc6fd
Merge: e6a8f9e e1275eb
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 31 15:50:00 2016 -0700

    merge commit

commit e6a8f9ebadcc3d1b1444c8676d15b730a0d7a5c0
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 31 15:19:27 2016 -0700

    move styling, update readme

commit d388eb737ae9febc197bf0fe22d505a28c669639
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed May 25 16:15:31 2016 -0700

    Add Export Private Key button to preferences

commit f49062639a8f4a67a576b90a02f4b0f06dd492d0
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed May 25 12:40:23 2016 -0700

    Add Export Public Key button to preferences

commit 847221b6a2c9b1a1e8b817708ed9032803de9261
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 15:26:37 2016 -0700

    update readme

commit 1f3f14bf9c69bfea7d7d1ac79c39fe0fe6befd37
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 15:23:26 2016 -0700

    Beautify Add Email button, catch naggling bugs

    The old add email button on the preferences page, which
    was not playing nice with the styling of KeybaseSearch, has
    been replaced by the drop-in EmailPopover element. Also, two bugs
    that were causing the console to flip out on normal, unencrypted
    emails were caught and fixed - the Decrypt Button now returns
    false instead of null, and attachments without filenames are
    allowed.

commit a7cb363e357ff996b92946e9685416b98133de0b
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 14:54:00 2016 -0700

    Debug and style the encryption popover modal

    The encryption popover was ugly as sin and also lacking in
    some functionality - it unnecessarily showed the email list,
    it contained no reference to the Key Management page, it
    didn't display any links to the keybase profile in question...
    now it is a pretty and functional popover.

commit b4e3f5421558d4d128ffc66125657fc8b1abf1f1
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 18:56:08 2016 -0700

    Unborks Import Key popover, obscures password fields

    The Import Key popover on the preferences page was rendering off
    the screen because it was always pointing at the last button
    created rather than the button clicked. Now that's fixed.

commit 20ab9f825d65f40451397c8a1df484bb7e63c593
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 17:17:44 2016 -0700

    Fixes identity filename/address management, dedupes search, encodes +

    Bunch of minor bugfixes here. The identity.path refactor broke
    PGPKeyStore.addAddressToKey and removeAddressFromKey, which this commit
    fixes. + characters were being incorrectly encoded in messages as &#43;
    which was breaking message decryption - that's now fixed in message
    preprocessing. Finally, basic Keybase search result deduplication
    is now in place.

commit 8a2a5e6dc3c84ade420b16e36d2954cea661d1f1
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 14:13:17 2016 -0700

    Adds private key management to preferences page; beautifies

    Previously, the KeyManager element on the Preferences page only
    exposed public keys, and there was no way to see, edit, or delete
    private keys. Now you can do all of those things. This commit
    also fixes some render-timing issues in PGPKeyStore._populate
    that were causing nonexistent keys to appear (one on key generation,
    one on deletion of the final key) and improves the styling of
    the Preferences page.

commit 3c39431dd6ad3a8c19770c6ddd46d508ee92d4bb
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 13 15:22:40 2016 -0700

    burns the dead code out of preferences-keybase

commit eb7b924d5e00946ebb3e7174f54f2c1dde1ff616
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 13 14:58:51 2016 -0700

    Fix key generation and saving from the preferences page

    An earlier refactor away from key objects and towards identity
    objects borked the old key saving code. This commit refactors
    the key-saving code so that keys can be manually added from
    the preferences page again, and also makes said code much
    clearer.

commit e1f78d0080505102bf421c8c3638fee527d84481
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 17:41:24 2016 -0700

    add README of todos

commit c44d7995fc3cc097fb8f98689dd18488920488cf
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 17:41:12 2016 -0700

    heavy style pass, misc bugfixes, add assets

commit 47208ca2d947ac4b04c1fec14b433f7c6105fe1d
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 14:03:06 2016 -0700

    Decrypting files first pass, problem with unlocking keys

commit fc2a56574c1eb0a7098280f3e7a2ca5d6697340c
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 4 17:52:32 2016 -0700

    Cause message to encrypt on key picker popover close

commit 545392dee9db2754c6b729bd9549c071e1568ddd
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 4 12:04:08 2016 -0700

    light design pass on the modal (now a popover)

commit 2726aab32dd74a68936a7d80d6e49b879f182ee3
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon May 2 15:47:45 2016 -0700

    Key picker modal functional for multiple recipients, still needs lots of cleanup

commit 050114acf36b8654de80726352812e945ac88968
Author: Dakota Nelson <dakota@nylas.com>
Date:   Fri Apr 29 14:52:33 2016 -0700

    Key picker modal functional for single recipient

commit 865c7c8894b30a55e4c6027dd6f03fea5d0bb330
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 28 17:31:08 2016 -0700

    modal now allows selection of keybase user

commit 728c995314b3238bdb3222c7d12353ab2e35a65a
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 28 10:46:47 2016 -0700

    consolidate key cache into one object, fix all tests

commit 4bdd49ebd60beadf72b7aa099cfded09861768af
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 27 11:49:54 2016 -0700

    checkpoint commit for key selection modal in composer

commit 7d33d832f6587814f8da142e14f9a7f8256114b9
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 17:46:01 2016 -0700

    Remove infinite loop of doom, match keys properly

commit 7a4c1d60c1f1455e70a190710b59970ab2ed3b2a
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 14:31:41 2016 -0700

    Re-add some of Logan's changes lost in the repo migration

commit 6c1e31601e499f69c8df5b9f1a5244480ec67da1
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 13:52:55 2016 -0700

    slightly improve style on the decryption interface

commit 78f115f59ce09f57621776be4287e1e3a9d047b4
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 13:10:27 2016 -0700

    update keybase profile type translation to include proofs

commit 664c61fdd5e03d9ba4afa40d14673ad7733b1e30
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 11:54:35 2016 -0700

    Replace undocumented 'key' objects with explicit 'identity' objects

commit 0824cc0a522e9d9d8045d7a87ef3b7ec8381090e
Author: Dakota Nelson <dakota@nylas.com>
Date:   Fri Apr 22 13:33:18 2016 -0700

    Remove unused code

commit f0fd315cf7daf64f63d933508a6799ae4b5b4f18
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 21 17:24:54 2016 -0700

    Further refactor keybase plugin

commit 086e86f289213789f5796b5b1febee709986b89a
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 21 15:53:54 2016 -0700

    Refactor, and a useless commit msg

commit 1fcda3875424a3ba4ba54f25644b43690b153324
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 16:16:26 2016 -0700

    Fix linter errors (reported via cd build; grunt lint)

commit fcc937e6b50e43932478437ecf6dabb2f96fa15c
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 16:08:42 2016 -0700

    Keybase API was 500'ing - move API adapter to standard (err, data) callback format and make it only call the callback once when error is hit (return after erroring)

commit fd0b4220af13cc80b4c88f9e2e63976a54ac50d4
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 15:56:27 2016 -0700

    Fix state were user has no pub keys

commit 13886101fcf63e4e497526c2e039cbb791670c06
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 20 15:25:30 2016 -0700

    add Logan's changes from the other repo

commit 6e9103b4188414ddd6e01db2c4c50c68ad54caca
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 20 15:22:34 2016 -0700

    minor styling change to improve keybase popover

commit fd0749a7c1c9202b94dc5a47bad26d8960c17099
Author: Dakota Nelson <dakota.w.nelson@gmail.com>
Date:   Wed Apr 20 12:30:40 2016 -0700

    feat(keybase): Initial commit from n1-keybase repo

commit e1275eb9ea548f76c06eb605b77a62d314f3d10f
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 31 15:19:27 2016 -0700

    move styling, update readme

commit 7f64713cc5d8cfdc59eca3c007cf673e5e2b6dea
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed May 25 16:15:31 2016 -0700

    Add Export Private Key button to preferences

commit 9d4e2552388f94fb7113ab80906519dce4d7350f
Author: logandavis <logankeahidavis@gmail.com>
Date:   Wed May 25 12:40:23 2016 -0700

    Add Export Public Key button to preferences

commit 166585dba9ece8d5dd529418b934947985ace5a3
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 15:26:37 2016 -0700

    update readme

commit f875c4146e98152cf4d050fc5af3c322c2d5fdae
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 15:23:26 2016 -0700

    Beautify Add Email button, catch naggling bugs

    The old add email button on the preferences page, which
    was not playing nice with the styling of KeybaseSearch, has
    been replaced by the drop-in EmailPopover element. Also, two bugs
    that were causing the console to flip out on normal, unencrypted
    emails were caught and fixed - the Decrypt Button now returns
    false instead of null, and attachments without filenames are
    allowed.

commit 48713d78381fb7320ce265b84c95f8b1497008f7
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 20 14:54:00 2016 -0700

    Debug and style the encryption popover modal

    The encryption popover was ugly as sin and also lacking in
    some functionality - it unnecessarily showed the email list,
    it contained no reference to the Key Management page, it
    didn't display any links to the keybase profile in question...
    now it is a pretty and functional popover.

commit a607f97f8082a14a29ebd5c2d8b397872ef5456a
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 18:56:08 2016 -0700

    Unborks Import Key popover, obscures password fields

    The Import Key popover on the preferences page was rendering off
    the screen because it was always pointing at the last button
    created rather than the button clicked. Now that's fixed.

commit 2afd41894aa297dcd6aec0b785cadbb533377f80
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 17:17:44 2016 -0700

    Fixes identity filename/address management, dedupes search, encodes +

    Bunch of minor bugfixes here. The identity.path refactor broke
    PGPKeyStore.addAddressToKey and removeAddressFromKey, which this commit
    fixes. + characters were being incorrectly encoded in messages as &#43;
    which was breaking message decryption - that's now fixed in message
    preprocessing. Finally, basic Keybase search result deduplication
    is now in place.

commit 4824556543dce31b14be81ebe471f8febb55e00c
Author: logandavis <logankeahidavis@gmail.com>
Date:   Tue May 17 14:13:17 2016 -0700

    Adds private key management to preferences page; beautifies

    Previously, the KeyManager element on the Preferences page only
    exposed public keys, and there was no way to see, edit, or delete
    private keys. Now you can do all of those things. This commit
    also fixes some render-timing issues in PGPKeyStore._populate
    that were causing nonexistent keys to appear (one on key generation,
    one on deletion of the final key) and improves the styling of
    the Preferences page.

commit 47c05fc485c80b297b6027f9bdabb5f921be77b3
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 13 15:22:40 2016 -0700

    burns the dead code out of preferences-keybase

commit 6fdf34e5c4188994844052d95d927cab1ed182ea
Author: logandavis <logankeahidavis@gmail.com>
Date:   Fri May 13 14:58:51 2016 -0700

    Fix key generation and saving from the preferences page

    An earlier refactor away from key objects and towards identity
    objects borked the old key saving code. This commit refactors
    the key-saving code so that keys can be manually added from
    the preferences page again, and also makes said code much
    clearer.

commit a1b4b4fdb4b35a844ca490d3e9029dbbf4d7bf24
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 17:41:24 2016 -0700

    add README of todos

commit 6747a0a447476ca6af829886ec304d57bc5dd0ee
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 17:41:12 2016 -0700

    heavy style pass, misc bugfixes, add assets

commit 8544e4b84341f0bc49a1765c057cbf443c220448
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 11 14:03:06 2016 -0700

    Decrypting files first pass, problem with unlocking keys

commit 428dc6739fb29f833c53e957e7a998abf25460ac
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 4 17:52:32 2016 -0700

    Cause message to encrypt on key picker popover close

commit 9d8ef053beca3ef6bbc910de272f1b98033599bf
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed May 4 12:04:08 2016 -0700

    light design pass on the modal (now a popover)

commit b4a0ad2fa2ac33dbf3333e4beb9ac0df04aa107d
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon May 2 15:47:45 2016 -0700

    Key picker modal functional for multiple recipients, still needs lots of cleanup

commit 6bf96d973c7e19de103695f3d6e3346c8db2dab3
Author: Dakota Nelson <dakota@nylas.com>
Date:   Fri Apr 29 14:52:33 2016 -0700

    Key picker modal functional for single recipient

commit f5f0e6aea8c16aefd3cdcbe19664bed708857693
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 28 17:31:08 2016 -0700

    modal now allows selection of keybase user

commit 83f7ad54bcab7c24da033d8fe60a602b27b79ded
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 28 10:46:47 2016 -0700

    consolidate key cache into one object, fix all tests

commit f8ec4576dee108584e946a7e4c6b9f17885921de
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 27 11:49:54 2016 -0700

    checkpoint commit for key selection modal in composer

commit c4dd851402162bcaa7e3b8cebf911d6a57fdfdbc
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 17:46:01 2016 -0700

    Remove infinite loop of doom, match keys properly

commit 537d2b423b9c4210c97bda0bf00cbbd1a4ebc80f
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 14:31:41 2016 -0700

    Re-add some of Logan's changes lost in the repo migration

commit 91d86ad6b69edee59de4938c24143e186ad5523a
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 13:52:55 2016 -0700

    slightly improve style on the decryption interface

commit 321ea22c01a6f346325870049a0664155b7ee71f
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 13:10:27 2016 -0700

    update keybase profile type translation to include proofs

commit b8821490432e582a53e7416b49476f90ab59f230
Author: Dakota Nelson <dakota@nylas.com>
Date:   Mon Apr 25 11:54:35 2016 -0700

    Replace undocumented 'key' objects with explicit 'identity' objects

commit 0999127bcd9c3cd75e17a8a4e6d37c5b53bc22ef
Author: Dakota Nelson <dakota@nylas.com>
Date:   Fri Apr 22 13:33:18 2016 -0700

    Remove unused code

commit 3ddfc4e6946b3ed3e051c561cdda7499d326058f
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 21 17:24:54 2016 -0700

    Further refactor keybase plugin

commit 1b5a9de2c9b2084a214619ce7c551ed6e9283a96
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 21 15:53:54 2016 -0700

    Refactor, and a useless commit msg

commit 999dd82e225b2d8209ee4f7be3cc0ab43c8e53b8
Author: Dakota Nelson <dakota@nylas.com>
Date:   Thu Apr 21 11:43:02 2016 -0700

    Modify participants func to optionally allow from and bcc

commit 9cdf258f5df9fd34ac94bf9822fdd7256af1bb9a
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 16:16:26 2016 -0700

    Fix linter errors (reported via cd build; grunt lint)

commit c14fc59b6f236191b759bda6561c5f343401e8ab
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 16:08:42 2016 -0700

    Keybase API was 500'ing - move API adapter to standard (err, data) callback format and make it only call the callback once when error is hit (return after erroring)

commit f0225603abb56b14e540f8419cb212760cf0883d
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed Apr 20 15:56:27 2016 -0700

    Fix state were user has no pub keys

commit 818881523c698b1bd368344e9ff29ae870f9aae2
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 20 15:25:30 2016 -0700

    add Logan's changes from the other repo

commit 69f2003ff11be741b97bbb36a7bb4afd29ac7ecc
Author: Dakota Nelson <dakota@nylas.com>
Date:   Wed Apr 20 15:22:34 2016 -0700

    minor styling change to improve keybase popover

commit 4081ad7ef2b4375f5665904eb2d61992bab9cf4d
Author: Dakota Nelson <dakota.w.nelson@gmail.com>
Date:   Wed Apr 20 12:30:40 2016 -0700

    feat(keybase): Initial commit from n1-keybase repo
This commit is contained in:
logandavis 2016-06-07 13:30:04 -07:00
parent e94a5bbe0f
commit b69f150e6a
39 changed files with 2888 additions and 27 deletions

View file

@ -122,7 +122,7 @@ describe 'CategoryPickerPopover', ->
count = ReactTestUtils.scryRenderedDOMComponentsWithClass(@picker, 'category-create-new-tag').length
expect(count).toBe 1
describe "_onSelectCategory()", ->
describe "_onSelectCategory", ->
beforeEach ->
setupForCreateNew.call @, "folder"
spyOn(TaskFactory, 'taskForRemovingCategory').andCallThrough()

1
internal_packages/keybase/.gitignore vendored Executable file
View file

@ -0,0 +1 @@
node_modules

View file

@ -0,0 +1,19 @@
## Keybase Plugin
TODO:
-----
* decryption error handling
* remove the .asc extension from decrypted downloads
* final refactor
* tests
* docs
WISHLIST:
-----
* message signing
* encrypted attachment handling
* integrate MIT PGP Keyserver search into Keybase searchbar
* make the decrypt interface a message body overlay instead of a button in the header
* improve search result deduping with keys on file
* add "this message was decrypted with the key for `___@___.com` above decrypted messages

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,98 @@
{MessageStore, React, ReactDOM, FileDownloadStore, MessageBodyProcessor, Actions} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
PassphrasePopover = require './passphrase-popover'
pgp = require 'kbpgp'
class DecryptMessageButton extends React.Component
@displayName: 'DecryptMessageButton'
@propTypes:
message: React.PropTypes.object.isRequired
constructor: (props) ->
super(props)
@state = @_getStateFromStores()
_getStateFromStores: ->
return {
isDecrypted: PGPKeyStore.isDecrypted(@props.message)
wasEncrypted: PGPKeyStore.hasEncryptedComponent(@props.message)
encryptedAttachments: PGPKeyStore.fetchEncryptedAttachments(@props.message)
status: PGPKeyStore.msgStatus(@props.message)
}
componentDidMount: ->
@unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange, @)
componentWillUnmount: ->
@unlistenKeystore()
_onKeystoreChange: ->
@setState(@_getStateFromStores())
# every time a new key gets unlocked/fetched, try to decrypt this message
if not @state.isDecrypted
PGPKeyStore.decrypt(@props.message)
_onClickDecrypt: (event) =>
popoverTarget = event.target.getBoundingClientRect()
Actions.openPopover(
<PassphrasePopover onPopoverDone={ @_decryptPopoverDone } />,
{originRect: popoverTarget, direction: 'down'}
)
_decryptPopoverDone: (passphrase) =>
{message} = @props
for recipient in message.to
# right now, just try to unlock all possible keys
# (many will fail - TODO?)
privateKeys = PGPKeyStore.privKeys(address: recipient.email, timed: false)
for privateKey in privateKeys
PGPKeyStore.getKeyContents(key: privateKey, passphrase: passphrase)
_onDecryptAttachments: =>
console.warn("decrypt attachments")
###
_decryptAttachments: =>
@_onClick() # unlock keys
PGPKeyStore.decryptAttachments(@state.encryptedAttachments)
###
render: =>
# TODO inform user of errors/etc. instead of failing without showing it
if not (@state.wasEncrypted or @state.encryptedAttachments.length > 0)
return false
decryptBody = false
if !@state.isDecrypted
decryptBody = <button title="Decrypt email body" className="btn btn-toolbar" onClick={@_onClickDecrypt} ref="button">Decrypt</button>
decryptAttachments = false
###
if @state.encryptedAttachments?.length == 1
decryptAttachments = <button onClick={ @_decryptAttachments } className="btn btn-toolbar">Decrypt Attachment</button>
else if @state.encryptedAttachments?.length > 1
decryptAttachments = <button onClick={ @_decryptAttachments } className="btn btn-toolbar">Decrypt Attachments</button>
###
if decryptAttachments or decryptBody
decryptionInterface = (<div className="decryption-interface">
{ decryptBody }
{ decryptAttachments }
</div>)
# TODO a message saying "this was decrypted with the key for ___@___.com"
title = if @state.isDecrypted then "Message Decrypted" else "Message Encrypted"
<div className="keybase-decrypt">
<div className="line-w-label">
<div className="border"></div>
<div className="title-text">{ title }</div>
{decryptionInterface}
<div className="border"></div>
</div>
</div>
module.exports = DecryptMessageButton

View file

@ -0,0 +1,16 @@
{MessageViewExtension, Actions} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
class DecryptPGPExtension extends MessageViewExtension
@formatMessageBody: ({message}) =>
if not PGPKeyStore.hasEncryptedComponent(message)
return message
if PGPKeyStore.isDecrypted(message)
message.body = PGPKeyStore.getDecrypted(message)
else
# trigger a decryption
PGPKeyStore.decrypt(message)
message
module.exports = DecryptPGPExtension

View file

@ -0,0 +1,34 @@
{React, Actions} = require 'nylas-exports'
{ParticipantsTextField} = require 'nylas-component-kit'
Identity = require './identity'
_ = require 'underscore'
module.exports =
class EmailPopover extends React.Component
constructor: ->
@state = {to: [], cc: [], bcc: []}
@propTypes:
profile: React.PropTypes.instanceOf(Identity).isRequired
render: ->
participants = @state
<div className="keybase-import-popover">
<span className="title">
Associate Emails with Key
</span>
<ParticipantsTextField
field="to"
className="keybase-participant-field"
participants={ participants }
change={ @_onRecipientFieldChange } />
<button className="btn btn-toolbar" onClick={ @_onDone }>Done</button>
</div>
_onRecipientFieldChange: (contacts) =>
@setState(contacts)
_onDone: =>
@props.onPopoverDone(_.pluck(@state.to, 'email'), @props.profile)
Actions.closePopover()

View file

@ -0,0 +1,158 @@
{Utils, DraftStore, React, Actions, DatabaseStore, Contact, ReactDOM} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
Identity = require './identity'
ModalKeyRecommender = require './modal-key-recommender'
{RetinaImg} = require 'nylas-component-kit'
{remote} = require 'electron'
pgp = require 'kbpgp'
_ = require 'underscore'
class EncryptMessageButton extends React.Component
@displayName: 'EncryptMessageButton'
# require that we have a draft object available
@propTypes:
draft: React.PropTypes.object.isRequired
session: React.PropTypes.object.isRequired
constructor: (props) ->
super(props)
# plaintext: store the message's plaintext in case the user wants to edit
# further after hitting the "encrypt" button (i.e. so we can "undo" the
# encryption)
# cryptotext: store the message's body here, for comparison purposes (so
# that if the user edits an encrypted message, we can revert it)
@state = {plaintext: "", cryptotext: "", currentlyEncrypted: false}
componentDidMount: ->
@unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange, @)
componentWillUnmount: ->
@unlistenKeystore()
componentWillReceiveProps: (nextProps) ->
if @state.currentlyEncrypted and nextProps.draft.body != @props.draft.body and nextProps.draft.body != @state.cryptotext
# A) we're encrypted
# B) someone changed something
# C) the change was AWAY from the "correct" cryptotext
body = @state.cryptotext
@props.session.changes.add({body: body})
_getKeys: ->
keys = []
for recipient in @props.draft.participants({includeFrom: false, includeBcc: true})
publicKeys = PGPKeyStore.pubKeys(recipient.email)
if publicKeys.length < 1
# no key for this user
keys.push(new Identity({addresses: [recipient.email]}))
else
# note: this, by default, encrypts using every public key associated
# with the address
for publicKey in publicKeys
if not publicKey.key?
PGPKeyStore.getKeyContents(key: publicKey)
else
keys.push(publicKey)
return keys
_onKeystoreChange: =>
# if something changes with the keys, check to make sure the recipients
# haven't changed (thus invalidating our encrypted message)
if @state.currentlyEncrypted
newKeys = _.map(@props.draft.participants(), (participant) ->
return PGPKeyStore.pubKeys(participant.email)
)
newKeys = _.flatten(newKeys)
oldKeys = _.map(@props.draft.participants(), (participant) ->
return PGPKeyStore.pubKeys(participant.email)
)
oldKeys = _.flatten(oldKeys)
if newKeys.length != oldKeys.length
# someone added/removed a key - our encrypted body is now out of date
@_toggleCrypt()
render: ->
classnames = "btn btn-toolbar"
if @state.currentlyEncrypted
classnames += " btn-enabled"
<div className="n1-keybase">
<button title="Encrypt email body" className={ classnames } onClick={ => @_onClick()} ref="button">
<RetinaImg url="nylas://keybase/encrypt-composer-button@2x.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
</div>
_onClick: =>
@_toggleCrypt()
_toggleCrypt: =>
# if decrypted, encrypt, and vice versa
# addresses which don't have a key
if @state.currentlyEncrypted
# if the message is already encrypted, place the stored plaintext back
# in the draft (i.e. un-encrypt)
@props.session.changes.add({body: @state.plaintext})
@setState({currentlyEncrypted: false})
else
# if not encrypted, save the plaintext, then encrypt
plaintext = @props.draft.body
identities = @_getKeys()
@_checkKeysAndEncrypt(plaintext, identities, (err, cryptotext) =>
if err
console.warn err
NylasEnv.showErrorDialog(err)
if cryptotext? and cryptotext != ""
# <pre> tag prevents gross HTML formatting in-flight
cryptotext = "<pre>#{cryptotext}</pre>"
@setState({
currentlyEncrypted: true
plaintext: plaintext
cryptotext: cryptotext
})
@props.session.changes.add({body: cryptotext})
)
_encrypt: (text, identities, cb) =>
# get the actual key objects
keys = _.pluck(identities, "key")
# remove the nulls
kms = _.compact(keys)
if kms.length == 0
NylasEnv.showErrorDialog("There are no PGP public keys loaded, so the message cannot be
encrypted. Compose a message, add recipients in the To: field, and try again.")
return
params =
encrypt_for: kms
msg: text
pgp.box(params, cb)
_checkKeysAndEncrypt: (text, identities, cb) =>
emails = _.chain(identities)
.pluck("addresses")
.flatten()
.uniq()
.value()
if _.every(identities, (identity) -> identity.key?)
# every key is present and valid
@_encrypt(text, identities, cb)
else
# open a popover to correct null keys
DatabaseStore.findAll(Contact, {email: emails}).then((contacts) =>
component = (<ModalKeyRecommender contacts={contacts} emails={emails} callback={ (newIdentities) => @_encrypt(text, newIdentities, cb) }/>)
Actions.openPopover(
component,
{
originRect: ReactDOM.findDOMNode(@).getBoundingClientRect(),
direction: 'up',
closeOnAppBlur: false,
})
)
module.exports = EncryptMessageButton

View file

@ -0,0 +1,53 @@
# A single user identity: a key, a way to find that key, one or more email
# addresses, and a keybase profile
{Utils} = require 'nylas-exports'
path = require 'path'
module.exports =
class Identity
constructor: ({key, addresses, isPriv, keybase_profile}) ->
@clientId = Utils.generateTempId()
@key = key ? null # keybase keymanager object
@isPriv = isPriv ? false # is this a private key?
@timeout = null # the time after which this key (if private) needs to be unlocked again
@addresses = addresses ? [] # email addresses associated with this identity
@keybase_profile = keybase_profile ? null # a kb profile object associated with this identity
Object.defineProperty(@, 'keyPath', {
get: ->
if @addresses.length > 0
keyDir = path.join(NylasEnv.getConfigDirPath(), 'keys')
thisDir = if @isPriv then path.join(keyDir, 'private') else path.join(keyDir, 'public')
keyPath = path.join(thisDir, @addresses.join(" "))
else
keyPath = null
return keyPath
})
if @isPriv
@setTimeout()
fingerprint: ->
if @key?
return @key.get_pgp_fingerprint().toString('hex')
return null
setTimeout: ->
delay = 1000 * 60 * 30 # 30 minutes in ms
@timeout = Date.now() + delay
isTimedOut: ->
return @timeout < Date.now()
uid: ->
if @key?
uid = @key.get_pgp_fingerprint().toString('hex')
else if @keybase_profile?
uid = @keybase_profile.components.username.val
else if @addresses.length > 0
uid = @addresses.join('')
else
uid = @clientId
return uid

View file

@ -0,0 +1,213 @@
{Utils, React, RegExpUtils} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
PGPKeyStore = require './pgp-key-store'
Identity = require './identity'
kb = require './keybase'
pgp = require 'kbpgp'
_ = require 'underscore'
fs = require 'fs'
module.exports =
class KeyAdder extends React.Component
@displayName: 'KeyAdder'
constructor: (props) ->
@state =
address: ""
keyContents: ""
passphrase: ""
generate: false
paste: false
import: false
isPriv: false
loading: false
validAddress: false
validKeyBody: false
_onPasteButtonClick: (event) =>
@setState
generate: false
paste: !@state.paste
import: false
address: ""
validAddress: false
keyContents: ""
_onGenerateButtonClick: (event) =>
@setState
generate: !@state.generate
paste: false
import: false
address: ""
validAddress: false
keyContents: ""
passphrase: ""
_onImportButtonClick: (event) =>
NylasEnv.showOpenDialog({
title: "Import PGP Key",
buttonLabel: "Import",
properties: ['openFile']
}, (filepath) =>
if filepath?
@setState
generate: false
paste: false
import: true
address: ""
validAddress: false
passphrase: ""
fs.readFile(filepath[0], (err, data) =>
pgp.KeyManager.import_from_armored_pgp {
armored: data
}, (err, km) =>
if err
PGPKeyStore._displayError("The file you selected for import is not a valid PGP Key.")
return
else
privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"
keyBody = if km.armored_pgp_private? then km.armored_pgp_private else km.armored_pgp_public
@setState
keyContents: keyBody
isPriv: keyBody.indexOf(privateStart) >= 0
validKeyBody: true
)
)
_onInnerGenerateButtonClick: (event) =>
@setState
loading: true
@_generateKeypair()
_generateKeypair: =>
pgp.KeyManager.generate_rsa { userid : @state.address }, (err, km) =>
km.sign {}, (err) =>
if err
console.warn(err)
km.export_pgp_private {passphrase: @state.passphrase}, (err, pgp_private) =>
ident = new Identity({
addresses: [@state.address]
isPriv: true
})
PGPKeyStore.saveNewKey(ident, pgp_private)
km.export_pgp_public {}, (err, pgp_public) =>
ident = new Identity({
addresses: [@state.address]
isPriv: false
})
PGPKeyStore.saveNewKey(ident, pgp_public)
@setState
keyContents: pgp_public
loading: false
_saveNewKey: =>
ident = new Identity({
addresses: [@state.address]
isPriv: @state.isPriv
})
PGPKeyStore.saveNewKey(ident, @state.keyContents)
_onAddressChange: (event) =>
address = event.target.value
valid = false
if (address and address.length > 0 and RegExpUtils.emailRegex().test(address))
valid = true
@setState
address: event.target.value
validAddress: valid
_onPassphraseChange: (event) =>
@setState
passphrase: event.target.value
_onKeyChange: (event) =>
privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"
@setState
keyContents: event.target.value
isPriv: event.target.value.indexOf(privateStart) >= 0
pgp.KeyManager.import_from_armored_pgp {
armored: event.target.value
}, (err, km) =>
if err
valid = false
else
valid = true
@setState
validKeyBody: valid
_renderAddButtons: ->
<div>
Add a PGP Key:
<button className="btn key-creation-button" title="Paste" onClick={@_onPasteButtonClick}>Paste in a New Key</button>
<button className="btn key-creation-button" title="Import" onClick={@_onImportButtonClick}>Import a Key From File</button>
<button className="btn key-creation-button" title="Generate" onClick={@_onGenerateButtonClick}>Generate a New Keypair</button>
</div>
_renderManualKey: ->
if !@state.validAddress and @state.address.length > 0
invalidMsg = <span className="invalid-msg">Invalid email address</span>
else if !@state.validKeyBody and @state.keyContents.length > 0
invalidMsg = <span className="invalid-msg">Invalid key body</span>
else
invalidMsg = <span className="invalid-msg"> </span>
invalidInputs = !(@state.validAddress and @state.validKeyBody)
buttonClass = if invalidInputs then "btn key-add-btn btn-disabled" else "btn key-add-btn"
passphraseInput = <input type="password" value={@state.passphrase} placeholder="Private Key Password" className="key-passphrase-input" onChange={@_onPassphraseChange} />
<div className="key-adder">
<div className="key-text">
<textarea ref="key-input"
value={@state.keyContents || ""}
onChange={@_onKeyChange}
placeholder="Paste in your PGP key here!"/>
</div>
<div className="credentials">
<input type="text" value={@state.address} placeholder="Email Address" className="key-email-input" onChange={@_onAddressChange} />
{if @state.isPriv then passphraseInput}
{invalidMsg}
<button className={buttonClass} disabled={invalidInputs} title="Save" onClick={@_saveNewKey}>Save</button>
</div>
</div>
_renderGenerateKey: ->
if !@state.validAddress and @state.address.length > 0
invalidMsg = <span className="invalid-msg">Invalid email address</span>
else
invalidMsg = <span className="invalid-msg"> </span>
loading = <RetinaImg style={width: 20, height: 20} name="inline-loading-spinner.gif" mode={RetinaImg.Mode.ContentPreserve} />
if @state.loading
keyPlaceholder = "Generating your key now. This could take a while."
else
keyPlaceholder = "Your generated public key will appear here. Share it with your friends!"
buttonClass = if !@state.validAddress then "btn key-add-btn btn-disabled" else "btn key-add-btn"
<div className="key-adder">
<div className="credentials">
<input type="text" value={@state.address} placeholder="Email Address" className="key-email-input" onChange={@_onAddressChange} />
<input type="password" value={@state.passphrase} placeholder="Private Key Password" className="key-passphrase-input" onChange={@_onPassphraseChange} />
{invalidMsg}
<button className={buttonClass} disabled={!(@state.validAddress)} title="Generate" onClick={@_onInnerGenerateButtonClick}>Generate</button>
</div>
<div className="key-text">
<div className="loading">{if @state.loading then loading}</div>
<textarea ref="key-output"
value={@state.keyContents || ""}
disabled
placeholder={keyPlaceholder}/>
</div>
</div>
render: ->
<div>
{@_renderAddButtons()}
{if @state.generate then @_renderGenerateKey()}
{if @state.paste or @state.import then @_renderManualKey()}
</div>

View file

@ -0,0 +1,97 @@
{Utils, React, Actions} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
KeybaseUser = require './keybase-user'
PassphrasePopover = require './passphrase-popover'
kb = require './keybase'
_ = require 'underscore'
pgp = require 'kbpgp'
fs = require 'fs'
module.exports =
class KeyManager extends React.Component
@displayName: 'KeyManager'
@propTypes:
pubKeys: React.PropTypes.array.isRequired
privKeys: React.PropTypes.array.isRequired
constructor: (props) ->
super(props)
_exportPopoverDone: (passphrase, identity) =>
# check the passphrase before opening the save dialog
fs.readFile(identity.keyPath, (err, data) =>
pgp.KeyManager.import_from_armored_pgp {
armored: data
}, (err, km) =>
if err
console.warn err
else
km.unlock_pgp { passphrase: passphrase }, (err) =>
if err
PGPKeyStore._displayError(err)
else
PGPKeyStore.exportKey({identity: identity, passphrase: passphrase})
)
_exportPrivateKey: (identity, event) =>
popoverTarget = event.target.getBoundingClientRect()
Actions.openPopover(
<PassphrasePopover identity={identity} onPopoverDone={ @_exportPopoverDone } />,
{originRect: popoverTarget, direction: 'left'}
)
render: ->
{pubKeys, privKeys} = @props
pubKeys = pubKeys.map (identity) =>
deleteButton = (<button title="Delete Public" className="btn btn-toolbar btn-danger" onClick={ => PGPKeyStore.deleteKey(identity) } ref="button">
Delete Key
</button>
)
exportButton = (<button title="Export Public" className="btn btn-toolbar" onClick={ => PGPKeyStore.exportKey({identity: identity}) } ref="button">
Export Key
</button>
)
actionButton = (<div className="key-actions">
{exportButton}
{deleteButton}
</div>
)
return <KeybaseUser profile={identity} key={identity.clientId} actionButton={actionButton}/>
privKeys = privKeys.map (identity) =>
deleteButton = (<button title="Delete Private" className="btn btn-toolbar btn-danger" onClick={ => PGPKeyStore.deleteKey(identity) } ref="button">
Delete Key
</button>
)
exportButton = (<button title="Export Private" className="btn btn-toolbar" onClick={ (event) => @_exportPrivateKey(identity, event) } ref="button">
Export Key
</button>
)
actionButton = (<div className="key-actions">
{exportButton}
{deleteButton}
</div>
)
return <KeybaseUser profile={identity} key={identity.clientId} actionButton={actionButton}/>
<div className="key-manager">
<div className="line-w-label">
<div className="border"></div>
<div className="title-text">Saved Public Keys</div>
<div className="border"></div>
</div>
<div>
{ pubKeys }
</div>
<div className="line-w-label">
<div className="border"></div>
<div className="title-text">Saved Private Keys</div>
<div className="border"></div>
</div>
<div>
{ privKeys }
</div>
</div>

View file

@ -0,0 +1,138 @@
{Utils, React, ReactDOM, Actions, RegExpUtils} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
EmailPopover = require './email-popover'
PGPKeyStore = require './pgp-key-store'
KeybaseUser = require '../lib/keybase-user'
Identity = require './identity'
kb = require './keybase'
_ = require 'underscore'
module.exports =
class KeybaseSearch extends React.Component
@displayName: 'KeybaseSearch'
@propTypes:
initialSearch: React.PropTypes.string
# importFunc: a alternate function to execute when the "import" button is
# clicked instead of the "please specify an email" popover
importFunc: React.PropTypes.function
# TODO consider just passing in a pre-specified email instead of a func?
@defaultProps:
initialSearch: ""
importFunc: false
constructor: (props) ->
super(props)
@state = {
query: props.initialSearch
results: []
loading: false
searchedByEmail: false
}
@debouncedSearch = _.debounce(@_search, 300)
componentDidMount: ->
@_search()
componentWillReceiveProps: (props) ->
@setState({query: props.initialSearch})
_search: ->
oldquery = @state.query
if @state.query != "" and @state.loading == false
@setState({loading: true})
kb.autocomplete(@state.query, (error, profiles) =>
if profiles?
profiles = _.map(profiles, (profile) ->
return new Identity({keybase_profile: profile, isPriv: false})
)
@setState({results: profiles, loading: false})
else
@setState({results: [], loading: false})
if @state.query != oldquery
@debouncedSearch()
)
else
# no query - empty out the results
@setState({results: []})
_importKey: (profile, event) =>
# opens a popover requesting user to enter 1+ emails to associate with a
# key - a button in the popover then calls _save to actually import the key
popoverTarget = event.target.getBoundingClientRect()
Actions.openPopover(
<EmailPopover profile={profile} onPopoverDone={ @_popoverDone } />,
{originRect: popoverTarget, direction: 'left'}
)
_popoverDone: (addresses, identity) =>
if addresses.length < 1
# no email addresses added, noop
return
else
identity.addresses = addresses
# TODO validate the addresses?
@_save(identity)
_save: (identity) =>
# save/import a key from keybase
keybaseUsername = identity.keybase_profile.components.username.val
kb.getKey(keybaseUsername, (error, key) =>
if error
console.error error
else
PGPKeyStore.saveNewKey(identity, key)
)
_queryChange: (event) =>
emailQuery = RegExpUtils.emailRegex().test(event.target.value)
@setState({query: event.target.value, searchedByEmail: emailQuery})
@debouncedSearch()
render: ->
profiles = _.map(@state.results, (profile) =>
# allow for overriding the import function
if typeof @props.importFunc is "function"
boundFunc = @props.importFunc
else
boundFunc = @_importKey
saveButton = (<button title="Import" className="btn btn-toolbar" onClick={ (event) => boundFunc(profile, event) } ref="button">
Import Key
</button>
)
# TODO improved deduping? tricky because of the kbprofile - email association
if not profile.keyPath?
return <KeybaseUser profile={profile} actionButton={ saveButton } />
)
if not profiles? or profiles.length < 1
profiles = []
if profiles.length < 1 and @state.searchedByEmail
badSearch = <span className="bad-search-msg">Keybase cannot be searched by email address. <br/>Try entering a name or Keybase username.</span>
else
badSearch = false
if @state.loading
loading = <RetinaImg style={width: 20, height: 20, marginTop: 2} name="inline-loading-spinner.gif" mode={RetinaImg.Mode.ContentPreserve} />
else
loading = null
<div className="keybase-search">
<div className="searchbar">
<input type="text" value={ @state.query } placeholder="Search for PGP public keys on Keybase" ref="searchbar" onChange={@_queryChange} />
<div className="loading">{ loading }</div>
</div>
<div className="results" ref="results">
{ profiles }
{ badSearch }
</div>
</div>

View file

@ -0,0 +1,135 @@
{Utils, React, Actions} = require 'nylas-exports'
{ParticipantsTextField} = require 'nylas-component-kit'
PGPKeyStore = require './pgp-key-store'
EmailPopover = require './email-popover'
Identity = require './identity'
kb = require './keybase'
_ = require 'underscore'
module.exports =
class KeybaseUser extends React.Component
@displayName: 'KeybaseUserProfile'
@propTypes:
profile: React.PropTypes.instanceOf(Identity).isRequired
actionButton: React.PropTypes.node
displayEmailList: React.PropTypes.bool
@defaultProps:
actionButton: false
displayEmailList: true
constructor: (props) ->
super(props)
componentDidMount: ->
PGPKeyStore.getKeybaseData(@props.profile)
_addEmail: (email) =>
PGPKeyStore.addAddressToKey(@props.profile, email)
_addEmailClick: (event) =>
popoverTarget = event.target.getBoundingClientRect()
Actions.openPopover(
<EmailPopover profile={@props.profile} onPopoverDone={ @_popoverDone } />,
{originRect: popoverTarget, direction: 'left'}
)
_popoverDone: (addresses, identity) =>
if addresses.length < 1
# no email addresses added, noop
return
else
_.each(addresses, (address) =>
@_addEmail(address))
_removeEmail: (email) =>
PGPKeyStore.removeAddressFromKey(@props.profile, email)
render: =>
{profile} = @props
keybaseDetails = <div className="details"></div>
if profile.keybase_profile?
keybase = profile.keybase_profile
# profile picture
if keybase.thumbnail?
picture = <img className="user-picture" src={ keybase.thumbnail }/>
else
hue = Utils.hueForString("Keybase")
bgColor = "hsl(#{hue}, 50%, 45%)"
abv = "K"
picture = <div className="default-profile-image" style={{backgroundColor: bgColor}}>{abv}</div>
# full name
if keybase.components.full_name?.val?
fullname = keybase.components.full_name.val
else
fullname = username
username = false
# link to keybase profile
keybase_url = "keybase.io/#{keybase.components.username.val}"
if keybase_url.length > 25
keybase_string = keybase_url.slice(0, 23).concat('...')
else
keybase_string = keybase_url
username = <a href="https://#{keybase_url}">{keybase_string}</a>
# TODO: potentially display confirmation on keybase-user objects
###
possible_profiles = ["twitter", "github", "coinbase"]
profiles = _.map(possible_profiles, (possible) =>
if keybase.components[possible]?.val?
# TODO icon instead of weird "service: username" text
return (<span key={ possible }><b>{ possible }</b>: { keybase.components[possible].val }</span>)
)
profiles = _.reject(profiles, (profile) -> profile is undefined)
profiles = _.map(profiles, (profile) ->
return <span key={ profile.key }>{ profile } </span>)
profileList = (<span>{ profiles }</span>)
###
keybaseDetails = (<div className="details">
<div className="profile-name">
{ fullname }
</div>
<div className="profile-username">
{ username }
</div>
</div>)
else
# if no keybase profile, default image is based on email address
hue = Utils.hueForString(@props.profile.addresses[0])
bgColor = "hsl(#{hue}, 50%, 45%)"
abv = @props.profile.addresses[0][0].toUpperCase()
picture = <div className="default-profile-image" style={{backgroundColor: bgColor}}>{abv}</div>
# email addresses
if profile.addresses?.length > 0
emails = _.map(profile.addresses, (email) =>
# TODO make that remove button not terrible
return <li key={ email }>{ email } <small><a onClick={ => @_removeEmail(email) }>(X)</a></small></li>)
emailList = (<ul> { emails }
<a ref="addEmail" onClick={ @_addEmailClick }>+ Add Email</a>
</ul>)
emailListDiv = (<div className="email-list">
<ul>
{ emailList }
</ul>
</div>)
<div className="keybase-profile">
<div className="profile-photo-wrap">
<div className="profile-photo">
{ picture }
</div>
</div>
{ keybaseDetails }
{if @props.displayEmailList then emailListDiv}
{ @props.actionButton }
</div>

View file

@ -0,0 +1,61 @@
_ = require 'underscore'
request = require 'request'
class KeybaseAPI
constructor: ->
@baseUrl = "https://keybase.io"
getUser: (key, keyType, callback) =>
if not keyType in ['usernames', 'domain', 'twitter', 'github', 'reddit',
'hackernews', 'coinbase', 'key_fingerprint']
console.error 'keyType must be a supported Keybase query type.'
this._keybaseRequest("/_/api/1.0/user/lookup.json?#{keyType}=#{key}", (err, resp, obj) =>
return callback(err, null) if err
return callback(new Error("Empty response!"), null) if not obj? or not obj.them?
if obj.status?
return callback(new Error(obj.status.desc), null) if obj.status.name != "OK"
callback(null, _.map(obj.them, @_regularToAutocomplete))
)
getKey: (username, callback) =>
request({url: @baseUrl + "/#{username}/key.asc", headers: {'User-Agent': 'request'}}, (err, resp, obj) =>
return callback(err, null) if err
return callback(new Error("No key found for #{username}"), null) if not obj?
return callback(new Error("No key returned from keybase for #{username}"), null) if not obj.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----")
callback(null, obj)
)
autocomplete: (query, callback) =>
url = "/_/api/1.0/user/autocomplete.json"
request({url: @baseUrl + url, form: {q: query}, headers: {'User-Agent': 'request'}, json: true}, (err, resp, obj) =>
return callback(err, null) if err
if obj.status?
return callback(new Error(obj.status.desc), null) if obj.status.name != "OK"
callback(null, obj.completions)
)
_keybaseRequest: (url, callback) =>
return request({url: @baseUrl + url, headers: {'User-Agent': 'request'}, json: true}, callback)
_regularToAutocomplete: (profile) ->
# converts a keybase profile to the weird format used in the autocomplete
# endpoint for backward compatability
# (does NOT translate accounts - e.g. twitter, github - yet)
# TODO this should be the other way around
cleanedProfile = {components: {}}
cleanedProfile.thumbnail = null
if profile.pictures?.primary?
cleanedProfile.thumbnail = profile.pictures.primary.url
safe_name = if profile.profile? then profile.profile.full_name else ""
cleanedProfile.components = {full_name: {val: safe_name }, username: {val: profile.basics.username}}
_.each(profile.proofs_summary.all, (connectedAccount) =>
component = {}
component[connectedAccount.proof_type] = {val: connectedAccount.nametag}
cleanedProfile.components = _.extend(cleanedProfile.components, component)
)
return cleanedProfile
module.exports = new KeybaseAPI()

View file

@ -0,0 +1,34 @@
import {PreferencesUIStore, ComponentRegistry, ExtensionRegistry} from 'nylas-exports';
import EncryptMessageButton from './encrypt-button';
import DecryptMessageButton from './decrypt-button';
import DecryptPGPExtension from './decryption-preprocess';
import RecipientKeyChip from './recipient-key-chip';
import PreferencesKeybase from './preferences-keybase';
const PREFERENCE_TAB_ID = 'Encryption'
export function activate() {
const preferencesTab = new PreferencesUIStore.TabItem({
tabId: PREFERENCE_TAB_ID,
displayName: 'Encryption',
component: PreferencesKeybase,
});
ComponentRegistry.register(EncryptMessageButton, {role: 'Composer:ActionButton'});
ComponentRegistry.register(DecryptMessageButton, {role: 'message:BodyHeader'});
ComponentRegistry.register(RecipientKeyChip, {role: 'Composer:RecipientChip'});
ExtensionRegistry.MessageView.register(DecryptPGPExtension);
PreferencesUIStore.registerPreferencesTab(preferencesTab);
}
export function deactivate() {
ComponentRegistry.unregister(EncryptMessageButton);
ComponentRegistry.unregister(DecryptMessageButton);
ComponentRegistry.unregister(RecipientKeyChip);
ExtensionRegistry.MessageView.unregister(DecryptPGPExtension);
PreferencesUIStore.unregisterPreferencesTab(PREFERENCE_TAB_ID);
}
export function serialize() {
return {};
}

View file

@ -0,0 +1,143 @@
{Utils, React, Actions} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
KeybaseSearch = require './keybase-search'
KeybaseUser = require './keybase-user'
kb = require './keybase'
_ = require 'underscore'
module.exports =
class ModalKeyRecommender extends React.Component
@displayName: 'ModalKeyRecommender'
@propTypes:
contacts: React.PropTypes.array.isRequired
emails: React.PropTypes.array
callback: React.PropTypes.function
@defaultProps:
callback: -> return # NOP
constructor: (props) ->
super(props)
@state = Object.assign({
currentContact: 0},
@_getStateFromStores())
componentDidMount: ->
@unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange)
componentWillUnmount: ->
@unlistenKeystore()
_onKeystoreChange: =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
identities: PGPKeyStore.pubKeys(@props.emails)
_selectProfile: (address, identity) =>
# TODO this is an almost exact duplicate of keybase-search.cjsx:_save
keybaseUsername = identity.keybase_profile.components.username.val
identity.addresses.push(address)
kb.getKey(keybaseUsername, (error, key) =>
if error
console.error error
else
PGPKeyStore.saveNewKey(identity, key)
)
_onNext: =>
# NOTE: this doesn't do bounds checks! you must do that in render()!
@setState({currentContact: @state.currentContact + 1})
_onPrev: =>
# NOTE: this doesn't do bounds checks! you must do that in render()!
@setState({currentContact: @state.currentContact - 1})
_setPage: (page) =>
# NOTE: this doesn't do bounds checks! you must do that in render()!
@setState({currentContact: page})
# indexes from 0 because what kind of monster doesn't
_onDone: =>
if @state.identities.length < @props.emails.length
if !PGPKeyStore._displayDialog(
'Encrypt without keys for all recipients?',
'Some recipients are missing PGP public keys. They will not be able to decrypt this message.',
['Encrypt', 'Cancel']
)
return
Actions.closePopover()
@props.callback(@state.identities)
_onManageKeys: =>
Actions.switchPreferencesTab('Encryption')
Actions.openPreferences()
render: ->
# find the email we're dealing with now
email = @props.emails[@state.currentContact]
# and a corresponding contact
contact = _.findWhere(@props.contacts, {'email': email})
contactString = if contact? then contact.toString() else email
# find the identity object that goes with this email (if any)
identity = _.find(@state.identities, (identity) ->
return email in identity.addresses
)
if @state.currentContact == (@props.emails.length - 1)
# last one
if @props.emails.length == 1
# only one
backButton = false
else
backButton = <button className="btn modal-back-button" onClick={ @_onPrev }>Back</button>
nextButton = <button className="btn modal-next-button" onClick={ @_onDone }>Done</button>
else if @state.currentContact == 0
# first one
backButton = false
nextButton = <button className="btn modal-next-button" onClick={ @_onNext }>Next</button>
else
# somewhere in the middle
backButton = <button className="btn modal-back-button" onClick={ @_onPrev }>Back</button>
nextButton = <button className="btn modal-next-button" onClick={ @_onNext }>Next</button>
if identity?
deleteButton = (<button title="Delete Public" className="btn btn-toolbar btn-danger" onClick={ => PGPKeyStore.deleteKey(identity) } ref="button">
Delete Key
</button>
)
body = [
<div key="title" className="picker-title">This PGP public key has been saved for <br/><b>{ contactString }.</b></div>
<div className="keybase-profile-solo">
<KeybaseUser key="keybase-user" profile={ identity }, displayEmailList={false}, actionButton={deleteButton}/>
</div>
]
else
if contact?
query = contact.fullName()
# don't search Keybase for emails, won't work anyways
if not query.match(/\s/)?
query = ""
else
query = ""
importFunc = ((identity) => @_selectProfile(email, identity))
body = [
<div key="title" className="picker-title">There is no PGP public key saved for <br/><b>{ contactString }.</b></div>
<KeybaseSearch key="keybase-search" initialSearch={ query }, importFunc={ importFunc } />
]
prefsButton = <button className="btn modal-prefs-button" onClick={@_onManageKeys}>Advanced Key Management</button>
prefsLink = <a className="preferences-references" onClick={@_onManageKeys}>Advanced Key Management</a>
<div className="key-picker-modal">
{ body }
<div style={{flex:1}}></div>
<div className="picker-controls">
<div style={{width: 60}}> { backButton } </div>
{ prefsButton }
<div style={{width: 60}}> { nextButton } </div>
</div>
</div>

View file

@ -0,0 +1,29 @@
{React, Actions} = require 'nylas-exports'
Identity = require './identity'
_ = require 'underscore'
module.exports =
class PassphrasePopover extends React.Component
constructor: ->
@state = {passphrase: ""}
@propTypes:
identity: React.PropTypes.instanceOf(Identity)
render: ->
passphrase = @state
<div className="passphrase-popover">
<input type="password" value={@state.passphrase} placeholder="PGP private key password" className="key-passphrase-input form-control" onChange={@_onPassphraseChange} />
<button className="btn btn-toolbar" onClick={ @_onDone }>Done</button>
</div>
_onPassphraseChange: (event) =>
@setState
passphrase: event.target.value
_onDone: =>
if @props.identity?
@props.onPopoverDone(@state.passphrase, @props.identity)
else
@props.onPopoverDone(@state.passphrase)
Actions.closePopover()

View file

@ -0,0 +1,477 @@
NylasStore = require 'nylas-store'
{Actions, FileDownloadStore, DraftStore, MessageBodyProcessor, RegExpUtils} = require 'nylas-exports'
{remote, shell} = require 'electron'
Identity = require './identity'
kb = require './keybase'
pgp = require 'kbpgp'
_ = require 'underscore'
path = require 'path'
fs = require 'fs'
class PGPKeyStore extends NylasStore
constructor: ->
super()
@_identities = {}
@_msgCache = []
@_msgStatus = []
# Recursive subdir watching only works on OSX / Windows. annoying
@_pubWatcher = null
@_privWatcher = null
@_keyDir = path.join(NylasEnv.getConfigDirPath(), 'keys')
@_pubKeyDir = path.join(@_keyDir, 'public')
@_privKeyDir = path.join(@_keyDir, 'private')
# Create the key storage file system if it doesn't already exist
fs.access(@_keyDir, fs.R_OK | fs.W_OK, (err) =>
if err
fs.mkdir(@_keyDir, (err) =>
if err
console.warn err
else
fs.mkdir(@_pubKeyDir, (err) =>
if err
console.warn err
else
fs.mkdir(@_privKeyDir, (err) =>
if err
console.warn err
else
@watch())))
else
fs.access(@_pubKeyDir, fs.R_OK | fs.W_OK, (err) =>
if err
fs.mkdir(@_pubKeyDir, (err) =>
if err
console.warn err))
fs.access(@_privKeyDir, fs.R_OK | fs.W_OK, (err) =>
if err
fs.mkdir(@_privKeyDir, (err) =>
if err
console.warn err))
@_populate()
@watch())
validAddress: (address, isPub) =>
if (!address || address.length == 0)
@_displayError('You must provide an email address.')
return false
if not (RegExpUtils.emailRegex().test(address))
@_displayError('Invalid email address.')
return false
keys = if isPub then @pubKeys(address) else @privKeys({address: address, timed: false})
keystate = if isPub then 'public' else 'private'
if (keys.length > 0)
@_displayError("A PGP #{keystate} key for that email address already exists.")
return false
return true
### I/O and File Tracking ###
watch: =>
if (!@_pubWatcher)
@_pubWatcher = fs.watch(@_pubKeyDir, @_populate)
if (!@_privWatcher)
@_privWatcher = fs.watch(@_privKeyDir, @_populate)
unwatch: =>
if (@_pubWatcher)
@_pubWatcher.close()
@_pubWatcher = null
if (@_privWatcher)
@_privWatcher.close()
@_privWatcher = null
_populate: =>
# add identity elements to later be populated with keys from disk
# TODO if this function is called multiple times in quick succession it
# will duplicate keys - need to do deduplication on add
fs.readdir(@_pubKeyDir, (err, pubFilenames) =>
fs.readdir(@_privKeyDir, (err, privFilenames) =>
@_identities = {}
_.each([[pubFilenames, false], [privFilenames, true]], (readresults) =>
filenames = readresults[0]
i = 0
if filenames.length == 0
@trigger(@)
while i < filenames.length
filename = filenames[i]
if filename[0] == '.'
continue
ident = new Identity({
addresses: filename.split(" ")
isPriv: readresults[1]
})
@_identities[ident.clientId] = ident
@trigger(@)
i++)
)
)
getKeyContents: ({key, passphrase, callback}) =>
# Reads an actual PGP key from disk and adds it to the preexisting metadata
if not key.keyPath?
console.error "Identity has no path for key!", key
return
fs.readFile(key.keyPath, (err, data) =>
pgp.KeyManager.import_from_armored_pgp {
armored: data
}, (err, km) =>
if err
console.warn err
else
if km.is_pgp_locked()
if passphrase?
km.unlock_pgp { passphrase: passphrase }, (err) =>
if err
console.warn err
else
console.error "No passphrase provided, but key is encrypted."
# NOTE this only allows for one priv key per address
# if it's already there, update, else insert
key.key = km
key.setTimeout()
@getKeybaseData(key)
@trigger(@)
if callback?
callback()
)
getKeybaseData: (identity) =>
# Given a key, fetches metadata from keybase about that key
# TODO currently only works for public keys
if not identity.key? and not identity.isPriv and not identity.keybase_profile
@getKeyContents(key: identity)
else
fingerprint = identity.fingerprint()
kb.getUser(fingerprint, 'key_fingerprint', (err, user) =>
if user?.length == 1
identity.keybase_profile = user[0]
@trigger(@)
)
saveNewKey: (identity, contents) =>
# Validate the email address(es), then write to file.
if not identity instanceof Identity
console.error "saveNewKey requires an identity object"
return
addresses = identity.addresses
if addresses.length < 1
console.error "Identity must have at least one email address to save key"
return
if _.every(addresses, (address) => @validAddress(address, !identity.isPriv))
# Just say no to trailing whitespace.
if contents.charAt(contents.length - 1) != '-'
contents = contents.slice(0, -1)
fs.writeFile(identity.keyPath, contents, (err) =>
if (err)
@_displayError(err)
)
exportKey: ({identity, passphrase}) =>
atIndex = identity.addresses[0].indexOf("@")
shortName = identity.addresses[0].slice(0, atIndex).concat(".asc")
savePath = path.join(NylasEnv.savedState.lastDownloadDirectory, shortName)
@getKeyContents(key: identity, passphrase: passphrase, callback: ( =>
NylasEnv.showSaveDialog({
title: "Export PGP Key",
defaultPath: savePath,
}, (keyPath) =>
if (!keyPath)
return
if passphrase?
identity.key.export_pgp_private {passphrase: passphrase}, (err, pgp_private) =>
if (err)
@_displayError(err)
fs.writeFile(keyPath, pgp_private, (err) =>
if (err)
@_displayError(err)
shell.showItemInFolder(keyPath)
)
else
identity.key.export_pgp_public {}, (err, pgp_public) =>
fs.writeFile(keyPath, pgp_public, (err) =>
if (err)
@_displayError(err)
shell.showItemInFolder(keyPath)
)
)
)
)
deleteKey: (key) =>
if this._displayDialog(
'Delete this key?',
'The key will be permanently deleted.',
['Delete', 'Cancel']
)
fs.unlink(key.keyPath, (err) =>
if (err)
@_displayError(err)
)
addAddressToKey: (profile, address) =>
if @validAddress(address, true)
oldPath = profile.keyPath
profile.addresses.push(address)
fs.rename(oldPath, profile.keyPath, (err) =>
if (err)
@_displayError(err)
)
removeAddressFromKey: (profile, address) =>
if profile.addresses.length > 1
oldPath = profile.keyPath
profile.addresses = _.without(profile.addresses, address)
fs.rename(oldPath, profile.keyPath, (err) =>
if (err)
@_displayError(err)
)
else
@deleteKey(profile)
### Internal Key Management ###
pubKeys: (addresses) =>
# fetch public identity/ies for an address (synchronous)
# if no address, return them all
identities = _.where(_.values(@_identities), {isPriv: false})
if not addresses?
return identities
if typeof addresses is "string"
addresses = [addresses]
identities = _.filter(identities, (identity) ->
return _.intersection(addresses, identity.addresses).length > 0
)
return identities
privKeys: ({address, timed} = {timed: true}) =>
# fetch private identity/ies for an address (synchronous).
# by default, only return non-timed-out keys
# if no address, return them all
identities = _.where(_.values(@_identities), {isPriv: true})
if address?
identities = _.filter(identities, (identity) ->
return address in identity.addresses
)
if timed
identities = _.reject(identities, (identity) ->
return identity.isTimedOut()
)
return identities
_displayError: (err) ->
dialog = remote.dialog
dialog.showErrorBox('Key Management Error', err.toString())
_displayDialog: (title, message, buttons) ->
dialog = remote.dialog
return (dialog.showMessageBox({
title: title,
message: title,
detail: message,
buttons: buttons,
type: 'info',
}) == 0)
msgStatus: (msg) ->
# fetch the latest status of a message
# (synchronous)
if not msg?
return null
else
clientId = msg.clientId
statuses = _.filter @_msgStatus, (status) ->
return status.clientId == clientId
status = _.max statuses, (stat) ->
return stat.time
return status.message
isDecrypted: (message) ->
# if the message is already decrypted, return true
# if the message has no encrypted component, return true
# if the message has an encrypted component that is not yet decrypted,
# return false
if not @hasEncryptedComponent(message)
return true
else if @getDecrypted(message)?
return true
else
return false
getDecrypted: (message) =>
# Fetch a cached decrypted message
# (synchronous)
if message.clientId in _.pluck(@_msgCache, 'clientId')
msg = _.findWhere(@_msgCache, {clientId: message.clientId})
if msg.timeout > Date.now()
return msg.body
# otherwise
return null
hasEncryptedComponent: (message) ->
if not message.body?
return false
# find a PGP block
pgpStart = "-----BEGIN PGP MESSAGE-----"
pgpEnd = "-----END PGP MESSAGE-----"
blockStart = message.body.indexOf(pgpStart)
blockEnd = message.body.indexOf(pgpEnd)
# if they're both present, assume an encrypted block
return (blockStart >= 0 and blockEnd >= 0)
fetchEncryptedAttachments: (message) ->
encrypted = _.map(message.files, (file) =>
# calendars don't have filenames
if file.filename?
tokenized = file.filename.split('.')
extension = tokenized[tokenized.length - 1]
if extension == "asc" or extension == "pgp"
# something.asc or something.pgp -> assume encrypted attachment
return file
else
return null
else
return null
)
# NOTE for now we don't verify that the .asc/.pgp files actually have a PGP
# block inside
return _.compact(encrypted)
decrypt: (message) =>
# decrypt a message, cache the result
# (asynchronous)
# check to make sure we haven't already decrypted and cached the message
# note: could be a race condition here causing us to decrypt multiple times
# (not that that's a big deal other than minor resource wastage)
if @getDecrypted(message)?
return
if not @hasEncryptedComponent(message)
return
# fill our keyring with all possible private keys
ring = new pgp.keyring.KeyRing
# (the unbox function will use the right one)
for key in @privKeys({timed: true})
if key.key?
ring.add_key_manager(key.key)
# find a PGP block
pgpStart = "-----BEGIN PGP MESSAGE-----"
blockStart = message.body.indexOf(pgpStart)
pgpEnd = "-----END PGP MESSAGE-----"
blockEnd = message.body.indexOf(pgpEnd) + pgpEnd.length
# if we don't find those, it isn't encrypted
return unless (blockStart >= 0 and blockEnd >= 0)
pgpMsg = message.body.slice(blockStart, blockEnd)
# Don't let '+' get encoded
pgpMsg = pgpMsg.replace(/&#43;/gm,'+')
# There seemed to be issues with HTML tags being added to the message.
# Hopefully the <pre> tag fixed this, but just in case, here's a line to safeguard:
# pgpMsg = pgpMsg.replace(/<[^>]*>/gm,'')
pgp.unbox { keyfetch: ring, armored: pgpMsg }, (err, literals, warnings, subkey) =>
if err
console.warn err
@_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": "Unable to decrypt message."})
else
if warnings._w.length > 0
console.warn warnings._w
if literals.length > 0
plaintext = literals[0].toString('utf8')
# can't use _.template :(
body = message.body.slice(0, blockStart) + plaintext + message.body.slice(blockEnd)
# TODO if message is already in the cache, consider updating its TTL
timeout = 1000 * 60 * 30 # 30 minutes in ms
@_msgCache.push({clientId: message.clientId, body: body, timeout: Date.now() + timeout})
keyprint = subkey.get_fingerprint().toString('hex')
@_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": "Message decrypted with key #{keyprint}!"})
# re-render messages
MessageBodyProcessor.resetCache()
@trigger(@)
else
console.warn "Unable to decrypt message."
@_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": "Unable to decrypt message."})
decryptAttachments: (files) =>
# fill our keyring with all possible private keys
keyring = new pgp.keyring.KeyRing
# (the unbox function will use the right one)
for key in @privKeys({timed: true})
if key.key?
keyring.add_key_manager(key.key)
FileDownloadStore._fetchAndSaveAll(files).then((filepaths) ->
# open, decrypt, and resave each of the newly-downloaded files in place
_.each(filepaths, (filepath) =>
fs.readFile(filepath, (err, data) =>
# find a PGP block
pgpStart = "-----BEGIN PGP MESSAGE-----"
blockStart = data.indexOf(pgpStart)
pgpEnd = "-----END PGP MESSAGE-----"
blockEnd = data.indexOf(pgpEnd) + pgpEnd.length
# if we don't find those, it isn't encrypted
return unless (blockStart >= 0 and blockEnd >= 0)
pgpMsg = data.slice(blockStart, blockEnd)
# decrypt the file
pgp.unbox({ keyfetch: keyring, armored: pgpMsg }, (err, literals, warnings, subkey) =>
if err
console.warn err
else
if warnings._w.length > 0
console.warn warnings._w
literalLen = literals?.length
# if we have no literals, failed to decrypt and should abort
return unless literalLen?
if literalLen == 1
# success! replace old encrypted file with awesome decrypted file
fs.writeFile(filepath, literals[0].toBuffer(), (err) =>
if err
console.warn err
# TODO mv the file -> remove .asc extension
)
else
console.warn "Attempt to decrypt attachment failed: #{literalLen} literals found, expected 1."
)
)
)
)
module.exports = new PGPKeyStore()

View file

@ -0,0 +1,50 @@
{React, RegExpUtils} = require 'nylas-exports'
PGPKeyStore = require './pgp-key-store'
KeybaseSearch = require './keybase-search'
KeyManager = require './key-manager'
KeyAdder = require './key-adder'
class PreferencesKeybase extends React.Component
@displayName: 'PreferencesKeybase'
constructor: (@props) ->
@_keySaveQueue = {}
{pubKeys, privKeys} = @_getStateFromStores()
@state =
pubKeys: pubKeys
privKeys: privKeys
componentDidMount: =>
@unlistenKeystore = PGPKeyStore.listen(@_onChange, @)
componentWillUnmount: =>
@unlistenKeystore()
_onChange: =>
@setState @_getStateFromStores()
_getStateFromStores: ->
pubKeys = PGPKeyStore.pubKeys()
privKeys = PGPKeyStore.privKeys(timed: false)
return {pubKeys, privKeys}
render: =>
noKeysMessage =
<div className="key-status-bar no-keys-message">
You have no saved PGP keys!
</div>
keyManager = <KeyManager pubKeys={@state.pubKeys} privKeys={@state.privKeys}/>
<div className="container-keybase">
<section className="key-add">
<KeyAdder/>
</section>
<section className="keybase">
<KeybaseSearch />
{if @state.pubKeys.length == 0 and @state.privKeys.length == 0 then noKeysMessage else keyManager}
</section>
</div>
module.exports = PreferencesKeybase

View file

@ -0,0 +1,53 @@
{MessageStore, React} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
PGPKeyStore = require './pgp-key-store'
pgp = require 'kbpgp'
_ = require 'underscore'
# Sits next to recipient chips in the composer and turns them green/red
# depending on whether or not there's a PGP key present for that user
class RecipientKeyChip extends React.Component
@displayName: 'RecipientKeyChip'
@propTypes:
contact: React.PropTypes.object.isRequired
constructor: (props) ->
super(props)
@state = @_getStateFromStores()
componentDidMount: ->
# fetch the actual key(s) from disk
keys = PGPKeyStore.pubKeys(@props.contact.email)
_.each(keys, (key) ->
PGPKeyStore.getKeyContents(key: key)
)
@unlistenKeystore = PGPKeyStore.listen(@_onKeystoreChange, @)
componentWillUnmount: ->
@unlistenKeystore()
_getStateFromStores: ->
return {
# true if there is at least one loaded key for the account
keys: PGPKeyStore.pubKeys(@props.contact.email).some((cv, ind, arr) =>
cv.hasOwnProperty('key')
)
}
_onKeystoreChange: ->
@setState(@_getStateFromStores())
render: ->
if @state.keys
<div className="n1-keybase-recipient-key-chip">
<RetinaImg url="nylas://keybase/key-present@2x.png" mode={RetinaImg.Mode.ContentPreserve} ref="keyIcon" />
</div>
else
<div className="n1-keybase-recipient-key-chip">
<span ref="noKeyIcon"></span>
</div>
module.exports = RecipientKeyChip

View file

@ -0,0 +1,23 @@
{
"name": "keybase",
"main": "./lib/main",
"version": "0.1.0",
"repository": {
"type": "git",
"url": "https://github.com/nylas/n1-keybase"
},
"engines": {
"nylas": ">=0.4.10-4e3595b"
},
"description": "PGP encryption for N1 using Keybase for public key exchange.",
"dependencies": {
"kbpgp": "^2.0.52",
"request": "^2.69.0",
"underscore": "^1.8.3"
},
"license": "MIT",
"windowTypes": {
"default": true,
"composer": true
}
}

View file

@ -0,0 +1,82 @@
{React, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
pgp = require 'kbpgp'
DecryptMessageButton = require '../lib/decrypt-button'
PGPKeyStore = require '../lib/pgp-key-store'
describe "DecryptMessageButton", ->
beforeEach ->
@unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: '<p>Body</p>'})
body = """-----BEGIN PGP MESSAGE-----
Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
=1aPN
-----END PGP MESSAGE-----"""
@encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
@msg = new Message({subject: 'Subject', body: '<p>Body</p>'})
@component = ReactTestUtils.renderIntoDocument(
<DecryptMessageButton message=@msg />
)
it "should try to decrypt the message whenever a new key is unlocked", ->
spyOn(PGPKeyStore, "decrypt")
spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
return false)
spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake((message) =>
return true)
PGPKeyStore.trigger(PGPKeyStore)
expect(PGPKeyStore.decrypt).toHaveBeenCalled()
xit "should not try to decrypt the message whenever a new key is unlocked
if the message is already decrypted", ->
spyOn(PGPKeyStore, "decrypt")
spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
return true)
spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake((message) =>
return true)
# TODO for some reason the above spyOn calls aren't working and false is
# being returned from isDecrypted, causing this test to fail
PGPKeyStore.trigger(PGPKeyStore)
expect(PGPKeyStore.decrypt).not.toHaveBeenCalled()
it "should have a button to decrypt a message", ->
@component = ReactTestUtils.renderIntoDocument(
<DecryptMessageButton message=@encryptedMsg />
)
expect(@component.refs.button).toBeDefined()
it "should not allow for the unlocking of a message with no encrypted component", ->
@component = ReactTestUtils.renderIntoDocument(
<DecryptMessageButton message=@unencryptedMsg />
)
expect(@component.refs.button).not.toBeDefined()
it "should indicate when a message has been decrypted", ->
spyOn(PGPKeyStore, "isDecrypted").andCallFake((message) =>
return true)
@component = ReactTestUtils.renderIntoDocument(
<DecryptMessageButton message=@encryptedMsg />
)
expect(@component.refs.button).not.toBeDefined()
it "should open a popover when clicked", ->
spyOn(DecryptMessageButton.prototype, "_onClickDecrypt")
msg = @encryptedMsg
msg.to = [{email: "test@example.com"}]
@component = ReactTestUtils.renderIntoDocument(
<DecryptMessageButton message=msg />
)
expect(@component.refs.button).toBeDefined()
ReactTestUtils.Simulate.click(@component.refs.button)
expect(DecryptMessageButton.prototype._onClickDecrypt).toHaveBeenCalled()

View file

@ -0,0 +1,143 @@
{React, ReactDOM, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
pgp = require 'kbpgp'
EncryptMessageButton = require '../lib/encrypt-button'
PGPKeyStore = require '../lib/pgp-key-store'
describe "EncryptMessageButton", ->
beforeEach ->
key = """-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: GnuPG v1
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
217s2OKjpJqtpHPf2vY=
=UY7Y
-----END PGP PRIVATE KEY BLOCK-----"""
pgp.KeyManager.import_from_armored_pgp {
armored: key
}, (err, km) =>
@km = km
waitsFor (=> @km?), "getting a key took too long", 1000
@msg = new Message({subject: 'Subject', body: '<p>Body</p>'})
@output = null
add = jasmine.createSpy('add')
spyOn(DraftStore, 'sessionForClientId').andCallFake((draftClientId) =>
d = @msg
session =
draft: =>
return d
changes:
add: (changes) =>
@output = changes
return Promise.resolve(session)
)
@component = ReactTestUtils.renderIntoDocument(
<EncryptMessageButton draftClientId="test" />
)
it "should render into the page", ->
expect(@component).toBeDefined()
it "should have a displayName", ->
expect(EncryptMessageButton.displayName).toBe('EncryptMessageButton')
it "should have an onClick behavior which encrypts the message", ->
spyOn(@component, '_onClick')
buttonNode = ReactDOM.findDOMNode(@component.refs.button)
ReactTestUtils.Simulate.click(buttonNode)
expect(@component._onClick).toHaveBeenCalled()
it "should store the message body's plaintext on encryption", ->
spyOn(@component, '_onClick')
buttonNode = ReactDOM.findDOMNode(@component.refs.button)
ReactTestUtils.Simulate.click(buttonNode)
expect(@component.plaintext is @msg.body)
it "should mark itself as encrypted", ->
spyOn(@component, '_onClick')
buttonNode = ReactDOM.findDOMNode(@component.refs.button)
ReactTestUtils.Simulate.click(buttonNode)
expect(@component.currentlyEncrypted is true)
xit "should be able to encrypt messages", ->
# NOTE: this doesn't work.
# As best I can tell, something is wrong with the pgp.box function -
# nothing seems to get it to complete. Weird.
runs( =>
console.log @km
@component._encrypt("test text", [@km])
@flag = false
pgp.box {encrypt_for: [@km], msg: "test text"}, (err, result_string) =>
expect(not err?)
@err = err
@result_string = result_string
@flag = true
)
waitsFor (=> console.log @flag; @flag), "encryption took too long", 5000
runs( =>
console.log @err
console.log @result_string
console.log @output
expect(@output is @result_string))

View file

@ -0,0 +1,9 @@
{React, ReactTestUtils, Message} = require 'nylas-exports'
KeybaseUser = require '../lib/keybase-user'
describe "KeybaseUserProfile", ->
it "should have a displayName", ->
expect(KeybaseUser.displayName).toBe('KeybaseUserProfile')
# behold, the most comprehensive test suite of all time

View file

@ -0,0 +1,16 @@
{React, ReactTestUtils, Message} = require 'nylas-exports'
KeybaseSearch = require '../lib/keybase-search'
describe "KeybaseSearch", ->
it "should have a displayName", ->
expect(KeybaseSearch.displayName).toBe('KeybaseSearch')
it "should have no results when rendered", ->
@component = ReactTestUtils.renderIntoDocument(
<KeybaseSearch />
)
expect(@component.state.results).toEqual([])
# behold, the most comprehensive test suite of all time

View file

@ -0,0 +1,51 @@
kb = require '../lib/keybase'
describe "keybase lib", ->
# TODO stub keybase calls?
it "should be able to fetch an account by username", ->
@them = null
runs( =>
kb.getUser('dakota', 'usernames', (err, them) =>
@them = them
)
)
waitsFor((=> @them != null), 2000)
runs( =>
expect(@them?[0].components.username.val).toEqual("dakota")
)
it "should be able to fetch an account by key fingerprint", ->
@them = null
runs( =>
kb.getUser('7FA5A43BBF2BAD1845C8D0E8145FCCD989968E3B', 'key_fingerprint', (err, them) =>
@them = them
)
)
waitsFor((=> @them != null), 2000)
runs( =>
expect(@them?[0].components.username.val).toEqual("dakota")
)
it "should be able to fetch a user's key", ->
@key = null
runs( =>
kb.getKey('dakota', (error, key) =>
@key = key
)
)
waitsFor((=> @key != null), 2000)
runs( =>
expect(@key?.startsWith('-----BEGIN PGP PUBLIC KEY BLOCK-----'))
)
it "should be able to return an autocomplete query", ->
@completions = null
runs( =>
kb.autocomplete('dakota', (error, completions) =>
@completions = completions
)
)
waitsFor((=> @completions != null), 2000)
runs( =>
expect(@completions[0].components.username.val).toEqual("dakota")
)

View file

@ -0,0 +1,39 @@
{ComponentRegistry, ExtensionRegistry} = require 'nylas-exports'
{activate, deactivate} = require '../lib/main'
EncryptMessageButton = require '../lib/encrypt-button'
DecryptMessageButton = require '../lib/decrypt-button'
DecryptPGPExtension = require '../lib/decryption-preprocess'
describe "activate", ->
it "should register the encryption button", ->
spyOn(ComponentRegistry, 'register')
activate()
expect(ComponentRegistry.register).toHaveBeenCalledWith(EncryptMessageButton, {role: 'Composer:ActionButton'})
it "should register the decryption button", ->
spyOn(ComponentRegistry, 'register')
activate()
expect(ComponentRegistry.register).toHaveBeenCalledWith(DecryptMessageButton, {role: 'message:BodyHeader'})
it "should register the decryption processor", ->
spyOn(ExtensionRegistry.MessageView, 'register')
activate()
expect(ExtensionRegistry.MessageView.register).toHaveBeenCalledWith(DecryptPGPExtension)
describe "deactivate", ->
it "should unregister the encrypt button", ->
spyOn(ComponentRegistry, 'unregister')
deactivate()
expect(ComponentRegistry.unregister).toHaveBeenCalledWith(EncryptMessageButton)
it "should unregister the decryption button", ->
spyOn(ComponentRegistry, 'unregister')
deactivate()
expect(ComponentRegistry.unregister).toHaveBeenCalledWith(DecryptMessageButton)
it "should unregister the decryption processor", ->
spyOn(ExtensionRegistry.MessageView, 'unregister')
deactivate()
expect(ExtensionRegistry.MessageView.unregister).toHaveBeenCalledWith(DecryptPGPExtension)

View file

@ -0,0 +1,210 @@
{React, ReactTestUtils, DraftStore, Message} = require 'nylas-exports'
pgp = require 'kbpgp'
_ = require 'underscore'
fs = require 'fs'
Identity = require '../lib/identity'
PGPKeyStore = require '../lib/pgp-key-store'
fdescribe "PGPKeyStore", ->
beforeEach ->
@TEST_KEY = """-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: GnuPG v1
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
217s2OKjpJqtpHPf2vY=
=UY7Y
-----END PGP PRIVATE KEY BLOCK-----"""
# mock getKeyContents to get rid of all the fs.readFiles
spyOn(PGPKeyStore, "getKeyContents").andCallFake( ({key, passphrase, callback}) =>
data = @TEST_KEY
pgp.KeyManager.import_from_armored_pgp {
armored: data
}, (err, km) =>
expect(err).toEqual(null)
if km.is_pgp_locked()
expect(passphrase).toBeDefined()
km.unlock_pgp { passphrase: passphrase }, (err) =>
expect(err).toEqual(null)
key.key = km
key.setTimeout()
if callback?
callback()
)
# define an encrypted and an unencrypted message
@unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: '<p>Body</p>'})
body = """-----BEGIN PGP MESSAGE-----
Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
=1aPN
-----END PGP MESSAGE-----"""
@encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
# blow away the saved identities and set up a test pub/priv keypair
PGPKeyStore._identities = {}
pubIdent = new Identity({
addresses: ["benbitdiddle@icloud.com"]
isPriv: false
})
PGPKeyStore._identities[pubIdent.clientId] = pubIdent
privIdent = new Identity({
addresses: ["benbitdiddle@icloud.com"]
isPriv: true
})
PGPKeyStore._identities[privIdent.clientId] = privIdent
describe "when handling private keys", ->
it 'should be able to retrieve and unlock a private key', ->
expect(PGPKeyStore.privKeys().some((cv, index, array) =>
cv.hasOwnProperty("key"))).toBeFalsey
key = PGPKeyStore.privKeys(address: "benbitdiddle@icloud.com", timed: false)[0]
PGPKeyStore.getKeyContents(key: key, passphrase: "", callback: =>
expect(PGPKeyStore.privKeys({timed: false}).some((cv, index, array) =>
cv.hasOwnProperty("key"))).toBeTruthy
)
it 'should not return a private key after its timeout has passed', ->
expect(PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).length).toEqual(1)
PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].timeout = Date.now() - 5
expect(PGPKeyStore.privKeys(address: "benbitdiddle@icloud.com", timed: true).length).toEqual(0)
PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].setTimeout()
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)
it 'should return all private keys when an address is not supplied', ->
expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1)
it 'should update an existing key when it is unlocked, not add a new one', ->
timeout = PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].timeout
PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
# expect no new keys to have been added
expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1)
# make sure the timeout is updated
expect(timeout < PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).timeout)
)
describe "when decrypting messages", ->
xit 'should be able to decrypt a message', ->
# TODO for some reason, the pgp.unbox has a problem with the message body
runs( =>
spyOn(PGPKeyStore, 'trigger')
PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
PGPKeyStore.decrypt(@encryptedMsg)
)
)
waitsFor((=> PGPKeyStore.trigger.callCount > 0), 'message to decrypt')
runs( =>
expect(_.findWhere(PGPKeyStore._msgCache,
{clientId: @encryptedMsg.clientId})).toExist()
)
it 'should be able to handle an unencrypted message', ->
PGPKeyStore.decrypt(@unencryptedMsg)
expect(_.findWhere(PGPKeyStore._msgCache,
{clientId: @unencryptedMsg.clientId})).not.toBeDefined()
it 'should be able to tell when a message has no encrypted component', ->
expect(PGPKeyStore.hasEncryptedComponent(@unencryptedMsg)).not
expect(PGPKeyStore.hasEncryptedComponent(@encryptedMsg))
it 'should be able to handle a message with no BEGIN PGP MESSAGE block', ->
body = """Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
=1aPN
-----END PGP MESSAGE-----"""
badMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
PGPKeyStore.decrypt(badMsg)
expect(_.findWhere(PGPKeyStore._msgCache,
{clientId: badMsg.clientId})).not.toBeDefined()
)
it 'should be able to handle a message with no END PGP MESSAGE block', ->
body = """-----BEGIN PGP MESSAGE-----
Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto
wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw=
=1aPN"""
badMsg = new Message({clientId: 'test2', subject: 'Subject', body: body})
PGPKeyStore.getKeyContents(key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: =>
PGPKeyStore.decrypt(badMsg)
expect(_.findWhere(PGPKeyStore._msgCache,
{clientId: badMsg.clientId})).not.toBeDefined()
)
it 'should not return a decrypted message which has timed out', ->
PGPKeyStore._msgCache.push({clientId: "testID", body: "example body", timeout: Date.now()})
msg = new Message({clientId: "testID"})
expect(PGPKeyStore.getDecrypted(msg)).toEqual(null)
it 'should return a decrypted message', ->
timeout = Date.now() + (1000*60*60)
PGPKeyStore._msgCache.push({clientId: "testID2", body: "example body", timeout: timeout})
msg = new Message({clientId: "testID2", body: "example body"})
expect(PGPKeyStore.getDecrypted(msg)).toEqual(msg.body)
describe "when handling public keys", ->
it "should immediately return a pre-cached key", ->
expect(PGPKeyStore.pubKeys('benbitdiddle@icloud.com').length).toEqual(1)

View file

@ -0,0 +1,40 @@
{React, ReactTestUtils, DraftStore, Contact} = require 'nylas-exports'
pgp = require 'kbpgp'
RecipientKeyChip = require '../lib/recipient-key-chip'
PGPKeyStore = require '../lib/pgp-key-store'
describe "DecryptMessageButton", ->
beforeEach ->
@contact = new Contact({email: "test@example.com"})
@component = ReactTestUtils.renderIntoDocument(
<RecipientKeyChip contact=@contact />
)
it "should render into the page", ->
expect(@component).toBeDefined()
it "should have a displayName", ->
expect(RecipientKeyChip.displayName).toBe('RecipientKeyChip')
xit "should indicate when a recipient has a PGP key available", ->
spyOn(PGPKeyStore, "pubKeys").andCallFake((address) =>
return [{'key':0}])
key = PGPKeyStore.pubKeys(@contact.email)
expect(key).toBeDefined()
# TODO these calls crash the tester because they require a call to getKeyContents
expect(@component.refs.keyIcon).toBeDefined()
expect(@component.refs.noKeyIcon).not.toBeDefined()
xit "should indicate when a recipient does not have a PGP key available", ->
component = ReactTestUtils.renderIntoDocument(
<RecipientKeyChip contact=@contact />
)
key = PGPKeyStore.pubKeys(@contact.email)
expect(key).toEqual([])
# TODO these calls crash the tester because they require a call to getKeyContents
expect(component.refs.keyIcon).not.toBeDefined()
expect(component.refs.noKeyIcon).toBeDefined()

View file

@ -0,0 +1,420 @@
@import "ui-variables";
@import "ui-mixins";
@code-bg-color: #fcf4db;
.keybase {
.no-keys-message {
text-align: center;
}
}
.container-keybase {
max-width: 640px;
margin: 0 auto;
}
.keybase-profile {
border: 1px solid @border-color-primary;
border-top: 0;
background: @background-primary;
padding: 10px;
overflow: auto;
display: flex;
.profile-photo-wrap {
width: 50px;
height: 50px;
border-radius: @border-radius-base;
padding: 3px;
box-shadow: 0 0 1px rgba(0,0,0,0.5);
background: @background-primary;
.profile-photo {
border-radius: @border-radius-small;
overflow: hidden;
text-align: center;
width: 44px;
height: 44px;
img, .default-profile-image {
width: 44px;
height: 44px;
}
.default-profile-image {
line-height: 44px;
font-size: 18px;
font-weight: 500;
color: white;
box-shadow: inset 0 0 1px rgba(0,0,0,0.18);
background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%);
}
.user-picture {
background: @background-secondary;
width: 44px;
height: 44px;
}
}
}
.key-actions {
display: flex;
flex-direction: column;
button {
margin: 2px 0 2px 10px;
white-space: nowrap;
display: inline-block;
float: right;
}
}
.details {
margin-left: 10px;
flex: 1;
}
button {
margin: 10px 0 10px 10px;
white-space: nowrap;
display: inline-block;
float: right;
}
keybase-participant-field {
float: right;
}
ul {
list-style-type: none;
}
.email-list {
padding-left: 10px;
word-break: break-all;
flex-grow: 3;
text-align: right;
}
}
.keybase-profile:first-child {
border-top: 1px solid @border-color-primary;
}
.fixed-popover-container, .email-list {
.keybase-participant-field {
margin-bottom: 10px;
.n1-keybase-recipient-key-chip {
display: none;
}
.tokenizing-field-label {
display: none;
padding-top: 0;
}
.tokenizing-field-input {
padding-left: 0;
padding-top: 0;
input {
border: none;
}
}
}
}
.fixed-popover-container {
.keybase-participant-field {
width: 300px;
background: @input-bg;
border: 1px solid @input-border-color;
.menu .content-container {
background: @background-secondary;
}
}
.passphrase-popover {
margin: 10px;
display: flex;
button {
margin-left: 5px;
flex: 0;
}
input {
min-width: 180px;
flex: 1;
}
}
.keybase-import-popover {
margin: 10px;
button {
width: 100%;
}
.title {
margin: 0 auto;
white-space: nowrap;
}
}
}
.email-list {
.keybase-participant-field {
width: 200px;
border-bottom: 1px solid @gray-light;
}
}
.keybase-decrypt {
.decryption-interface {
button {
margin-right: 10px;
}
}
div.line-w-label {
display: flex;
align-items: center;
color: rgba(128, 128, 128, 0.5);
}
div.title-text {
padding: 0 10px;
}
div.border {
height: 1px;
background: rgba(128, 128, 128, 0.5);
flex: 1;
}
}
.key-manager {
div.line-w-label {
display: flex;
align-items: center;
color: rgba(128, 128, 128, 0.5);
margin: 10px 0;
}
div.title-text {
padding: 0 10px;
}
div.border {
height: 1px;
background: rgba(128, 128, 128, 0.5);
flex: 1;
}
}
.key-status-bar {
background-color: @code-bg-color;
color: darken(@code-bg-color, 70%);
border: 1.5px solid darken(@code-bg-color, 10%);
border-radius: @border-radius-small;
font-size: @font-size-small;
margin-bottom: 10px;
}
.key-add {
padding-top:10px;
.no-keys-message {
text-align: center;
}
.key-adder {
position: relative;
border: 1px solid @input-border-color;
padding: 10px;
padding-top: 0;
margin-bottom: 10px;
.key-text {
margin-top: 10px;
min-height: 200px;
display: flex;
.loading {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, 50%);
}
textarea {
border: 0;
padding: 0;
font-size: 0.9em;
flex: 1;
}
}
}
.credentials {
display: flex;
flex-direction: horizontal;
.key-add-btn {
margin: 10px 5px 0 0;
flex: 0;
}
.key-email-input {
margin: 10px 5px 0 0;
flex: 1;
}
.key-passphrase-input {
margin: 10px 5px 0 0;
flex: 1;
}
.invalid-msg {
color: #AAA;
white-space: nowrap;
text-align: right;
margin: 12px 5px 0 0;
flex: 1;
}
}
.key-creation-button {
display: inline-block;
margin: 0 5px 10px 5px;
}
.editor-note {
color: #AAA;
}
}
.key-instructions {
color: #333;
font-size: small;
margin-top: 20px;
}
.keybase-search {
margin-top: 15px;
margin-bottom: 15px;
overflow: scroll;
position: relative;
input {
padding: 10px;
margin-bottom: 10px;
}
.loading {
position: absolute;
right: 10px;
top: 8px; // lol I wonder how long until this is a problem
}
.bad-search-msg {
display: inline-block;
width: 100%;
text-align: center;
color: rgba(128, 128, 128, 0.5);
br {
display: none;
}
}
}
.key-picker-modal {
width: 400px;
height: 400px;
display: flex;
flex-direction: column;
.keybase-search {
display: flex;
flex-direction: column;
height: 100%;
margin-bottom: 0;
margin-top: 10px;
.searchbar {
width: 380px;
margin-left: auto;
margin-right: auto;
}
.loading {
right: 20px;
}
.results {
overflow: scroll;
height: 100%;
width: 100%;
}
.bad-search-msg {
br {
display: inline;
}
}
}
.picker-controls {
width: 380px;
margin: 5px auto 10px auto;
display: flex;
flex-shrink: 0;
flex-direction: row;
.modal-back-button {
float: left;
}
.modal-prefs-button {
flex: 1;
margin: 0 35px;
}
.modal-next-button {
float: right;
}
}
.keybase-profile-solo {
border: 1px solid @border-color-primary;
margin-top: 10px;
}
.picker-title {
margin-top: 10px;
margin-left: auto;
margin-right: auto;
text-align: center;
}
}
.decrypted {
display: block;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
padding: 8px 12px;
margin-bottom: 5px;
border: 1px solid rgb(235, 204, 209);
border-radius: 4px;
background-color: rgb(121, 212, 91);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -37,6 +37,7 @@ class FixedPopover extends Component {
children: PropTypes.element,
direction: PropTypes.string,
fallbackDirection: PropTypes.string,
closeOnAppBlur: PropTypes.bool,
originRect: PropTypes.shape({
bottom: PropTypes.number,
top: PropTypes.number,
@ -48,6 +49,10 @@ class FixedPopover extends Component {
focusElementWithTabIndex: PropTypes.func,
};
static defaultProps = {
closeOnAppBlur: true,
}
constructor(props) {
super(props);
this.mounted = false;
@ -123,6 +128,9 @@ class FixedPopover extends Component {
onBlur = (event) => {
const target = event.nativeEvent.relatedTarget;
if (!this.props.closeOnAppBlur && target === null) {
return
}
if (!target || (!findDOMNode(this).contains(target))) {
Actions.closePopover();
}

View file

@ -183,8 +183,10 @@ export default class ParticipantsTextField extends React.Component {
}
render() {
const classSet = {};
classSet[this.props.field] = true;
const classSet = {
[this.props.field]: true,
};
const draftId = this.props.draft ? this.props.draft.clientId : null
// TODO Ahh now that this component is part of the component kit this
// injected region feels out of place
return (
@ -211,7 +213,7 @@ export default class ParticipantsTextField extends React.Component {
menuPrompt: this.props.field,
field: this.props.field,
draft: this.props.draft,
draftClientId: this.props.draft.clientId,
draftClientId: draftId,
session: this.props.session,
}}
/>

View file

@ -252,11 +252,18 @@ Message(date DESC) WHERE draft = 1`,
}
// Public: Returns a set of uniqued message participants by combining the
// `to`, `cc`, && `from` fields.
participants() {
// `to`, `cc`, `bcc` && (optionally) `from` fields.
participants({includeFrom, includeBcc} = {includeFrom: true, includeBcc: false}) {
const seen = {}
const all = []
for (const contact of [].concat(this.to, this.cc, this.from)) {
let contacts = [].concat(this.to, this.cc)
if (includeFrom) {
contacts = _.union(contacts, (this.from || []))
}
if (includeBcc) {
contacts = _.union(contacts, (this.bcc || []))
}
for (const contact of contacts) {
if (!contact.email) {
continue
}

View file

@ -254,26 +254,28 @@ FileDownloadStore = Reflux.createStore
properties: ['openDirectory', 'createDirectory'],
}
NylasEnv.showOpenDialog options, (selected) =>
return unless selected
dirPath = selected[0]
return unless dirPath
NylasEnv.savedState.lastDownloadDirectory = dirPath
return new Promise (resolve, reject) =>
NylasEnv.showOpenDialog options, (selected) =>
return unless selected
dirPath = selected[0]
return unless dirPath
NylasEnv.savedState.lastDownloadDirectory = dirPath
lastSavePath = null
savePromises = files.map (file) =>
savePath = path.join(dirPath, file.safeDisplayName())
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then ->
lastSavePath = savePath
lastSavePaths = []
savePromises = files.map (file) =>
savePath = path.join(dirPath, file.safeDisplayName())
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then ->
lastSavePaths.push(savePath)
Promise.all(savePromises)
.then =>
shell.showItemInFolder(lastSavePath) if lastSavePath
.catch(@_catchFSErrors)
.catch (error) =>
@_presentError({error})
Promise.all(savePromises)
.then =>
shell.showItemInFolder(lastSavePaths[0]) if lastSavePaths.length > 0
resolve(lastSavePaths)
.catch(@_catchFSErrors)
.catch =>
@_presentError(file)
_abortFetchFile: (file) ->
download = @_downloads[file.id]

View file

@ -37,11 +37,12 @@ class PopoverStore extends NylasStore {
});
};
openPopover = (element, {originRect, direction, fallbackDirection, callback = () => {}}) => {
openPopover = (element, {originRect, direction, fallbackDirection, closeOnAppBlur, callback = () => {}}) => {
const props = {
direction,
originRect,
fallbackDirection,
closeOnAppBlur,
};
if (this.isOpen) {

View file

@ -21,7 +21,6 @@
background-color: @background-secondary;
border-radius: @border-radius-base;
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15);
overflow: hidden;
.menu {
z-index:1;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB