Commit f87f0b6a8e for openssl.org

commit f87f0b6a8e04bcdeb98afc82b0da06a2d537f399
Author: Viktor Dukhovni <viktor@openssl.org>
Date:   Mon May 18 18:09:44 2026 +1000

    Apply the buffered IV on the AES-OCB EVP_Cipher() path

    aes_ocb_cipher(), the OCB provider's OSSL_FUNC_CIPHER_CIPHER slot,
    processed input without flushing the buffered IV into the OCB
    context.  Effective nonce was 0 regardless of the caller's IV;
    EVP_*Final_ex() then emitted a tag depending only on (key, iv).
    This gave (key, nonce) reuse and single-query universal forgery on
    the EVP_Cipher() path.

    Apply update_iv() at the head of aes_ocb_cipher() to mirror the
    streaming handler.  The matching GCM one-shot does this already.

    Add a cross-driver round-trip test for AES-{GCM,CCM,OCB} and
    ChaCha20-Poly1305 in test/evp_extra_test.c.  Each cipher is
    exercised with and without AAD; the no-AAD case is needed because
    any prior EVP_CipherUpdate(NULL, aad, ...) routes through the
    streaming handler and applies the IV itself, masking the bug.

    Fixes CVE-2026-45445

    Reviewed-by: Eugene Syromiatnikov <esyr@openssl.org>
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    MergeDate: Mon Jun  8 20:02:00 2026

diff --git a/providers/implementations/ciphers/cipher_aes_ocb.c b/providers/implementations/ciphers/cipher_aes_ocb.c
index 1f6da4b212..329d4bfa3e 100644
--- a/providers/implementations/ciphers/cipher_aes_ocb.c
+++ b/providers/implementations/ciphers/cipher_aes_ocb.c
@@ -498,6 +498,19 @@ static int aes_ocb_cipher(void *vctx, unsigned char *out, size_t *outl,
         return 0;
     }

+    /*
+     * Mirror the streaming handler: refuse if the key has not been set,
+     * and push the buffered IV into the OCB context before any data is
+     * processed.  Without this, CRYPTO_ocb128_encrypt/decrypt runs with
+     * Offset_0 = 0 regardless of the caller's IV -- catastrophic
+     * (key, nonce) reuse, and a subsequent EVP_*Final_ex() emits a tag
+     * that is a function of (key, iv) only.
+     */
+    if (!ctx->key_set || !update_iv(ctx)) {
+        ERR_raise(ERR_LIB_PROV, PROV_R_CIPHER_OPERATION_FAILED);
+        return 0;
+    }
+
     if (!aes_generic_ocb_cipher(ctx, in, out, inl)) {
         ERR_raise(ERR_LIB_PROV, PROV_R_CIPHER_OPERATION_FAILED);
         return 0;
diff --git a/test/evp_extra_test.c b/test/evp_extra_test.c
index 2ca0669265..0965f73032 100644
--- a/test/evp_extra_test.c
+++ b/test/evp_extra_test.c
@@ -7221,6 +7221,270 @@ end:
     EVP_CIPHER_CTX_free(ctx);
     return ret;
 }
+
+/*
+ * Cross-driver round-trip test for AEAD one-shot vs streaming paths.
+ *
+ * The streaming path (EVP_CipherUpdate/Final, dispatched to
+ * OSSL_FUNC_CIPHER_UPDATE/_FINAL) is treated as the oracle.  For each
+ * AEAD configuration we encrypt and decrypt the same (key, iv, aad, pt),
+ * driving the body in two combinations:
+ *
+ *   1.  body encrypt via EVP_Cipher() (one-shot, OSSL_FUNC_CIPHER_CIPHER),
+ *       body decrypt via EVP_CipherUpdate (streaming).
+ *   2.  body encrypt via EVP_CipherUpdate, body decrypt via EVP_Cipher().
+ *
+ * Both combinations must recover the plaintext and verify the tag.  AAD
+ * is always fed via EVP_CipherUpdate(NULL, ...): OCB's one-shot is body
+ * only and the asymmetric "AAD streaming, body one-shot" call shape is
+ * the natural pattern a caller reaching for EVP_Cipher() for throughput
+ * would write anyway.
+ *
+ * CVE-2026-45445 (AES-OCB EVP_Cipher() ignored IV) was a silent failure
+ * in this matrix: the one-shot encrypt path produced ciphertext under
+ * Offset_0 = 0 regardless of IV, which the streaming decrypt path then
+ * could not verify.  Adding this cross-check catches the same class of
+ * bug for any future AEAD whose one-shot dispatch diverges from its
+ * streaming dispatch.
+ */
+typedef struct {
+    const char *name; /* EVP_CIPHER fetch name */
+    size_t keylen;
+    size_t ivlen;
+    size_t taglen;
+    int is_ccm; /* needs length-up-front + tag-before-body dance */
+} AEAD_ONESHOT_CFG;
+
+static const AEAD_ONESHOT_CFG aead_oneshot_cfgs[] = {
+    { "AES-128-GCM", 16, 12, 16, 0 },
+    { "AES-256-GCM", 32, 12, 16, 0 },
+    { "AES-128-CCM", 16, 12, 16, 1 },
+    { "AES-256-CCM", 32, 12, 16, 1 },
+    { "AES-128-OCB", 16, 12, 16, 0 },
+    { "AES-256-OCB", 32, 12, 16, 0 },
+    { "ChaCha20-Poly1305", 32, 12, 16, 0 }
+};
+
+/*
+ * Drive an encrypt or decrypt operation.  AAD always via EVP_CipherUpdate.
+ * Body via EVP_Cipher() when oneshot_body is non-zero, EVP_CipherUpdate
+ * otherwise.  On encrypt, fills *out and the caller-provided tag buffer.
+ * On decrypt, reads from in and verifies tag; returns 0 if verification
+ * fails (the test asserts the expected outcome).
+ */
+static int aead_oneshot_op(const AEAD_ONESHOT_CFG *cfg, int enc,
+    int oneshot_body, const unsigned char *key,
+    const unsigned char *iv, const unsigned char *aad,
+    size_t aad_len, const unsigned char *in, size_t in_len,
+    unsigned char *out, unsigned char *tag, const char **why)
+{
+    EVP_CIPHER_CTX *ctx = NULL;
+    EVP_CIPHER *cipher = NULL;
+    int outl = 0, tmpl = 0;
+    int ok = 0;
+    int body_rv;
+
+    *why = NULL;
+
+    if (!TEST_ptr(cipher = EVP_CIPHER_fetch(testctx, cfg->name, testpropq))) {
+        *why = "CIPHER_FETCH";
+        goto end;
+    }
+    if (!TEST_ptr(ctx = EVP_CIPHER_CTX_new())) {
+        *why = "CTX_NEW";
+        goto end;
+    }
+    if (!TEST_true(EVP_CipherInit_ex(ctx, cipher, NULL, NULL, NULL, enc))) {
+        *why = "INIT_CIPHER";
+        goto end;
+    }
+    if (!TEST_int_gt(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN,
+                         (int)cfg->ivlen, NULL),
+            0)) {
+        *why = "SET_IVLEN";
+        goto end;
+    }
+    if (cfg->is_ccm) {
+        /* Placeholder taglen on encrypt, real tag on decrypt; both before key+iv. */
+        if (!TEST_int_gt(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG,
+                             (int)cfg->taglen, enc ? NULL : tag),
+                0)) {
+            *why = "CCM_SET_TAG";
+            goto end;
+        }
+    }
+    if (!TEST_true(EVP_CipherInit_ex(ctx, NULL, NULL, key, iv, enc))) {
+        *why = "INIT_KEY_IV";
+        goto end;
+    }
+    if (cfg->is_ccm) {
+        if (!TEST_true(EVP_CipherUpdate(ctx, NULL, &outl, NULL, (int)in_len))) {
+            *why = "CCM_LEN_DECL";
+            goto end;
+        }
+    }
+    if (aad_len > 0
+        && !TEST_true(EVP_CipherUpdate(ctx, NULL, &outl, aad, (int)aad_len))) {
+        *why = "AAD";
+        goto end;
+    }
+    if (!enc && !cfg->is_ccm
+        && !TEST_int_gt(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG,
+                            (int)cfg->taglen, tag),
+            0)) {
+        *why = "SET_TAG";
+        goto end;
+    }
+
+    if (oneshot_body) {
+        body_rv = EVP_Cipher(ctx, out, in, (unsigned int)in_len);
+        if (cfg->is_ccm && !enc) {
+            /* CCM decrypt: 0 means tag verify failed, < 0 means error. */
+            if (!TEST_int_gt(body_rv, 0)) {
+                *why = "ONESHOT_DECRYPT";
+                goto end;
+            }
+        } else {
+            if (!TEST_int_ge(body_rv, 0)) {
+                *why = "ONESHOT_BODY";
+                goto end;
+            }
+        }
+        outl = (int)in_len;
+    } else {
+        if (!TEST_true(EVP_CipherUpdate(ctx, out, &outl, in, (int)in_len))) {
+            *why = enc ? "STREAM_BODY_ENC" : "STREAM_BODY_DEC";
+            goto end;
+        }
+    }
+
+    if (!cfg->is_ccm) {
+        if (!TEST_true(EVP_CipherFinal_ex(ctx, out + outl, &tmpl))) {
+            *why = enc ? "FINAL_ENC" : "FINAL_DEC";
+            goto end;
+        }
+    }
+
+    if (enc) {
+        if (!TEST_int_gt(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG,
+                             (int)cfg->taglen, tag),
+                0)) {
+            *why = "GET_TAG";
+            goto end;
+        }
+    }
+    ok = 1;
+end:
+    EVP_CIPHER_CTX_free(ctx);
+    EVP_CIPHER_free(cipher);
+    return ok;
+}
+
+/*
+ * For each AEAD row we run two AAD modes, and within each AAD mode two
+ * cross-driver round trips:
+ *
+ *   aad_mode 0:  no AAD.  Critical for catching the OCB-style bug: any
+ *                EVP_CipherUpdate(NULL, aad, ...) call before the body
+ *                would itself pass through the (correct) streaming
+ *                handler and apply the buffered IV, masking the one-shot
+ *                handler's failure to do so.  With aad_len == 0 we make
+ *                EVP_Cipher() the very first cipher operation on the
+ *                context, which is the shape the bug requires.
+ *
+ *   aad_mode 1:  with AAD via streaming.  Catches divergence between the
+ *                drivers when AAD is in play.
+ *
+ *   leg 0:       encrypt-oneshot   + decrypt-streaming
+ *   leg 1:       encrypt-streaming + decrypt-oneshot
+ *
+ * The test index encodes (cipher, aad_mode) so a failure points at both.
+ */
+static int test_aead_oneshot_roundtrip(int idx)
+{
+    static const unsigned char fixed_key[32] = {
+        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+        0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
+        0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
+        0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f
+    };
+    static const unsigned char fixed_iv[12] = {
+        0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab
+    };
+    static const unsigned char fixed_aad[] = "extra:context";
+    static const unsigned char fixed_pt[] = "THE QUICK BROWN FOX JUMPS OVER LAZY!!";
+    const AEAD_ONESHOT_CFG *cfg = &aead_oneshot_cfgs[idx / 2];
+    int with_aad = idx % 2;
+    size_t aad_len = with_aad ? sizeof(fixed_aad) - 1 : 0;
+    size_t pt_len = sizeof(fixed_pt) - 1;
+    EVP_CIPHER *probe = NULL;
+    unsigned char ct[64], pt[64];
+    unsigned char tag_oneshot[16], tag_stream[16];
+    const char *why = NULL;
+    int leg, ok = 0;
+
+    /*
+     * Probe for the cipher: a build with no-ocb / no-chacha / etc. will
+     * not have it, and we treat that as a pass (nothing to test here).
+     */
+    ERR_set_mark();
+    probe = EVP_CIPHER_fetch(testctx, cfg->name, testpropq);
+    ERR_pop_to_mark();
+    if (probe == NULL) {
+        TEST_info("skipping, '%s' is not available", cfg->name);
+        return 1;
+    }
+    EVP_CIPHER_free(probe);
+
+    for (leg = 0; leg <= 1; leg++) {
+        int enc_oneshot = (leg == 0);
+        unsigned char *tag = enc_oneshot ? tag_oneshot : tag_stream;
+
+        memset(ct, 0, sizeof(ct));
+        memset(pt, 0, sizeof(pt));
+        memset(tag, 0, cfg->taglen);
+
+        if (!aead_oneshot_op(cfg, /*enc=*/1, /*oneshot_body=*/enc_oneshot,
+                fixed_key, fixed_iv, fixed_aad, aad_len,
+                fixed_pt, pt_len, ct, tag, &why)) {
+            TEST_error("%s (%s): encrypt leg %d (%s body) failed at %s",
+                cfg->name, with_aad ? "with AAD" : "no AAD",
+                leg, enc_oneshot ? "oneshot" : "stream",
+                why ? why : "?");
+            goto end;
+        }
+        if (!aead_oneshot_op(cfg, /*enc=*/0, /*oneshot_body=*/!enc_oneshot,
+                fixed_key, fixed_iv, fixed_aad, aad_len,
+                ct, pt_len, pt, tag, &why)) {
+            TEST_error("%s (%s): decrypt leg %d (%s body) failed at %s",
+                cfg->name, with_aad ? "with AAD" : "no AAD",
+                leg, enc_oneshot ? "stream" : "oneshot",
+                why ? why : "?");
+            goto end;
+        }
+        if (!TEST_mem_eq(pt, pt_len, fixed_pt, pt_len)) {
+            TEST_error("%s (%s): leg %d: recovered plaintext differs",
+                cfg->name, with_aad ? "with AAD" : "no AAD", leg);
+            goto end;
+        }
+    }
+
+    /*
+     * Both legs share the same (key, iv, aad, pt) and must therefore
+     * agree on the tag bit-for-bit, regardless of which driver computed
+     * it.  This catches the OCB-style failure where the one-shot path
+     * silently emits a different ciphertext/tag from the streaming path.
+     */
+    if (!TEST_mem_eq(tag_oneshot, cfg->taglen, tag_stream, cfg->taglen)) {
+        TEST_error("%s (%s): oneshot-encrypt tag != streaming-encrypt tag",
+            cfg->name, with_aad ? "with AAD" : "no AAD");
+        goto end;
+    }
+    ok = 1;
+end:
+    return ok;
+}
+
 #ifndef OPENSSL_NO_DES
 static int test_EVP_CIPHER_get_type_des_ede3(void)
 {
@@ -8136,6 +8400,8 @@ int setup_tests(void)
     ADD_TEST(test_aes_rc4_keylen_change_cve_2023_5363);
 #endif

+    ADD_ALL_TESTS(test_aead_oneshot_roundtrip, 2 * OSSL_NELEM(aead_oneshot_cfgs));
+
     ADD_TEST(test_invalid_ctx_for_digest);

     ADD_TEST(test_evp_cipher_negative_length);