Skip to content

fips: run pending CASTs at provider init - #508

Closed
MarkAtwood wants to merge 1 commit into
wolfSSL:masterfrom
MarkAtwood:fips-cast-at-init
Closed

MarkAtwood wants to merge 1 commit into
wolfSSL:masterfrom
MarkAtwood:fips-cast-at-init

Conversation

@MarkAtwood

@MarkAtwood MarkAtwood commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

With a FIPS build, threads that make the first use of an algorithm at the same time can fail. Eight threads started together, each running its first EVP_KDF_derive() on TLS1-PRF, TLS13-KDF or SSHKDF, or its first HMAC, in a fresh process with wolfProvider loaded:

$ ./test/fips_cast_init.test        # new test, src/ and include/ at master
[INFO]   TLS1-PRF: 8 of 8 processes had a failed thread
[INFO]   TLS13-KDF: 8 of 8 processes had a failed thread
[INFO]   SSHKDF: 7 of 8 processes had a failed thread
[INFO]   HMAC: 5 of 8 processes had a failed thread
[ERROR]   CAST 12 is INIT after provider load
...

The failing calls return 0. On FIPS Ready that call is lost. On FIPS v5 it is worse: the thread that finds the CAST in progress puts the module in degraded mode, and every later crypto call in the process fails with FIPS_NOT_ALLOWED_E (-197). In our wolfProvider 1.2.2 package testing (1000 launches, 8 threads), 109 to 208 per 1000 processes lost all crypto on v5 that way through the TLS 1.2, TLS 1.3 and SSH KDFs.

wolfCrypt FIPS runs each CAST on the first use of its algorithm. wp_init_cast() serializes the CAST groups it runs, but not the KDF CASTs wolfCrypt runs inside the KDFs, nor the HMAC CASTs they depend on. wp_init_cast() also calls wc_RunCast_fips() on CASTs that already passed, which puts them back in progress while other threads may be using them.

The fix runs, once per process at provider init, every CAST still in FIPS_CAST_STATE_INIT except DH and ECC primitive-Z. Those are the slow ones and stay lazy as #351 intends (decode_oid_precheck depends on that). Every path to a wolfCrypt call gated on them now goes through wp_init_cast() first: wp_dh_new() for every DH key, and the ECC part of the ML-KEM hybrids, which now takes the ECDH group CAST like wp_ecdh_init(). wp_init_cast() skips CASTs that already passed. A failed CAST is logged and init continues; its algorithms fail when used. Non-FIPS builds are unchanged. Cost is once per process: about +5 to +9 ms for openssl list -providers in our 1.2.2 package measurements (v5 and FIPS Ready).

New standalone test test/fips_cast_init.test, in make check, models decode_oid_precheck. It checks that no CAST except DH and ECC primitive-Z is left to run after the provider loads, and that DH and ECC primitive-Z are still cold. It also races 8 threads on the first use of TLS1-PRF, TLS13-KDF, SSHKDF and HMAC, 8 fresh processes each. A module built without a CAST (ECC CDH here) leaves it PROCESSING when run, so only INIT and FAILURE count as errors.

Tested on master cd8836f with scripts/build-wolfprovider.sh (OpenSSL 3.5.4, wolfSSL 5.9.4):

  • FIPS Ready 5.9.4 bundle (--fips-check=ready): unit.test 224/224, decode_oid_precheck and fips_cast_init pass. With only the src/ and include/ changes reverted, fips_cast_init failed 20 of 20 runs. Every run had 25 CASTs still INIT after load. In the race, processes with a failed thread were TLS1-PRF 136/160, TLS13-KDF 147/160, SSHKDF 121/160 and HMAC 80/160. With the fix: 0.
  • Default build: 247/247; fips_cast_init skips.
  • --replace-default --enable-replace-default-testing: 247/247; fips_cast_init skips.

FIPS v5 was not built here. The v5 numbers above are from our wolfProvider 1.2.2 package testing with the same change, where the failures dropped to 0 of 1000.

wolfCrypt FIPS runs an algorithm's CAST on first use. Threads making
that first use at once race on the CAST state, and the losing calls
fail; on FIPS v5 the loser puts the module in degraded mode, so every
later call fails with FIPS_NOT_ALLOWED_E. wp_init_cast() serializes the
CAST groups it runs, but not the TLS 1.2, TLS 1.3 and SSH KDF CASTs
wolfCrypt runs inside the KDFs, nor the HMAC CASTs those depend on.

Provider init now runs, once per process, every CAST still in
FIPS_CAST_STATE_INIT except the DH and ECC primitive-Z ones. Those stay
lazy as wolfSSL#351 intends (decode_oid_precheck relies on it), and every path
to a wolfCrypt call gated on them goes through wp_init_cast() first:
wp_dh_new() for all DH keys, and the ECC hybrid encap/decap in
wp_mlx_kem.c, which now takes the ECDH group CAST like wp_ecdh_init().
CASTs that passed are no longer re-run by wp_init_cast():
wc_RunCast_fips() puts them back in progress, failing other threads. A
failed CAST is logged and init goes on; its algorithms fail when used.

Add test/fips_cast_init.test to make check. It checks that no CAST but
DH and ECC primitive-Z is left to run once the provider is loaded, and
races 8 threads on the first use of TLS1-PRF, TLS13-KDF, SSHKDF and
HMAC in fresh processes. Non-FIPS builds skip it.
Copilot AI balanced review requested due to automatic review settings October 7, 2026 18:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The regression test does not compile on supported pthread platforms without POSIX barriers and mishandles barrier initialization failure.

1 open finding
What changed in this PR

Runs pending FIPS CASTs during provider initialization to prevent first-use races while preserving lazy DH/ECC primitive-Z tests.

Changes:

  • Pre-runs pending CASTs once per process.
  • Serializes lazy DH and hybrid ML-KEM ECDH CASTs.
  • Adds a concurrent regression test.
File Description
src/​wp_internal.c Implements pending CAST execution.
src/​wp_wolfprov.c Runs CASTs during provider initialization.
src/​wp_dh_kmgmt.c Serializes lazy DH CAST initialization.
src/​wp_mlx_kem.c Serializes hybrid ECDH CAST initialization.
include/​wolfprovider/​internal.h Declares the pending-CAST API.
test/​standalone/​include.am Adds the regression test to make check.
test/​standalone/​tests/​fips_cast_init/​test_fips_cast_init.c Tests CAST states and concurrent first use.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

}

typedef struct race_ctx {
pthread_barrier_t barrier;
@MarkAtwood

Copy link
Copy Markdown
Contributor Author

Reproducers for the first-use CAST race, as asked on Slack.

  • wcast5.c: direct wolfCrypt FIPS. Threads released by a barrier into their first use of one algorithm (rsa gcm hmac ecdsa drbg tls12 tls13 ssh mixed), optional precast/precast2/rerun modes. Prints per-thread errors plus FIPS mode/status after.
  • wpcast.c: the same through OpenSSL 3 EVP with only libwolfprov loaded (rsa gcm ecb hmac tls12 tls13 ssh ecdsa mixed).
  • cast-run.sh: runs each case N times (one process per trial, 8 threads, 4 in parallel) and summarises.

Build: cc -O2 -pthread wcast5.c -o wcast5 -lwolfssl against the FIPS libwolfssl; cc -O2 -pthread wpcast.c -o wpcast -lcrypto. Run: cast-run.sh <label> 1000.

The KDF rows (tls12 tls13 ssh) through wolfProvider are the ones that lose the race on v5 (206/165/140 per 1000 processes dead).

wcast5.c
/* First-use FIPS CAST race probe, extended from gwt/wcast.c (yjgh.18).
 * usage: wcast5 ALG THREADS [MODE]
 *   ALG:  rsa | gcm | hmac | ecdsa | drbg | tls12 | tls13 | ssh | mixed
 *   MODE: none (default) | precast  (wc_RunAllCast_fips() in main)
 *                        | precast2 (TEMP gnutls patch v2 logic: wc_RunCast_fips
 *                                    only for CASTs in FIPS_CAST_STATE_INIT)
 *                        | rerun    (precast2 in main; then thread 0 calls
 *                                    wc_RunCast_fips(cast-of-ALG) while the
 *                                    other threads use ALG: a consumer
 *                                    re-running an already-passed CAST)
 * One process = one trial. Prints per-thread errors, FIPS mode/status after,
 * and the return of one more call after the threads joined. */
#include <wolfssl/options.h>
#include <wolfssl/wolfcrypt/settings.h>
#include <wolfssl/wolfcrypt/fips_test.h>
#include <wolfssl/wolfcrypt/random.h>
#include <wolfssl/wolfcrypt/rsa.h>
#include <wolfssl/wolfcrypt/aes.h>
#include <wolfssl/wolfcrypt/hmac.h>
#include <wolfssl/wolfcrypt/ecc.h>
#include <wolfssl/wolfcrypt/kdf.h>
#include <wolfssl/wolfcrypt/wc_port.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const char *algs[] = { "rsa", "gcm", "ecdsa", "tls12", "tls13", "ssh",
                              "hmac", "drbg" };
static const char *alg;
static int mode_rerun;
static pthread_barrier_t bar;

static int cast_of(const char *a)
{
    if (!strcmp(a, "rsa"))   return FIPS_CAST_RSA_SIGN_PKCS1v15;
    if (!strcmp(a, "gcm"))   return FIPS_CAST_AES_GCM;
    if (!strcmp(a, "hmac"))  return FIPS_CAST_HMAC_SHA2_256;
    if (!strcmp(a, "ecdsa")) return FIPS_CAST_ECDSA;
    if (!strcmp(a, "tls12")) return FIPS_CAST_KDF_TLS12;
    if (!strcmp(a, "tls13")) return FIPS_CAST_KDF_TLS13;
    if (!strcmp(a, "ssh"))   return FIPS_CAST_KDF_SSH;
    return FIPS_CAST_DRBG;
}

static int op(const char *a)
{
    int ret;
    byte key[32] = {1}, out[64];

    if (!strcmp(a, "rsa")) {
        RsaKey k;
        ret = wc_InitRsaKey(&k, NULL);
        if (ret == 0) wc_FreeRsaKey(&k);
    } else if (!strcmp(a, "gcm")) {
        Aes x;
        ret = wc_AesInit(&x, NULL, INVALID_DEVID);
        if (ret == 0) { ret = wc_AesGcmSetKey(&x, key, 16); wc_AesFree(&x); }
    } else if (!strcmp(a, "hmac")) {
        Hmac h;
        ret = wc_HmacInit(&h, NULL, INVALID_DEVID);
        if (ret == 0) { ret = wc_HmacSetKey(&h, WC_SHA256, key, sizeof key); wc_HmacFree(&h); }
    } else if (!strcmp(a, "ecdsa")) {
        byte hash[32] = {1}, sig[80]; word32 sl = sizeof sig; WC_RNG r; ecc_key k;
        ret = wc_InitRng(&r);
        if (ret == 0) {
            ret = wc_ecc_init(&k);
            PRIVATE_KEY_UNLOCK();
            if (ret == 0) ret = wc_ecc_make_key(&r, 32, &k);
            if (ret == 0) ret = wc_ecc_sign_hash(hash, sizeof hash, sig, &sl, &r, &k);
            PRIVATE_KEY_LOCK();
            wc_ecc_free(&k);
            wc_FreeRng(&r);
        }
    } else if (!strcmp(a, "tls12")) {
        PRIVATE_KEY_UNLOCK();
        ret = wc_PRF_TLS(out, 48, key, sizeof key, (const byte *)"master secret",
                         13, key, sizeof key, 1, sha256_mac, NULL, INVALID_DEVID);
        PRIVATE_KEY_LOCK();
    } else if (!strcmp(a, "tls13")) {
        PRIVATE_KEY_UNLOCK();
        ret = wc_Tls13_HKDF_Extract(out, key, 32, key, 32, WC_SHA256);
        PRIVATE_KEY_LOCK();
    } else if (!strcmp(a, "ssh")) {
        PRIVATE_KEY_UNLOCK();
        ret = wc_SSH_KDF(WC_HASH_TYPE_SHA256, 'A', out, 32, key, sizeof key,
                         key, sizeof key, key, sizeof key);
        PRIVATE_KEY_LOCK();
    } else {
        WC_RNG r;
        ret = wc_InitRng(&r);
        if (ret == 0) wc_FreeRng(&r);
    }
    return ret;
}

struct th { int idx, ret; const char *a; };

static void *run(void *p)
{
    struct th *t = p;
    pthread_barrier_wait(&bar);
    if (mode_rerun && t->idx == 0)
        t->ret = wc_RunCast_fips(cast_of(t->a));
    else
        t->ret = op(t->a);
    return NULL;
}

static int precast2(void)
{
    int fails = 0;
    for (int i = 0; i < FIPS_CAST_COUNT; i++) {
        int s = wc_GetCastStatus_fips(i);
        if (s == FIPS_CAST_STATE_INIT && wc_RunCast_fips(i) != 0) fails++;
        else if (s == FIPS_CAST_STATE_FAILURE) fails++;
    }
    return fails;
}

int main(int argc, char **argv)
{
    int n, fails = 0;
    const char *mode;
    pthread_t th[64];
    struct th t[64];

    if (argc < 3) return 2;
    alg = argv[1]; n = atoi(argv[2]); mode = argc > 3 ? argv[3] : "none";
    if (n < 1 || n > 64) return 2;
    wolfCrypt_Init();
    wc_SetSeed_Cb(wc_GenerateSeed);
    if (!strcmp(mode, "precast") && wc_RunAllCast_fips() != 0) { printf("precast_failed\n"); return 3; }
    if ((!strcmp(mode, "precast2") || !strcmp(mode, "rerun")) && precast2() != 0) { printf("precast_failed\n"); return 3; }
    mode_rerun = !strcmp(mode, "rerun");
    pthread_barrier_init(&bar, NULL, n);
    for (int i = 0; i < n; i++) {
        t[i].idx = i;
        t[i].a = strcmp(alg, "mixed") ? alg : algs[i % 8];
        pthread_create(&th[i], NULL, run, &t[i]);
    }
    for (int i = 0; i < n; i++) pthread_join(th[i], NULL);
    printf("alg=%s threads=%d mode=%s", alg, n, mode);
    for (int i = 0; i < n; i++)
        if (t[i].ret != 0) { fails++; printf(" err=%s:%d", t[i].a, t[i].ret); }
    printf(" fails=%d fipsmode=%d status=%d after=%d\n", fails,
           wolfCrypt_GetMode_fips(), wolfCrypt_GetStatus_fips(),
           op(strcmp(alg, "mixed") ? alg : "rsa"));
    return 0;
}
wpcast.c
/* First-use FIPS CAST race probe through wolfProvider (OpenSSL 3 EVP).
 * usage: wpcast ALG THREADS
 *   ALG: rsa | gcm | ecb | hmac | tls12 | tls13 | ssh | ecdsa | mixed
 * Loads only libwolfprov (no default provider). One process = one trial:
 * THREADS threads released by a barrier into their first EVP use of ALG
 * (mixed: thread i uses algorithm i % 8). Prints per-thread failures with the
 * OpenSSL error string, then one more RSA-free probe (SHA-256 HMAC) after. */
#include <openssl/evp.h>
#include <openssl/err.h>
#include <openssl/kdf.h>
#include <openssl/core_names.h>
#include <openssl/provider.h>
#include <openssl/rsa.h>
#include <openssl/ec.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const char *algs[] = { "rsa", "gcm", "ecb", "hmac", "tls12", "tls13",
                              "ssh", "ecdsa" };
static pthread_barrier_t bar;

static int kdf(const char *name, OSSL_PARAM *p)
{
    unsigned char out[48];
    EVP_KDF *k = EVP_KDF_fetch(NULL, name, NULL);
    EVP_KDF_CTX *c = k ? EVP_KDF_CTX_new(k) : NULL;
    int ok = c && EVP_KDF_derive(c, out, 32, p) == 1;
    EVP_KDF_CTX_free(c);
    EVP_KDF_free(k);
    return ok;
}

static int cipher(const char *name)
{
    unsigned char key[32] = {1}, iv[12] = {2}, in[32] = {3}, out[64];
    int l, ok;
    EVP_CIPHER *ci = EVP_CIPHER_fetch(NULL, name, NULL);
    EVP_CIPHER_CTX *c = EVP_CIPHER_CTX_new();
    ok = ci && c && EVP_EncryptInit_ex2(c, ci, key, iv, NULL) == 1 &&
         EVP_EncryptUpdate(c, out, &l, in, sizeof in) == 1 &&
         EVP_EncryptFinal_ex(c, out + l, &l) == 1;
    EVP_CIPHER_CTX_free(c);
    EVP_CIPHER_free(ci);
    return ok;
}

static int op(const char *a)
{
    unsigned char key[32] = {1}, out[64];
    char md[] = "SHA256";

    if (!strcmp(a, "rsa") || !strcmp(a, "ecdsa")) {
        int rsa = !strcmp(a, "rsa"), ok;
        EVP_PKEY *pk = NULL;
        EVP_PKEY_CTX *c = EVP_PKEY_CTX_new_from_name(NULL, rsa ? "RSA" : "EC", NULL);
        ok = c && EVP_PKEY_keygen_init(c) == 1 &&
             (rsa ? EVP_PKEY_CTX_set_rsa_keygen_bits(c, 2048)
                  : EVP_PKEY_CTX_set_group_name(c, "P-256")) == 1 &&
             EVP_PKEY_keygen(c, &pk) == 1;
        EVP_PKEY_CTX_free(c);
        if (ok) {
            unsigned char sig[512]; size_t sl = sizeof sig;
            EVP_MD_CTX *m = EVP_MD_CTX_new();
            ok = m && EVP_DigestSignInit_ex(m, NULL, "SHA256", NULL, NULL, pk, NULL) == 1 &&
                 EVP_DigestSign(m, sig, &sl, key, sizeof key) == 1;
            EVP_MD_CTX_free(m);
        }
        EVP_PKEY_free(pk);
        return ok;
    }
    if (!strcmp(a, "gcm")) return cipher("AES-256-GCM");
    if (!strcmp(a, "ecb")) return cipher("AES-128-ECB");
    if (!strcmp(a, "hmac")) {
        size_t l = 0; int ok;
        OSSL_PARAM p[] = { OSSL_PARAM_utf8_string(OSSL_MAC_PARAM_DIGEST, md, 0), OSSL_PARAM_END };
        EVP_MAC *m = EVP_MAC_fetch(NULL, "HMAC", NULL);
        EVP_MAC_CTX *c = m ? EVP_MAC_CTX_new(m) : NULL;
        ok = c && EVP_MAC_init(c, key, sizeof key, p) == 1 &&
             EVP_MAC_update(c, key, sizeof key) == 1 &&
             EVP_MAC_final(c, out, &l, sizeof out) == 1;
        EVP_MAC_CTX_free(c);
        EVP_MAC_free(m);
        return ok;
    }
    if (!strcmp(a, "tls12")) {
        OSSL_PARAM p[] = {
            OSSL_PARAM_utf8_string(OSSL_KDF_PARAM_DIGEST, md, 0),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_SECRET, key, 32),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_SEED, key, 32),
            OSSL_PARAM_END };
        return kdf("TLS1-PRF", p);
    }
    if (!strcmp(a, "tls13")) {
        int m = EVP_KDF_HKDF_MODE_EXTRACT_ONLY;
        OSSL_PARAM p[] = {
            OSSL_PARAM_int(OSSL_KDF_PARAM_MODE, &m),
            OSSL_PARAM_utf8_string(OSSL_KDF_PARAM_DIGEST, md, 0),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_KEY, key, 32),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_SALT, key, 32),
            OSSL_PARAM_END };
        return kdf("TLS13-KDF", p);
    }
    if (!strcmp(a, "ssh")) {
        char type[] = "A";
        OSSL_PARAM p[] = {
            OSSL_PARAM_utf8_string(OSSL_KDF_PARAM_DIGEST, md, 0),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_KEY, key, 32),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_SSHKDF_XCGHASH, key, 32),
            OSSL_PARAM_octet_string(OSSL_KDF_PARAM_SSHKDF_SESSION_ID, key, 32),
            OSSL_PARAM_utf8_string(OSSL_KDF_PARAM_SSHKDF_TYPE, type, 1),
            OSSL_PARAM_END };
        return kdf("SSHKDF", p);
    }
    return 0;
}

struct th { const char *a; int ok; unsigned long e; };

static void *run(void *p)
{
    struct th *t = p;
    pthread_barrier_wait(&bar);
    t->ok = op(t->a);
    t->e = t->ok ? 0 : ERR_peek_last_error();
    return NULL;
}

int main(int argc, char **argv)
{
    int n, fails = 0;
    const char *alg;
    pthread_t th[64];
    struct th t[64];
    char buf[256];

    if (argc < 3) return 2;
    alg = argv[1]; n = atoi(argv[2]);
    if (n < 1 || n > 64) return 2;
    if (OSSL_PROVIDER_load(NULL, "libwolfprov") == NULL) {
        ERR_print_errors_fp(stdout);
        printf("provider_load_failed\n");
        return 3;
    }
    pthread_barrier_init(&bar, NULL, n);
    for (int i = 0; i < n; i++) {
        t[i].a = strcmp(alg, "mixed") ? alg : algs[i % 8];
        pthread_create(&th[i], NULL, run, &t[i]);
    }
    for (int i = 0; i < n; i++) pthread_join(th[i], NULL);
    printf("alg=%s threads=%d", alg, n);
    for (int i = 0; i < n; i++)
        if (!t[i].ok) {
            fails++;
            ERR_error_string_n(t[i].e, buf, sizeof buf);
            printf(" err=%s:[%s]", t[i].a, buf);
        }
    printf(" fails=%d after_hmac=%d after_gcm=%d\n", fails, op("hmac"), op("gcm"));
    return 0;
}
cast-run.sh
#!/bin/bash
# usage: cast-run.sh LIB N   (inside container). Raw logs + summary in /work/out/LIB.
set -u
LIB=$1 N=$2 O=/work/out/$1
mkdir -p $O
summ() {  # file label
  awk -v l="$2" '
    /^alg=/ { t++; for (i=1;i<=NF;i++) { split($i,kv,"=");
        if (kv[1]=="fails") { f+=kv[2]; if (kv[2]>0) r++ }
        if (kv[1]=="fipsmode" && kv[2]!=1) dg++
        if ((kv[1]=="after" || kv[1]=="after_hmac" || kv[1]=="after_gcm") && kv[2]!=(kv[1]=="after"?0:1)) dead[NR]=1
        if (kv[1]=="err") { sub(/^err=/,"",$i); e[$i]++ } } }
    /^rc=/ { bad++ }
    END { for (k in dead) dd++;
      printf "%-28s trials=%d racy=%d failed_calls=%d degraded=%d dead_after=%d abnormal_exit=%d", l, t, r, f, dg+0, dd+0, bad+0;
      for (k in e) printf " [%s x%d]", k, e[k]; print "" }' "$1"
}
one() {  # prog alg mode
  local f=$O/$1-$2-${3:-none}.log
  : > $f
  seq $N | xargs -P 4 -I{} sh -c "timeout 120 $1 $2 8 $3 || echo rc=\$?" >> $f
  summ $f "$1 $2 ${3:-none}" | tee -a $O/summary.txt
}
for a in rsa gcm ecdsa tls12 tls13 ssh hmac drbg mixed; do
  one wcast5 $a none; one wcast5 $a precast2
done
one wcast5 mixed precast
one wcast5 rsa rerun
one wcast5 gcm rerun
export OPENSSL_CONF=/dev/null
for a in rsa gcm ecb hmac tls12 tls13 ssh ecdsa mixed; do one wpcast $a ""; done
echo CAST_RUN_DONE

@MarkAtwood

Copy link
Copy Markdown
Contributor Author

Closing per discussion with @ColtonWilley: he is handling CAST startup in wolfProvider, and the failure here was only shown by a synthetic stress test, not on a real application path. The reproducers stay in the comment above for reference.

@MarkAtwood MarkAtwood closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants