/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.crypto; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; import java.text.ParseException; import java.util.Arrays; import java.util.EnumSet; import java.util.Iterator; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FilenameUtils; import org.apache.http.util.EncodingUtils; import org.bouncycastle.openpgp.PGPCompressedData; import org.bouncycastle.openpgp.PGPEncryptedDataList; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPLiteralData; import org.bouncycastle.openpgp.PGPObjectFactory; import org.bouncycastle.openpgp.PGPOnePassSignature; import org.bouncycastle.openpgp.PGPOnePassSignatureList; import org.bouncycastle.openpgp.PGPPrivateKey; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData; import org.bouncycastle.openpgp.PGPSignature; import org.bouncycastle.openpgp.PGPSignatureList; import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; import org.jivesoftware.smack.packet.Message; import org.kontalk.client.OpenPGPExtension.SignCryptElement; import org.kontalk.model.Contact; import org.kontalk.model.Model; import org.kontalk.model.message.DecryptMessage; import org.kontalk.model.message.MessageContent; import org.kontalk.model.message.MessageContent.InAttachment; import org.kontalk.system.AttachmentManager; import org.kontalk.util.CPIMMessage; import org.kontalk.util.ClientUtils; import org.kontalk.util.MediaUtils; import org.kontalk.util.XMPPParserUtils; /** * Decrypt message content. Message parameter is internally changed by methods. * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ final class Decryptor { private static final Logger LOGGER = Logger.getLogger(Decryptor.class.getName()); private static class DecryptionResult { final EnumSet<Coder.Error> errors = EnumSet.noneOf(Coder.Error.class); Coder.Signing signing = Coder.Signing.UNKNOWN; } // note: signing requires also encryption static boolean decryptMessage(DecryptMessage message, PersonalKey myKey) { if (!message.isEncrypted()) { LOGGER.warning("message not encrypted"); return false; } // decrypt String encryptedContent = message.getEncryptedContent(); if (encryptedContent.isEmpty()) { LOGGER.warning("no encrypted data in encrypted message"); } byte[] encryptedData = org.kontalk.util.EncodingUtils.base64ToBytes(encryptedContent); // if sender signing key not found -> can decrypt but not verify PGPUtils.PGPCoderKey senderKey = Coder.contactkey(message.getContact()).orElse(null); InputStream encryptedIn = new ByteArrayInputStream(encryptedData); ByteArrayOutputStream plainOut = new ByteArrayOutputStream(); DecryptionResult decResult; try { decResult = decryptAndVerify(encryptedIn, plainOut, myKey.getPrivateEncryptionKey(), senderKey != null ? Optional.of(senderKey.signKey) : Optional.empty()); } catch (IOException | PGPException ex) { LOGGER.log(Level.WARNING, "can't decrypt message", ex); return false; } EnumSet<Coder.Error> allErrors = decResult.errors; message.setSigning(decResult.signing); // parse decrypted CPIM content String myUID = myKey.getUserId(); String senderUID = senderKey != null ? senderKey.userID : null; String decryptedContent = EncodingUtils.getString( plainOut.toByteArray(), CPIMMessage.CHARSET); MessageContent content; // NOTE: we are not restricting the expected decrypted content to match the outer protocol // extension. E.g. somebody could wrap a CPIM message inside a XEP-0373 extension element // TODO ugly, but working if (decryptedContent.startsWith("<" + SignCryptElement.ELEMENT_NAME)) { content = parseSignCryptElement(decryptedContent, allErrors); } else { content = parseCPIMOrNull(decryptedContent, myUID, Optional.ofNullable(senderUID), allErrors); } // set errors message.setSecurityErrors(allErrors); if (content != null) { // everything went better than expected LOGGER.info("message decryption successful"); message.setDecryptedContent(content); return true; } else { LOGGER.warning("message decryption failed"); return false; } } static void decryptAttachment(InAttachment attachment, PersonalKey mMyKey, Contact sender) { Path inPath = attachment.getFilePath(); String outName = inPath.getFileName().toString(); if (outName.startsWith(AttachmentManager.ENCRYPT_PREFIX)) { outName = outName.substring(AttachmentManager.ENCRYPT_PREFIX.length()); } File outFile = MediaUtils.nonExistingFileForPath(inPath.getParent().resolve(outName)); // decrypt // if sender signing key not found -> can decrypt but not verify PGPUtils.PGPCoderKey senderKey = Coder.contactkey(sender).orElse(null); DecryptionResult decResult; File inFile = inPath.toFile(); try (FileInputStream encryptedIn = new FileInputStream(inFile); FileOutputStream plainOut = new FileOutputStream(outFile)) { decResult = decryptAndVerify(encryptedIn, plainOut, mMyKey.getPrivateEncryptionKey(), senderKey != null ? Optional.of(senderKey.signKey) : Optional.empty()); } catch (IOException | PGPException ex){ LOGGER.log(Level.WARNING, "can't decrypt attachment", ex); attachment.setErrors(EnumSet.of(Coder.Error.UNKNOWN_ERROR)); return; } attachment.setErrors(decResult.errors); attachment.setSigning(decResult.signing); Path outPath = outFile.toPath(); // security check for correct extension String ext = MediaUtils.extensionForMIME(MediaUtils.mimeForFile(outPath)); if (!ext.equals(FilenameUtils.getExtension(outFile.getName()))) { boolean succ = !MediaUtils.renameFile(outPath, outFile.getName() + "." + ext) .toString().isEmpty(); if (succ) LOGGER.info("corrected extension: " + ext); } attachment.setDecryptedFile(outPath.toFile().getName()); LOGGER.info("success, decrypted file: "+outPath); boolean succ = inFile.delete(); if (!succ) { LOGGER.warning("can't delete obsolete decrypted attachment file"); } } /** Decrypt, verify and write input stream data to output stream. */ private static DecryptionResult decryptAndVerify( InputStream encryptedInput, OutputStream plainOutput, PGPPrivateKey myKey, Optional<PGPPublicKey> senderSigningKey) throws PGPException, IOException { // note: the signature is inside the encrypted data DecryptionResult result = new DecryptionResult(); PGPObjectFactory pgpFactory = new PGPObjectFactory(encryptedInput, PGPUtils.FP_CALC); // the first object might be a PGP marker packet Object o = pgpFactory.nextObject(); // nullable if (!(o instanceof PGPEncryptedDataList)) { o = pgpFactory.nextObject(); // nullable } if (!(o instanceof PGPEncryptedDataList)) { LOGGER.warning("can't find encrypted data list in data"); result.errors.add(Coder.Error.INVALID_DATA); return result; } PGPEncryptedDataList encDataList = (PGPEncryptedDataList) o; // check if secret key matches our encryption keyID Iterator<?> it = encDataList.getEncryptedDataObjects(); PGPPrivateKey sKey = null; PGPPublicKeyEncryptedData pbe = null; long myKeyID = myKey.getKeyID(); while (sKey == null && it.hasNext()) { Object i = it.next(); if (!(i instanceof PGPPublicKeyEncryptedData)) continue; pbe = (PGPPublicKeyEncryptedData) i; if (pbe.getKeyID() == myKeyID) sKey = myKey; } if (sKey == null) { LOGGER.warning("private key for message not found"); result.errors.add(Coder.Error.INVALID_PRIVATE_KEY); return result; } InputStream clear = pbe.getDataStream(new BcPublicKeyDataDecryptorFactory(sKey)); PGPObjectFactory plainFactory = new PGPObjectFactory(clear, PGPUtils.FP_CALC); Object object = plainFactory.nextObject(); // nullable if (object instanceof PGPCompressedData) { PGPCompressedData cData = (PGPCompressedData) object; plainFactory = new PGPObjectFactory(cData.getDataStream(), PGPUtils.FP_CALC); object = plainFactory.nextObject(); // nullable } // the first object could be the signature list // get signature from it PGPOnePassSignature ops = null; if (object instanceof PGPOnePassSignatureList) { PGPOnePassSignatureList signatureList = (PGPOnePassSignatureList) object; // there is a signature list, so we assume the message is signed // (makes sense) result.signing = Coder.Signing.SIGNED; if (signatureList.isEmpty()) { LOGGER.warning("signature list is empty"); result.errors.add(Coder.Error.INVALID_SIGNATURE_DATA); } else if (senderSigningKey.isPresent()) { ops = signatureList.get(0); try { ops.init(new BcPGPContentVerifierBuilderProvider(), senderSigningKey.get()); } catch (ClassCastException e) { LOGGER.warning("legacy signature not supported"); result.errors.add(Coder.Error.INVALID_SIGNATURE_DATA); ops = null; } } object = plainFactory.nextObject(); // nullable } else { LOGGER.warning("signature list not found"); result.signing = Coder.Signing.NOT; } if (!(object instanceof PGPLiteralData)) { LOGGER.warning("unknown packet type: " + object.getClass().getName()); result.errors.add(Coder.Error.INVALID_DATA); return result; } PGPLiteralData ld = (PGPLiteralData) object; InputStream unc = ld.getInputStream(); int ch; while ((ch = unc.read()) >= 0) { plainOutput.write(ch); if (ops != null) ops.update((byte) ch); } if (ops != null) { result = verifySignature(result, plainFactory, ops); } // verify message integrity if (pbe.isIntegrityProtected()) { if (!pbe.verify()) { LOGGER.warning("integrity check failed"); result.errors.add(Coder.Error.INVALID_INTEGRITY); } } else { LOGGER.warning("data is not integrity protected"); result.errors.add(Coder.Error.NO_INTEGRITY); } return result; } private static DecryptionResult verifySignature(DecryptionResult result, PGPObjectFactory pgpFact, PGPOnePassSignature ops) throws PGPException, IOException { Object object = pgpFact.nextObject(); // nullable if (!(object instanceof PGPSignatureList)) { LOGGER.warning("invalid signature packet"); result.errors.add(Coder.Error.INVALID_SIGNATURE_DATA); return result; } PGPSignatureList signatureList = (PGPSignatureList) object; if (signatureList.isEmpty()) { LOGGER.warning("no signature in signature list"); result.errors.add(Coder.Error.INVALID_SIGNATURE_DATA); return result; } PGPSignature signature = signatureList.get(0); // TODO signature.getCreationTime() if (ops.verify(signature)) { // signature verification successful! result.signing = Coder.Signing.VERIFIED; } else { LOGGER.warning("signature verification failed"); result.errors.add(Coder.Error.INVALID_SIGNATURE); } return result; } /** * Parse and verify CPIM ( https://tools.ietf.org/html/rfc3860 ). * * The decrypted content of a message is in CPIM format. */ private static MessageContent parseCPIMOrNull(String cpim, String myUID, Optional<String> senderKeyUID, EnumSet<Coder.Error> allErrors) { CPIMMessage cpimMessage; try { cpimMessage = CPIMMessage.parse(cpim); } catch (ParseException ex) { LOGGER.log(Level.WARNING, "can't find valid CPIM data", ex); allErrors.add(Coder.Error.INVALID_DATA); return null; } String mime = cpimMessage.getMime(); // check mime type // why is that necessary here? //if (!mime.equalsIgnoreCase("text/plain") && // !mime.equalsIgnoreCase(XMPPUtils.XML_XMPP_TYPE)) { // LOGGER.warning("MIME type mismatch"); //} // check that the recipient matches the full UID of the personal key if (!Arrays.stream(cpimMessage.getTo()) .anyMatch(s -> s.contains(myUID))) { LOGGER.warning("receiver list does not include own UID"); allErrors.add(Coder.Error.INVALID_RECIPIENT); } // check that the sender matches the full UID of the sender's key if (senderKeyUID.isPresent() && !senderKeyUID.get().equals(cpimMessage.getFrom())) { LOGGER.warning("sender does not match UID in public key of sender"); allErrors.add(Coder.Error.INVALID_SENDER); } // TODO check DateTime (possibly compare it with <delay/>) String content = cpimMessage.getBody().toString(); MessageContent decryptedContent; if (XMPPParserUtils.XML_XMPP_TYPE.equalsIgnoreCase(mime)) { // XMPP XML format for advanced content (attachments) Message parsedMessage; try { parsedMessage = XMPPParserUtils.parseMessageStanza(content); } catch (Exception ex) { LOGGER.log(Level.WARNING, "can't parse XMPP XML string", ex); allErrors.add(Coder.Error.INVALID_DATA); return null; } LOGGER.config("decrypted XML: "+parsedMessage.toXML()); decryptedContent = ClientUtils.parseMessageContent(parsedMessage, true); } else { // text/plain MIME type for simple text messages decryptedContent = MessageContent.plainText(content); } return decryptedContent; } /** Parse and verify OpenPGP <signcrypt/> element (XEP-0373) */ private static MessageContent parseSignCryptElement(String signcrypt, EnumSet<Coder.Error> allErrors) { SignCryptElement signCryptElement; try { signCryptElement = SignCryptElement.parse(signcrypt); } catch (Exception ex) { LOGGER.log(Level.WARNING, "cannot parse", ex); return null; } if (!signCryptElement.getJIDs().contains(Model.getUserJID().string())) { LOGGER.warning("receiver list does not include own JID: "+signCryptElement.getJIDs()); allErrors.add(Coder.Error.INVALID_RECIPIENT); } // TODO "RECOMMENDED", compare with delay date //signCryptElement.getTimeStamp(); return ClientUtils.extensionsToContent(signCryptElement.getPayload()); } }