Mailspring/internal_packages/keybase/lib/pgp-key-store.cjsx
Ben Gotow 09addb80a2 Pgp patch (#2430)
* update icons

* style commit

* Debugs export function

The key Export function used to not successfully show items in
their directories and also depend on the most recent attachment
download location. This commit adds a new savedState attribute
just for Keybase keys and also handles the case where that value
is null.

* Forces delete to populate

fs.watch() was acting up and not triggering populates on deletes.
Now deleteKey() just triggers a populate.

* Re-enables decryption of attachments from Enigmail

Decryption of attachments was disabled in the Great Password Popover
Refactor of Early June 2016. This commit adds that feature back
(and makes some changes to getKeyContents to facilitate that
change).
2016-06-14 13:22:38 -07:00

487 lines
15 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()
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("@")
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, 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: (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()