]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Project] Add encrypted zip support to archives module
authorVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 22 Sep 2025 20:31:46 +0000 (21:31 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 22 Sep 2025 20:31:46 +0000 (21:31 +0100)
This commit adds encrypted zip support to the archives module.

It adds a new function to the archives module that encrypts a zip file using AES-256-CBC.

It also adds a new function to the archives module that decrypts a zip file using AES-256-CBC.

src/libmime/archives.c
src/libmime/archives.h

index b02a659e2e9c96b447c3b4b6415946f76a6bbd8e..736f9813158df27ebd2060ca657d1c7ad788ef95 100644 (file)
 
 #include <archive.h>
 #include <archive_entry.h>
+#include <zlib.h>
+#ifdef HAVE_OPENSSL
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+#include <openssl/err.h>
+#include <openssl/hmac.h>
+#endif
 
 #define msg_debug_archive(...) rspamd_conditional_debug_fast(NULL, NULL,                                                 \
                                                                                                                         rspamd_archive_log_id, "archive", task->task_pool->tag.uid, \
                                                                                                                         G_STRFUNC,                                                  \
                                                                                                                         __VA_ARGS__)
+#define msg_debug_archive_taskless(...) rspamd_conditional_debug_fast(NULL, NULL,                             \
+                                                                                                                                         rspamd_archive_log_id, "archive", NULL, \
+                                                                                                                                         G_STRFUNC,                              \
+                                                                                                                                         __VA_ARGS__)
 
 INIT_LOG_MODULE(archive)
 
+static GQuark
+rspamd_archives_err_quark(void)
+{
+       static GQuark q = 0;
+       if (G_UNLIKELY(q == 0)) {
+               q = g_quark_from_static_string("archives");
+       }
+
+       return q;
+}
+
 static void
 rspamd_archive_dtor(gpointer p)
 {
@@ -54,6 +76,627 @@ rspamd_archive_dtor(gpointer p)
        g_ptr_array_free(arch->files, TRUE);
 }
 
+static inline guint16
+rspamd_zip_time_dos(time_t t)
+{
+       struct tm lt;
+
+       if (t == 0) {
+               t = time(NULL);
+       }
+
+       (void) localtime_r(&t, &lt);
+
+       guint16 dos_time = ((guint16) (lt.tm_hour & 0x1f) << 11) |
+                                          ((guint16) (lt.tm_min & 0x3f) << 5) |
+                                          ((guint16) ((lt.tm_sec / 2) & 0x1f));
+
+       return dos_time;
+}
+
+static inline guint16
+rspamd_zip_date_dos(time_t t)
+{
+       struct tm lt;
+
+       if (t == 0) {
+               t = time(NULL);
+       }
+
+       (void) localtime_r(&t, &lt);
+
+       int year = lt.tm_year + 1900;
+       if (year < 1980) {
+               year = 1980; /* DOS date epoch */
+       }
+
+       guint16 dos_date = ((guint16) ((year - 1980) & 0x7f) << 9) |
+                                          ((guint16) ((lt.tm_mon + 1) & 0x0f) << 5) |
+                                          ((guint16) (lt.tm_mday & 0x1f));
+
+       return dos_date;
+}
+
+static inline void
+rspamd_ba_append_u16le(GByteArray *ba, guint16 v)
+{
+       union {
+               guint16 u16;
+               unsigned char b[2];
+       } u;
+
+       u.u16 = GUINT16_TO_LE(v);
+       g_byte_array_append(ba, u.b, sizeof(u.b));
+}
+
+static inline void
+rspamd_ba_append_u32le(GByteArray *ba, guint32 v)
+{
+       union {
+               guint32 u32;
+               unsigned char b[4];
+       } u;
+
+       u.u32 = GUINT32_TO_LE(v);
+       g_byte_array_append(ba, u.b, sizeof(u.b));
+}
+
+static gboolean
+rspamd_zip_deflate_alloc(const unsigned char *in,
+                                                gsize inlen,
+                                                unsigned char **outbuf,
+                                                gsize *outlen)
+{
+       int rc;
+       z_stream strm;
+
+       memset(&strm, 0, sizeof(strm));
+       /* raw DEFLATE stream for ZIP */
+       rc = deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED,
+                                         -MAX_WBITS, MAX_MEM_LEVEL - 1, Z_DEFAULT_STRATEGY);
+
+       if (rc != Z_OK) {
+               return FALSE;
+       }
+
+       /* Compute upper bound and allocate */
+       uLong bound = deflateBound(&strm, (uLong) inlen);
+       unsigned char *obuf = g_malloc(bound);
+
+       strm.next_in = (unsigned char *) in;
+       strm.avail_in = inlen;
+       strm.next_out = obuf;
+       strm.avail_out = bound;
+
+       rc = deflate(&strm, Z_FINISH);
+
+       if (rc != Z_STREAM_END && rc != Z_OK && rc != Z_BUF_ERROR) {
+               deflateEnd(&strm);
+               g_free(obuf);
+               return FALSE;
+       }
+
+       *outlen = bound - strm.avail_out;
+       *outbuf = obuf;
+       deflateEnd(&strm);
+
+       return TRUE;
+}
+
+static gboolean
+rspamd_zip_validate_name(const char *name)
+{
+       if (name == NULL || *name == '\0') {
+               return FALSE;
+       }
+       /* Disallow absolute paths and parent traversals */
+       if (name[0] == '/' || name[0] == '\\') {
+               return FALSE;
+       }
+       if (strstr(name, "..") != NULL) {
+               return FALSE;
+       }
+       if (strchr(name, ':') != NULL) {
+               return FALSE;
+       }
+
+       return TRUE;
+}
+
+static void
+rspamd_zip_write_local_header(GByteArray *zip,
+                                                         const char *name,
+                                                         guint16 ver_needed,
+                                                         guint16 gp_flags,
+                                                         guint16 method,
+                                                         time_t mtime,
+                                                         guint32 crc,
+                                                         guint32 csize,
+                                                         guint32 usize,
+                                                         guint16 extra_len)
+{
+       /* Local file header */
+       /* signature */
+       rspamd_ba_append_u32le(zip, 0x04034b50);
+       /* version needed to extract */
+       rspamd_ba_append_u16le(zip, ver_needed);
+       /* general purpose bit flag */
+       rspamd_ba_append_u16le(zip, gp_flags);
+       /* compression method */
+       rspamd_ba_append_u16le(zip, method);
+       /* last mod file time/date */
+       rspamd_ba_append_u16le(zip, rspamd_zip_time_dos(mtime));
+       rspamd_ba_append_u16le(zip, rspamd_zip_date_dos(mtime));
+       /* CRC-32 */
+       rspamd_ba_append_u32le(zip, crc);
+       /* compressed size */
+       rspamd_ba_append_u32le(zip, csize);
+       /* uncompressed size */
+       rspamd_ba_append_u32le(zip, usize);
+       /* file name length */
+       rspamd_ba_append_u16le(zip, (guint16) strlen(name));
+       /* extra field length */
+       rspamd_ba_append_u16le(zip, extra_len);
+       /* file name */
+       g_byte_array_append(zip, (const guint8 *) name, strlen(name));
+}
+
+static void
+rspamd_zip_write_central_header(GByteArray *cd,
+                                                               const char *name,
+                                                               guint16 ver_needed,
+                                                               guint16 gp_flags,
+                                                               guint16 method,
+                                                               time_t mtime,
+                                                               guint32 crc,
+                                                               guint32 csize,
+                                                               guint32 usize,
+                                                               guint32 lfh_offset,
+                                                               guint32 mode,
+                                                               guint16 extra_len)
+{
+       /* Central directory file header */
+       rspamd_ba_append_u32le(cd, 0x02014b50);
+       /* version made by: 3 (UNIX) << 8 | 20 */
+       rspamd_ba_append_u16le(cd, (guint16) ((3 << 8) | 20));
+       /* version needed to extract */
+       rspamd_ba_append_u16le(cd, ver_needed);
+       /* general purpose bit flag */
+       rspamd_ba_append_u16le(cd, gp_flags);
+       /* compression method */
+       rspamd_ba_append_u16le(cd, method);
+       /* time/date */
+       rspamd_ba_append_u16le(cd, rspamd_zip_time_dos(mtime));
+       rspamd_ba_append_u16le(cd, rspamd_zip_date_dos(mtime));
+       /* CRC and sizes */
+       rspamd_ba_append_u32le(cd, crc);
+       rspamd_ba_append_u32le(cd, csize);
+       rspamd_ba_append_u32le(cd, usize);
+       /* name len, extra len, comment len */
+       rspamd_ba_append_u16le(cd, (guint16) strlen(name));
+       rspamd_ba_append_u16le(cd, extra_len);
+       rspamd_ba_append_u16le(cd, 0);
+       /* disk number start, internal attrs */
+       rspamd_ba_append_u16le(cd, 0);
+       rspamd_ba_append_u16le(cd, 0);
+       /* external attrs: UNIX perms in upper 16 bits */
+       guint32 xattr = (mode ? mode : 0644);
+       xattr = (xattr & 0xFFFF) << 16;
+       rspamd_ba_append_u32le(cd, xattr);
+       /* relative offset of local header */
+       rspamd_ba_append_u32le(cd, lfh_offset);
+       /* file name */
+       g_byte_array_append(cd, (const guint8 *) name, strlen(name));
+}
+
+#define ZIP_AES_EXTRA_ID 0x9901
+
+static void
+rspamd_zip_write_extra_aes(GByteArray *ba, guint16 vendor_version, guint8 strength, guint16 actual_method)
+{
+       /* Extra field header id and size */
+       rspamd_ba_append_u16le(ba, ZIP_AES_EXTRA_ID);
+       /* data size = 7 */
+       rspamd_ba_append_u16le(ba, 7);
+       /* Vendor version */
+       rspamd_ba_append_u16le(ba, vendor_version);
+       /* Vendor ID 'AE' */
+       const guint8 vid[2] = {'A', 'E'};
+       g_byte_array_append(ba, vid, sizeof(vid));
+       /* Strength */
+       g_byte_array_append(ba, &strength, 1);
+       /* Actual compression method */
+       rspamd_ba_append_u16le(ba, actual_method);
+}
+
+GByteArray *
+rspamd_archives_zip_write(const struct rspamd_zip_file_spec *files,
+                                                 gsize nfiles,
+                                                 const char *password,
+                                                 GError **err)
+{
+       GByteArray *zip = NULL, *cd = NULL;
+       GQuark q = rspamd_archives_err_quark();
+
+       if (files == NULL || nfiles == 0) {
+               g_set_error(err, q, EINVAL, "no files to archive");
+               return NULL;
+       }
+
+       zip = g_byte_array_new();
+       cd = g_byte_array_new();
+
+       for (gsize i = 0; i < nfiles; i++) {
+               const struct rspamd_zip_file_spec *f = &files[i];
+               if (!rspamd_zip_validate_name(f->name)) {
+                       g_set_error(err, q, EINVAL, "invalid zip entry name: %s", f->name ? f->name : "(null)");
+                       g_byte_array_free(cd, TRUE);
+                       g_byte_array_free(zip, TRUE);
+                       return NULL;
+               }
+
+               guint32 crc = crc32(0L, Z_NULL, 0);
+               crc = crc32(crc, f->data, f->len);
+               guint16 method = 8;            /* deflate */
+               guint16 gp_flags = (1u << 11); /* UTF-8 */
+               guint16 ver_needed = 20;       /* default */
+               const gboolean use_aes = (password != NULL && *password != '\0');
+
+               /* actual method will be decided after deflate; default is deflate */
+
+               guint16 extra_len = 0;
+               guint16 actual_method = method;
+               guint32 csize_for_header = 0;
+               const guint8 aes_strength = 0x03;      /* AES-256 */
+               const guint16 aes_vendor_ver = 0x0002; /* AE-2 */
+               guint8 salt_len = 0;
+               if (use_aes) {
+                       /* Per APPNOTE: method=99 (0x63), AES extra 0x9901 in both headers */
+                       ver_needed = MAX(ver_needed, (guint16) 51);
+                       gp_flags |= 1u; /* encrypted */
+                       method = 99;
+                       extra_len = 2 /*id*/ + 2 /*size*/ + 7 /*payload*/;
+                       /* salt length by strength */
+                       salt_len = (aes_strength == 0x01 ? 8 : (aes_strength == 0x02 ? 12 : 16));
+                       /* compressed size will be computed after deflate/encrypt */
+                       /* CRC-32 not used with AES: set to 0 */
+                       crc = 0;
+               }
+
+               guint32 lfh_off = zip->len;
+               rspamd_zip_write_local_header(zip, f->name, ver_needed, gp_flags, method, f->mtime, crc,
+                                                                         csize_for_header,
+                                                                         (guint32) f->len, extra_len);
+               if (use_aes) {
+                       /* Write AES extra for local header */
+                       rspamd_zip_write_extra_aes(zip, aes_vendor_ver, aes_strength, actual_method);
+
+#ifdef HAVE_OPENSSL
+                       /* Derive keys and encrypt: PBKDF2-HMAC-SHA1 per AE-2 */
+                       unsigned char salt[16];
+                       if (RAND_bytes(salt, salt_len) != 1) {
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "cannot generate AES salt");
+                               return NULL;
+                       }
+
+                       /* key sizes by strength */
+                       int klen = (aes_strength == 0x01 ? 16 : (aes_strength == 0x02 ? 24 : 32));
+                       int dklen = klen * 2 + 2;
+                       unsigned char *dk = g_malloc(dklen);
+                       if (PKCS5_PBKDF2_HMAC(password, (int) strlen(password), salt, salt_len, 1000, EVP_sha1(), dklen, dk) != 1) {
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "PBKDF2(HMAC-SHA1) failed");
+                               return NULL;
+                       }
+                       unsigned char *ekey = dk;          /* klen */
+                       unsigned char *akey = dk + klen;   /* klen */
+                       unsigned char *pv = dk + klen * 2; /* 2 bytes */
+
+                       /* Append salt and password verification value */
+                       g_byte_array_append(zip, salt, salt_len);
+                       g_byte_array_append(zip, pv, 2);
+
+                       /* AES-CTR encrypt */
+                       EVP_CIPHER_CTX *cctx = EVP_CIPHER_CTX_new();
+                       const EVP_CIPHER *cipher = (klen == 16 ? EVP_aes_128_ctr() : (klen == 24 ? EVP_aes_192_ctr() : EVP_aes_256_ctr()));
+                       unsigned char iv[16];
+                       memset(iv, 0, sizeof(iv)); /* WinZip AES uses counter mode starting at 0 */
+                       if (EVP_EncryptInit_ex(cctx, cipher, NULL, ekey, iv) != 1) {
+                               EVP_CIPHER_CTX_free(cctx);
+                               rspamd_explicit_memzero(dk, dklen);
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "AES-CTR init failed");
+                               return NULL;
+                       }
+
+                       /* Deflate directly into zip and encrypt in-place */
+                       z_stream zst;
+                       memset(&zst, 0, sizeof(zst));
+                       if (deflateInit2(&zst, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, MAX_MEM_LEVEL - 1, Z_DEFAULT_STRATEGY) != Z_OK) {
+                               EVP_CIPHER_CTX_free(cctx);
+                               rspamd_explicit_memzero(dk, dklen);
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "deflateInit2 failed");
+                               return NULL;
+                       }
+                       uLong bound = deflateBound(&zst, (uLong) f->len);
+                       deflateEnd(&zst);
+                       /* Append salt+pv already written; now reserve deflate bound */
+                       gsize plain_off = zip->len;
+                       g_byte_array_set_size(zip, zip->len + bound);
+                       unsigned char *plain_ptr = zip->data + plain_off;
+                       memset(&zst, 0, sizeof(zst));
+                       if (deflateInit2(&zst, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, MAX_MEM_LEVEL - 1, Z_DEFAULT_STRATEGY) != Z_OK) {
+                               EVP_CIPHER_CTX_free(cctx);
+                               rspamd_explicit_memzero(dk, dklen);
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "deflateInit2 failed");
+                               return NULL;
+                       }
+                       zst.next_in = (unsigned char *) f->data;
+                       zst.avail_in = f->len;
+                       zst.next_out = plain_ptr;
+                       zst.avail_out = bound;
+                       int rc = deflate(&zst, Z_FINISH);
+                       if (rc != Z_STREAM_END && rc != Z_OK && rc != Z_BUF_ERROR) {
+                               deflateEnd(&zst);
+                               EVP_CIPHER_CTX_free(cctx);
+                               rspamd_explicit_memzero(dk, dklen);
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "deflate failed");
+                               return NULL;
+                       }
+                       gsize produced = bound - zst.avail_out;
+                       deflateEnd(&zst);
+                       if (produced >= f->len) {
+                               /* fallback to store */
+                               g_byte_array_set_size(zip, plain_off);
+                               g_byte_array_set_size(zip, zip->len + f->len);
+                               memcpy(zip->data + plain_off, f->data, f->len);
+                               produced = f->len;
+                               actual_method = 0;
+                       }
+
+                       /* Encrypt in place */
+                       gsize enc_before = plain_off;
+                       unsigned char *enc_ptr = zip->data + enc_before;
+                       int outl = 0, finl = 0;
+                       if (EVP_EncryptUpdate(cctx, enc_ptr, &outl, enc_ptr, (int) produced) != 1 ||
+                               EVP_EncryptFinal_ex(cctx, enc_ptr + outl, &finl) != 1) {
+                               EVP_CIPHER_CTX_free(cctx);
+                               rspamd_explicit_memzero(dk, dklen);
+                               g_free(dk);
+                               g_byte_array_free(cd, TRUE);
+                               g_byte_array_free(zip, TRUE);
+                               g_set_error(err, q, EIO, "AES-CTR encrypt failed");
+                               return NULL;
+                       }
+                       EVP_CIPHER_CTX_free(cctx);
+                       /* shrink to ciphertext size */
+                       g_byte_array_set_size(zip, enc_before + outl + finl);
+
+                       /* HMAC-SHA1 over ciphertext */
+                       unsigned char mac[EVP_MAX_MD_SIZE];
+                       unsigned int maclen = 0;
+                       HMAC(EVP_sha1(), akey, klen, zip->data + enc_before, (int) (zip->len - enc_before), mac, &maclen);
+                       /* append first 10 bytes */
+                       g_byte_array_append(zip, mac, 10);
+
+                       /* cleanup */
+                       rspamd_explicit_memzero(dk, dklen);
+                       g_free(dk);
+
+                       /* Patch local header: compressed size and actual method in AES extra */
+                       csize_for_header = (guint32) (salt_len + 2 + (outl + finl) + 10);
+                       /* compressed size at offset lfh_off + 18 */
+                       guint32 *p32 = (guint32 *) (zip->data + lfh_off + 18);
+                       *p32 = GUINT32_TO_LE(csize_for_header);
+                       /* patch actual method in AES extra (last 2 bytes of AES extra payload) */
+                       guint16 *p16 = (guint16 *) (zip->data + lfh_off + 30 + (guint32) strlen(f->name) + 9);
+                       *p16 = GUINT16_TO_LE(actual_method);
+#else
+                       g_byte_array_free(cdata, TRUE);
+                       g_byte_array_free(cd, TRUE);
+                       g_byte_array_free(zip, TRUE);
+                       g_set_error(err, q, ENOTSUP, "AES-CTR encryption requires OpenSSL");
+                       return NULL;
+#endif
+               }
+               else {
+                       /* Not encrypted: deflate directly into zip, fallback to store */
+                       z_stream zst;
+                       memset(&zst, 0, sizeof(zst));
+                       if (deflateInit2(&zst, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, MAX_MEM_LEVEL - 1, Z_DEFAULT_STRATEGY) != Z_OK) {
+                               g_set_error(err, q, EIO, "deflateInit2 failed");
+                               return NULL;
+                       }
+                       uLong bound = deflateBound(&zst, (uLong) f->len);
+                       deflateEnd(&zst);
+                       gsize off = zip->len;
+                       g_byte_array_set_size(zip, zip->len + bound);
+                       unsigned char *outp = zip->data + off;
+                       memset(&zst, 0, sizeof(zst));
+                       if (deflateInit2(&zst, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, MAX_MEM_LEVEL - 1, Z_DEFAULT_STRATEGY) != Z_OK) {
+                               g_set_error(err, q, EIO, "deflateInit2 failed");
+                               return NULL;
+                       }
+                       zst.next_in = (unsigned char *) f->data;
+                       zst.avail_in = f->len;
+                       zst.next_out = outp;
+                       zst.avail_out = bound;
+                       int rc = deflate(&zst, Z_FINISH);
+                       if (rc != Z_STREAM_END && rc != Z_OK && rc != Z_BUF_ERROR) {
+                               deflateEnd(&zst);
+                               g_set_error(err, q, EIO, "deflate failed");
+                               return NULL;
+                       }
+                       gsize produced = bound - zst.avail_out;
+                       deflateEnd(&zst);
+                       if (produced >= f->len) {
+                               /* store */
+                               g_byte_array_set_size(zip, off);
+                               g_byte_array_set_size(zip, zip->len + f->len);
+                               memcpy(zip->data + off, f->data, f->len);
+                               produced = f->len;
+                               method = 0;
+                               /* patch method in local header (offset +8) */
+                               guint16 *pm = (guint16 *) (zip->data + lfh_off + 8);
+                               *pm = GUINT16_TO_LE(method);
+                       }
+                       else {
+                               g_byte_array_set_size(zip, off + produced);
+                       }
+                       csize_for_header = (guint32) produced;
+                       /* patch CRC (offset +14) and compressed size (offset +18) */
+                       guint32 *p32 = (guint32 *) (zip->data + lfh_off + 14);
+                       *p32 = GUINT32_TO_LE(crc);
+                       p32 = (guint32 *) (zip->data + lfh_off + 18);
+                       *p32 = GUINT32_TO_LE(csize_for_header);
+               }
+
+               rspamd_zip_write_central_header(cd, f->name, ver_needed, gp_flags, method, f->mtime, crc,
+                                                                               csize_for_header,
+                                                                               (guint32) f->len,
+                                                                               lfh_off, f->mode, extra_len);
+               if (use_aes) {
+                       rspamd_zip_write_extra_aes(cd, aes_vendor_ver, aes_strength, actual_method);
+               }
+
+               guint64 logged_csize = (guint64) csize_for_header;
+               msg_debug_archive_taskless("zip: added entry '%s' (usize=%L, csize=%L, method=%s)",
+                                                                  f->name, (int64_t) f->len, (int64_t) logged_csize,
+                                                                  method == 0 ? "store" : "deflate");
+       }
+
+       /* Central directory start */
+       guint32 cd_start = zip->len;
+       g_byte_array_append(zip, cd->data, cd->len);
+       guint32 cd_size = cd->len;
+       g_byte_array_free(cd, TRUE);
+
+       /* EOCD */
+       rspamd_ba_append_u32le(zip, 0x06054b50);
+       /* disk numbers */
+       rspamd_ba_append_u16le(zip, 0);
+       rspamd_ba_append_u16le(zip, 0);
+       /* total entries on this disk / total entries */
+       rspamd_ba_append_u16le(zip, (guint16) nfiles);
+       rspamd_ba_append_u16le(zip, (guint16) nfiles);
+       /* size of central directory */
+       rspamd_ba_append_u32le(zip, cd_size);
+       /* offset of central directory */
+       rspamd_ba_append_u32le(zip, cd_start);
+       /* zip comment length */
+       rspamd_ba_append_u16le(zip, 0);
+
+       msg_debug_archive_taskless("zip: created archive (%L bytes)", (int64_t) zip->len);
+       return zip;
+}
+
+GByteArray *
+rspamd_archives_encrypt_aes256_cbc(const unsigned char *in,
+                                                                  gsize inlen,
+                                                                  const char *password,
+                                                                  GError **err)
+{
+#ifndef HAVE_OPENSSL
+       (void) in;
+       (void) inlen;
+       (void) password;
+       GQuark q = rspamd_archives_err_quark();
+       g_set_error(err, q, ENOTSUP, "OpenSSL is not available");
+       return NULL;
+#else
+       GQuark q = rspamd_archives_err_quark();
+       unsigned char salt[16];
+       unsigned char iv[16];
+       unsigned char key[32];
+       const int kdf_iters = 100000;
+       GByteArray *out = NULL;
+       EVP_CIPHER_CTX *ctx = NULL;
+
+       if (password == NULL || *password == '\0') {
+               g_set_error(err, q, EINVAL, "empty password");
+               return NULL;
+       }
+
+       if (RAND_bytes(salt, sizeof(salt)) != 1 || RAND_bytes(iv, sizeof(iv)) != 1) {
+               g_set_error(err, q, EIO, "cannot generate random salt/iv: %s", ERR_error_string(ERR_get_error(), NULL));
+               return NULL;
+       }
+
+       if (PKCS5_PBKDF2_HMAC(password, (int) strlen(password), salt, (int) sizeof(salt),
+                                                 kdf_iters, EVP_sha256(), (int) sizeof(key), key) != 1) {
+               g_set_error(err, q, EIO, "PBKDF2 failed: %s", ERR_error_string(ERR_get_error(), NULL));
+               return NULL;
+       }
+
+       ctx = EVP_CIPHER_CTX_new();
+       if (ctx == NULL) {
+               g_set_error(err, q, ENOMEM, "cannot alloc cipher ctx");
+               rspamd_explicit_memzero(key, sizeof(key));
+               return NULL;
+       }
+
+       if (EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv) != 1) {
+               g_set_error(err, q, EIO, "cipher init failed: %s", ERR_error_string(ERR_get_error(), NULL));
+               EVP_CIPHER_CTX_free(ctx);
+               rspamd_explicit_memzero(key, sizeof(key));
+               return NULL;
+       }
+
+       /* Prepare output: magic + salt + iv + ciphertext; write directly into GByteArray */
+       const char magic[8] = {'R', 'Z', 'A', 'E', '0', '0', '0', '1'};
+       out = g_byte_array_sized_new(8 + sizeof(salt) + sizeof(iv) + inlen + 32);
+       g_byte_array_append(out, (const guint8 *) magic, sizeof(magic));
+       g_byte_array_append(out, salt, sizeof(salt));
+       g_byte_array_append(out, iv, sizeof(iv));
+
+       gsize before = out->len;
+       g_byte_array_set_size(out, out->len + inlen + EVP_CIPHER_block_size(EVP_aes_256_cbc()));
+       unsigned char *cptr = out->data + before;
+       int outlen = 0;
+
+       if (EVP_EncryptUpdate(ctx, cptr, &outlen, in, (int) inlen) != 1) {
+               g_set_error(err, q, EIO, "encrypt update failed: %s", ERR_error_string(ERR_get_error(), NULL));
+               EVP_CIPHER_CTX_free(ctx);
+               rspamd_explicit_memzero(key, sizeof(key));
+               g_byte_array_set_size(out, before);
+               g_byte_array_free(out, TRUE);
+               return NULL;
+       }
+
+       int fin = 0;
+       if (EVP_EncryptFinal_ex(ctx, cptr + outlen, &fin) != 1) {
+               g_set_error(err, q, EIO, "encrypt final failed: %s", ERR_error_string(ERR_get_error(), NULL));
+               EVP_CIPHER_CTX_free(ctx);
+               rspamd_explicit_memzero(key, sizeof(key));
+               g_byte_array_set_size(out, before);
+               g_byte_array_free(out, TRUE);
+               return NULL;
+       }
+
+       g_byte_array_set_size(out, before + outlen + fin);
+       EVP_CIPHER_CTX_free(ctx);
+       rspamd_explicit_memzero(key, sizeof(key));
+
+       msg_info("zip: AES-256-CBC envelope created (PBKDF2-SHA256 iters=%d)", kdf_iters);
+       return out;
+#endif
+}
+
 static bool
 rspamd_archive_file_try_utf(struct rspamd_task *task,
                                                        struct rspamd_archive *arch,
index cafa06047779c402c41253bbb8147c41b62ffb73..8bc75f2172b11026dbc9a8c61f9328fba9678946 100644 (file)
@@ -55,6 +55,37 @@ struct rspamd_archive {
        GPtrArray *files; /* Array of struct rspamd_archive_file */
 };
 
+/* Writer API */
+struct rspamd_zip_file_spec {
+       const char *name;          /* UTF-8 relative path */
+       const unsigned char *data; /* file content */
+       gsize len;                 /* content length */
+       /* Optional attrs */
+       time_t mtime; /* 0 means now */
+       guint32 mode; /* UNIX perm bits; 0 means 0644 */
+};
+
+/**
+ * Create a ZIP archive in-memory from provided files (DEFLATE compression)
+ * If password is non-NULL, the ZIP is created normally and then encrypted as a whole
+ * using AES-256-CBC with PBKDF2-HMAC-SHA256 and a random salt/IV. The result format is:
+ *  [ 'RZAE0001' (8 bytes) | salt (16 bytes) | iv (16 bytes) | ciphertext ]
+ * Returns newly allocated GByteArray on success, NULL on error and sets err
+ */
+GByteArray *rspamd_archives_zip_write(const struct rspamd_zip_file_spec *files,
+                                                                         gsize nfiles,
+                                                                         const char *password,
+                                                                         GError **err);
+
+/**
+ * AES-256-CBC encrypts arbitrary data buffer using PBKDF2-HMAC-SHA256 derived key.
+ * Output format: [ 'RZAE0001' | salt(16) | iv(16) | ciphertext ]
+ */
+GByteArray *rspamd_archives_encrypt_aes256_cbc(const unsigned char *in,
+                                                                                          gsize inlen,
+                                                                                          const char *password,
+                                                                                          GError **err);
+
 /**
  * Process archives from a worker task
  */