mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-14 12:08:27 +08:00
d1003fbac4
* Fix private key email-adder, add "no private key" error The decrypt UI is seriously confusing some people. This commit adds an error message that should at least stop them from trying to decrypt a message without a private key to speak of. Also, there was a dumb hardcoded true in validAddress. * Adds incorrect passphrase notification; enables carriage return for popover The passphrase popover was woefully inadequate. It didn't tell users when they had the wrong password - it just closed without saying anything - and you couldn't even use carriage return to submit the password. This commit fixes those mistakes by buffing out passphrase-popover.cjsx. * Adds private key popover to decrypt button The decrypt UI was confusing and didn't provide the user with an option to get a key imported from the message view. This mondo commit adds an entirely new popover so that the user never again will be forced to go to the preferences page. * Adds more forgiving encrypted block parsing * Overhauls decryption error handling The decrypt UI didn't clearly communicate error messages from the failure in PGPKeyStore.decrypt up to the user. This commit adds nice error surfacing as well as some pretty colors. * Fix encrypt modal key miscount error via getKeyContents coercion On Linux and Windows, fs.watch double-triggers on some actions in the key folder, for reasons that aren't super clear. This was causing issues where the encrypt modal would report not having a key loaded even though the key was totally loaded and saved. This commit sort of kludgily forces the modal to run getKeyContents for every key it has saved right before it returns them all. This would probably be better fixed with a refactor the the PGP Keystore. * remember to close popover, d'oh * patch key picker modal styling for Linux and Windows * response to review
496 lines
16 KiB
CoffeeScript
Executable file
496 lines
16 KiB
CoffeeScript
Executable file
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'
|
|
os = require 'os'
|
|
|
|
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()
|
|
# private key - check passphrase
|
|
passphrase ?= ""
|
|
km.unlock_pgp { passphrase: passphrase }, (err) =>
|
|
if err
|
|
# decrypt checks all keys, so DON'T open an error dialog
|
|
console.warn err
|
|
return
|
|
else
|
|
key.key = km
|
|
key.setTimeout()
|
|
if callback?
|
|
callback(key)
|
|
else
|
|
# public key - get keybase data
|
|
key.key = km
|
|
key.setTimeout()
|
|
@getKeybaseData(key)
|
|
if callback?
|
|
callback(key)
|
|
@trigger(@)
|
|
)
|
|
|
|
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()
|
|
if fingerprint?
|
|
kb.getUser(fingerprint, 'key_fingerprint', (err, user) =>
|
|
if err
|
|
console.error(err)
|
|
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("@")
|
|
suffix = if identity.isPriv then "-private.asc" else ".asc"
|
|
shortName = identity.addresses[0].slice(0, atIndex).concat(suffix)
|
|
NylasEnv.savedState.lastKeybaseDownloadDirectory ?= os.homedir()
|
|
savePath = path.join(NylasEnv.savedState.lastKeybaseDownloadDirectory, shortName)
|
|
@getKeyContents(key: identity, passphrase: passphrase, callback: ( (identity) =>
|
|
NylasEnv.showSaveDialog({
|
|
title: "Export PGP Key",
|
|
defaultPath: savePath,
|
|
}, (keyPath) =>
|
|
if (!keyPath)
|
|
return
|
|
NylasEnv.savedState.lastKeybaseDownloadDirectory = keyPath.slice(0, keyPath.length - shortName.length)
|
|
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)
|
|
@_populate()
|
|
)
|
|
|
|
addAddressToKey: (profile, address) =>
|
|
if @validAddress(address, !profile.isPriv)
|
|
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
|
|
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)
|
|
|
|
# Some users may send messages from sources that pollute the encrypted block.
|
|
pgpMsg = pgpMsg.replace(/+/gm,'+')
|
|
pgpMsg = pgpMsg.replace(/(<br>)/g, '\n')
|
|
pgpMsg = pgpMsg.replace(/<\/(blockquote|div|dl|dt|dd|form|h1|h2|h3|h4|h5|h6|hr|ol|p|pre|table|tr|td|ul|li|section|header|footer)>/g, '\n')
|
|
pgpMsg = pgpMsg.replace(/<(.+?)>/g, '')
|
|
pgpMsg = pgpMsg.replace(/ /g, ' ')
|
|
|
|
pgp.unbox { keyfetch: ring, armored: pgpMsg }, (err, literals, warnings, subkey) =>
|
|
if err
|
|
console.warn err
|
|
errMsg = "Unable to decrypt message."
|
|
if err.toString().indexOf("tailer found") >= 0 or err.toString().indexOf("checksum mismatch") >= 0
|
|
errMsg = "Unable to decrypt message. Encrypted block is malformed."
|
|
else if err.toString().indexOf("key not found:") >= 0
|
|
errMsg = "Unable to decrypt message. Private key does not match encrypted block."
|
|
if !@msgStatus(message)?
|
|
errMsg = "Decryption preprocessing failed."
|
|
@_msgStatus.push({"clientId": message.clientId, "time": Date.now(), "message": errMsg})
|
|
else
|
|
if warnings._w.length > 0
|
|
console.warn warnings._w
|
|
|
|
if literals.length > 0
|
|
plaintext = literals[0].toString('utf8')
|
|
|
|
# <pre> tag for consistent styling
|
|
if plaintext.indexOf("<pre>") == -1
|
|
plaintext = "<pre>\n" + plaintext + "\n</pre>"
|
|
|
|
# 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: (identity, files) =>
|
|
# fill our keyring with all possible private keys
|
|
keyring = new pgp.keyring.KeyRing
|
|
# (the unbox function will use the right one)
|
|
|
|
if identity.key?
|
|
keyring.add_key_manager(identity.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
|
|
filepath = filepath.slice(0, filepath.length-3).concat("txt")
|
|
fs.writeFile(filepath, literals[0].toBuffer(), (err) =>
|
|
if err
|
|
console.warn err
|
|
)
|
|
else
|
|
console.warn "Attempt to decrypt attachment failed: #{literalLen} literals found, expected 1."
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
module.exports = new PGPKeyStore()
|