diff --git a/.ci/install.sh b/.ci/install.sh
index 1cb1657dc16d3546d380da9e337891d34c218ee7..2826c63d7062f10a61b706637d3e6daefce94afb 100755
--- a/.ci/install.sh
+++ b/.ci/install.sh
@@ -5,7 +5,7 @@ set -ex
 if [ $TRAVIS_OS_NAME == osx ]; then
     brew update || true
     brew upgrade boost || true
-    brew install libsodium clang-format
+    brew install libsodium clang-format ninja
     brew tap nlohmann/json
     # the nlohmann install seems to make travis angry 
     # because of the number of log messages
diff --git a/.travis.yml b/.travis.yml
index cff7bacd93f673297f21a6fac91d2560f6f61a27..1661291115c0547fe0a9ca0df119a4acab7e9383 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -13,18 +13,20 @@ notifications:
 matrix:
   include:
     - os: osx
-      osx_image: xcode9
+      # osx_image: xcode9 # don't specify an image here to use the default
       compiler: clang
     - os: linux
       compiler: gcc
       env:
         - CXX_VERSION=g++-8
         - CC_VERSION=gcc-8
+        - CMAKE_GENERATOR=Ninja
     - os: linux
       compiler: clang
       env:
         - CXX_VERSION=clang++-6.0
         - CC_VERSION=clang-6.0
+        - CMAKE_GENERATOR=Ninja
         - COVERAGE=ON
 
 install:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index eaaaec2fd54d1018081d0e54606dad67efbc8bb2..9e19afe0b6477fc200b4ef82181d278921ed03d9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -92,7 +92,7 @@ set_package_properties(nlohmann_json PROPERTIES
 set(Boost_USE_STATIC_LIBS OFF)
 set(Boost_USE_STATIC_RUNTIME OFF)
 set(Boost_USE_MULTITHREADED ON)
-find_package(Boost 1.66
+find_package(Boost 1.70
              COMPONENTS atomic
                         chrono
                         date_time
diff --git a/Dockerfile b/Dockerfile
index ee3842047a6c053c953949dfde24be296eebefaa..b87283c0754c74aa414310ecbcf587662dbc049b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,12 +4,12 @@ ENV LIBSODIUM_VERSION=1.0.16
 ENV SPDLOG_VERSION=1.1.0
 ENV OLM_VERSION=2.2.2
 ENV NLOHMANN_VERSION=v3.2.0
-ENV CMAKE_VERSION=3.12.1
-ENV CMAKE_SHORT_VERSION=3.12
+ENV CMAKE_VERSION=3.15.5
+ENV CMAKE_SHORT_VERSION=3.15
 
 RUN \
     apt-get update -qq && \
-    apt-get install -y --no-install-recommends apt-transport-https software-properties-common curl && \
+    apt-get install -y --no-install-recommends apt-transport-https software-properties-common curl ninja-build && \
     # cmake
     curl https://cmake.org/files/v${CMAKE_SHORT_VERSION}/cmake-${CMAKE_VERSION}-Linux-x86_64.sh -o cmake-install.sh && \
     bash cmake-install.sh --skip-license --prefix=/usr/local && \
@@ -52,8 +52,8 @@ RUN \
     cmake --build build --target install && \
     # boost
     mkdir -p /build/boost && cd /build/boost && \
-    curl -L https://dl.bintray.com/boostorg/release/1.68.0/source/boost_1_68_0.tar.gz -o boost_1_68_0.tar.gz && \
-    tar xfz boost_1_68_0.tar.gz && cd /build/boost/boost_1_68_0/ && \
+    curl -L https://dl.bintray.com/boostorg/release/1.70.0/source/boost_1_70_0.tar.gz -o boost_1_70_0.tar.gz && \
+    tar xfz boost_1_70_0.tar.gz && cd /build/boost/boost_1_70_0/ && \
     ./bootstrap.sh --with-libraries=random,thread,system,iostreams,atomic,chrono,date_time,regex && \
     ./b2 -d0 cxxstd=14 variant=release link=static threading=multi --layout=system && \
     ./b2 -d0 install && \
diff --git a/README.md b/README.md
index 3a5a4b86339e88211cb91e7b3e1d942e8a3ad807..2f0ac223b31c1a832571eb298eb6eb5141d55379 100644
--- a/README.md
+++ b/README.md
@@ -11,10 +11,10 @@ Client API library for the Matrix protocol, built on top of Boost.Asio.
 
 ### Dependencies
 
-- Boost 1.66 (includes Boost.Beast)
+- Boost 1.70 (includes Boost.Beast and makes the strand interface usable)
 - OpenSSL
 - C++ 14 compiler
-- CMake 3.1 or greater
+- CMake 3.15 or greater (lower versions can work, but they tend to mess up linking the right boost libraries)
 - Google Test (for testing)
 - libsodium 1.0.14 or greater
 
diff --git a/appveyor.yml b/appveyor.yml
index 780d8d7727724eb96999851c490d4498d4285f45..bce5d3447615689ef0acf3973a3faabcd4be56e8 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,6 +1,6 @@
 ---
 
-version: 0.2.0-{build}
+version: 0.3.0-{build}
 
 configuration: Release
 image: Visual Studio 2017
@@ -30,6 +30,7 @@ install:
             openssl:%PLATFORM%-windows
             spdlog:%PLATFORM%-windows
             zlib:%PLATFORM%-windows
+    - vcpkg upgrade --no-dry-run
 
 build_script:
     - cmake --version
diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt
index 1644d66fd84f5be02a3c6a424c870a9c7e0ec34d..7d930075a014fd44da2501f2c0d86553d5a2ad9c 100644
--- a/deps/CMakeLists.txt
+++ b/deps/CMakeLists.txt
@@ -40,9 +40,9 @@ if(USE_BUNDLED_BOOST)
 endif()
 
 set(BOOST_URL
-    https://dl.bintray.com/boostorg/release/1.69.0/source/boost_1_69_0.tar.bz2)
+    https://dl.bintray.com/boostorg/release/1.70.0/source/boost_1_70_0.tar.bz2)
 set(BOOST_SHA256
-    8f32d4617390d1c2d16f26a27ab60d97807b35440d45891fa340fc2648b04406)
+    430ae8354789de4fd19ee52f3b1f739e1fba576f0aded0897c3c2bc00fb38778)
 
 set(GTEST_URL https://github.com/google/googletest/archive/release-1.8.0.tar.gz)
 set(GTEST_SHA1 e7e646a6204638fe8e87e165292b8dd9cd4c36ed)
diff --git a/include/mtx/common.hpp b/include/mtx/common.hpp
index 6dbb8038768736273110c85f9eeee7cd1f763960..37c6c95028fa09cccd9ca26bd3f1d12d272d846a 100644
--- a/include/mtx/common.hpp
+++ b/include/mtx/common.hpp
@@ -54,5 +54,46 @@ from_json(const json &obj, DeviceKeys &res);
 void
 to_json(json &obj, const DeviceKeys &res);
 
+struct JWK
+{
+        //! Required. Key type. Must be oct.
+        std::string kty;
+        //! Required. Key operations. Must at least contain encrypt and decrypt.
+        std::vector<std::string> key_ops;
+        //! Required. Algorithm. Must be A256CTR.
+        std::string alg;
+        //! Required. The key, encoded as urlsafe unpadded base64.
+        std::string k;
+        //! Required. Extractable. Must be true. This is a W3C extension.
+        bool ext;
+};
+
+void
+from_json(const json &obj, JWK &res);
+
+void
+to_json(json &obj, const JWK &res);
+
+struct EncryptedFile
+{
+        //! Required. The URL to the file.
+        std::string url;
+        //! Required. A JSON Web Key object. (The encryption key)
+        JWK key;
+        //! Required. The Initialisation Vector used by AES-CTR, encoded as unpadded base64.
+        std::string iv;
+        //! Required. A map from an algorithm name to a hash of the ciphertext, encoded as unpadded
+        //! base64. Clients should support the SHA-256 hash, which uses the key sha256.
+        std::map<std::string, std::string> hashes;
+        //! Required. Version of the encrypted attachments protocol. Must be v2.
+        std::string v;
+};
+
+void
+from_json(const json &obj, EncryptedFile &res);
+
+void
+to_json(json &obj, const EncryptedFile &res);
+
 } // namespace crypto
 } // namespace mtx
diff --git a/include/mtx/events/common.hpp b/include/mtx/events/common.hpp
index e98f86da969f337f7abc90b3d686250d948d708c..9daf4fbb85c37f0e1cde55240d5ea475692e87cf 100644
--- a/include/mtx/events/common.hpp
+++ b/include/mtx/events/common.hpp
@@ -1,7 +1,10 @@
 #pragma once
 
+#include <boost/optional.hpp>
 #include <nlohmann/json.hpp>
 
+#include "mtx/common.hpp"
+
 using json = nlohmann::json;
 
 namespace mtx {
@@ -47,6 +50,8 @@ struct ImageInfo
         std::string thumbnail_url;
         //! The mimetype of the image, `e.g. image/jpeg`.
         std::string mimetype;
+        //! Encryption members. If present, they replace thumbnail_url.
+        boost::optional<crypto::EncryptedFile> thumbnail_file;
 };
 
 //! Deserialization method needed by @p nlohmann::json.
@@ -68,6 +73,8 @@ struct FileInfo
         std::string thumbnail_url;
         //! The mimetype of the file e.g `application/pdf`.
         std::string mimetype;
+        //! Encryption members. If present, they replace thumbnail_url.
+        boost::optional<crypto::EncryptedFile> thumbnail_file;
 };
 
 //! Deserialization method needed by @p nlohmann::json.
@@ -114,6 +121,8 @@ struct VideoInfo
         std::string thumbnail_url;
         //! Metadata about the image referred to in @p thumbnail_url.
         ThumbnailInfo thumbnail_info;
+        //! Encryption members. If present, they replace thumbnail_url.
+        boost::optional<crypto::EncryptedFile> thumbnail_file;
 };
 
 //! Deserialization method needed by @p nlohmann::json.
diff --git a/include/mtx/events/messages/audio.hpp b/include/mtx/events/messages/audio.hpp
index f671a38571db4455d4172cc5f78135b96629ed0d..8451f2cb80b28f6ca8a07abb6a8f7f2ab195eaec 100644
--- a/include/mtx/events/messages/audio.hpp
+++ b/include/mtx/events/messages/audio.hpp
@@ -1,8 +1,10 @@
 #pragma once
 
+#include <boost/optional.hpp>
 #include <nlohmann/json.hpp>
 #include <string>
 
+#include "mtx/common.hpp"
 #include "mtx/events/common.hpp"
 
 using json = nlohmann::json;
@@ -24,6 +26,8 @@ struct Audio
         std::string url;
         // Metadata for the audio clip referred to in url.
         common::AudioInfo info;
+        // Encryption members. If present, they replace url.
+        boost::optional<crypto::EncryptedFile> file;
 };
 
 void
diff --git a/include/mtx/events/messages/file.hpp b/include/mtx/events/messages/file.hpp
index 9db6730925c4aa6df7c8df82904198df8c31881c..e871951a0aa2dc384968683bd959b0d6c87798b7 100644
--- a/include/mtx/events/messages/file.hpp
+++ b/include/mtx/events/messages/file.hpp
@@ -3,6 +3,9 @@
 #include <nlohmann/json.hpp>
 #include <string>
 
+#include <boost/optional.hpp>
+
+#include "mtx/common.hpp"
 #include "mtx/events/common.hpp"
 
 using json = nlohmann::json;
@@ -27,6 +30,8 @@ struct File
         std::string url;
         // Information about the file referred to in the url.
         common::FileInfo info;
+        // Encryption members. If present, they replace url.
+        boost::optional<crypto::EncryptedFile> file;
 };
 
 void
diff --git a/include/mtx/events/messages/image.hpp b/include/mtx/events/messages/image.hpp
index a42a9c368eee59f5b7ca3fd540700ca77e6fb66b..baf7484c4e13294ac5f382a3f93a88a757eaf61a 100644
--- a/include/mtx/events/messages/image.hpp
+++ b/include/mtx/events/messages/image.hpp
@@ -1,8 +1,10 @@
 #pragma once
 
+#include <boost/optional.hpp>
 #include <nlohmann/json.hpp>
 #include <string>
 
+#include "mtx/common.hpp"
 #include "mtx/events/common.hpp"
 
 using json = nlohmann::json;
@@ -25,6 +27,8 @@ struct Image
         std::string url;
         // Metadata about the image referred to in `url`.
         common::ImageInfo info;
+        // Encryption members. If present, they replace url.
+        boost::optional<crypto::EncryptedFile> file;
 };
 
 struct StickerImage
@@ -37,6 +41,8 @@ struct StickerImage
         std::string url;
         // Metadata about the image referred to in `url`.
         common::ImageInfo info;
+        // Encryption members. If present, they replace url.
+        boost::optional<crypto::EncryptedFile> file;
 };
 
 void
diff --git a/include/mtx/events/messages/video.hpp b/include/mtx/events/messages/video.hpp
index 2231de964965d5ff7d272182127433a4af63bcdb..965a403a03dc621cf84019ce31bdad85dd9321a3 100644
--- a/include/mtx/events/messages/video.hpp
+++ b/include/mtx/events/messages/video.hpp
@@ -1,8 +1,10 @@
 #pragma once
 
+#include <boost/optional.hpp>
 #include <nlohmann/json.hpp>
 #include <string>
 
+#include "mtx/common.hpp"
 #include "mtx/events/common.hpp"
 
 using json = nlohmann::json;
@@ -24,6 +26,8 @@ struct Video
         std::string url;
         // Metadata for the video clip referred to in url.
         common::VideoInfo info;
+        // Encryption members. If present, they replace url.
+        boost::optional<crypto::EncryptedFile> file;
 };
 
 void
diff --git a/include/mtxclient/crypto/client.hpp b/include/mtxclient/crypto/client.hpp
index 7b7197299a58d52a98b9234716971cc1a33b48be..cb626241a49615485a9c069e97e5df29525a0543 100644
--- a/include/mtxclient/crypto/client.hpp
+++ b/include/mtxclient/crypto/client.hpp
@@ -55,19 +55,6 @@ private:
         std::string msg_;
 };
 
-class sodium_exception : public std::exception
-{
-public:
-        sodium_exception(std::string func, const char *msg)
-          : msg_(func + ": " + std::string(msg))
-        {}
-
-        virtual const char *what() const throw() { return msg_.c_str(); }
-
-private:
-        std::string msg_;
-};
-
 template<class T>
 std::string
 pickle(typename T::olm_type *object, const std::string &key)
@@ -244,12 +231,6 @@ encrypt_exported_sessions(const mtx::crypto::ExportedSessionKeys &keys, std::str
 mtx::crypto::ExportedSessionKeys
 decrypt_exported_sessions(const std::string &data, std::string pass);
 
-std::string
-base642bin(const std::string &b64);
-
-std::string
-bin2base64(const std::string &b64);
-
 BinaryBuf
 derive_key(const std::string &pass, const BinaryBuf &salt);
 
diff --git a/include/mtxclient/crypto/utils.hpp b/include/mtxclient/crypto/utils.hpp
index f8af46f7556f5b72bc31c78aa3a5a697f40bbefd..fc81969c486042be0b5b8da530839cebd741c2ba 100644
--- a/include/mtxclient/crypto/utils.hpp
+++ b/include/mtxclient/crypto/utils.hpp
@@ -1,20 +1,28 @@
 #pragma once
 
-#include <algorithm>
 #include <string>
 #include <vector>
 
-#include <openssl/aes.h>
-#include <openssl/evp.h>
-#include <openssl/hmac.h>
-#include <openssl/sha.h>
-
 #include <sodium.h>
 
 #include <boost/algorithm/string.hpp>
 
+#include "mtx/common.hpp"
+
 namespace mtx {
 namespace crypto {
+class sodium_exception : public std::exception
+{
+public:
+        sodium_exception(std::string func, const char *msg)
+          : msg_(func + ": " + std::string(msg))
+        {}
+
+        virtual const char *what() const throw() { return msg_.c_str(); }
+
+private:
+        std::string msg_;
+};
 
 //! Data representation used to interact with libolm.
 using BinaryBuf = std::vector<uint8_t>;
@@ -32,6 +40,19 @@ create_buffer(std::size_t nbytes)
         return buf;
 }
 
+inline BinaryBuf
+to_binary_buf(const std::string &str)
+{
+        return BinaryBuf(reinterpret_cast<const uint8_t *>(str.data()),
+                         reinterpret_cast<const uint8_t *>(str.data()) + str.size());
+}
+
+inline std::string
+to_string(const BinaryBuf &buf)
+{
+        return std::string(reinterpret_cast<const char *>(buf.data()), buf.size());
+}
+
 //! Simple wrapper around the OpenSSL PKCS5_PBKDF2_HMAC function
 BinaryBuf
 PBKDF2_HMAC_SHA_512(const std::string pass, const BinaryBuf salt, uint32_t iterations);
@@ -45,6 +66,18 @@ AES_CTR_256_Decrypt(const std::string ciphertext, const BinaryBuf aes256Key, Bin
 BinaryBuf
 HMAC_SHA256(const BinaryBuf hmacKey, const BinaryBuf data);
 
+std::string
+sha256(const std::string &data);
+
+//! Decrypt matrix EncryptedFile
+BinaryBuf
+decrypt_file(const std::string &ciphertext, const mtx::crypto::EncryptedFile &encryption_info);
+
+//! Encrypt matrix EncryptedFile
+// Remember to set the url member of the EncryptedFile struct!
+std::pair<BinaryBuf, mtx::crypto::EncryptedFile>
+encrypt_file(const std::string &plaintext);
+
 //! Translates the data back into the binary buffer, taking care
 //! to remove the header and footer elements.
 std::string
@@ -63,5 +96,23 @@ uint32_to_uint8(uint8_t b[4], uint32_t u32);
 void
 print_binary_buf(const BinaryBuf buf);
 
+std::string
+base642bin(const std::string &b64);
+
+std::string
+bin2base64(const std::string &bin);
+
+std::string
+base642bin_unpadded(const std::string &b64);
+
+std::string
+bin2base64_unpadded(const std::string &bin);
+
+std::string
+base642bin_urlsafe_unpadded(const std::string &b64);
+
+std::string
+bin2base64_urlsafe_unpadded(const std::string &bin);
+
 } // namespace crypto
-} // namespace mtx
\ No newline at end of file
+} // namespace mtx
diff --git a/lib/crypto/client.cpp b/lib/crypto/client.cpp
index ac180273a7e428bd13883bfce8418a8195a6e2bd..6f60d5c316f36be7e87a9d1b628022b0df0ff670 100644
--- a/lib/crypto/client.cpp
+++ b/lib/crypto/client.cpp
@@ -1,5 +1,8 @@
 #include <iostream>
 
+#include <openssl/aes.h>
+#include <openssl/sha.h>
+
 #include "mtxclient/crypto/client.hpp"
 #include "mtxclient/crypto/types.hpp"
 #include "mtxclient/crypto/utils.hpp"
@@ -669,50 +672,6 @@ mtx::crypto::decrypt_exported_sessions(const std::string &data, std::string pass
         return json::parse(plaintext);
 }
 
-std::string
-mtx::crypto::base642bin(const std::string &b64)
-{
-        std::size_t bin_maxlen = b64.size();
-        std::size_t bin_len;
-
-        const char *max_end;
-
-        auto ciphertext = create_buffer(bin_maxlen);
-
-        const int rc = sodium_base642bin(reinterpret_cast<unsigned char *>(ciphertext.data()),
-                                         ciphertext.size(),
-                                         b64.data(),
-                                         b64.size(),
-                                         nullptr,
-                                         &bin_len,
-                                         &max_end,
-                                         sodium_base64_VARIANT_ORIGINAL);
-        if (rc != 0)
-                throw sodium_exception{"sodium_base642bin", "encoding failed"};
-
-        if (bin_len != bin_maxlen)
-                ciphertext.resize(bin_len);
-
-        return std::string(std::make_move_iterator(ciphertext.begin()),
-                           std::make_move_iterator(ciphertext.end()));
-}
-
-std::string
-mtx::crypto::bin2base64(const std::string &bin)
-{
-        auto base64buf =
-          create_buffer(sodium_base64_encoded_len(bin.size(), sodium_base64_VARIANT_ORIGINAL));
-
-        sodium_bin2base64(reinterpret_cast<char *>(base64buf.data()),
-                          base64buf.size(),
-                          reinterpret_cast<const unsigned char *>(bin.data()),
-                          bin.size(),
-                          sodium_base64_VARIANT_ORIGINAL);
-
-        // Removing the null byte.
-        return std::string(base64buf.begin(), base64buf.end() - 1);
-}
-
 BinaryBuf
 mtx::crypto::derive_key(const std::string &pass, const BinaryBuf &salt)
 {
diff --git a/lib/crypto/utils.cpp b/lib/crypto/utils.cpp
index 2833835395df8c9ce962369de9109d3a220c6041..cd5b79e26f7425a3dabf8b50d3e83e8fcee73878 100644
--- a/lib/crypto/utils.cpp
+++ b/lib/crypto/utils.cpp
@@ -1,5 +1,12 @@
 #include "mtxclient/crypto/utils.hpp"
 
+#include <openssl/aes.h>
+#include <openssl/evp.h>
+#include <openssl/hmac.h>
+#include <openssl/sha.h>
+
+#include <algorithm>
+#include <iomanip>
 #include <iostream>
 
 namespace mtx {
@@ -32,7 +39,8 @@ AES_CTR_256_Encrypt(const std::string plaintext, const BinaryBuf aes256Key, Bina
 
         int ciphertext_len;
 
-        BinaryBuf encrypted = create_buffer(plaintext.size());
+        // The ciphertext expand up to block size, which is 128 for AES256
+        BinaryBuf encrypted = create_buffer(plaintext.size() + AES_BLOCK_SIZE);
 
         uint8_t *iv_data = iv.data();
         // need to set bit 63 to 0
@@ -67,6 +75,7 @@ AES_CTR_256_Encrypt(const std::string plaintext, const BinaryBuf aes256Key, Bina
         }
 
         ciphertext_len += len;
+        encrypted.resize(ciphertext_len);
 
         /* Clean up */
         EVP_CIPHER_CTX_free(ctx);
@@ -118,6 +127,7 @@ AES_CTR_256_Decrypt(const std::string ciphertext, const BinaryBuf aes256Key, Bin
                 //  handleErrors();
         }
         plaintext_len += len;
+        decrypted.resize(plaintext_len);
 
         /* Clean up */
         EVP_CIPHER_CTX_free(ctx);
@@ -125,6 +135,98 @@ AES_CTR_256_Decrypt(const std::string ciphertext, const BinaryBuf aes256Key, Bin
         return decrypted;
 }
 
+std::string
+sha256(const std::string &data)
+{
+        bool success = false;
+        std::string hashed;
+
+#if OPENSSL_VERSION_NUMBER < 0x10100000L
+        EVP_MD_CTX *context = EVP_MD_CTX_create();
+#else
+        EVP_MD_CTX *context = EVP_MD_CTX_new();
+#endif
+
+        if (context != NULL) {
+                if (EVP_DigestInit_ex(context, EVP_sha256(), NULL)) {
+                        if (EVP_DigestUpdate(context, data.c_str(), data.length())) {
+                                unsigned char hash[EVP_MAX_MD_SIZE];
+                                unsigned int lengthOfHash = 0;
+
+                                if (EVP_DigestFinal_ex(context, hash, &lengthOfHash)) {
+                                        hashed  = std::string(hash, hash + lengthOfHash);
+                                        success = true;
+                                }
+                        }
+                }
+
+#if OPENSSL_VERSION_NUMBER < 0x10100000L
+                EVP_MD_CTX_destroy(context);
+#else
+                EVP_MD_CTX_free(context);
+#endif
+        }
+
+        if (success)
+                return hashed;
+        throw std::runtime_error("sha256 failed!");
+}
+
+BinaryBuf
+decrypt_file(const std::string &ciphertext, const mtx::crypto::EncryptedFile &encryption_info)
+{
+        if (encryption_info.v != "v2")
+                throw std::invalid_argument("Unsupported encrypted file version");
+
+        if (encryption_info.key.kty != "oct")
+                throw std::invalid_argument("Unsupported key type");
+
+        if (encryption_info.key.alg != "A256CTR")
+                throw std::invalid_argument("Unsupported algorithm");
+
+        // Be careful, the key should be urlsafe and unpadded, the iv and sha only need to
+        // be unpadded
+        if (bin2base64_unpadded(sha256(ciphertext)) != encryption_info.hashes.at("sha256"))
+                throw std::invalid_argument(
+                  "sha256 of encrypted file does not match the ciphertext, expected '" +
+                  bin2base64_unpadded(sha256(ciphertext)) + "', got '" +
+                  encryption_info.hashes.at("sha256") + "'");
+
+        return AES_CTR_256_Decrypt(
+          ciphertext,
+          to_binary_buf(base642bin_urlsafe_unpadded(encryption_info.key.k)),
+          to_binary_buf(base642bin_unpadded(encryption_info.iv)));
+}
+
+std::pair<BinaryBuf, mtx::crypto::EncryptedFile>
+encrypt_file(const std::string &plaintext)
+{
+        mtx::crypto::EncryptedFile encryption_info;
+
+        // not sure if 16 bytes would be enough, 32 seems to be safe though
+        BinaryBuf key = create_buffer(32);
+        BinaryBuf iv  = create_buffer(32);
+
+        BinaryBuf cyphertext = AES_CTR_256_Encrypt(plaintext, key, iv);
+
+        // Be careful, the key should be urlsafe and unpadded, the iv and sha only need to
+        // be unpadded
+        JWK web_key;
+        web_key.ext     = true;
+        web_key.kty     = "oct";
+        web_key.key_ops = {"encrypt", "decrypt"};
+        web_key.alg     = "A256CTR";
+        web_key.k       = bin2base64_urlsafe_unpadded(to_string(key));
+        web_key.ext     = true;
+
+        encryption_info.key              = web_key;
+        encryption_info.iv               = bin2base64_unpadded(to_string(iv));
+        encryption_info.hashes["sha256"] = bin2base64_unpadded(sha256(to_string(cyphertext)));
+        encryption_info.v                = "v2";
+
+        return std::make_pair(cyphertext, encryption_info);
+}
+
 template<typename T>
 void
 remove_substrs(std::basic_string<T> &s, const std::basic_string<T> &p)
@@ -182,5 +284,135 @@ uint32_to_uint8(uint8_t b[4], uint32_t u32)
         b[0] = (uint8_t)(u32 >>= 8);
 }
 
+std::string
+base642bin(const std::string &b64)
+{
+        std::size_t bin_maxlen = b64.size();
+        std::size_t bin_len;
+
+        const char *max_end;
+
+        auto ciphertext = create_buffer(bin_maxlen);
+
+        const int rc = sodium_base642bin(reinterpret_cast<unsigned char *>(ciphertext.data()),
+                                         ciphertext.size(),
+                                         b64.data(),
+                                         b64.size(),
+                                         nullptr,
+                                         &bin_len,
+                                         &max_end,
+                                         sodium_base64_VARIANT_ORIGINAL);
+        if (rc != 0)
+                throw sodium_exception{"sodium_base642bin", "encoding failed"};
+
+        if (bin_len != bin_maxlen)
+                ciphertext.resize(bin_len);
+
+        return std::string(std::make_move_iterator(ciphertext.begin()),
+                           std::make_move_iterator(ciphertext.end()));
+}
+
+std::string
+bin2base64(const std::string &bin)
+{
+        auto base64buf =
+          create_buffer(sodium_base64_encoded_len(bin.size(), sodium_base64_VARIANT_ORIGINAL));
+
+        sodium_bin2base64(reinterpret_cast<char *>(base64buf.data()),
+                          base64buf.size(),
+                          reinterpret_cast<const unsigned char *>(bin.data()),
+                          bin.size(),
+                          sodium_base64_VARIANT_ORIGINAL);
+
+        // Removing the null byte.
+        return std::string(base64buf.begin(), base64buf.end() - 1);
+}
+std::string
+base642bin_unpadded(const std::string &b64)
+{
+        std::size_t bin_maxlen = b64.size();
+        std::size_t bin_len;
+
+        const char *max_end;
+
+        auto ciphertext = create_buffer(bin_maxlen);
+
+        const int rc = sodium_base642bin(reinterpret_cast<unsigned char *>(ciphertext.data()),
+                                         ciphertext.size(),
+                                         b64.data(),
+                                         b64.size(),
+                                         nullptr,
+                                         &bin_len,
+                                         &max_end,
+                                         sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
+        if (rc != 0)
+                throw sodium_exception{"sodium_base642bin", "encoding failed"};
+
+        if (bin_len != bin_maxlen)
+                ciphertext.resize(bin_len);
+
+        return std::string(std::make_move_iterator(ciphertext.begin()),
+                           std::make_move_iterator(ciphertext.end()));
+}
+
+std::string
+bin2base64_unpadded(const std::string &bin)
+{
+        auto base64buf = create_buffer(
+          sodium_base64_encoded_len(bin.size(), sodium_base64_VARIANT_ORIGINAL_NO_PADDING));
+
+        sodium_bin2base64(reinterpret_cast<char *>(base64buf.data()),
+                          base64buf.size(),
+                          reinterpret_cast<const unsigned char *>(bin.data()),
+                          bin.size(),
+                          sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
+
+        // Removing the null byte.
+        return std::string(base64buf.begin(), base64buf.end() - 1);
+}
+std::string
+base642bin_urlsafe_unpadded(const std::string &b64)
+{
+        std::size_t bin_maxlen = b64.size();
+        std::size_t bin_len;
+
+        const char *max_end;
+
+        auto ciphertext = create_buffer(bin_maxlen);
+
+        const int rc = sodium_base642bin(reinterpret_cast<unsigned char *>(ciphertext.data()),
+                                         ciphertext.size(),
+                                         b64.data(),
+                                         b64.size(),
+                                         nullptr,
+                                         &bin_len,
+                                         &max_end,
+                                         sodium_base64_VARIANT_URLSAFE_NO_PADDING);
+        if (rc != 0)
+                throw sodium_exception{"sodium_base642bin", "encoding failed"};
+
+        if (bin_len != bin_maxlen)
+                ciphertext.resize(bin_len);
+
+        return std::string(std::make_move_iterator(ciphertext.begin()),
+                           std::make_move_iterator(ciphertext.end()));
+}
+
+std::string
+bin2base64_urlsafe_unpadded(const std::string &bin)
+{
+        auto base64buf = create_buffer(
+          sodium_base64_encoded_len(bin.size(), sodium_base64_VARIANT_URLSAFE_NO_PADDING));
+
+        sodium_bin2base64(reinterpret_cast<char *>(base64buf.data()),
+                          base64buf.size(),
+                          reinterpret_cast<const unsigned char *>(bin.data()),
+                          bin.size(),
+                          sodium_base64_VARIANT_URLSAFE_NO_PADDING);
+
+        // Removing the null byte.
+        return std::string(base64buf.begin(), base64buf.end() - 1);
+}
+
 } // namespace crypto
 } // namespace mtx
diff --git a/lib/structs/common.cpp b/lib/structs/common.cpp
index 6a1870a0d8236752b59ae4066ce10492add3c3be..20601e9ad8e8d761eb7dcfb3fba172832256eb77 100644
--- a/lib/structs/common.cpp
+++ b/lib/structs/common.cpp
@@ -44,5 +44,45 @@ to_json(json &obj, const DeviceKeys &res)
         if (!res.unsigned_info.device_display_name.empty())
                 obj["unsigned"] = res.unsigned_info;
 }
+
+void
+from_json(const json &obj, JWK &res)
+{
+        res.kty     = obj.at("kty").get<std::string>();
+        res.key_ops = obj.at("key_ops").get<std::vector<std::string>>();
+        res.alg     = obj.at("alg").get<std::string>();
+        res.k       = obj.at("k").get<std::string>();
+        res.ext     = obj.at("ext").get<bool>();
+}
+
+void
+to_json(json &obj, const JWK &res)
+{
+        obj["kty"]     = res.kty;
+        obj["key_ops"] = res.key_ops;
+        obj["alg"]     = res.alg;
+        obj["k"]       = res.k;
+        obj["ext"]     = res.ext;
+}
+
+void
+from_json(const json &obj, EncryptedFile &res)
+{
+        res.url    = obj.at("url").get<std::string>();
+        res.key    = obj.at("key").get<JWK>();
+        res.iv     = obj.at("iv").get<std::string>();
+        res.hashes = obj.at("hashes").get<std::map<std::string, std::string>>();
+        res.v      = obj.at("v").get<std::string>();
+}
+
+void
+to_json(json &obj, const EncryptedFile &res)
+{
+        obj["url"]    = res.url;
+        obj["key"]    = res.key;
+        obj["iv"]     = res.iv;
+        obj["hashes"] = res.hashes;
+        obj["v"]      = res.v;
+}
 }
 }
diff --git a/lib/structs/events/common.cpp b/lib/structs/events/common.cpp
index 6f36b59506a71a6004498e7f79ac41901bdf29ec..a1d1b60d18e0f766abdd996f0133b71b444e3aa2 100644
--- a/lib/structs/events/common.cpp
+++ b/lib/structs/events/common.cpp
@@ -52,6 +52,9 @@ from_json(const json &obj, ImageInfo &info)
 
         if (obj.find("thumbnail_info") != obj.end())
                 info.thumbnail_info = obj.at("thumbnail_info").get<ThumbnailInfo>();
+
+        if (obj.find("thumbnail_file") != obj.end())
+                info.thumbnail_file = obj.at("thumbnail_file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -63,6 +66,8 @@ to_json(json &obj, const ImageInfo &info)
         obj["mimetype"]       = info.mimetype;
         obj["thumbnail_url"]  = info.thumbnail_url;
         obj["thumbnail_info"] = info.thumbnail_info;
+        if (info.thumbnail_file)
+                obj["thumbnail_file"] = info.thumbnail_file.value();
 }
 
 void
@@ -79,6 +84,9 @@ from_json(const json &obj, FileInfo &info)
 
         if (obj.find("thumbnail_info") != obj.end())
                 info.thumbnail_info = obj.at("thumbnail_info").get<ThumbnailInfo>();
+
+        if (obj.find("thumbnail_file") != obj.end())
+                info.thumbnail_file = obj.at("thumbnail_file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -88,6 +96,8 @@ to_json(json &obj, const FileInfo &info)
         obj["mimetype"]       = info.mimetype;
         obj["thumbnail_url"]  = info.thumbnail_url;
         obj["thumbnail_info"] = info.thumbnail_info;
+        if (info.thumbnail_file)
+                obj["thumbnail_file"] = info.thumbnail_file.value();
 }
 
 void
@@ -134,6 +144,9 @@ from_json(const json &obj, VideoInfo &info)
 
         if (obj.find("thumbnail_info") != obj.end())
                 info.thumbnail_info = obj.at("thumbnail_info").get<ThumbnailInfo>();
+
+        if (obj.find("thumbnail_file") != obj.end())
+                info.thumbnail_file = obj.at("thumbnail_file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -146,6 +159,8 @@ to_json(json &obj, const VideoInfo &info)
         obj["thumbnail_url"]  = info.thumbnail_url;
         obj["thumbnail_info"] = info.thumbnail_info;
         obj["mimetype"]       = info.mimetype;
+        if (info.thumbnail_file)
+                obj["thumbnail_file"] = info.thumbnail_file.value();
 }
 
 void
diff --git a/lib/structs/events/messages/audio.cpp b/lib/structs/events/messages/audio.cpp
index a2cd2269aec984d49f998af7d707ef1d566cde00..e425a590e2c9d358cd5cfe20e626d03572ba9fc4 100644
--- a/lib/structs/events/messages/audio.cpp
+++ b/lib/structs/events/messages/audio.cpp
@@ -22,6 +22,9 @@ from_json(const json &obj, Audio &content)
 
         if (obj.find("info") != obj.end())
                 content.info = obj.at("info").get<common::AudioInfo>();
+
+        if (obj.find("file") != obj.end())
+                content.file = obj.at("file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -31,6 +34,9 @@ to_json(json &obj, const Audio &content)
         obj["body"]    = content.body;
         obj["url"]     = content.url;
         obj["info"]    = content.info;
+
+        if (content.file)
+                obj["file"] = content.file.value();
 }
 
 } // namespace msg
diff --git a/lib/structs/events/messages/file.cpp b/lib/structs/events/messages/file.cpp
index add735a9cb0dbf11b367b57157d39b6da242bab9..dd36030cd52ca4f238b3cff4f6a342481c26cf81 100644
--- a/lib/structs/events/messages/file.cpp
+++ b/lib/structs/events/messages/file.cpp
@@ -25,6 +25,9 @@ from_json(const json &obj, File &content)
 
         if (obj.find("info") != obj.end())
                 content.info = obj.at("info").get<common::FileInfo>();
+
+        if (obj.find("file") != obj.end())
+                content.file = obj.at("file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -35,6 +38,9 @@ to_json(json &obj, const File &content)
         obj["filename"] = content.filename;
         obj["url"]      = content.url;
         obj["info"]     = content.info;
+
+        if (content.file)
+                obj["file"] = content.file.value();
 }
 
 } // namespace msg
diff --git a/lib/structs/events/messages/image.cpp b/lib/structs/events/messages/image.cpp
index f0e5200738994cac464853dc803fc5afde9a2270..c586d38be2fd6e96fb9b7b99940dbca938bda7a6 100644
--- a/lib/structs/events/messages/image.cpp
+++ b/lib/structs/events/messages/image.cpp
@@ -22,6 +22,9 @@ from_json(const json &obj, Image &content)
 
         if (obj.find("info") != obj.end())
                 content.info = obj.at("info").get<common::ImageInfo>();
+
+        if (obj.find("file") != obj.end())
+                content.file = obj.at("file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -31,6 +34,9 @@ to_json(json &obj, const Image &content)
         obj["body"]    = content.body;
         obj["url"]     = content.url;
         obj["info"]    = content.info;
+
+        if (content.file)
+                obj["file"] = content.file.value();
 }
 
 void
@@ -43,6 +49,9 @@ from_json(const json &obj, StickerImage &content)
 
         if (obj.find("info") != obj.end())
                 content.info = obj.at("info").get<common::ImageInfo>();
+
+        if (obj.find("file") != obj.end())
+                content.file = obj.at("file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -51,6 +60,9 @@ to_json(json &obj, const StickerImage &content)
         obj["body"] = content.body;
         obj["url"]  = content.url;
         obj["info"] = content.info;
+
+        if (content.file)
+                obj["file"] = content.file.value();
 }
 
 } // namespace msg
diff --git a/lib/structs/events/messages/video.cpp b/lib/structs/events/messages/video.cpp
index bac206acc245ee6e41025e37b681f0aedd225f37..0d4749a6d54e1b63b2f2d41972f7dfa940a9da43 100644
--- a/lib/structs/events/messages/video.cpp
+++ b/lib/structs/events/messages/video.cpp
@@ -23,6 +23,9 @@ from_json(const json &obj, Video &content)
 
         if (obj.find("info") != obj.end())
                 content.info = obj.at("info").get<common::VideoInfo>();
+
+        if (obj.find("file") != obj.end())
+                content.file = obj.at("file").get<crypto::EncryptedFile>();
 }
 
 void
@@ -32,6 +35,9 @@ to_json(json &obj, const Video &content)
         obj["body"]    = content.body;
         obj["url"]     = content.url;
         obj["info"]    = content.info;
+
+        if (content.file)
+                obj["file"] = content.file.value();
 }
 
 } // namespace msg
diff --git a/tests/crypto.cpp b/tests/crypto.cpp
index 2c4871ffe36ad93380fd405539af2813e0bd701a..d9cd67c0aa1f22df5be2acd31c0769fdce58c36a 100644
--- a/tests/crypto.cpp
+++ b/tests/crypto.cpp
@@ -62,3 +62,34 @@ TEST(Crypto, DeviceKeys)
 
         EXPECT_EQ(device2.unsigned_info.device_display_name, "Alice's mobile phone");
 }
+
+TEST(Crypto, EncryptedFile)
+{
+        json j = R"({
+      "url": "mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe",
+      "v": "v2",
+      "key": {
+        "alg": "A256CTR",
+        "ext": true,
+        "k": "aWF6-32KGYaC3A_FEUCk1Bt0JA37zP0wrStgmdCaW-0",
+        "key_ops": ["encrypt","decrypt"],
+        "kty": "oct"
+      },
+      "iv": "w+sE15fzSc0AAAAAAAAAAA",
+      "hashes": {
+        "sha256": "fdSLu/YkRx3Wyh3KQabP3rd6+SFiKg5lsJZQHtkSAYA"
+      }})"_json;
+
+        EncryptedFile file = j;
+        // json j2            = file;
+
+        // EXPECT_EQ(j, j2);
+        EXPECT_EQ(file.v, "v2");
+        EXPECT_EQ(file.iv, "w+sE15fzSc0AAAAAAAAAAA");
+        EXPECT_EQ(file.hashes.at("sha256"), "fdSLu/YkRx3Wyh3KQabP3rd6+SFiKg5lsJZQHtkSAYA");
+        EXPECT_EQ(file.key.alg, "A256CTR");
+        EXPECT_EQ(file.key.ext, true);
+        EXPECT_EQ(file.key.k, "aWF6-32KGYaC3A_FEUCk1Bt0JA37zP0wrStgmdCaW-0");
+        EXPECT_EQ(file.key.key_ops.size(), 2);
+        EXPECT_EQ(file.key.kty, "oct");
+}
diff --git a/tests/e2ee.cpp b/tests/e2ee.cpp
index dd0aa9ed8bb323f78ee4d4e9ff55351775322ddd..885789b096aa8230efde2b478c67a81b10e1b667 100644
--- a/tests/e2ee.cpp
+++ b/tests/e2ee.cpp
@@ -24,6 +24,8 @@ using namespace mtx::responses;
 
 using namespace std;
 
+using namespace nlohmann;
+
 struct OlmCipherContent
 {
         std::string body;
@@ -1130,6 +1132,60 @@ TEST(ExportSessions, InboundMegolmSessions)
         ASSERT_EQ(restored_output_str, secret_message);
 }
 
+TEST(Encryption, EncryptedFile)
+{
+        std::string plaintext = "This is some plain text payload";
+        auto encryption_data  = mtx::crypto::encrypt_file(plaintext);
+        ASSERT_NE(plaintext, mtx::crypto::to_string(encryption_data.first));
+        ASSERT_EQ(plaintext,
+                  mtx::crypto::to_string(mtx::crypto::decrypt_file(
+                    mtx::crypto::to_string(encryption_data.first), encryption_data.second)));
+
+        json j                                            = R"({
+  "type": "m.room.message",
+  "content": {
+    "body": "test.txt",
+    "info": {
+      "size": 8,
+      "mimetype": "text/plain"
+    },
+    "msgtype": "m.file",
+    "file": {
+      "v": "v2",
+      "key": {
+        "alg": "A256CTR",
+        "ext": true,
+        "k": "6osKLzUKV1YZ06WEX0b77D784Te8oAj5eNU-gAgkjs4",
+        "key_ops": [
+          "encrypt",
+          "decrypt"
+        ],
+        "kty": "oct"
+      },
+      "iv": "7zRP/t89YWcAAAAAAAAAAA",
+      "hashes": {
+        "sha256": "5g41hn7n10sCw3+2j7CQ9SJl6R/v5EBT4MshdFgHhzo"
+      },
+      "url": "mxc://neko.dev/WPKoOAPfPlcHiZZTEoaIoZhN",
+      "mimetype": "text/plain"
+    }
+ },
+ "event_id": "$1575320135447DEPky:neko.dev",
+  "origin_server_ts": 1575320135324,
+  "sender": "@test:neko.dev",
+  "unsigned": {
+    "age": 1081,
+    "transaction_id": "m1575320142400.8"
+  },
+  "room_id": "!YnUlhwgbBaGcAFsJOJ:neko.dev"
+})"_json;
+        mtx::events::RoomEvent<mtx::events::msg::File> ev = j;
+
+        ASSERT_EQ("abcdefg\n",
+                  mtx::crypto::to_string(mtx::crypto::decrypt_file("=\xFDX\xAB\xCA\xEB\x8F\xFF",
+                                                                   ev.content.file.value())));
+}
+
 TEST(Encryption, DISABLED_HandleRoomKeyEvent) {}
 TEST(Encryption, DISABLED_HandleRoomKeyRequestEvent) {}
 TEST(Encryption, DISABLED_HandleNewDevices) {}
diff --git a/tests/messages.cpp b/tests/messages.cpp
index a8d4eb1115fdce86750e24966aad90db71ef16bc..23b5f334ec53c4eda4cbd7f52e330b9efa7575a8 100644
--- a/tests/messages.cpp
+++ b/tests/messages.cpp
@@ -142,6 +142,86 @@ TEST(RoomEvents, FileMessage)
         EXPECT_EQ(event.content.info.size, 40565);
 }
 
+TEST(RoomEvents, EncryptedImageMessage)
+{
+        json data                   = R"(
+{
+  "content": {
+    "body": "something-important.jpg",
+    "file": {
+      "url": "mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe",
+      "mimetype": "image/jpeg",
+      "v": "v2",
+      "key": {
+        "alg": "A256CTR",
+        "ext": true,
+        "k": "aWF6-32KGYaC3A_FEUCk1Bt0JA37zP0wrStgmdCaW-0",
+        "key_ops": ["encrypt","decrypt"],
+        "kty": "oct"
+      },
+      "iv": "w+sE15fzSc0AAAAAAAAAAA",
+      "hashes": {
+        "sha256": "fdSLu/YkRx3Wyh3KQabP3rd6+SFiKg5lsJZQHtkSAYA"
+      }
+    },
+    "info": {
+      "mimetype": "image/jpeg",
+      "h": 1536,
+      "size": 422018,
+      "thumbnail_file": {
+        "hashes": {
+          "sha256": "/NogKqW5bz/m8xHgFiH5haFGjCNVmUIPLzfvOhHdrxY"
+        },
+        "iv": "U+k7PfwLr6UAAAAAAAAAAA",
+        "key": {
+          "alg": "A256CTR",
+          "ext": true,
+          "k": "RMyd6zhlbifsACM1DXkCbioZ2u0SywGljTH8JmGcylg",
+          "key_ops": ["encrypt", "decrypt"],
+          "kty": "oct"
+        },
+        "mimetype": "image/jpeg",
+        "url": "mxc://example.org/pmVJxyxGlmxHposwVSlOaEOv",
+        "v": "v2"
+      },
+      "thumbnail_info": {
+        "h": 768,
+        "mimetype": "image/jpeg",
+        "size": 211009,
+        "w": 432
+      },
+      "w": 864
+    },
+    "msgtype": "m.image"
+  },
+  "event_id": "$143273582443PhrSn:example.org",
+  "origin_server_ts": 1432735824653,
+  "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
+  "sender": "@example:example.org",
+  "type": "m.room.message",
+  "unsigned": {
+      "age": 1234
+  }
+})"_json;
+        RoomEvent<msg::Image> event = data;
+
+        EXPECT_EQ(event.type, EventType::RoomMessage);
+        EXPECT_EQ(event.event_id, "$143273582443PhrSn:example.org");
+        EXPECT_EQ(event.room_id, "!jEsUZKDJdhlrceRyVU:example.org");
+        EXPECT_EQ(event.sender, "@example:example.org");
+        EXPECT_EQ(event.origin_server_ts, 1432735824653L);
+        EXPECT_EQ(event.unsigned_data.age, 1234);
+
+        EXPECT_EQ(event.content.body, "something-important.jpg");
+        EXPECT_EQ(event.content.msgtype, "m.image");
+        EXPECT_EQ(event.content.url, "");
+        EXPECT_EQ(event.content.info.mimetype, "image/jpeg");
+        EXPECT_EQ(event.content.info.size, 422018);
+        EXPECT_EQ(event.content.file.value().url, "mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe");
+        EXPECT_EQ(event.content.info.thumbnail_file.value().url,
+                  "mxc://example.org/pmVJxyxGlmxHposwVSlOaEOv");
+}
+
 TEST(RoomEvents, ImageMessage)
 {
         json data = R"({