diff --git a/include/session/attachments.h b/include/session/attachments.h new file mode 100644 index 00000000..1dc3b228 --- /dev/null +++ b/include/session/attachments.h @@ -0,0 +1,250 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include "export.h" + +typedef enum ATTACHMENT_DOMAIN { + ATTACHMENT_DOMAIN_ATTACHMENT = 0x00, + ATTACHMENT_DOMAIN_PROFILE_PIC = 0x01, +} ATTACHMENT_DOMAIN; + +// The size of the encryption key, which is always 32 bytes. +extern const size_t ATTACHMENT_ENCRYPT_KEY_SIZE; + +// The maximum allowed size of an regular attachment that might be sent or retrieved via onion +// requests. Larger attachments are permitted, but will be too large (after padding+encryption) for +// transmission via onion requests. +extern const size_t ATTACHMENT_MAX_REGULAR_SIZE; // 10218286 input == 10223616 encrypted + +/// API: crypto/session_attachment_encrypted_size +/// +/// Returns the exact encrypted+padded output size of an input of `plaintext_size` bytes. This is +/// the size of buffer that must be preallocated for the session_attachment_encrypt functions. +LIBSESSION_EXPORT size_t session_attachment_encrypted_size(size_t plaintext_size); + +/// API: crypto/session_attachment_decrypted_max_size +/// +/// Returns the maximum possible size of the plaintext data given encrypted data of the given size. +/// The actual decrypted size can be smaller, depending on the amount of padding in the encrypted +/// attachment. Returns `(size_t)-1` if the given encrypted size is too small to be a valid +/// encrypted attachment. +LIBSESSION_EXPORT size_t session_attachment_decrypted_max_size(size_t encrypted_size); + +/// API: crypto/session_attachment_encrypt +/// +/// Encrypt an attachment for storage on the file server into a preallocated buffer. +/// +/// Inputs: +/// - `seed` -- the 32-byte unique sender data; typically simply the sender's Ed25519 seed. +/// - `data` -- pointer to the buffer of data to encrypt +/// - `datalen` -- length of the data to encrypt +/// - `domain` -- domain separator; should be an ATTACHMENT_DOMAIN value. +/// - `key_out` -- Pointer to an existing 32-byte buffer where the 32-byte binary decryption key +/// will be written. +/// - `out` -- Pointer to an output buffer, which must be able to contain exactly +/// `session_attachment_encrypted_size(datalen)` encrypted output bytes. +/// - `error` -- if non-NULL and encryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// This method always passes `allow_large` to the underlying C++ implementation; the caller should +/// ensure that the input is less than `ATTACHMENT_MAX_REGULAR_SIZE` bytes before calling this (if +/// compatibility with onion requests is needed). +/// +/// Outputs: +/// - returns the number of encrypted data bytes written to `out` on success, 0 if encryption fails +/// (in which case `error` will be written with the error reason, if non-NULL). +LIBSESSION_EXPORT bool session_attachment_encrypt( + const unsigned char* seed, + const unsigned char* data, + size_t datalen, + ATTACHMENT_DOMAIN domain, + unsigned char* key_out, + unsigned char* out, + char* error); + +/// API: crypto/session_attachment_encrypt_file +/// +/// Encrypt an attachment for storage on the file server from a plaintext file on disk into a +/// preallocated buffer. +/// +/// Note that this implementation needs to read the file *twice*: a first pass to obtain the +/// file-dependent encryption and nonce; and then a second pass to perform the actual encryption, +/// but unlike first reading into a memory buffer, does not have to store the file contents in +/// memory. +/// +/// Inputs: +/// - `seed` -- the 32-byte unique sender data; typically simply the sender's Ed25519 seed. +/// - `filename` -- the filename to read (null-terminated C string). +/// - `domain` -- domain separator; should be an ATTACHMENT_DOMAIN value. +/// - `key_out` -- Pointer to an existing 32-byte buffer where the 32-byte binary decryption key +/// will be written. +/// - `make_buffer` -- callback to invoke to allocate a buffer of the required size into which to +/// write the encrypted data. It is passed two arguments: the required output buffer size, and +/// `ctx`, and must return a pointer to the beginning of a buffer of the requested size in which +/// to write the encrypted data. Can return NULL to abort encryption (e.g. if the size is too +/// large). +/// - `ctx` - arbitrary pointer (which can be null) to pass to make_buffer to pass user-defined +/// context into the callback. This encrypt function does not otherwise touch the pointer. +/// - `error` -- if non-NULL and encryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// This method always passes `allow_large` to the underlying C++ implementation; the caller should +/// ensure that the input is less than `ATTACHMENT_MAX_REGULAR_SIZE` bytes before calling this (if +/// compatibility with onion requests is needed). +/// +/// Outputs: +/// - returns the number of encrypted data bytes written (which will always equal the value passed +/// into `make_buffer`) on success; 0 if encryption fails (in which case `error` will be written +/// with the error reason, if non-NULL). +LIBSESSION_EXPORT size_t session_attachment_encrypt_file( + const unsigned char* seed, + const char* filename, + ATTACHMENT_DOMAIN domain, + unsigned char* key_out, + unsigned char* (*make_buffer)(size_t, void* ctx), + void* ctx, + char* error); + +/// API: crypto/session_attachment_decrypt +/// +/// Decrypts an attachment allegedly produced by session_attachment_encrypt into a provided +/// in-memory buffer. +/// +/// Inputs: +/// - `data` -- pointer to the buffer of data to decrypt +/// - `datalen` -- length of the `data` +/// - `key` -- pointer to the 32-byte binary decryption key +/// - `out` -- output buffer pointer; this buffer must be able to accept the entire decrypted file, +/// which can be anything up to `session_attachment_decrypted_max_size(datalen)` bytes. Note that +/// this buffer may be partially overwritten even if the function returns false (for cases when +/// the decryption error happens later in the encrypted stream). +/// - `outlen` -- pointer in which to store the final decrypted data size written to `out`, which is +/// often shorter than `session_attachment_decrypted_max_size(datalen)` (because of removed +/// padding). Not touched if the function returns false. +/// - `error` -- if non-NULL and decryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// Outputs: +/// - returns true if decryption succeeds: the `*outlen` bytes decrypted value will be written +/// starting at `out`. Returns false decryption fails (in which case `error` will be written with +/// the error reason, if provided). +LIBSESSION_EXPORT bool session_attachment_decrypt( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + unsigned char* out, + size_t* outlen, + char* error); + +/// API: crypto/session_attachment_decrypt +/// +/// Decrypts an attachment allegedly produced by session_attachment_encrypt into a single in-memory +/// allocated buffer. +/// +/// Inputs: +/// - `data` -- pointer to the buffer of data to decrypt +/// - `datalen` -- length of the `data` +/// - `key` -- pointer to the 32-byte binary decryption key +/// - `out` -- Pointer-pointer to an output buffer; a new buffer is allocated, the decrypted +/// attachment written to it, and then the pointer to that buffer is stored here. This buffer +/// must be `free()`d by the caller when done with it *unless* the function returns false, in +/// which case the buffer pointer will not be set. +/// - `outlen` -- pointer in which to store final decrypted data size. Not touched if the function +/// returns false. +/// - `error` -- if non-NULL and decryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// Outputs: +/// - returns true if decryption succeeds and `out` was set to the decrypted data; false if +/// decryption fails (in which case `error` will be written with the error reason, if provided). +LIBSESSION_EXPORT bool session_attachment_decrypt_alloc( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + unsigned char** out, + size_t* outlen, + char* error); + +/// API: crypto/session_attachment_decrypt_file +/// +/// Decrypts an attachment from a file on disk and loads the decrypted content into an in-memory +/// buffer. +/// +/// Inputs: +/// - `file_in` -- C string of input filename +/// - `datalen` -- length of the `data` +/// - `key` -- pointer to the 32-byte binary decryption key +/// - `make_buffer` -- callback to invoke to allocate a buffer of the required size into which to +/// write the decrypted data. It is passed two arguments: the required output buffer size, and +/// `ctx`, and must return a pointer to the beginning of a buffer of the requested size in which +/// to write the encrypted data. Can return NULL to abort encryption (e.g. if the size is too +/// large). Note that some of the buffer may not end up being used, due to padding: the return +/// value of `session_attachment_decrypt_file` indicates the actual amount of decrypted data +/// written to the buffer. +/// - `ctx` - arbitrary pointer (which can be null) to pass to make_buffer to pass user-defined +/// context into the callback. This encrypt function does not otherwise touch the pointer. +/// - `error` -- if non-NULL and decryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// Outputs: +/// - returns the amount of decrypted data written to the buffer, (which can be 0, for an empty +/// encrypted file!). If decryption fails this will be set to `(size_t)-1` to indicate the +/// failure, and `error` will be written with the error reason, if provided. +/// +LIBSESSION_EXPORT size_t session_attachment_decrypt_file( + const char* file_in, + const unsigned char* key, + unsigned char* (*make_buffer)(size_t, void* ctx), + void* ctx, + char* error); + +/// API: crypto/session_attachment_decrypt_to_file +/// +/// Decrypts an attachment from a in-memory buffer writing the decrypted data to a file on disk. +/// +/// Inputs: +/// - `data` -- pointer to the buffer of data to decrypt +/// - `datalen` -- length of the `data` +/// - `key` -- pointer to the 32-byte binary decryption key +/// - `filename` -- C string of output filename to write the decrypted data to. The file will be +/// overwritten if it exists. If a failure occurs during decryption the file will be removed. +/// - `error` -- if non-NULL and decryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// Outputs: +/// - returns true if decryption succeeds; false if decryption fails (in which case `error` will be +/// written with the error reason, if provided). +/// +LIBSESSION_EXPORT bool session_attachment_decrypt_to_file( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + const char* filename, + char* error); + +/// API: crypto/session_attachment_decrypt_file_to_file +/// +/// Decrypts an attachment from an encrypted file on disk to another path on disk. +/// +/// - `file_in` -- C string input filename containing encrypted data. +/// - `key` -- pointer to the 32-byte binary decryption key +/// - `file_out` -- C string of output filename to write the decrypted data to. The file will be +/// overwritten if it exists. If a failure occurs during decryption the file will be removed. +/// - `error` -- if non-NULL and decryption fails then a reason for the error is written here; must +/// be a buffer of at least 256 bytes (or NULL). +/// +/// Outputs: +/// - returns true if decryption succeeds; false if decryption fails (in which case `error` will be +/// written with the error reason, if provided). +LIBSESSION_EXPORT bool session_attachment_decrypt_file_to_file( + const char* file_in, const unsigned char* key, const char* file_out, char* error); + +#ifdef __cplusplus +} +#endif diff --git a/include/session/attachments.hpp b/include/session/attachments.hpp new file mode 100644 index 00000000..3cb568f1 --- /dev/null +++ b/include/session/attachments.hpp @@ -0,0 +1,402 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include "session/sodium_array.hpp" + +namespace session::attachment { + +/// Attachment domain separators, used to differentiate the key/nonce generated for an attachment +/// used for a different purpose. +enum class Domain : uint8_t { + /// Domain for a generic attachment, i.e. a file sent from one user to another: + ATTACHMENT = 0x00, + /// Domain for profile pics: + PROFILE_PIC = 0x01, +}; + +// Size of initial encryption header (== crypto_secretstream_xchacha20poly1305_HEADERBYTES) +constexpr size_t ENCRYPT_HEADER = 24; +// Size of chunks that we encrypt at a time: +constexpr size_t ENCRYPT_CHUNK_SIZE = 32'768; +// The overhead of the mac+tag added to each chunk (== crypto_secretstream_xchacha20poly1305_ABYTES) +constexpr size_t ENCRYPT_CHUNK_OVERHEAD = 17; + +constexpr size_t ENCRYPTED_CHUNK_TOTAL = ENCRYPT_CHUNK_SIZE + ENCRYPT_CHUNK_OVERHEAD; + +// Random encryption key size: (== crypto_secretstream_xchacha20poly1305_KEYBYTES) +constexpr size_t ENCRYPT_KEY_SIZE = 32; + +// The maximum file size that may be encrypted (unless passing the `allow_large` flag). This is the +// maximum size (with a small allowance for padding and request overhead) that can be sent or +// retrieved via oxen-storage-server onion requests, and its padded size is the maximum attachment +// size allowed by the storage server. (Technically this value was chosen as it is the largest +// unencrypted data size that has the same padded+encrypted size as a 10'000'000B file). +constexpr size_t MAX_REGULAR_SIZE = + 10218286; // == 10223616 after stream mac+tag and (1-byte) padding + +// Returns the amount of padding to add to an attachment to obfuscate the true size, given +// crypto_secretstream encryption with a 32kiB chunk size. We determine the padded size as +// follows, given an input size N: +// +// - compute the total raw size M as N plus: +// - 1 for the 'S' prefix (outside the encryption) +// - 17 byte encryption overhead (crypto_secretstream_xchacha20poly1305_ABYTES = poly1305 MAC + +// 1-byte tag) for every 32kiB (or piece thereof) +// - 1 byte for the minimum padding size +// +// We then take keep the most-significant bit of M (i.e. reduce to the largest power of 2 <= M), +// right-shift this by 5, and that round up to the next multiple of that padding factor. +// +// For example, for an input of 1MB (N=100000), we have an unpadded total size of 1000000+1+31*17 = +// 1000528 (i.e. accounting for the 'S' identifier, and the 31 mac+tag values). We then obtain the +// highest power of two <= this (524288 = 2^19), right-shift by 5 to get 16384 (2^14), and then +// round up the total size to the next multiple of that: 1015808. Thus we require an additional +// 15280 padding bytes, and so in total we get: +// +// 1 -- the leading (unencrypted) S +// + 15279 × 0x00 -- leading padding bytes +// + 1 × 0x01 -- final padding byte +// + 1000000 -- encrypted file stream data (ignoring embedded mac+tags) +// + 31 × 17 -- embedded mac+tags after every 32kiB of file stream data +// = 1015808 final output. +// +// (Note that we always including at least one padding byte, and there are some complications in +// the calculation as padding values get large enough to start inducing additional mac+tags; see +// the implementation for details). +size_t encrypted_padding(size_t data_size); + +/// API: crypto/attachment::encrypted_size +/// +/// Returns the exact final encrypted (including any overhead and padding) of an input of +/// `plaintext_size`. +size_t encrypted_size(size_t plaintext_size); + +/// API: crypto/attachment::decrypted_max_size +/// +/// Returns the maximum possible decrypted size of encrypted data of length `encrypted_size`. The +/// actual size can be (and usually is) less than this depending on how much padding is in the data. +/// Returns std::nullopt if the input is too small to be a valid encrypted attachment. +std::optional decrypted_max_size(size_t encrypted_size); + +/// API: crypto/attachment::encrypt +/// +/// Encrypt an attachment for storage on the file server and distribution to other users using +/// deterministic encryption where we use a cryptographically secure hash of the sending user's +/// private key and file content to generate the encryption key/nonce pair. The main advantage of +/// this is deduplication: the same attachment uploaded by the same user will result in the same +/// encrypted content, thus allowing deduplication of identical uploads on the file server. This is +/// particularly important for profile pictures, which are frequently re-uploaded to keep the +/// attachment alive. +/// +/// We currently always encrypt in chunks of (max) 32kiB via libsodium's crypto_secretstream API, +/// and prefix the encrypted data with a 0x53 ('S') to indicate this. Any other value of the first +/// byte is reserved for possible alternative future encryption mechanisms. +/// +/// We prepend padding of at least 1 byte before the actual data, by prepending (PADDING-1) 0x00 +/// bytes followed by a single 0x01 byte to the actual data stream; this data is discard when +/// decrypting. +/// +/// Inputs: +/// - `seed` -- the 32-byte seed of the sender; it is recommended that this be the 32-byte Session +/// seed so that the same Session ID always uses the same base seed, but any 32-byte value can be +/// passed (i.e. it is not strictly required that it be a Session seed). Only the first 32 bytes +/// of longer values will be used (and thus passing the 64-byte seed+pubkey libsodium full secret +/// is equivalent to passing just the seed). +/// +/// - `data` -- the buffer of data to encrypt. +/// +/// - `domain` -- domain separator; uploads of funamentally different types should use a different +/// value, so that an identical upload used for different purposes will have unrelated key/nonce +/// values. +/// +/// - `allow_large` -- defaults to false; if true, this function will accept an input larger value +/// than MAX_REGULAR_SIZE. This option should only be passed when compatibility with onion +/// requests is not needed. +/// +/// Outputs: +/// - Pair of values: the padded+encrypted data, and the decryption key (32 bytes), both in raw +/// bytes. +/// +/// Throws std::invalid_argument if `seed` is shorter than 32 bytes, or if data is larger than +/// MAX_REGULAR_SIZE (unless `allow_large` is true). +/// +std::pair, std::array> encrypt( + std::span seed, + std::span data, + Domain domain, + bool allow_large = false); + +/// API: crypto/attachment::encrypt +/// +/// Similar to the above `encrypt` except that instead of allocating and returning a vector it +/// writes the encrypted result directly into a given output span. The output span *must* be +/// exactly `encrypted_size()` bytes long (but this is checked via assertion in debug builds). +/// +/// Inputs: +/// - `seed` -- as above +/// - `data` -- as above +/// - `domain` -- as above +/// - `out` -- writeable span into which the encrypted data will be written. This span must be +/// exactly `encrypted_size(data.size())` bytes long. +/// - `allow_large` -- as above. +/// +/// Outputs: +/// - 32 byte decryption key +/// +/// Throws std::invalid_argument if `seed` is shorter than 32 bytes, or if data is larger than +/// MAX_REGULAR_SIZE (unless `allow_large` is true). +std::array encrypt( + std::span seed, + std::span data, + Domain domain, + std::span out, + bool allow_large = false); + +/// API: crypto/attachment::encrypt +/// +/// Encrypts the contents of a file on disk into a buffer. This requires reading the file twice +/// (once in order to generate the deterministic encryption key and nonce, and then a second time +/// for the actual encryption), but does not require holding the file contents in memory. +/// +/// Inputs: +/// - `seed`, `domain`, `allow_large` -- see above. +/// - `file` -- path to the file to encrypt. +/// +/// Outputs: +/// - Pair of values: the padded+encrypted data, and the decryption key (32 bytes), both in raw +/// bytes. +/// +/// Throws std::invalid_argument if `seed` is shorter than 32 bytes, or if the file is larger than +/// MAX_REGULAR_SIZE. +std::pair, std::array> encrypt( + std::span seed, + const std::filesystem::path& file, + Domain domain, + bool allow_large = false); + +/// API: crypto/attachment::encrypt +/// +/// Encrypts the contents of a file on disk into a buffer. This method is a more general version of +/// the above that allows allocation of the encrypted buffer via a callback once the size is +/// determined from the file. +/// +/// Inputs: +/// - `seed`, `domain`, `allow_large` -- see above. +/// - `file` -- path to the file to encrypt. +/// - `make_buffer` -- callback that is invoked with the exact required encrypted size for the file +/// that must return a byte span of that exact file where the encrypted data will be written. +/// +/// Outputs: +/// - The 32-byte decryption key, in raw bytes. +/// +/// Throws std::invalid_argument if `seed` is shorter than 32 bytes, or if the file is larger than +/// MAX_REGULAR_SIZE. +/// Throws std::runtime_error if the file size changes between first and second passes. +std::array encrypt( + std::span seed, + const std::filesystem::path& file, + Domain domain, + std::function(size_t enc_size)> make_buffer, + bool allow_large = false); + +/// API: crypto/attachment::encrypt +/// +/// Encrypts the contents of a plaintext buffer, writing the encrypted data to a file. The file +/// will be overwritten. +/// +/// Inputs: +/// - `seed`, `domain`, `allow_large` -- see above. +/// - `data` -- the buffer of data to encrypt. +/// - `file` -- path to the file to write to. +/// +/// Outputs: +/// - The 32-byte decryption key, in raw bytes. +/// +/// Throws std::invalid_argument if `seed` is shorter than 32 bytes, or if data is larger than +/// MAX_REGULAR_SIZE (unless `allow_large` is given). Throws on I/O error. If decryption fails +/// then any partially written output file will be removed. +std::array encrypt( + std::span seed, + std::span data, + Domain domain, + const std::filesystem::path& file, + bool allow_large = false); + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an attachment allegedly produced by attachment::encrypt to an in-memory byte vector. +/// +/// Inputs: +/// - `data` -- in-memory buffer of data to decrypt. +/// - `key` -- the 32-byte decryption key +/// +/// Outputs: +/// - std::vector of decrypted, de-padded data. +/// +/// Throws std::runtime_error if decryption fails. +std::vector decrypt( + std::span encrypted, std::span key); + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an attachment allegedly produced by attachment::encrypt to a single in-memory, +/// caller-provided buffer. This version writes into a given output span rather than allocating a +/// new vector. +/// +/// Inputs: +/// - `data` -- in-memory buffer of data to decrypt. +/// - `key` -- the 32-byte decryption key +/// - `out` -- writeable output span in which the decrypted value should be written. The given span +/// must be at least `decrypted_max_size(data.size())` bytes large. +/// +/// Outputs: +/// - size_t -- the actual decrypted data size written into `out` which could be (and often is, due +/// to padding) less than `out.size()`. +/// +/// Throws std::runtime_error if decryption fails. +size_t decrypt( + std::span encrypted, + std::span key, + std::span out); + +/// API: crypto/attachment::Decryptor +/// +/// Object-based interfaced to streaming decryption. The basic usage is to construct the object +/// with an output callback, then repeatedly feed it any amount of additional data via `update()` +/// until all data has been provided. Calls to `update()` will invoke the output callback as soon +/// as enough data has been provided to advance to the next stream chunk(s), and so one call to +/// update() could result in any number of calls to output(), including 0. Once all data has been +/// provided, `finalize()` is called to signal the end of the input data. +/// +/// If a problem with the data is found, the `update()` or `finalize()` call will returns false +/// indicating that the decryption failed, and any partially decrypted output data provided to the +/// output callback should be discarded or deleted. Further calls to `update()` or `finalize()` +/// after such a failure will simply return false without processing any additional data. +/// +/// This class is not recommended if the intention is to build an in-memory buffer from existing +/// in-memory data: `decrypt()` will be more memory efficient in that case. +class Decryptor { + std::function decrypted)> output; + std::vector buf; + bool header = false; + bool depadded = false; + bool failed = false; + bool finished = false; + bool hit_final = false; + cleared_uc32 key; + unsigned char st_data[52]; // crypto_secretstream_xchacha20poly1305_state data + + void process_header(std::span chunk); + void process_chunk(std::span chunk, bool is_final = false); + + public: + /// Constructs a decryptor. The given output will be called as soon as enough data has been + /// accumulated to validate additional decrypted data. + Decryptor( + std::span key, + std::function decrypted)> output); + + /// Provides more data to the decryptor. If the additional data completes an input data chunk + /// then output will be called with the partially decrypted data. Returns true if the data was + /// accepted, false if data stream decryption failed (either because of the new data, or some + /// previous failure). + /// + /// Throws std::logic_error if called after a successful finalize(). + bool update(std::span enc_data); + + /// Called to signal the end of the encrypted data stream. If all data was processed + /// successfully and the stream ended properly, this returns true; returns false if the stream + /// data did not indicate finality (or if a previous update returned failure). + /// + /// Throws std::logic_error if called after a successful finalize(). + bool finalize(); +}; + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an attachment allegedly produced by attachment::encrypt to an output file. Overwrites +/// the file if it already exists. +/// +/// Inputs: +/// - `data` -- in-memory buffer of data to decrypt. +/// - `key` -- the 32-byte decryption key. +/// - `filename` -- where to write the output file. +/// +/// Outputs: None. +/// +/// Throws std::runtime_error if decryption fails or if writing to the file fails. Upon exception a +/// partially written file will be deleted. +void decrypt( + std::span encrypted, + std::span key, + const std::filesystem::path& filename); + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an encrypted attachment stored in an input file into a byte vector. +/// +/// Inputs: +/// - `filename` -- path to encrypted file. +/// - `key` -- the 32-byte decryption key. +/// +/// Outputs: +/// - vector of decrypted content. +/// +/// Throws std::runtime_error if decryption fails; can throw I/O exceptions if reading the file +/// fails. +std::vector decrypt( + const std::filesystem::path& encrypted_file, + std::span key); + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an encrypted attachment stored in an input file into a provided memory buffer. +/// +/// Inputs: +/// - `filename` -- path to encrypted file. +/// - `key` -- the 32-byte decryption key. +/// - `make_buffer` -- callback that is invoked to allocate the buffer into which the content should +/// be written. This is passed the required buffer size. Note that this buffer may not be +/// completely filled: the return value of `decrypt()` indicates the actual amount of the buffer +/// that was written. +/// +/// Outputs: +/// - size_t the actual decrypted size. Can be less than the value passed to `decrypted.size()` +/// because of padding. +/// +/// Throws std::runtime_error if decryption fails; can throw I/O exceptions if reading the file +/// fails. +size_t decrypt( + const std::filesystem::path& encrypted_file, + std::span key, + std::function(size_t dec_size)> make_buffer); + +/// API: crypto/attachment::decrypt +/// +/// Decrypts an attachment allegedly produced by attachment::encrypt stored in a file to another +/// output file. Overwrites the destination file if it already exists. +/// +/// Unlike the various decrypt functions above, this version does not need to hold more than a few +/// kB of the input/output file in memory at a time, regardless of the size of the input or output +/// files. +/// +/// Inputs: +/// - `file_in` -- filename containing the data to decrypt. +/// - `key` -- the 32-byte decryption key. +/// - `file_out` -- where to write the output file. +/// +/// Outputs: None. +/// +/// Throws std::runtime_error if decryption fails or if writing to the file fails. Upon exception a +/// partially written file will be deleted. +void decrypt( + const std::filesystem::path& file_in, + std::span key, + const std::filesystem::path& file_out); + +} // namespace session::attachment diff --git a/include/session/util.hpp b/include/session/util.hpp index fc57908f..8ff560a4 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -21,9 +21,13 @@ namespace session { using namespace oxenc; // Helper functions to convert to/from spans -template -inline std::span as_span(const std::span& sp) { - return {reinterpret_cast(sp.data()), sp.size()}; +template +inline std::span as_span(std::span sp) { + return std::span{reinterpret_cast(sp.data()), sp.size()}; +} +template +inline std::span as_span(std::span sp) { + return std::span{reinterpret_cast(sp.data()), sp.size()}; } template diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1e4ad566..7b4e66b7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,6 +43,7 @@ add_libsession_util_library(util ) add_libsession_util_library(crypto + attachments.cpp blinding.cpp curve25519.cpp ed25519.cpp diff --git a/src/attachments.cpp b/src/attachments.cpp new file mode 100644 index 00000000..ef437419 --- /dev/null +++ b/src/attachments.cpp @@ -0,0 +1,1082 @@ +#include "session/attachments.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "internal-util.hpp" + +namespace session::attachment { + +using namespace oxen::log::literals; + +static_assert(ENCRYPT_HEADER == crypto_secretstream_xchacha20poly1305_HEADERBYTES); +static_assert(ENCRYPT_CHUNK_OVERHEAD == crypto_secretstream_xchacha20poly1305_ABYTES); +static_assert(ENCRYPT_KEY_SIZE == crypto_secretstream_xchacha20poly1305_KEYBYTES); + +size_t encrypted_padding(size_t data_size) { + constexpr size_t prefix_size = 1 + ENCRYPT_HEADER; + constexpr size_t min_padding = 1; + + // the number of mac+tag values embedded every 32kiB in the data stream: + const size_t stream_chunks = (data_size + ENCRYPT_CHUNK_SIZE - 1) / ENCRYPT_CHUNK_SIZE; + + const size_t enc_size = + data_size + prefix_size + min_padding + stream_chunks * ENCRYPT_CHUNK_OVERHEAD; + + const size_t pad_factor = std::bit_floor(std::max(enc_size, 131072)) >> 5; + + // Round up to next multiple of pad_factor: + const size_t padded_size = (enc_size + pad_factor - 1) / pad_factor * pad_factor; + + size_t padding = padded_size - enc_size + min_padding; + + // For every complete ENCRYPT_CHUNK_SIZE padding that we add we implicitly also add a stream + // tag, and so we want to subtract one tag per (ENCRYPT_CHUNK_SIZE+ENCRYPT_CHUNK_OVERHEAD) bytes + // of padding to compensate (so that the added tag gets counted as an implicit part of the + // padding): + size_t implicit_padding = 0; + if (padding >= ENCRYPTED_CHUNK_TOTAL) + implicit_padding = (padding / ENCRYPTED_CHUNK_TOTAL) * ENCRYPT_CHUNK_OVERHEAD; + + // After accounting for the full stream + tag blocks above, we might still have enough to spill + // over the stream into the next chunk, and so if that is going to happen, we want to count the + // implied additional tag as part of the padding as well. + + // This is how much padding we can add without spilling over into a new stream chunk: + const size_t free_padding = stream_chunks * ENCRYPT_CHUNK_SIZE - data_size; + + if (padding % ENCRYPTED_CHUNK_TOTAL > free_padding) + implicit_padding += ENCRYPT_CHUNK_OVERHEAD; + + padding -= implicit_padding; + + return padding; +} + +size_t encrypted_size(size_t plaintext_size) { + size_t padding = encrypted_padding(plaintext_size); + size_t padded_size = plaintext_size + padding; + size_t tags_size = + (padded_size + ENCRYPT_CHUNK_SIZE - 1) / ENCRYPT_CHUNK_SIZE * ENCRYPT_CHUNK_OVERHEAD; + + return 1 /* 'S' identifier */ + ENCRYPT_HEADER + plaintext_size + padding + tags_size; +} + +std::optional decrypted_max_size(size_t encrypted_size) { + size_t sz = encrypted_size - 1 /* 'S' identifier */ - 1 /* minimum padding length */ - + ENCRYPT_HEADER; + + // The data is chunked into 32kiB+17B chunks (32kiB of data + 17 bytes of per-chunk stream + // tag+mac), so we can figure out how many 17 byte overhead values should be present: + size_t overhead = + (sz + ENCRYPTED_CHUNK_TOTAL - 1) / ENCRYPTED_CHUNK_TOTAL * ENCRYPT_CHUNK_OVERHEAD; + + sz -= overhead; + + if (sz > encrypted_size) // Overflow + return std::nullopt; + return sz; +} + +// We have to roll our own custom version of crypto_secretstream_xchacha20poly1305_init_push here +// because libsodium offers no way to provide the randomness it uses (it hard codes a call to +// randombytes_buf), and so this repeats its internal implementation but using our hashed data for +// the randomness. +static crypto_secretstream_xchacha20poly1305_state +secretstream_xchacha20poly1305_init_push_with_nonce( + std::span header, + std::span key, + std::span nonce) { + + crypto_secretstream_xchacha20poly1305_state st; + + std::memcpy(header.data(), nonce.data(), ENCRYPT_HEADER); + crypto_core_hchacha20( + st.k, header.data(), reinterpret_cast(key.data()), nullptr); + static_assert(sizeof(st) == 52); + std::memset(st.nonce, 0, 4 /*crypto_secretstream_xchacha20poly1305_COUNTERBYTES*/); + st.nonce[0] = 1; + std::memcpy( + st.nonce + 4 /*crypto_secretstream_xchacha20poly1305_COUNTERBYTES*/, + header.data() + crypto_core_hchacha20_INPUTBYTES, + 8 /*crypto_secretstream_xchacha20poly1305_INONCEBYTES*/); + std::memset(st._pad, 0, sizeof(st._pad)); + + return st; +} + +// Encryption implementation function. `get_chunk(N)` returns a pair of [span, bool] of the next N (max ENCRYPT_CHUNK_SIZE) bytes, less than N only at the end of the +// input, where the bool is true if there is at least 1 byte more of data to be retrieved (i.e. +// false means the end of the data). It may not return an empty chunk except for the very first +// call. +template ReadData> +static void encrypt_impl( + std::span out, + size_t data_size, + std::span nonce_key, + ReadData get_chunk) { + size_t padding = encrypted_padding(data_size); + assert(padding >= 1); + size_t padded_size = data_size + padding; + + assert(out.size() == encrypted_size(data_size)); + out[0] = std::byte{'S'}; + + std::span uout{reinterpret_cast(out.data()), out.size()}; + + std::span header{uout.data() + 1, ENCRYPT_HEADER}; + + auto st = secretstream_xchacha20poly1305_init_push_with_nonce( + header, nonce_key.last(), nonce_key.first()); + + auto* outpos = uout.data() + 1 + ENCRYPT_HEADER; + auto* const outend = uout.data() + uout.size(); + + // Now we build a buffer containing padding, plus whatever initial actual data goes on the end + // of the last chunk of padding: + bool done = false; + { + std::vector buf; + buf.reserve(std::min(ENCRYPT_CHUNK_SIZE, padded_size)); + for (size_t padding_remaining = padding; padding_remaining;) { + unsigned char tag = 0; + if (padding_remaining > ENCRYPT_CHUNK_SIZE) { + // Full chunk of 0x00 padding (with more padding in the next chunk) + buf.resize(ENCRYPT_CHUNK_SIZE); + padding_remaining -= ENCRYPT_CHUNK_SIZE; + } else { + buf.resize(padding_remaining - 1); // 0x00 padding + buf.push_back(0x01); // padding terminator + auto [chunk, more] = get_chunk(ENCRYPT_CHUNK_SIZE - padding_remaining); + assert(chunk.size() == ENCRYPT_CHUNK_SIZE - padding_remaining || !more); + if (!chunk.empty()) + buf.insert(buf.end(), chunk.begin(), chunk.end()); + padding_remaining = 0; + if (!more) { + tag = crypto_secretstream_xchacha20poly1305_TAG_FINAL; + done = true; + } + } + + assert(outpos + buf.size() + crypto_secretstream_xchacha20poly1305_ABYTES <= outend); + + unsigned long long out_len; + crypto_secretstream_xchacha20poly1305_push( + &st, outpos, &out_len, buf.data(), buf.size(), nullptr, 0, tag); + assert(out_len == buf.size() + crypto_secretstream_xchacha20poly1305_ABYTES); + outpos += out_len; + } + } + + // Now we're through the initial padding (and probably some initial data): now all we need to do + // is push the rest of the data + + while (!done) { + auto [chunk, more] = get_chunk(ENCRYPT_CHUNK_SIZE); + assert(!chunk.empty()); + assert(chunk.size() == ENCRYPT_CHUNK_SIZE || !more); + assert(outpos + chunk.size() + crypto_secretstream_xchacha20poly1305_ABYTES <= outend); + + unsigned char tag = more ? 0 : crypto_secretstream_xchacha20poly1305_TAG_FINAL; + + unsigned long long out_len; + crypto_secretstream_xchacha20poly1305_push( + &st, outpos, &out_len, chunk.data(), chunk.size(), nullptr, 0, tag); + assert(out_len == chunk.size() + crypto_secretstream_xchacha20poly1305_ABYTES); + outpos += out_len; + if (!more) + done = true; + } +} + +static std::tuple< + std::array, + std::array, + const unsigned char*, + const unsigned char*> +encrypt_buffer_init( + std::span seed, + std::span data, + Domain domain, + bool allow_large) { + std::tuple< + std::array, + std::array, + const unsigned char*, + const unsigned char*> + result; + auto& [nonce_key, key, inpos, inend] = result; + + if (seed.size() < 32) + throw std::invalid_argument{"attachment::encrypt requires a 32-byte uploader seed"}; + + if (data.size() > MAX_REGULAR_SIZE && !allow_large) + throw std::invalid_argument{"data to encrypt is too large"}; + + std::span udata{ + reinterpret_cast(data.data()), data.size()}; + + crypto_generichash_blake2b_state b_st; + const auto domain_byte = static_cast(domain); + crypto_generichash_blake2b_init(&b_st, &domain_byte, 1, nonce_key.size()); + crypto_generichash_blake2b_update( + &b_st, reinterpret_cast(seed.data()), 32); + crypto_generichash_blake2b_update(&b_st, udata.data(), udata.size()); + crypto_generichash_blake2b_final(&b_st, nonce_key.data(), nonce_key.size()); + std::memcpy(key.data(), nonce_key.data() + ENCRYPT_HEADER, ENCRYPT_KEY_SIZE); + + inpos = udata.data(); + inend = inpos + udata.size(); + + return result; +} + +std::array encrypt( + std::span seed, + std::span data, + Domain domain, + std::span out, + bool allow_large) { + + auto [nonce_key, key, inpos, inend] = encrypt_buffer_init(seed, data, domain, allow_large); + + encrypt_impl( + out, + data.size(), + nonce_key, + [&inpos, &inend](size_t size) -> std::pair, bool> { + auto* start = inpos; + auto* end = std::min(inpos + size, inend); + inpos = end; + return {{start, end}, inpos != inend}; + }); + + return key; +} + +std::pair, std::array> encrypt( + std::span seed, + std::span data, + Domain domain, + bool allow_large) { + + if (seed.size() < 32) + throw std::invalid_argument{"attachment::encrypt requires a 32-byte uploader seed"}; + + if (data.size() > MAX_REGULAR_SIZE && !allow_large) + throw std::invalid_argument{"data to encrypt is too large"}; + + std::pair, std::array> result; + auto& [out, key] = result; + + out.resize(encrypted_size(data.size())); + + key = encrypt(seed, data, domain, out, allow_large); + + return result; +} + +std::array encrypt( + std::span seed, + const std::filesystem::path& file, + Domain domain, + std::function(size_t enc_size)> make_buffer, + bool allow_large) { + + std::ifstream in; + in.exceptions(std::ios::badbit); + in.open(file, std::ios::binary | std::ios::ate); + size_t size = in.tellg(); + in.seekg(0, std::ios::beg); + + size = encrypted_size(size); + + std::array nonce_key; + + crypto_generichash_blake2b_state b_st; + const auto domain_byte = static_cast(domain); + crypto_generichash_blake2b_init(&b_st, &domain_byte, 1, nonce_key.size()); + crypto_generichash_blake2b_update( + &b_st, reinterpret_cast(seed.data()), 32); + + size_t in_size = 0; + std::array chunk; + while (in.read(reinterpret_cast(chunk.data()), chunk.size())) { + crypto_generichash_blake2b_update( + &b_st, reinterpret_cast(chunk.data()), chunk.size()); + in_size += chunk.size(); + } + if (in.gcount() > 0) { + crypto_generichash_blake2b_update( + &b_st, reinterpret_cast(chunk.data()), in.gcount()); + in_size += in.gcount(); + } + + crypto_generichash_blake2b_final(&b_st, nonce_key.data(), nonce_key.size()); + + std::array key; + std::memcpy(key.data(), nonce_key.data() + ENCRYPT_HEADER, ENCRYPT_KEY_SIZE); + + in.clear(); + in.exceptions(std::ios::badbit | std::ios::failbit); + in.seekg(0, std::ios::beg); + + auto encrypted = make_buffer(size); + if (encrypted.size() != size) + throw std::logic_error{ + "make_buffer returned span of invalid size: expected {}, got {}"_format( + size, encrypted.size())}; + + std::array buf; + encrypt_impl( + encrypted, + in_size, + nonce_key, + [&in, &in_size, &buf](size_t size) -> std::pair, bool> { + size_t consumed = in.tellg(); + if (consumed + size > in_size) + size = in_size - consumed; + + if (size > 0) + in.read(reinterpret_cast(buf.data()), size); + + in.peek(); + return {std::span{buf}.first(size), !in.eof()}; + }); + + return key; +} + +std::pair, std::array> encrypt( + std::span seed, + const std::filesystem::path& file, + Domain domain, + bool allow_large) { + + std::pair, std::array> result; + auto& [encrypted, key] = result; + + key = encrypt( + seed, + file, + domain, + [&encrypted](size_t enc_size) { + encrypted.resize(enc_size); + return std::span{encrypted}; + }, + allow_large); + + return result; +} + +std::array encrypt( + std::span seed, + std::span data, + Domain domain, + const std::filesystem::path& file, + bool allow_large) { + + auto [nonce_key, key, inpos, inend] = encrypt_buffer_init(seed, data, domain, allow_large); + + size_t padding = encrypted_padding(data.size()); + assert(padding >= 1); + size_t padded_size = data.size() + padding; + + try { + std::ofstream out; + out.exceptions(std::ios::failbit | std::ios::badbit); + out.open(file, std::ios::binary | std::ios::trunc); + out.write("S", 1); + + std::array cbuf; + std::span ubuf{reinterpret_cast(cbuf.data()), cbuf.size()}; + + auto st = secretstream_xchacha20poly1305_init_push_with_nonce( + ubuf.first(), + std::span{nonce_key}.last(), + std::span{nonce_key}.first()); + + out.write(cbuf.data(), ENCRYPT_HEADER); + + // Now we build a buffer containing padding, plus whatever initial actual data goes on the + // end of the last chunk of padding, and write those encrypted padding chunks to the file: + { + std::vector buf; + buf.reserve(std::min(ENCRYPT_CHUNK_SIZE, padded_size)); + for (size_t padding_remaining = padding; padding_remaining;) { + if (padding_remaining > ENCRYPT_CHUNK_SIZE) { + // Full chunk of 0x00 padding (with more padding in the next chunk) + buf.resize(ENCRYPT_CHUNK_SIZE); + padding_remaining -= ENCRYPT_CHUNK_SIZE; + } else { + buf.resize(padding_remaining - 1); // 0x00 padding + buf.push_back(0x01); // padding terminator + if (size_t first_data = + std::min(ENCRYPT_CHUNK_SIZE - padding_remaining, data.size())) { + buf.insert(buf.end(), inpos, inpos + first_data); + inpos += first_data; + } + padding_remaining = 0; + } + + unsigned char tag = + inpos < inend ? 0 : crypto_secretstream_xchacha20poly1305_TAG_FINAL; + + unsigned long long out_len; + crypto_secretstream_xchacha20poly1305_push( + &st, ubuf.data(), &out_len, buf.data(), buf.size(), nullptr, 0, tag); + assert(out_len == buf.size() + crypto_secretstream_xchacha20poly1305_ABYTES); + out.write(cbuf.data(), out_len); + } + } + + // Now we're through the initial padding (and probably some initial data): now all we need + // to do is write the rest of the data in chunks + while (inpos < inend) { + auto* chunk_start = inpos; + inpos = std::min(chunk_start + ENCRYPT_CHUNK_SIZE, inend); + unsigned char tag = inpos < inend ? 0 : crypto_secretstream_xchacha20poly1305_TAG_FINAL; + + unsigned long long out_len; + crypto_secretstream_xchacha20poly1305_push( + &st, ubuf.data(), &out_len, chunk_start, inpos - chunk_start, nullptr, 0, tag); + assert(out_len == inpos - chunk_start + crypto_secretstream_xchacha20poly1305_ABYTES); + + out.write(cbuf.data(), out_len); + } + } catch (const std::exception& e) { + std::error_code ec; + std::filesystem::remove(file, ec); + throw; + } + + return key; +} + +size_t decrypt( + std::span encrypted, + std::span key, + std::span out) { + + auto max_size = decrypted_max_size(encrypted.size()); + if (!max_size) + throw std::runtime_error{"Attachment decryption failed: encrypted data too short"}; + + if (encrypted.front() != std::byte{'S'}) + throw std::runtime_error{ + "Attachment decryption failed: unknown encryption type 0x{:02x}; expected 0x53 (S)"_format( + +static_cast(encrypted.front()))}; + + if (out.size() < *max_size) + throw std::logic_error{ + "Attachment decryption failed: output buffer too small to decrypt contents"}; + + std::span uenc{ + reinterpret_cast(encrypted.data()), encrypted.size()}; + + crypto_secretstream_xchacha20poly1305_state st; + crypto_secretstream_xchacha20poly1305_init_pull( + &st, uenc.data() + 1, reinterpret_cast(key.data())); + + auto* inpos = uenc.data() + 1 + ENCRYPT_HEADER; + auto* const inend = uenc.data() + uenc.size(); + + std::byte* decrypted = out.data(); + bool done = false; + + // Discard any leading padding chunks (of which there is *always* at least 1 because we always + // have at least one byte of padding, even for an empty file). The last such chunk will + // typically have the beginning of the actual data. Once we figure out how much padding there + // is we can calculate the remaining data and reserve the output buffer. + { + std::vector padbuf; + padbuf.reserve(std::min(inend - inpos - ENCRYPT_CHUNK_OVERHEAD, ENCRYPT_CHUNK_SIZE)); + do { + if (inpos + ENCRYPT_CHUNK_OVERHEAD >= inend) + throw std::runtime_error{ + "Attachment decryption failed: data ended in the middle of padding"}; + + size_t chunk_size = + std::min(inend - inpos - ENCRYPT_CHUNK_OVERHEAD, ENCRYPT_CHUNK_SIZE); + padbuf.resize(chunk_size); + + unsigned char tag; + if (crypto_secretstream_xchacha20poly1305_pull( + &st, + reinterpret_cast(padbuf.data()), + nullptr, + &tag, + inpos, + chunk_size + ENCRYPT_CHUNK_OVERHEAD, + nullptr, + 0) != 0) + throw std::runtime_error{ + "Attachment decryption failed: invalid key or corrupted data"}; + + inpos += chunk_size + ENCRYPT_CHUNK_OVERHEAD; + + auto padend = std::find_if_not(padbuf.begin(), padbuf.end(), [](const std::byte c) { + return c == std::byte{0x00}; + }); + if (padend != padbuf.end()) { + if (*padend != std::byte{0x01}) + throw std::runtime_error{"Attachment decryption failed: invalid padding"}; + ++padend; + + std::span init_data{padend, padbuf.end()}; + // We've identified the start of the data: assuming it is valid, the remaining of + // the encrypted data consists of N chunks of + // (ENCRYPT_CHUNK_SIZE+ENCRYPT_CHUNK_OVERHEAD) full data chunks plus one final + // (chunk+ENCRYPT_CHUNK_OVERHEAD). + size_t final_size = init_data.size() + (inend - inpos) - + (inend - inpos + ENCRYPTED_CHUNK_TOTAL - 1) / + ENCRYPTED_CHUNK_TOTAL * ENCRYPT_CHUNK_OVERHEAD; + assert(out.size() >= final_size); + decrypted = std::copy(padend, padbuf.end(), decrypted); + + if (tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) { + if (inpos != inend) + throw std::runtime_error{ + "Attachment decryption failed: FINAL tag before end of the " + "encrypted data"}; + done = true; + } else if ( + inpos == inend && tag != crypto_secretstream_xchacha20poly1305_TAG_FINAL) { + throw std::runtime_error{ + "Attachment decryption failed: end of data without FINAL tag"}; + } + + break; + } + } while (true); + } + + while (!done) { + if (inpos + ENCRYPT_CHUNK_OVERHEAD >= inend) + throw std::runtime_error{ + "Attachment decryption failed: data ended before end of stream"}; + + size_t chunk_size = std::min(inend - inpos - ENCRYPT_CHUNK_OVERHEAD, ENCRYPT_CHUNK_SIZE); + assert(decrypted + chunk_size <= out.data() + out.size()); + + unsigned char tag; + if (crypto_secretstream_xchacha20poly1305_pull( + &st, + reinterpret_cast(decrypted), + nullptr, + &tag, + inpos, + chunk_size + ENCRYPT_CHUNK_OVERHEAD, + nullptr, + 0) != 0) + throw std::runtime_error{"Attachment decryption failed: invalid key or corrupted data"}; + + decrypted += chunk_size; + inpos += chunk_size + ENCRYPT_CHUNK_OVERHEAD; + + if (tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) { + if (inpos != inend) + throw std::runtime_error{ + "Attachment decryption failed: FINAL tag before end of the " + "encrypted data"}; + done = true; + } else if (inpos == inend && tag != crypto_secretstream_xchacha20poly1305_TAG_FINAL) { + throw std::runtime_error{"Attachment decryption failed: end of data without FINAL tag"}; + } + } + + return decrypted - out.data(); +} + +std::vector decrypt( + std::span encrypted, std::span key) { + + auto max_size = decrypted_max_size(encrypted.size()); + if (!max_size) + throw std::runtime_error{"Attachment decryption failed: encrypted data too short"}; + + if (encrypted.front() != std::byte{'S'}) + throw std::runtime_error{ + "Attachment decryption failed: unknown encryption type 0x{:02x}; expected 0x53 (S)"_format( + +static_cast(encrypted.front()))}; + + std::vector result; + result.resize(*max_size); + + size_t actual = decrypt(encrypted, key, result); + result.resize(actual); + + return result; +} + +Decryptor::Decryptor( + std::span key_, + std::function decrypted)> output_) : + output{std::move(output_)} { + + std::memcpy(key.data(), key_.data(), key.size()); + + static_assert( + sizeof(crypto_secretstream_xchacha20poly1305_state) == sizeof(Decryptor::st_data)); + static_assert(alignof(crypto_secretstream_xchacha20poly1305_state) == 1); + static_assert(std::is_trivially_copyable_v); +} + +static crypto_secretstream_xchacha20poly1305_state* st(unsigned char* st_data) { + return reinterpret_cast(st_data); +} +void Decryptor::process_header(std::span hdr) { + assert(!header); + + if (hdr[0] != std::byte{'S'}) { + failed = true; + return; + } + + crypto_secretstream_xchacha20poly1305_init_pull( + st(st_data), + reinterpret_cast(hdr.data() + 1), + reinterpret_cast(key.data())); + header = true; +} + +void Decryptor::process_chunk(std::span chunk, bool is_final) { + if (hit_final) { + failed = true; + return; + } + assert(is_final || chunk.size() == ENCRYPTED_CHUNK_TOTAL); + assert(chunk.size() <= ENCRYPTED_CHUNK_TOTAL); + if (chunk.size() < ENCRYPT_CHUNK_OVERHEAD) { + failed = true; + return; + } + + unsigned char tag; + std::array outa; + std::span out{outa.data(), chunk.size() - ENCRYPT_CHUNK_OVERHEAD}; + if (crypto_secretstream_xchacha20poly1305_pull( + st(st_data), + reinterpret_cast(out.data()), + nullptr, + &tag, + reinterpret_cast(chunk.data()), + chunk.size(), + nullptr, + 0) != 0) { + failed = true; + return; + } + + if (tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) + hit_final = true; + + if (!depadded) { + auto padend = std::find_if_not( + out.begin(), out.end(), [](const std::byte c) { return c == std::byte{0x00}; }); + if (padend != out.end()) { + if (*padend != std::byte{0x01}) { + failed = true; + return; + } + depadded = true; + if (++padend != out.end()) + output(std::span{padend, out.end()}); + } + return; + } + + output(out); +} + +bool Decryptor::update(std::span enc_data) { + if (failed) + return false; + if (finished) + throw std::logic_error{"cannot call update after finalize()"}; + + if (!buf.empty()) { + auto buf_steal = [this, &enc_data](size_t target_buf_size) { + assert(buf.size() < target_buf_size); + size_t steal = std::min(target_buf_size - buf.size(), enc_data.size()); + buf.insert(buf.end(), enc_data.begin(), enc_data.begin() + steal); + enc_data = enc_data.subspan(steal); + assert(buf.size() <= target_buf_size); + return buf.size() == target_buf_size; + }; + + if (!header) { + if (!buf_steal(1 + ENCRYPT_HEADER)) + return true; + process_header(std::span{buf}.first<1 + ENCRYPT_HEADER>()); + } else { + if (!buf_steal(ENCRYPTED_CHUNK_TOTAL)) + return true; + process_chunk(std::span{buf}.first()); + } + buf.clear(); + if (failed) + return false; + } + + if (!header) { + if (enc_data.size() >= 1 + ENCRYPT_HEADER) { + process_header(enc_data.first<1 + ENCRYPT_HEADER>()); + if (failed) + return false; + enc_data = enc_data.subspan(1 + ENCRYPT_HEADER); + } else { + buf.assign(enc_data.begin(), enc_data.end()); + return true; + } + } + + while (enc_data.size() >= ENCRYPTED_CHUNK_TOTAL) { + process_chunk(enc_data.first()); + if (failed) + return false; + enc_data = enc_data.subspan(ENCRYPTED_CHUNK_TOTAL); + } + + if (!enc_data.empty()) + buf.assign(enc_data.begin(), enc_data.end()); + + return true; +} + +bool Decryptor::finalize() { + if (failed) + return false; + + if (!buf.empty()) { + process_chunk(buf, true); + buf.clear(); + } + + if (failed) + return false; + + if (!hit_final) { + failed = true; + return false; + } + + return true; +} + +void decrypt( + std::span encrypted, + std::span key, + const std::filesystem::path& filename) { + + try { + std::ofstream out; + out.exceptions(std::ios::failbit | std::ios::badbit); + out.open(filename, std::ios::binary | std::ios::out | std::ios::trunc); + Decryptor d{key, [&out](std::span data) { + out.write(reinterpret_cast(data.data()), data.size()); + }}; + + d.update(encrypted); + d.finalize(); + } catch (const std::exception& e) { + std::error_code ec; + std::filesystem::remove(filename, ec); + throw; + } +} + +size_t decrypt( + const std::filesystem::path& encrypted_file, + std::span key, + std::function(size_t dec_size)> make_buffer) { + + std::ifstream in; + in.exceptions(std::ios::badbit); + in.open(encrypted_file, std::ios::binary | std::ios::ate); + size_t size = in.tellg(); + in.seekg(0, std::ios::beg); + + auto max_size = decrypted_max_size(size); + if (!max_size) + throw std::runtime_error{ + "Decryption failed: file is too small to contain an encrypted attachment"}; + auto out = make_buffer(*max_size); + if (out.size() != *max_size) + throw std::logic_error{ + "make_buffer returned span of invalid size: expected {}, got {}"_format( + *max_size, out.size())}; + + auto decrypted = out.begin(); + auto end = out.end(); + Decryptor d{key, [&decrypted, &end](std::span data) { + if (data.size() > end - decrypted) + throw std::runtime_error{ + "Decryption failed: output span is too small to contain decrypted " + "data"}; + decrypted = std::copy(data.begin(), data.end(), decrypted); + }}; + + std::array chunk; + while (in.read(reinterpret_cast(chunk.data()), chunk.size())) + d.update(chunk); + if (in.gcount() > 0) + d.update(std::span{chunk}.first(in.gcount())); + + d.finalize(); + + return decrypted - out.begin(); +} + +std::vector decrypt( + const std::filesystem::path& encrypted_file, + std::span key) { + + std::vector plaintext; + size_t actual = decrypt(encrypted_file, key, [&plaintext](size_t size) { + plaintext.resize(size); + return std::span{plaintext}; + }); + plaintext.resize(actual); + return plaintext; +} + +void decrypt( + const std::filesystem::path& file_in, + std::span key, + const std::filesystem::path& file_out) { + + try { + std::ifstream in; + in.exceptions(std::ios::badbit); + in.open(file_in, std::ios::binary | std::ios::ate); + size_t size = in.tellg(); + in.seekg(0, std::ios::beg); + + auto max_size = decrypted_max_size(size); + if (!max_size) + throw std::runtime_error{ + "Decryption failed: file is too small to contain an encrypted attachment"}; + + std::ofstream out; + out.exceptions(std::ios::failbit | std::ios::badbit); + out.open(file_out, std::ios::binary | std::ios::trunc); + + Decryptor d{key, [&out](std::span data) { + out.write(reinterpret_cast(data.data()), data.size()); + }}; + + std::array chunk; + while (in.read(reinterpret_cast(chunk.data()), chunk.size())) + d.update(chunk); + if (in.gcount() > 0) + d.update(std::span{chunk}.first(in.gcount())); + d.finalize(); + } catch (const std::exception& e) { + std::error_code ec; + std::filesystem::remove(file_out, ec); + throw; + } +} + +} // namespace session::attachment + +extern "C" { + +using namespace session; + +const size_t ATTACHMENT_ENCRYPT_KEY_SIZE = attachment::ENCRYPT_KEY_SIZE; +const size_t ATTACHMENT_MAX_REGULAR_SIZE = attachment::MAX_REGULAR_SIZE; + +LIBSESSION_C_API size_t session_attachment_encrypted_size(size_t plaintext_len) { + return attachment::encrypted_size(plaintext_len); +} + +LIBSESSION_C_API size_t session_attachment_decrypted_max_size(size_t encrypted_len) { + return attachment::decrypted_max_size(encrypted_len) + .value_or(std::numeric_limits::max()); +} + +LIBSESSION_C_API bool session_attachment_encrypt( + const unsigned char* seed, + const unsigned char* data, + size_t datalen, + ATTACHMENT_DOMAIN domain, + unsigned char* key_out, + unsigned char* out, + char* error) { + try { + auto key = attachment::encrypt( + std::span{reinterpret_cast(seed), 32}, + std::span{reinterpret_cast(data), datalen}, + static_cast(domain), + std::span{reinterpret_cast(out), attachment::encrypted_size(datalen)}, + /*allow_large=*/true); + std::memcpy(key_out, key.data(), key.size()); + sodium_zero_buffer(key.data(), key.size()); + return true; + } catch (const std::exception& e) { + return set_error(error, e); + } +} + +LIBSESSION_C_API bool session_attachment_decrypt( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + unsigned char* out, + size_t* outlen, + char* error) { + + try { + auto max_size = attachment::decrypted_max_size(datalen); + if (!max_size) + throw std::runtime_error{"encrypted data too small"}; + + *outlen = attachment::decrypt( + std::span{reinterpret_cast(data), datalen}, + std::span{ + reinterpret_cast(key), attachment::ENCRYPT_KEY_SIZE}, + std::span{reinterpret_cast(out), *max_size}); + return true; + } catch (const std::exception& e) { + return set_error(error, e); + } +} + +LIBSESSION_C_API bool session_attachment_decrypt_alloc( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + unsigned char** out, + size_t* outlen, + char* error) { + std::byte* decrypted = nullptr; + try { + auto max_size = attachment::decrypted_max_size(datalen); + if (!max_size) + throw std::runtime_error{"encrypted data too small"}; + + auto* decrypted = static_cast(std::malloc(*max_size)); + *outlen = attachment::decrypt( + std::span{reinterpret_cast(data), datalen}, + std::span{ + reinterpret_cast(key), attachment::ENCRYPT_KEY_SIZE}, + std::span{decrypted, *max_size}); + *out = reinterpret_cast(decrypted); + return true; + } catch (const std::exception& e) { + if (decrypted) + std::free(decrypted); + return set_error(error, e); + } +} + +LIBSESSION_C_API size_t session_attachment_encrypt_file( + const unsigned char* seed, + const char* filename, + ATTACHMENT_DOMAIN domain, + unsigned char* key_out, + unsigned char* (*make_buffer)(size_t, void* ctx), + void* ctx, + char* error) { + + try { + size_t enc_size = 0; + auto key = attachment::encrypt( + std::span{reinterpret_cast(seed), 32}, + std::filesystem::path{filename}, + static_cast(domain), + [make_buffer, ctx, &enc_size](size_t s) { + auto* buf = make_buffer(s, ctx); + if (!buf) + throw std::runtime_error{ + "encryption failed: make_buffer function returned NULL"}; + assert(!enc_size); + enc_size = s; + return std::span{reinterpret_cast(buf), s}; + }, + /*allow_large=*/true); + assert(enc_size); + std::memcpy(key_out, key.data(), key.size()); + sodium_zero_buffer(key.data(), key.size()); + return enc_size; + } catch (const std::exception& e) { + set_error(error, e); + return 0; + } +} + +LIBSESSION_C_API size_t session_attachment_decrypt_file( + const char* file_in, + const unsigned char* key, + unsigned char* (*make_buffer)(size_t, void* ctx), + void* ctx, + char* error) { + + try { + return attachment::decrypt( + std::filesystem::path{file_in}, + std::span{ + reinterpret_cast(key), attachment::ENCRYPT_KEY_SIZE}, + [make_buffer, ctx](size_t s) { + auto* buf = make_buffer(s, ctx); + if (!buf) + throw std::runtime_error{ + "decryption failed: make_buffer function returned NULL"}; + return std::span{reinterpret_cast(buf), s}; + }); + } catch (const std::exception& e) { + set_error(error, e); + return std::numeric_limits::max(); + } +} + +LIBSESSION_C_API bool session_attachment_decrypt_to_file( + const unsigned char* data, + size_t datalen, + const unsigned char* key, + const char* file_out, + char* error) { + + try { + attachment::decrypt( + std::span{reinterpret_cast(data), datalen}, + std::span{ + reinterpret_cast(key), attachment::ENCRYPT_KEY_SIZE}, + std::filesystem::path{file_out}); + return true; + } catch (const std::exception& e) { + return set_error(error, e); + } +} + +LIBSESSION_C_API bool session_attachment_decrypt_file_to_file( + const char* file_in, const unsigned char* key, const char* file_out, char* error) { + + try { + attachment::decrypt( + std::filesystem::path{file_in}, + std::span{ + reinterpret_cast(key), attachment::ENCRYPT_KEY_SIZE}, + std::filesystem::path{file_out}); + return true; + } catch (const std::exception& e) { + return set_error(error, e); + } +} +} diff --git a/src/internal-util.hpp b/src/internal-util.hpp new file mode 100644 index 00000000..2525666a --- /dev/null +++ b/src/internal-util.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +namespace session { + +// Used by various C APIs with false returns to write a caught exception message into an error +// buffer (if provided) on the way out. The error buffer is expected to have at least 256 bytes +// available (the exception message will be truncated if longer than 255). +inline bool set_error(char* error, const std::exception& e) { + if (error) { + std::string_view err{e.what()}; + if (err.size() > 255) + err.remove_suffix(err.size() - 255); + std::memcpy(error, err.data(), err.size()); + error[err.size()] = 0; + } + + return false; +} + +} // namespace session diff --git a/src/session_network.cpp b/src/session_network.cpp index eaa85de3..8cfdabab 100644 --- a/src/session_network.cpp +++ b/src/session_network.cpp @@ -20,6 +20,7 @@ #include #include +#include "internal-util.hpp" #include "session/blinding.hpp" #include "session/ed25519.hpp" #include "session/export.h" @@ -2824,17 +2825,6 @@ inline session::network::Network& unbox(network_object* network_) { return *static_cast(network_->internals); } -inline bool set_error(char* error, const std::exception& e) { - if (!error) - return false; - - std::string msg = e.what(); - if (msg.size() > 255) - msg.resize(255); - std::memcpy(error, msg.c_str(), msg.size() + 1); - return false; -} - } // namespace extern "C" { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b436795f..77afe5c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,7 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release") endif() set(LIB_SESSION_UTESTS_SOURCES + test_attachment_encrypt.cpp test_blinding.cpp test_bt_merge.cpp test_bugs.cpp diff --git a/tests/test_attachment_encrypt.cpp b/tests/test_attachment_encrypt.cpp new file mode 100644 index 00000000..61d02843 --- /dev/null +++ b/tests/test_attachment_encrypt.cpp @@ -0,0 +1,364 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "utils.hpp" + +using namespace session::config; + +namespace attachment = session::attachment; + +static std::vector make_data(size_t len) { + std::vector v; + v.reserve(len); + for (int i = 0; i < len; i++) + v.push_back(static_cast(i * 7 % 256)); + return v; +} + +using Catch::Matchers::Message; + +TEST_CASE("Attachment encryption/decryption", "[attachments]") { + + auto DATA_SIZE = GENERATE( + 0, + 1, + 2, + 10, + 100, + 1000, + 2000, + 4000, + 4053, + 4054, + 8149, + 8150, + 33333, + 261982, + 261983, + 523990, + 523991, + 6543210, + 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE < 8150 ? 8192 + : DATA_SIZE < 10000 ? 12288 + : DATA_SIZE == 33333 ? 36864 + : DATA_SIZE < 261983 ? 262144 + : DATA_SIZE < 262000 ? 270336 + : DATA_SIZE < 523991 ? 524288 + : DATA_SIZE < 524000 ? 540672 + : DATA_SIZE == 6543210 ? 6553600 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + + auto [enc2, key2] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + + CHECK(oxenc::to_hex(key) == oxenc::to_hex(key2)); + CHECK(enc.size() == expected_size); + CHECK(!!(enc == enc2)); // Prevent catch2 from trying to expand this on failure + + auto decr = attachment::decrypt(enc, key); + CHECK(decr == data); +} + +TEST_CASE("Attachment encryption/decryption -- large files", "[attachments][large]") { + + auto DATA_SIZE = GENERATE(0, 60'000, 10'000'000, 25'000'000); + + auto expected_size = DATA_SIZE == 0 ? 4096 + : DATA_SIZE == 60000 ? 61440 + : DATA_SIZE == 10000000 ? 10223616 + : DATA_SIZE == 25000000 ? 25165824 + : -1; + + auto seed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + std::vector data; + data.reserve(DATA_SIZE); + for (int i = 0; i < DATA_SIZE; i++) + data.push_back(static_cast(i * 7 % 256)); + + std::vector enc; + std::array key; + if (DATA_SIZE > 10'000'000) { + CHECK_THROWS_MATCHES( + std::tie(enc, key) = + attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT), + std::invalid_argument, + Message("data to encrypt is too large")); + } + std::tie(enc, key) = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT, true); + + CHECK(enc.size() == expected_size); + + auto decr = attachment::decrypt(enc, key); + CHECK(!!(decr == data)); +} + +const auto bad_data_message = + Message("Attachment decryption failed: invalid key or corrupted data"); + +TEST_CASE("Attachment encryption/decryption -- key separation", "[attachments][key-sep]") { + + auto DATA_SIZE = GENERATE(0, 20, 100, 1000, 33333); + + auto seed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + auto seed2 = GENERATE( + "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b, + "0123456789abcdef0123456789abcdef1123456789abcdef0123456789abcdef"_hex_b, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde7"_hex_b); + + const auto data = make_data(DATA_SIZE); + + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + auto [enc2, key2] = attachment::encrypt(seed2, data, attachment::Domain::ATTACHMENT); + + CHECK(oxenc::to_hex(key) != oxenc::to_hex(key2)); + CHECK(!(enc == enc2)); + + CHECK_THROWS_MATCHES(attachment::decrypt(enc, key2), std::runtime_error, bad_data_message); + CHECK_THROWS_MATCHES(attachment::decrypt(enc2, key), std::runtime_error, bad_data_message); +} + +TEST_CASE("Attachment encryption/decryption -- key separation", "[attachments][domain-sep]") { + + auto DATA_SIZE = GENERATE(0, 20, 100, 1000, 33333); + + auto seed = "2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + auto [enc2, key2] = attachment::encrypt(seed, data, attachment::Domain::PROFILE_PIC); + + CHECK(oxenc::to_hex(key) != oxenc::to_hex(key2)); + CHECK(!(enc == enc2)); + + CHECK_THROWS_MATCHES(attachment::decrypt(enc, key2), std::runtime_error, bad_data_message); + CHECK_THROWS_MATCHES(attachment::decrypt(enc2, key), std::runtime_error, bad_data_message); +} + +TEST_CASE("Attachment encryption/decryption -- content separation", "[attachments][content-sep]") { + + auto seed = "3123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(50000); + auto data2 = data; + data2[43210] = std::byte{0x42}; + + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + auto [enc2, key2] = attachment::encrypt(seed, data2, attachment::Domain::ATTACHMENT); + + CHECK(oxenc::to_hex(key) != oxenc::to_hex(key2)); + CHECK(enc.size() == enc2.size()); + CHECK(!(enc == enc2)); + + CHECK_THROWS_MATCHES(attachment::decrypt(enc, key2), std::runtime_error, bad_data_message); + CHECK_THROWS_MATCHES(attachment::decrypt(enc2, key), std::runtime_error, bad_data_message); +} + +TEST_CASE("Attachment Decryptor", "[attachments][decryptor]") { + + auto DATA_SIZE = GENERATE( + 0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 8149, 8150, 33333, 6543210, 10218286); + + auto FEED_SIZE = GENERATE(1, 2, 41, 4096, 10000000000); + + auto seed = "4123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + + std::vector decrypted; + attachment::Decryptor d{key, [&decrypted](std::span data) { + decrypted.insert(decrypted.end(), data.begin(), data.end()); + }}; + + std::span input{enc}; + while (!input.empty()) { + auto sz = std::min(FEED_SIZE, input.size()); + REQUIRE(d.update(input.first(sz))); + input = input.subspan(sz); + } + + REQUIRE(d.finalize()); + CHECK(!!(decrypted == data)); +} + +struct temp_data_file { + inline static int i = 1; + std::filesystem::path path = + std::filesystem::temp_directory_path() / + std::filesystem::path{"libsession-util-attachment-test-{}"_format(i++)}; + + ~temp_data_file() { + if (std::filesystem::exists(path)) + std::filesystem::remove(path); + } + + // Constructs a temp filename without actually creating the file + temp_data_file() = default; + + // Constructs a plaintext file with deterministic output based on its size: + explicit temp_data_file(int len) { + std::ofstream out; + out.exceptions(std::ios::failbit | std::ios::badbit); + out.open(path, std::ios::binary | std::ios::trunc); + for (int i = 0; i < len; i++) { + std::byte v{static_cast(i * 7 % 256)}; + out.write(reinterpret_cast(&v), 1); + } + } +}; + +TEST_CASE( + "Attachment encryption: plaintext file to encrypted buffer", + "[attachments][files][encrypt]") { + + auto DATA_SIZE = GENERATE(0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 261983, 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE == 4054 ? 8192 + : DATA_SIZE == 261983 ? 270336 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "5123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + temp_data_file f{DATA_SIZE}; + + auto [enc, key] = attachment::encrypt(seed, f.path, attachment::Domain::ATTACHMENT); + CHECK(enc.size() == expected_size); + auto decr = attachment::decrypt(enc, key); + CHECK(!!(decr == make_data(DATA_SIZE))); +} + +static std::vector slurp_file(const std::filesystem::path& filename) { + std::ifstream in; + in.exceptions(std::ios::failbit | std::ios::badbit); + in.open(filename, std::ios::binary | std::ios::ate); + auto endpos = in.tellg(); + in.seekg(0, std::ios::beg); + auto size = endpos - in.tellg(); + + std::vector contents; + contents.resize(size); + in.read(reinterpret_cast(contents.data()), contents.size()); + + return contents; +} + +TEST_CASE( + "Attachment encryption: plaintext buffer to encrypted file", + "[attachments][files][encrypt]") { + + auto DATA_SIZE = GENERATE(0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 261983, 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE == 4054 ? 8192 + : DATA_SIZE == 261983 ? 270336 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "6123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + auto data = make_data(DATA_SIZE); + temp_data_file f; + + auto key = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT, f.path); + auto enc = slurp_file(f.path); + CHECK(enc.size() == expected_size); + auto decr = attachment::decrypt(enc, key); + CHECK(!!(decr == data)); +} + +TEST_CASE( + "Attachment decryption: encrypted buffer to plaintext file", + "[attachments][files][decrypt]") { + + auto DATA_SIZE = GENERATE(0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 261983, 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE == 4054 ? 8192 + : DATA_SIZE == 261983 ? 270336 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "7123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + auto [enc, key] = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT); + + temp_data_file out{}; + + attachment::decrypt(enc, key, out.path); + + auto contents = slurp_file(out.path); + CHECK(contents.size() == data.size()); + CHECK(!!(contents == data)); +} + +TEST_CASE( + "Attachment decryption: encrypted file to plaintext buffer", + "[attachments][files][decrypt]") { + + auto DATA_SIZE = GENERATE(0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 261983, 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE == 4054 ? 8192 + : DATA_SIZE == 261983 ? 270336 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "8123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + + temp_data_file out; + auto key = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT, out.path); + + auto decrypted = attachment::decrypt(out.path, key); + + CHECK(decrypted.size() == data.size()); + CHECK(!!(decrypted == data)); +} + +TEST_CASE( + "Attachment decryption: encrypted file to plaintext file", + "[attachments][files][decrypt]") { + + auto DATA_SIZE = GENERATE(0, 1, 2, 10, 100, 1000, 2000, 4000, 4053, 4054, 261983, 10218286); + + auto expected_size = DATA_SIZE < 4054 ? 4096 + : DATA_SIZE == 4054 ? 8192 + : DATA_SIZE == 261983 ? 270336 + : DATA_SIZE == 10218286 ? 10223616 + : -1; + + auto seed = "9123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"_hex_b; + + const auto data = make_data(DATA_SIZE); + + temp_data_file out_enc, out_dec; + auto key = attachment::encrypt(seed, data, attachment::Domain::ATTACHMENT, out_enc.path); + + attachment::decrypt(out_enc.path, key, out_dec.path); + + auto contents = slurp_file(out_dec.path); + CHECK(contents.size() == data.size()); + CHECK(!!(contents == data)); +}