Commit 82fb77d867 for strongswan.org
commit 82fb77d8671be394e9220a481aab66273b8e06e4
Author: Tobias Brunner <tobias@strongswan.org>
Date: Thu May 28 10:45:45 2026 +0200
ml: Properly support ML-DSA keys in expanded format
The previous code failed to produce a valid encoding so e.g. passing
an encrypted key loaded from the expanded format couldn't get loaded
via VICI. It also didn't have the public key available so encoding that
or generating a fingerprint wasn't possible. The consistency of the
loaded key is now also checked (matching tr and t0 to the values derived
from the private key).
The tests are expanded to check that we get the expected encoding (i.e.
seed-only except if we loaded a key in expanded format). Same for the
public key in expanded format.
Fixes: 59afe805b0d1 ("ml: Support all ML-DSA private key formats")
diff --git a/src/libstrongswan/plugins/ml/ml_dsa_private_key.c b/src/libstrongswan/plugins/ml/ml_dsa_private_key.c
index 088ec44bc6..79635de43b 100644
--- a/src/libstrongswan/plugins/ml/ml_dsa_private_key.c
+++ b/src/libstrongswan/plugins/ml/ml_dsa_private_key.c
@@ -519,14 +519,23 @@ METHOD(private_key_t, get_encoding, bool,
{
bool success = TRUE;
int oid = key_type_to_oid(this->type);
+ int asn1_type = ASN1_CONTEXT_S_0;
+ chunk_t content = this->keyseed;
+
+ /* generally, we produce a seed-only encoding, however, if we loaded
+ * an expanded key without seed, we can't, so export it as such */
+ if (!content.len)
+ {
+ asn1_type = ASN1_OCTET_STRING;
+ content = this->privkey;
+ }
*encoding = asn1_wrap(ASN1_SEQUENCE, "cmm",
ASN1_INTEGER_0,
asn1_algorithmIdentifier(oid),
asn1_wrap(ASN1_OCTET_STRING, "m",
- asn1_simple_object(ASN1_CONTEXT_S_0,
- this->keyseed))
- );
+ asn1_simple_object(asn1_type, content)));
+
if (type == PRIVKEY_PEM)
{
chunk_t asn1_encoding = *encoding;
@@ -791,6 +800,56 @@ end:
return success;
}
+/**
+ * Generate and encode a public key from a private key. Returns t0.
+ *
+ * Part of Algorithm 6 in FIPS 204.
+ */
+static bool generate_public_key(private_private_key_t *this, xof_t *G,
+ chunk_t rho, ml_dsa_poly_t *s1,
+ ml_dsa_poly_t *s2, ml_dsa_poly_t *t0)
+{
+ const u_int k = this->params->k;
+ const u_int l = this->params->l;
+ ml_dsa_poly_t a[k*l], s1_hat[l], t1[k];
+ bool success = FALSE;
+
+ if (!ml_dsa_expand_a(this->params, G, rho, a))
+ {
+ goto cleanup;
+ }
+
+ /* apply NTT to a copy of the s1 vector */
+ ml_dsa_poly_copy_vec(l, s1, s1_hat);
+ ml_dsa_poly_ntt_vec(l, s1_hat);
+
+ /* multiply vector s1_hat with matrix a in the NTT domain */
+ ml_dsa_poly_mult_mat(k, l, a, s1_hat, t1);
+
+ /* reduce the elements of vector t1 to the range -6283008 <= r <= 6283008 */
+ ml_dsa_poly_reduce_vec(k, t1);
+
+ /* apply the inverse NTT to vector t1 */
+ ml_dsa_poly_inv_ntt_vec(k, t1);
+
+ /* add error vector s2 to t1 */
+ ml_dsa_poly_add_vec(k, s2, t1, t1);
+
+ /* make all polynomial coefficients positive by conditionally adding q */
+ ml_dsa_poly_cond_add_q_vec(k, t1);
+
+ /* decomposes t1 into (t1, t0) such that t1 ≡ t1 * 2^d + t0 mod q */
+ ml_dsa_poly_power2round_vec(k, t1, t0, t1);
+
+ success = encode_public_key(this, rho, t1);
+
+cleanup:
+ memwipe(a, sizeof(a));
+ memwipe(s1_hat, sizeof(s1_hat));
+
+ return success;
+}
+
/**
* Generates a public/private key pair from a seed
*
@@ -800,7 +859,7 @@ static bool generate_keypair(private_private_key_t *this, chunk_t keyseed)
{
const u_int k = this->params->k;
const u_int l = this->params->l;
- ml_dsa_poly_t a[k*l], s1[l], s1_hat[l], s2[k], t1[k], t0[k];
+ ml_dsa_poly_t s1[l], s2[k], t0[k];
xof_t *G, *H;
bool success = FALSE;
@@ -838,44 +897,59 @@ static bool generate_keypair(private_private_key_t *this, chunk_t keyseed)
if (!H || !G ||
!H->set_seed(H, seed) ||
!H->get_bytes(H, sizeof(seedbuf), seedbuf) ||
- !ml_dsa_expand_a(this->params, G, rho, a) ||
!expand_s(this, H, rhoprime, s1, s2))
{
goto cleanup;
}
- /* apply NTT to a copy of the s1 vector */
- ml_dsa_poly_copy_vec(l, s1, s1_hat);
- ml_dsa_poly_ntt_vec(l, s1_hat);
-
- /* multiply vector s1_hat with matrix a in the NTT domain */
- ml_dsa_poly_mult_mat(k, l, a, s1_hat, t1);
-
- /* reduce the elements of vector t1 to the range -6283008 <= r <= 6283008 */
- ml_dsa_poly_reduce_vec(k, t1);
+ success = generate_public_key(this, G, rho, s1, s2, t0) &&
+ encode_secret_key(this, H, rho, K, s1, s2, t0);
- /* apply the inverse NTT to vector t1 */
- ml_dsa_poly_inv_ntt_vec(k, t1);
+cleanup:
+ memwipe(seedbuf, sizeof(seedbuf));
+ memwipe(s1, sizeof(s1));
+ memwipe(s2, sizeof(s2));
+ memwipe(t0, sizeof(t0));
+ DESTROY_IF(G);
+ DESTROY_IF(H);
- /* add error vector s2 to t1 */
- ml_dsa_poly_add_vec(k, s2, t1, t1);
+ return success;
+}
- /* make all polynomial coefficients positive by conditionally adding q */
- ml_dsa_poly_cond_add_q_vec(k, t1);
+/**
+ * Derive the public key from an expanded private key and check its consistency.
+ */
+static bool derive_public_key(private_private_key_t *this)
+{
+ const u_int k = this->params->k;
+ const u_int l = this->params->l;
+ ml_dsa_poly_t s1[l], s2[k], t0[k], t0_d[k];
+ xof_t *G, *H;
+ bool success = FALSE;
- /* decomposes t1 into (t1, t0) such that t1 ≡ t1 * 2^d + t0 mod q */
- ml_dsa_poly_power2round_vec(k, t1, t0, t1);
+ chunk_t rho = chunk_alloca(ML_DSA_SEED_LEN);
+ chunk_t K = chunk_alloca(ML_DSA_K_LEN);
+ chunk_t tr = chunk_alloca(ML_DSA_TR_LEN);
+ chunk_t tr_d = chunk_alloca(ML_DSA_TR_LEN);
- success = encode_public_key(this, rho, t1) &&
- encode_secret_key(this, H, rho, K, s1, s2, t0);
+ G = lib->crypto->create_xof(lib->crypto, XOF_SHAKE_128);
+ H = lib->crypto->create_xof(lib->crypto, XOF_SHAKE_256);
+ if (!G || !H ||
+ !decode_secret_key(this, rho, K, tr, s1, s2, t0) ||
+ !generate_public_key(this, G, rho, s1, s2, t0_d) ||
+ !H->set_seed(H, this->pubkey) ||
+ !H->get_bytes(H, ML_DSA_TR_LEN, tr_d.ptr))
+ {
+ goto cleanup;
+ }
+ success = chunk_equals_const(tr, tr_d) && memeq_const(t0, t0_d, sizeof(t0));
cleanup:
- memwipe(seedbuf, sizeof(seedbuf));
- memwipe(a, sizeof(a));
memwipe(s1, sizeof(s1));
- memwipe(s1_hat, sizeof(s1_hat));
memwipe(s2, sizeof(s2));
memwipe(t0, sizeof(t0));
+ memwipe(t0_d, sizeof(t0_d));
+ memwipe(K.ptr, K.len);
DESTROY_IF(G);
DESTROY_IF(H);
@@ -1037,6 +1111,12 @@ private_key_t *ml_dsa_private_key_load(key_type_t type, va_list args)
return NULL;
}
memcpy(this->privkey.ptr, priv.ptr, priv.len);
+ if (!derive_public_key(this))
+ {
+ DBG1(DBG_LIB, "failed to verify ML-DSA expanded private key");
+ destroy(this);
+ return NULL;
+ }
break;
/* private key in both seed and expanded format */
@@ -1061,7 +1141,7 @@ private_key_t *ml_dsa_private_key_load(key_type_t type, va_list args)
destroy(this);
return NULL;
}
- if (!chunk_equals(priv, this->privkey))
+ if (!chunk_equals_const(this->privkey, priv))
{
DBG1(DBG_LIB, "loaded expanded private key is not derived "
"from loaded seed");
diff --git a/src/libstrongswan/tests/suites/test_ml_dsa.c b/src/libstrongswan/tests/suites/test_ml_dsa.c
index 2e124b3e59..9c42ce6d56 100644
--- a/src/libstrongswan/tests/suites/test_ml_dsa.c
+++ b/src/libstrongswan/tests/suites/test_ml_dsa.c
@@ -2636,12 +2636,13 @@ END_TEST
START_TEST(test_ml_dsa_sig)
{
+ public_key_t *pubkey;
private_key_t *key;
signature_scheme_t scheme;
pqc_params_t pqc_params;
- chunk_t msg, signature, asn1, asn1_privkey;
+ chunk_t msg, signature, asn1, asn1_privkey, encoding;
- /* load private key in seed format*/
+ /* load private key in seed format */
key = lib->creds->create(lib->creds, CRED_PRIVATE_KEY, tests[_i].type,
BUILD_BLOB, tests[_i].seed, BUILD_END);
ck_assert(key != NULL);
@@ -2677,11 +2678,10 @@ START_TEST(test_ml_dsa_sig)
chunk_free(&signature);
key->destroy(key);
- /* load private key in expanded format*/
+ /* load private key in expanded format */
asn1 = chunk_cat("cc", tests[_i].asn1_privkey, tests[_i].privkey);
key = lib->creds->create(lib->creds, CRED_PRIVATE_KEY, KEY_ANY,
BUILD_BLOB_ASN1_DER, asn1, BUILD_END);
- chunk_free(&asn1);
if (key == NULL)
{
@@ -2689,21 +2689,34 @@ START_TEST(test_ml_dsa_sig)
}
else
{
+ /* check ML-DSA private key encoding (expanded-only) */
+ ck_assert(key->get_encoding(key, PRIVKEY_ASN1_DER, &encoding));
+ ck_assert_chunk_eq(encoding, asn1);
+ chunk_free(&encoding);
+ chunk_free(&asn1);
/* generate and verify signature with empty context string */
ck_assert(key->sign(key, scheme, &pqc_params, msg, &signature));
ck_assert_chunk_eq(signature, tests[_i].sig);
chunk_free(&signature);
+ /* make sure we get a public key from expanded-only format */
+ pubkey = key->get_public_key(key);
+ ck_assert(pubkey);
+ asn1 = chunk_cat("cc", tests[_i].asn1_pubkey, tests[_i].pubkey);
+ ck_assert(pubkey->get_encoding(pubkey, PUBKEY_SPKI_ASN1_DER, &encoding));
+ ck_assert_chunk_eq(encoding, asn1);
+ chunk_free(&encoding);
+ pubkey->destroy(pubkey);
key->destroy(key);
}
+ chunk_free(&asn1);
- /* load private key in combined seed/expanded format*/
+ /* load private key in combined seed/expanded format */
asn1_privkey = chunk_skip(tests[_i].asn1_privkey,
tests[_i].asn1_privkey.len - 4);
asn1 = chunk_cat("cccc", tests[_i].asn1_seed_key, tests[_i].seed,
asn1_privkey, tests[_i].privkey);
key = lib->creds->create(lib->creds, CRED_PRIVATE_KEY, KEY_ANY,
BUILD_BLOB_ASN1_DER, asn1, BUILD_END);
- chunk_free(&asn1);
if (key == NULL)
{
@@ -2711,12 +2724,34 @@ START_TEST(test_ml_dsa_sig)
}
else
{
+ /* check ML-DSA private key encoding (seed-only), but we accept
+ * the combined format */
+ asn1_privkey = chunk_cat("cc", tests[_i].asn1_seed, tests[_i].seed);
+ ck_assert(key->get_encoding(key, PRIVKEY_ASN1_DER, &encoding));
+ if (chunk_equals(encoding, asn1))
+ {
+ warn("ML-DSA private key encoded in both format instead of seed-only");
+ }
+ else
+ {
+ ck_assert_chunk_eq(encoding, asn1_privkey);
+ }
+ chunk_free(&asn1_privkey);
+ chunk_free(&encoding);
/* generate and verify signature with empty context string */
ck_assert(key->sign(key, scheme, &pqc_params, msg, &signature));
ck_assert_chunk_eq(signature, tests[_i].sig);
chunk_free(&signature);
key->destroy(key);
}
+
+ /* loading private key in combined seed/expanded format should fail if
+ * expanded key doesn't match the seed-derived key */
+ asn1.ptr[asn1.len-1] ^= 0xff;
+ key = lib->creds->create(lib->creds, CRED_PRIVATE_KEY, KEY_ANY,
+ BUILD_BLOB_ASN1_DER, asn1, BUILD_END);
+ ck_assert(!key);
+ chunk_free(&asn1);
}
END_TEST