Commit 84419e373a for openssl.org

commit 84419e373ac1f28194fc4b2d9398f664322c9b75
Author: Viktor Dukhovni <openssl-users@dukhovni.org>
Date:   Tue Nov 4 17:40:53 2025 +1100

    Improved handling of AKID/SKID extensions in CSRs and certs

    With the advent of the new AKID and SKID value syntax:

        - subjectKeyIdentifier = none
        - authorityKeyIdentifier = none

    the resulting extensions are not directly suppressed, instead they
    are parsed respectively as an empty OCTET STRING or an empty SEQUENCE.

    There was new code in "apps/" to then drop the extension when signing
    certificates, but this did not address applications that sign
    certificates via the API, nor did it avoid adding these in CSRs.

    We now drop these specific empty extensions from both certificates and
    CSRs as part of X509v3_add_ext(), with the parsed extension never added
    to the extension stack.

    This means that in the "apps" the default "hash" or "keyid, issuer"
    values must now be created first, with config settings and command-line
    options applied after replacing the default as requested (including
    "none" to suppress the extensions if desired).

    If somehow an application manages to construct a TBS cert or CSR with a
    pending empty SKID or AKID extension, an error is raised and the TBS is
    not signed.

    When no SKID is present in a self-signed cert, attempts to
    force an AKID keyid need to fail, rather than produce an AKID
    that will never match.

    Similarly, when issuer cert == subject cert (same object in memory), but
    the issuer key is not the subject key (forced signing key), there is no
    way to infer the unknown issuer's name or serial number, so, with
    "issuer:always" we must fail, rather than record the subject's own
    issuer name and serial.

    New "keyid:nonss" and "issuer:nonss" syntax is now supported, which
    allows either part of the AKID to be conditional on the certificate not
    being self-signed.  In the case if "issuer:nonss" this is also
    conditional on there being no non-empty issuer keyid.  To force
    inclusion of issuer+serial in the AKID use "issuer:always".

    Reviewed-by: Matt Caswell <matt@openssl.org>
    Reviewed-by: Neil Horman <nhorman@openssl.org>
    MergeDate: Sat Feb 28 15:22:47 2026
    (Merged from https://github.com/openssl/openssl/pull/29057)

diff --git a/apps/lib/apps.c b/apps/lib/apps.c
index 0e9e1bf7b6..268b7de666 100644
--- a/apps/lib/apps.c
+++ b/apps/lib/apps.c
@@ -2508,33 +2508,6 @@ int do_EXT_REQ_add_nconf(CONF *conf1, CONF *conf2, X509V3_CTX *ctx,
     return 0;
 }

-static int adapt_keyid_ext(X509 *cert, X509V3_CTX *ext_ctx,
-    const char *name, const char *value, int add_default)
-{
-    const STACK_OF(X509_EXTENSION) *exts = X509_get0_extensions(cert);
-    X509_EXTENSION *new_ext = X509V3_EXT_nconf(NULL, ext_ctx, name, value);
-    int idx, rv = 0;
-
-    if (new_ext == NULL)
-        return rv;
-
-    idx = X509v3_get_ext_by_OBJ(exts, X509_EXTENSION_get_object(new_ext), -1);
-    if (idx >= 0) {
-        const X509_EXTENSION *found_ext = X509v3_get_ext(exts, idx);
-        const ASN1_OCTET_STRING *encoded = X509_EXTENSION_get_data(found_ext);
-        int disabled = ASN1_STRING_length(encoded) <= 2; /* indicating "none" */
-
-        if (disabled) {
-            X509_EXTENSION_free(X509_delete_ext(cert, idx));
-        } /* else keep existing key identifier, which might be outdated */
-        rv = 1;
-    } else {
-        rv = !add_default || X509_add_ext(cert, new_ext, -1);
-    }
-    X509_EXTENSION_free(new_ext);
-    return rv;
-}
-
 int cert_matches_key(const X509 *cert, const EVP_PKEY *pkey)
 {
     int match;
@@ -2549,32 +2522,14 @@ int cert_matches_key(const X509 *cert, const EVP_PKEY *pkey)
 int do_X509_sign(X509 *cert, int force_v1, EVP_PKEY *pkey, const char *md,
     STACK_OF(OPENSSL_STRING) *sigopts, X509V3_CTX *ext_ctx)
 {
-    EVP_MD_CTX *mctx = EVP_MD_CTX_new();
-    int self_sign;
+    EVP_MD_CTX *mctx;
     int rv = 0;

-    if (!force_v1) {
-        if (!X509_set_version(cert, X509_VERSION_3))
-            goto end;
-
-        /*
-         * Add default SKID before AKID such that AKID can make use of it
-         * in case the certificate is self-signed
-         */
-        /* Prevent X509_V_ERR_MISSING_SUBJECT_KEY_IDENTIFIER */
-        if (!adapt_keyid_ext(cert, ext_ctx, "subjectKeyIdentifier", "hash", 1))
-            goto end;
-        /* Prevent X509_V_ERR_MISSING_AUTHORITY_KEY_IDENTIFIER */
-        self_sign = cert_matches_key(cert, pkey);
-        if (!adapt_keyid_ext(cert, ext_ctx, "authorityKeyIdentifier",
-                "keyid, issuer", !self_sign))
-            goto end;
-    }
-    /* May add further measures for ensuring RFC 5280 compliance, see #19805 */
-
-    if (mctx != NULL && do_sign_init(mctx, pkey, md, sigopts) > 0)
+    if (!force_v1 && !X509_set_version(cert, X509_VERSION_3))
+        return 0;
+    if ((mctx = EVP_MD_CTX_new()) != NULL
+        && do_sign_init(mctx, pkey, md, sigopts) > 0)
         rv = (X509_sign_ctx(cert, mctx) > 0);
-end:
     EVP_MD_CTX_free(mctx);
     return rv;
 }
diff --git a/apps/req.c b/apps/req.c
index fc4c12d7c3..e44744a221 100644
--- a/apps/req.c
+++ b/apps/req.c
@@ -593,7 +593,7 @@ int req_main(int argc, char **argv)
             digest = p;
     }

-    if (extsect == NULL)
+    if (extsect == NULL && !x509v1)
         extsect = app_conf_try_string(req_conf, section,
             gen_x509 ? V3_EXTENSIONS : REQ_EXTENSIONS);
     if (extsect != NULL) {
diff --git a/apps/x509.c b/apps/x509.c
index 836d0a4011..1ecb54bc9b 100644
--- a/apps/x509.c
+++ b/apps/x509.c
@@ -330,6 +330,40 @@ static int add_object(STACK_OF(ASN1_OBJECT) **sk, const char *name,
     return 0;
 }

+static CONF *load_ext_conf(const char *file, const char *sect, int quiet)
+{
+    BIO *bio = NULL;
+    CONF *conf;
+
+    if (file == NULL)
+        file = default_config_file;
+    if (file != NULL && *file != '\0')
+        bio = bio_open_default_quiet(file, 'r', FORMAT_TEXT);
+    else if (!quiet) {
+        BIO_puts(bio_err, "The -extfile option, or else the default "
+                          "configuration filename must be nonempty");
+        return NULL;
+    }
+
+    if (bio == NULL) {
+        if (!quiet)
+            BIO_printf(bio_err, "Error opening: %s\n", file);
+        return NULL;
+    }
+
+    conf = NCONF_new_ex(app_get0_libctx(), NULL);
+    if (conf != NULL && NCONF_load_bio(conf, bio, NULL) <= 0) {
+        NCONF_free(conf);
+        conf = NULL;
+    }
+    BIO_free(bio);
+    if (conf != NULL)
+        return conf;
+    if (!quiet)
+        BIO_printf(bio_err, "Error loading configuration from: %s\n", file);
+    return conf;
+}
+
 int x509_main(int argc, char **argv)
 {
     ASN1_INTEGER *sno = NULL;
@@ -339,7 +373,7 @@ int x509_main(int argc, char **argv)
     X509V3_CTX ext_ctx;
     EVP_PKEY *privkey = NULL, *CAkey = NULL, *pubkey = NULL;
     EVP_PKEY *pkey;
-    int newcert = 0;
+    int newcert = 0, newout = 0;
     char *issu = NULL, *subj = NULL, *digest = NULL;
     X509_NAME *fissu = NULL, *fsubj = NULL;
     const unsigned long chtype = MBSTRING_ASC;
@@ -679,6 +713,29 @@ int x509_main(int argc, char **argv)
     if (!opt_check_md(digest))
         goto opthelp;

+    if (reqfile || newcert || privkey != NULL || CAfile != NULL)
+        newout = 1;
+    else if (sno != NULL
+        || not_before != NULL
+        || not_after != NULL
+        || days != UNSET_DAYS
+        || preserve_dates
+        || issu != NULL
+        || subj != NULL
+        || pubkeyfile != NULL
+        || clrext
+        || extfile != NULL
+        || extsect != NULL
+        || sigopts != NULL) {
+        BIO_printf(bio_err,
+            "The %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s and %s "
+            "options are only valid when signing a new certificate\n",
+            "set_serial", "not_before", "not_after", "days",
+            "preserve_dates", "set_issuer", "set_subject", "subj",
+            "force_pubkey", "clrext", "extfile", "extensions", "sigopt");
+        goto err;
+    }
+
     if (preserve_dates && not_before != NULL) {
         BIO_puts(bio_err, "Cannot use -preserve_dates with -not_before option\n");
         goto err;
@@ -766,24 +823,29 @@ int x509_main(int argc, char **argv)
             WARN_NO_CA("-CAcreateserial");
     }

-    if (extfile == NULL) {
-        if (extsect != NULL)
-            BIO_puts(bio_err,
-                "Warning: ignoring -extensions option without -extfile\n");
-    } else {
+    /*
+     * Failure to find or process the default config file is silent, when no
+     * `-extensions` was specified.  Otherwise, the requested extensions
+     * section must be present.
+     */
+    int confquiet = extsect == NULL && extfile == NULL;
+    if (newout)
+        extconf = load_ext_conf(extfile, extsect, confquiet);
+
+    if (extconf != NULL) {
         X509V3_CTX ctx2;

-        if ((extconf = app_load_config(extfile)) == NULL)
-            goto err;
         if (extsect == NULL) {
             extsect = app_conf_try_string(extconf, "default", "extensions");
-            if (extsect == NULL)
+            if (extfile != NULL && extsect == NULL)
                 extsect = "default";
         }
         X509V3_set_ctx_test(&ctx2);
         if (!do_EXT_add_nconf(extconf, extconf, &ctx2, NULL,
                 "Error checking extension section %s\n", extsect))
             goto err;
+    } else if (newout && !confquiet) {
+        goto err;
     }

     if (multi && (reqfile || newcert)) {
@@ -947,10 +1009,11 @@ cert_loop:
         if (!X509V3_set_issuer_pkey(&ext_ctx, privkey))
             goto err;
     }
-    if (extconf != NULL && !x509toreq
-        && !do_EXT_add_nconf(extconf, extconf, &ext_ctx, x,
-            "Error adding extensions from section %s\n", extsect))
-        goto err;
+    if (extconf != NULL && !x509toreq) {
+        if (!do_EXT_add_nconf(extconf, extconf, &ext_ctx, x,
+                "Error adding extensions from section %s\n", extsect))
+            goto err;
+    }

     /* At this point the contents of the certificate x have been finished. */

@@ -971,10 +1034,11 @@ cert_loop:
         }
         if ((rq = x509_to_req(x, ext_copy, ext_names)) == NULL)
             goto err;
-        if (extconf != NULL
-            && !do_EXT_REQ_add_nconf(extconf, extconf, &ext_ctx, rq,
-                "Error adding request extensions from section %s\n", extsect))
-            goto err;
+        if (extconf != NULL) {
+            if (!do_EXT_REQ_add_nconf(extconf, extconf, &ext_ctx, rq,
+                    "Error adding request extensions from section %s\n", extsect))
+                goto err;
+        }
         if (!do_X509_REQ_sign(rq, privkey, digest, sigopts))
             goto err;
         if (!noout) {
diff --git a/crypto/err/openssl.txt b/crypto/err/openssl.txt
index 41a0108967..43ddba739d 100644
--- a/crypto/err/openssl.txt
+++ b/crypto/err/openssl.txt
@@ -1852,6 +1852,7 @@ X509_R_IDP_MISMATCH:128:idp mismatch
 X509_R_INVALID_ATTRIBUTES:138:invalid attributes
 X509_R_INVALID_DIRECTORY:113:invalid directory
 X509_R_INVALID_DISTPOINT:143:invalid distpoint
+X509_R_INVALID_EXTENSION:146:invalid extension
 X509_R_INVALID_FIELD_NAME:119:invalid field name
 X509_R_INVALID_TRUST:123:invalid trust
 X509_R_ISSUER_MISMATCH:129:issuer mismatch
diff --git a/crypto/x509/v3_akid.c b/crypto/x509/v3_akid.c
index d5afc4f880..7556d10533 100644
--- a/crypto/x509/v3_akid.c
+++ b/crypto/x509/v3_akid.c
@@ -109,30 +109,32 @@ static AUTHORITY_KEYID *v2i_AUTHORITY_KEYID(X509V3_EXT_METHOD *method,
     const X509_EXTENSION *ext;
     X509 *issuer_cert;
     int same_issuer, ss;
+    int nonss = 0, always = 0;
     AUTHORITY_KEYID *akeyid = AUTHORITY_KEYID_new();

     if (akeyid == NULL)
         goto err;

-    if (n == 1 && strcmp(sk_CONF_VALUE_value(values, 0)->name, "none") == 0) {
+    if (n == 1 && strcmp(sk_CONF_VALUE_value(values, 0)->name, "none") == 0)
         return akeyid;
-    }

     for (i = 0; i < n; i++) {
         cnf = sk_CONF_VALUE_value(values, i);
-        if (cnf->value != NULL && strcmp(cnf->value, "always") != 0) {
-            ERR_raise_data(ERR_LIB_X509V3, X509V3_R_UNKNOWN_OPTION,
-                "name=%s option=%s", cnf->name, cnf->value);
-            goto err;
+        if (cnf->value != NULL && *cnf->value != '\0') {
+            if (strcmp(cnf->value, "always") == 0) {
+                always = 1;
+            } else if (strcmp(cnf->value, "nonss") == 0) {
+                nonss = 1;
+            } else {
+                ERR_raise_data(ERR_LIB_X509V3, X509V3_R_UNKNOWN_OPTION,
+                    "name=%s option=%s", cnf->name, cnf->value);
+                goto err;
+            }
         }
         if (strcmp(cnf->name, "keyid") == 0 && keyid == 0) {
-            keyid = 1;
-            if (cnf->value != NULL)
-                keyid = 2;
+            keyid = always ? 3 : (nonss ? 1 : 2);
         } else if (strcmp(cnf->name, "issuer") == 0 && issuer == 0) {
-            issuer = 1;
-            if (cnf->value != NULL)
-                issuer = 2;
+            issuer = always ? 3 : (nonss ? 1 : 2);
         } else if (strcmp(cnf->name, "none") == 0
             || strcmp(cnf->name, "keyid") == 0
             || strcmp(cnf->name, "issuer") == 0) {
@@ -165,43 +167,66 @@ static AUTHORITY_KEYID *v2i_AUTHORITY_KEYID(X509V3_EXT_METHOD *method,
         ss = same_issuer;
     ERR_pop_to_mark();

-    /* unless forced with "always", AKID is suppressed for self-signed certs */
-    if (keyid == 2 || (keyid == 1 && !ss)) {
+    if (keyid > 1 || (keyid == 1 && !ss)) {
         /*
-         * prefer any pre-existing subject key identifier of the issuer cert
-         * except issuer cert is same as subject cert and is not self-signed
+         * The subject key identifier of the issuer cert is acceptable unless
+         * the issuer cert is same as subject cert, but the subject will not
+         * not be self-signed (i.e. will be signed with a different key).
          */
         i = X509_get_ext_by_NID(issuer_cert, NID_subject_key_identifier, -1);
         if (i >= 0 && (ext = X509_get_ext(issuer_cert, i)) != NULL
             && !(same_issuer && !ss)) {
             ikeyid = X509V3_EXT_d2i(ext);
-            if (ASN1_STRING_length(ikeyid) == 0) /* indicating "none" */ {
+            /* Ignore empty keyids in the issuer cert */
+            if (ASN1_STRING_length(ikeyid) == 0) {
                 ASN1_OCTET_STRING_free(ikeyid);
                 ikeyid = NULL;
             }
         }
-        if (ikeyid == NULL && same_issuer && ctx->issuer_pkey != NULL) {
-            /* generate fallback AKID, emulating s2i_skey_id(..., "hash") */
+        /*
+         * If we have that other key in hand, synthesise a fallback AKID,
+         * emulating s2i_skey_id(..., "hash").
+         *
+         * When creating self-signed certificates, we do not synthesise
+         * best-effort keyids, instead the keyid needs be set first.  The
+         * X509V3_EXT_add_nconf_sk() function makes every effort to process the
+         * SKID before the AKID, but some callers may specify extensions
+         * piecemeal.  We don't want to mispredict it being set later and end
+         * up with a "dangling" AKID keyid.
+         */
+        if (ikeyid == NULL && same_issuer && !ss && ctx->issuer_pkey != NULL) {
             X509_PUBKEY *pubkey = NULL;

             if (X509_PUBKEY_set(&pubkey, ctx->issuer_pkey))
                 ikeyid = ossl_x509_pubkey_hash(pubkey);
             X509_PUBKEY_free(pubkey);
         }
-        if (keyid == 2 && ikeyid == NULL) {
+        if (keyid == 3 && ikeyid == NULL) {
             ERR_raise(ERR_LIB_X509V3, X509V3_R_UNABLE_TO_GET_ISSUER_KEYID);
             goto err;
         }
     }

-    if (issuer == 2 || (issuer == 1 && !ss && ikeyid == NULL)) {
-        isname = X509_NAME_dup(X509_get_issuer_name(issuer_cert));
-        serial = ASN1_INTEGER_dup(X509_get0_serialNumber(issuer_cert));
-        if (isname == NULL || serial == NULL) {
-            ERR_raise(ERR_LIB_X509V3, X509V3_R_UNABLE_TO_GET_ISSUER_DETAILS);
-            goto err;
+    /*
+     * When the same object is specified as both the issuer and subject
+     * certificate, but with a different (forced) issuer public key (so not
+     * self-signed), we don't have access to the true issuer certificate's
+     * serial number, so can't create an isser+serial AKID.
+     */
+    if (!same_issuer || ss) {
+        if (issuer == 3 /* always */
+            || (ikeyid == NULL
+                && (issuer == 2 /* absent a keyid */
+                    || (issuer == 1 && !ss) /* unless self-signed */))) {
+            isname = X509_NAME_dup(X509_get_issuer_name(issuer_cert));
+            serial = ASN1_INTEGER_dup(X509_get0_serialNumber(issuer_cert));
         }
     }
+    /* "always" fails unless both issuer and serial are available */
+    if (issuer == 3 && (isname == NULL || serial == NULL)) {
+        ERR_raise(ERR_LIB_X509V3, X509V3_R_UNABLE_TO_GET_ISSUER_DETAILS);
+        goto err;
+    }

     if (isname != NULL) {
         if ((gens = sk_GENERAL_NAME_new_null()) == NULL
diff --git a/crypto/x509/v3_conf.c b/crypto/x509/v3_conf.c
index 4f0539c90e..81cfce8b48 100644
--- a/crypto/x509/v3_conf.c
+++ b/crypto/x509/v3_conf.c
@@ -375,6 +375,37 @@ int X509V3_EXT_CRL_add_nconf(CONF *conf, X509V3_CTX *ctx, const char *section,
     return X509V3_EXT_add_nconf_sk(conf, ctx, section, sk);
 }

+static int
+update_req_extensions(X509_REQ *req, int *pnid, STACK_OF(X509_EXTENSION) *exts)
+{
+    unsigned char *ext = NULL;
+    int ret = 0, loc = -1, extlen;
+
+    if (pnid == NULL || *pnid == NID_undef)
+        if ((pnid = X509_REQ_get_extension_nids()) == NULL)
+            return 0;
+    loc = X509at_get_attr_by_NID(req->req_info.attributes, *pnid, -1);
+
+    extlen = ASN1_item_i2d((const ASN1_VALUE *)exts,
+        &ext, ASN1_ITEM_rptr(X509_EXTENSIONS));
+    if (extlen <= 0)
+        return ret;
+
+    if (loc != -1) {
+        X509_ATTRIBUTE *att = X509at_delete_attr(req->req_info.attributes, loc);
+
+        if (att == NULL)
+            goto end;
+        X509at_delete_attr(req->req_info.attributes, loc);
+        X509_ATTRIBUTE_free(att);
+    }
+    ret = X509_REQ_add1_attr_by_NID(req, *pnid, V_ASN1_SEQUENCE, ext, extlen);
+
+end:
+    OPENSSL_free(ext);
+    return ret;
+}
+
 /*
  * Add extensions to certificate request. Just check in case req is NULL.
  * Note that on error new elements may remain added to req if req != NULL.
@@ -383,10 +414,26 @@ int X509V3_EXT_REQ_add_nconf(CONF *conf, X509V3_CTX *ctx, const char *section,
     X509_REQ *req)
 {
     STACK_OF(X509_EXTENSION) *exts = NULL;
-    int ret = X509V3_EXT_add_nconf_sk(conf, ctx, section, &exts);
+    int ret, *pnid = NULL;
+
+    /*
+     * Load current extensions if any, so we can replace any duplicates, possibly
+     * with nothing in the case of empty AKID/SKID.
+     */
+    if (req != NULL) {
+        for (pnid = X509_REQ_get_extension_nids(); *pnid != NID_undef; pnid++) {
+            exts = ossl_x509_req_get1_extensions_by_nid(req, *pnid);
+            if (sk_X509_EXTENSION_num(exts) > 0)
+                break;
+            sk_X509_EXTENSION_free(exts);
+            exts = NULL;
+        }
+    }

+    ret = X509V3_EXT_add_nconf_sk(conf, ctx, section, &exts);
+    /* Replace original extension list (stack) with updated stack */
     if (ret && req != NULL && exts != NULL)
-        ret = X509_REQ_add_extensions(req, exts);
+        ret = update_req_extensions(req, pnid, exts);
     sk_X509_EXTENSION_pop_free(exts, X509_EXTENSION_free);
     return ret;
 }
diff --git a/crypto/x509/x509_err.c b/crypto/x509/x509_err.c
index 3d6e8768f8..80ec494ddd 100644
--- a/crypto/x509/x509_err.c
+++ b/crypto/x509/x509_err.c
@@ -1,6 +1,6 @@
 /*
  * Generated by util/mkerr.pl DO NOT EDIT
- * Copyright 1995-2024 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 1995-2025 The OpenSSL Project Authors. All Rights Reserved.
  *
  * Licensed under the Apache License 2.0 (the "License").  You may not use
  * this file except in compliance with the License.  You can obtain a copy
@@ -39,6 +39,7 @@ static const ERR_STRING_DATA X509_str_reasons[] = {
         "invalid attributes" },
     { ERR_PACK(ERR_LIB_X509, 0, X509_R_INVALID_DIRECTORY), "invalid directory" },
     { ERR_PACK(ERR_LIB_X509, 0, X509_R_INVALID_DISTPOINT), "invalid distpoint" },
+    { ERR_PACK(ERR_LIB_X509, 0, X509_R_INVALID_EXTENSION), "invalid extension" },
     { ERR_PACK(ERR_LIB_X509, 0, X509_R_INVALID_FIELD_NAME),
         "invalid field name" },
     { ERR_PACK(ERR_LIB_X509, 0, X509_R_INVALID_TRUST), "invalid trust" },
diff --git a/crypto/x509/x509_ext.c b/crypto/x509/x509_ext.c
index f5c807caf5..55aae29618 100644
--- a/crypto/x509/x509_ext.c
+++ b/crypto/x509/x509_ext.c
@@ -102,12 +102,30 @@ const X509_EXTENSION *X509_get_ext(const X509 *x, int loc)

 X509_EXTENSION *X509_delete_ext(X509 *x, int loc)
 {
-    return delete_ext(&x->cert_info.extensions, loc);
+    X509_EXTENSION *ret;
+
+    if (x->cert_info.extensions == NULL)
+        return NULL;
+    if ((ret = delete_ext(&x->cert_info.extensions, loc)) != NULL)
+        x->cert_info.enc.modified = 1;
+    return ret;
 }

 int X509_add_ext(X509 *x, const X509_EXTENSION *ex, int loc)
 {
-    return (X509v3_add_ext(&(x->cert_info.extensions), ex, loc) != NULL);
+    STACK_OF(X509_EXTENSION) *exts = x->cert_info.extensions;
+
+    if (X509v3_add_ext(&exts, ex, loc) == NULL)
+        return 0;
+    if (sk_X509_EXTENSION_num(exts) != 0) {
+        x->cert_info.extensions = exts;
+    } else {
+        sk_X509_EXTENSION_pop_free(exts, X509_EXTENSION_free);
+        sk_X509_EXTENSION_pop_free(x->cert_info.extensions, X509_EXTENSION_free);
+        x->cert_info.extensions = NULL;
+    }
+    x->cert_info.enc.modified = 1;
+    return 1;
 }

 void *X509_get_ext_d2i(const X509 *x, int nid, int *crit, int *idx)
@@ -118,6 +136,11 @@ void *X509_get_ext_d2i(const X509 *x, int nid, int *crit, int *idx)
 int X509_add1_ext_i2d(X509 *x, int nid, void *value, int crit,
     unsigned long flags)
 {
+    /*
+     * Assume modified, sadly the underlying function does not tell us whether
+     * changes were made, or not.
+     */
+    x->cert_info.enc.modified = 1;
     return X509V3_add1_i2d(&x->cert_info.extensions, nid, value, crit,
         flags);
 }
diff --git a/crypto/x509/x509_req.c b/crypto/x509/x509_req.c
index 540cc23e48..e66ea7b0b6 100644
--- a/crypto/x509/x509_req.c
+++ b/crypto/x509/x509_req.c
@@ -117,8 +117,8 @@ void X509_REQ_set_extension_nids(int *nids)
     ext_nids = nids;
 }

-static STACK_OF(X509_EXTENSION) *get_extensions_by_nid(const X509_REQ *req,
-    int nid)
+STACK_OF(X509_EXTENSION) *
+ossl_x509_req_get1_extensions_by_nid(const X509_REQ *req, int nid)
 {
     X509_ATTRIBUTE *attr;
     const ASN1_TYPE *ext = NULL;
@@ -147,7 +147,7 @@ STACK_OF(X509_EXTENSION) *X509_REQ_get_extensions(const X509_REQ *req)
     if (req == NULL || ext_nids == NULL)
         return NULL;
     for (pnid = ext_nids; *pnid != NID_undef; pnid++) {
-        exts = get_extensions_by_nid(req, *pnid);
+        exts = ossl_x509_req_get1_extensions_by_nid(req, *pnid);
         if (exts == NULL)
             return NULL;
         if (sk_X509_EXTENSION_num(exts) > 0)
@@ -176,7 +176,7 @@ int X509_REQ_add_extensions_nid(X509_REQ *req,

     loc = X509at_get_attr_by_NID(req->req_info.attributes, nid, -1);
     if (loc != -1) {
-        if ((mod_exts = get_extensions_by_nid(req, nid)) == NULL)
+        if ((mod_exts = ossl_x509_req_get1_extensions_by_nid(req, nid)) == NULL)
             return 0;
         if (X509v3_add_extensions(&mod_exts, exts) == NULL)
             goto end;
diff --git a/crypto/x509/x509_v3.c b/crypto/x509/x509_v3.c
index b24950b570..ac53530914 100644
--- a/crypto/x509/x509_v3.c
+++ b/crypto/x509/x509_v3.c
@@ -120,6 +120,20 @@ STACK_OF(X509_EXTENSION) *X509v3_add_ext(STACK_OF(X509_EXTENSION) **x,
     } else
         sk = *x;

+    /*
+     * Empty OCTET STRINGs and empty SEQUENCEs encode to just two bytes of tag
+     * (0x04 or 0x30) and length (0x00).
+     */
+    if (ex->value.length == 2
+        && (ex->value.data[0] == 0x30 || ex->value.data[0] == 0x04)) {
+        ASN1_OBJECT *obj = ex->object;
+        ASN1_OBJECT *skid = OBJ_nid2obj(NID_subject_key_identifier);
+        ASN1_OBJECT *akid = OBJ_nid2obj(NID_authority_key_identifier);
+
+        if (OBJ_cmp(obj, skid) == 0 || OBJ_cmp(obj, akid) == 0)
+            goto done;
+    }
+
     n = sk_X509_EXTENSION_num(sk);
     if (loc > n)
         loc = n;
@@ -134,6 +148,7 @@ STACK_OF(X509_EXTENSION) *X509v3_add_ext(STACK_OF(X509_EXTENSION) **x,
         ERR_raise(ERR_LIB_X509, ERR_R_CRYPTO_LIB);
         goto err;
     }
+done:
     if (*x == NULL)
         *x = sk;
     return sk;
diff --git a/crypto/x509/x_all.c b/crypto/x509/x_all.c
index 96a20d2631..fa7218ef86 100644
--- a/crypto/x509/x_all.c
+++ b/crypto/x509/x_all.c
@@ -25,6 +25,7 @@
 #include <openssl/dsa.h>
 #include <openssl/x509v3.h>
 #include "internal/asn1.h"
+#include "crypto/asn1.h"
 #include "crypto/pkcs7.h"
 #include "crypto/x509.h"
 #include "crypto/x509_acert.h"
@@ -74,14 +75,46 @@ int NETSCAPE_SPKI_verify(NETSCAPE_SPKI *a, EVP_PKEY *r)
         &a->sig_algor, a->signature, a->spkac, r);
 }

+/* Detect invalid empty SKID or AKID extensions. */
+
+static int bad_keyid_exts(const STACK_OF(X509_EXTENSION) *exts)
+{
+    int i, n = sk_X509_EXTENSION_num(exts);
+
+    for (i = 0; i < n; ++i) {
+        X509_EXTENSION *ext = sk_X509_EXTENSION_value(exts, i);
+        const ASN1_STRING *der = X509_EXTENSION_get_data(ext);
+
+        /*
+         * Empty OCTET STRINGs and empty SEQUENCEs encode to just two bytes of
+         * tag (0x04 or 0x30) and length (0x00).
+         */
+        if (der->length == 2 && (der->data[0] == 0x04 || der->data[0] == 0x30)) {
+            const ASN1_OBJECT *obj = X509_EXTENSION_get_object(ext);
+            const ASN1_OBJECT *skid = OBJ_nid2obj(NID_subject_key_identifier);
+            const ASN1_OBJECT *akid = OBJ_nid2obj(NID_authority_key_identifier);
+
+            if (OBJ_cmp(obj, skid) != 0 && OBJ_cmp(obj, akid) != 0)
+                continue;
+            ERR_raise_data(ERR_LIB_X509, X509_R_INVALID_EXTENSION,
+                "Invalid empty X.509 %s extension", obj->sn);
+            return 1;
+        }
+    }
+    return 0;
+}
+
 int X509_sign(X509 *x, EVP_PKEY *pkey, const EVP_MD *md)
 {
+    const STACK_OF(X509_EXTENSION) *exts;
+
     if (x == NULL) {
         ERR_raise(ERR_LIB_X509, ERR_R_PASSED_NULL_PARAMETER);
         return 0;
     }
-    if (sk_X509_EXTENSION_num(X509_get0_extensions(x)) > 0
-        && !X509_set_version(x, X509_VERSION_3))
+    if ((exts = X509_get0_extensions(x)) != NULL
+        && sk_X509_EXTENSION_num(exts) > 0
+        && (bad_keyid_exts(exts) || !X509_set_version(x, X509_VERSION_3)))
         return 0;

     /*
@@ -99,10 +132,14 @@ int X509_sign(X509 *x, EVP_PKEY *pkey, const EVP_MD *md)

 int X509_sign_ctx(X509 *x, EVP_MD_CTX *ctx)
 {
+    const STACK_OF(X509_EXTENSION) *exts;
+
     if (x == NULL) {
         ERR_raise(ERR_LIB_X509, ERR_R_PASSED_NULL_PARAMETER);
         return 0;
     }
+    if ((exts = X509_get0_extensions(x)) != NULL && bad_keyid_exts(exts))
+        return 0;
     if (sk_X509_EXTENSION_num(X509_get0_extensions(x)) > 0
         && !X509_set_version(x, X509_VERSION_3))
         return 0;
@@ -139,10 +176,18 @@ X509 *X509_load_http(const char *url, BIO *bio, BIO *rbio, int timeout)

 int X509_REQ_sign(X509_REQ *x, EVP_PKEY *pkey, const EVP_MD *md)
 {
+    STACK_OF(X509_EXTENSION) *exts;
+    int bad = 0;
+
     if (x == NULL) {
         ERR_raise(ERR_LIB_X509, ERR_R_PASSED_NULL_PARAMETER);
         return 0;
     }
+    if ((exts = ossl_x509_req_get1_extensions_by_nid(x, NID_ext_req)) != NULL)
+        bad = bad_keyid_exts(exts);
+    sk_X509_EXTENSION_pop_free(exts, X509_EXTENSION_free);
+    if (bad)
+        return 0;
     x->req_info.enc.modified = 1;
     return ASN1_item_sign_ex(ASN1_ITEM_rptr(X509_REQ_INFO), &x->sig_alg, NULL,
         x->signature, &x->req_info, NULL,
@@ -151,10 +196,18 @@ int X509_REQ_sign(X509_REQ *x, EVP_PKEY *pkey, const EVP_MD *md)

 int X509_REQ_sign_ctx(X509_REQ *x, EVP_MD_CTX *ctx)
 {
+    STACK_OF(X509_EXTENSION) *exts;
+    int bad = 0;
+
     if (x == NULL) {
         ERR_raise(ERR_LIB_X509, ERR_R_PASSED_NULL_PARAMETER);
         return 0;
     }
+    if ((exts = ossl_x509_req_get1_extensions_by_nid(x, NID_ext_req)) != NULL)
+        bad = bad_keyid_exts(exts);
+    sk_X509_EXTENSION_pop_free(exts, X509_EXTENSION_free);
+    if (bad)
+        return 0;
     x->req_info.enc.modified = 1;
     return ASN1_item_sign_ctx(ASN1_ITEM_rptr(X509_REQ_INFO),
         &x->sig_alg, NULL, x->signature, &x->req_info,
diff --git a/include/crypto/x509.h b/include/crypto/x509.h
index dffbac9d58..fdc223e5b0 100644
--- a/include/crypto/x509.h
+++ b/include/crypto/x509.h
@@ -331,6 +331,8 @@ int ossl_x509_add_cert_new(STACK_OF(X509) **sk, const X509 *cert, int flags);
 int ossl_x509_add_certs_new(STACK_OF(X509) **p_sk, const STACK_OF(X509) *certs, int flags);

 STACK_OF(X509_ATTRIBUTE) *ossl_x509at_dup(const STACK_OF(X509_ATTRIBUTE) *x);
+STACK_OF(X509_EXTENSION) *
+ossl_x509_req_get1_extensions_by_nid(const X509_REQ *req, int nid);

 int ossl_x509_PUBKEY_get0_libctx(OSSL_LIB_CTX **plibctx, const char **ppropq,
     const X509_PUBKEY *key);
diff --git a/include/crypto/x509err.h b/include/crypto/x509err.h
index 4d4572861b..9a72276234 100644
--- a/include/crypto/x509err.h
+++ b/include/crypto/x509err.h
@@ -1,6 +1,6 @@
 /*
  * Generated by util/mkerr.pl DO NOT EDIT
- * Copyright 2020-2022 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 2020-2025 The OpenSSL Project Authors. All Rights Reserved.
  *
  * Licensed under the Apache License 2.0 (the "License").  You may not use
  * this file except in compliance with the License.  You can obtain a copy
diff --git a/include/openssl/x509err.h b/include/openssl/x509err.h
index 7123a725e8..a564f5963d 100644
--- a/include/openssl/x509err.h
+++ b/include/openssl/x509err.h
@@ -1,6 +1,6 @@
 /*
  * Generated by util/mkerr.pl DO NOT EDIT
- * Copyright 1995-2024 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 1995-2025 The OpenSSL Project Authors. All Rights Reserved.
  *
  * Licensed under the Apache License 2.0 (the "License").  You may not use
  * this file except in compliance with the License.  You can obtain a copy
@@ -35,6 +35,7 @@
 #define X509_R_INVALID_ATTRIBUTES 138
 #define X509_R_INVALID_DIRECTORY 113
 #define X509_R_INVALID_DISTPOINT 143
+#define X509_R_INVALID_EXTENSION 146
 #define X509_R_INVALID_FIELD_NAME 119
 #define X509_R_INVALID_TRUST 123
 #define X509_R_ISSUER_MISMATCH 129
diff --git a/test/recipes/25-test_req.t b/test/recipes/25-test_req.t
index a10aef8628..2230d39476 100644
--- a/test/recipes/25-test_req.t
+++ b/test/recipes/25-test_req.t
@@ -610,7 +610,7 @@ has_AKID($cert, 0); # forced no AKID

 $cert = "self-signed_v3_CA_explicit_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = keyid");
-has_AKID($cert, 0); # for self-signed cert, AKID suppressed and not forced
+has_AKID($cert, 1);

 $cert = "self-signed_v3_CA_forced_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = keyid:always");
@@ -619,7 +619,7 @@ strict_verify($cert, 1);

 $cert = "self-signed_v3_CA_issuer_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = issuer");
-has_AKID($cert, 0); # suppressed AKID since not forced
+has_AKID($cert, 1);

 $cert = "self-signed_v3_CA_forced_issuer_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = issuer:always");
@@ -627,11 +627,11 @@ cert_contains($cert, "Authority Key Identifier: DirName:/CN=CA serial:", 1); # f

 $cert = "self-signed_v3_CA_nonforced_keyid_issuer_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = keyid, issuer");
-has_AKID($cert, 0); # AKID not present because not forced and cert self-signed
+has_AKID($cert, 1);

 $cert = "self-signed_v3_CA_keyid_forced_issuer_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = keyid, issuer:always");
-cert_contains($cert, "Authority Key Identifier: DirName:/CN=CA serial:", 1); # issuer AKID forced, with keyid not forced
+cert_contains($cert, "Authority Key Identifier: keyid(:[0-9A-Fa-f]{2})+ DirName:/CN=CA serial:", 1);

 $cert = "self-signed_v3_CA_forced_keyid_issuer_AKID.pem";
 generate_cert($cert, @v3_ca, "-addext", "authorityKeyIdentifier = keyid:always, issuer");