Skip to content

Commit 116a59c

Browse files
committed
ACVP: Support FIPS203-tr1 encapDecap (v1.1.0.43)
The FIPS203-tr1 encapDecap revision adds keyFormat 'seed'/'expanded' groups: the decapsulation key may be supplied as a seed (d||z) to expand rather than as the expanded dk. Add a decode_dk() entry point to the ACVP harness accepting either dk=HEX or seed=HEX (expanding the seed via crypto_kem_keypair_derand), and route the decapsulation function through it. keyFormat is inspected per group, so the client copes whether or not a given file carries it. decode_dk is MLK_NOINLINE so its keyGen scratch stays out of main's stack frame, which under -fsanitize=undefined would otherwise overflow AVR RAM. Download and run the ML-KEM-encapDecap-FIPS203-tr1 vectors when the version ships them (v1.1.0.43+); keep the base FIPS203 dataset. Two quirks in the v1.1.0.43 sample vectors are worth recording: - decapsulationKeyCheck cases carry only a tcId with no dk, yet their expected results are a non-trivial mix of pass and fail, so they cannot be reproduced offline. The client drops decapsulationKeyCheck cases that lack a key, from prompt and expected alike; they run normally again once a key is present. This is a known upstream sample bug (usnistgov/ACVP-Server#459), where the maintainers confirm keyFormat there should be 'expanded' with the dk provided. - The base ML-KEM-encapDecap-FIPS203 file also gained keyFormat at v1.1.0.43, whereas for ML-DSA only the -tr1 revision carries it. This looks like an unintended regeneration of the base file; the per-group handling above keeps the client correct whether or not it is fixed. Bump the default ACVP version to v1.1.0.43 and roll the CI matrix forward to the three latest revisions. Signed-off-by: Matthias J. Kannwischer <matthias@zerorisc.com>
1 parent 61c8313 commit 116a59c

4 files changed

Lines changed: 110 additions & 8 deletions

File tree

.github/workflows/base.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
name: 'aarch64'
6868
- runner: ubuntu-latest
6969
name: 'x86_64'
70-
acvp-version: [v1.1.0.40, v1.1.0.41, v1.1.0.42]
70+
acvp-version: [v1.1.0.41, v1.1.0.42, v1.1.0.43]
7171
exclude:
7272
- {external: true,
7373
target: {

scripts/tests

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,8 +1353,8 @@ def cli():
13531353
)
13541354
acvp_parser.add_argument(
13551355
"--version",
1356-
default="v1.1.0.41",
1357-
help="ACVP test vector version (default: v1.1.0.41)",
1356+
default="v1.1.0.43",
1357+
help="ACVP test vector version (default: v1.1.0.43)",
13581358
)
13591359

13601360
# wycheproof arguments

test/acvp/acvp_client.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
exec_prefix = exec_prefix.split(" ") if exec_prefix != "" else []
2121

2222

23+
def acvp_version_has_tr1(version):
24+
"""Whether `version` ships the FIPS203-tr1 encapDecap vectors (v1.1.0.43+)."""
25+
try:
26+
parts = tuple(int(x) for x in version.lstrip("v").split("."))
27+
except ValueError:
28+
# Non-numeric ref (branch or commit); assume the vectors are present.
29+
return True
30+
return parts >= (1, 1, 0, 43)
31+
32+
2333
def download_acvp_files(version):
2434
"""Download ACVP test files for the specified version if not present."""
2535
base_url = f"https://raw.githubusercontent.com/usnistgov/ACVP-Server/{version}/gen-val/json-files"
@@ -32,6 +42,13 @@ def download_acvp_files(version):
3242
"ML-KEM-encapDecap-FIPS203/expectedResults.json",
3343
]
3444

45+
# The FIPS203-tr1 encapDecap vectors add seed/expanded key-format groups.
46+
if acvp_version_has_tr1(version):
47+
files_to_download += [
48+
"ML-KEM-encapDecap-FIPS203-tr1/prompt.json",
49+
"ML-KEM-encapDecap-FIPS203-tr1/expectedResults.json",
50+
]
51+
3552
# Create directory structure
3653
data_dir = Path(f"test/acvp/.acvp-data/{version}/files")
3754
data_dir.mkdir(parents=True, exist_ok=True)
@@ -63,6 +80,38 @@ def download_acvp_files(version):
6380
return True
6481

6582

83+
def unwrap_acvts(data):
84+
# ACVTS files wrap the payload as [{"acvVersion": ...}, {...}].
85+
return data[1] if isinstance(data, list) else data
86+
87+
88+
def drop_keyless_decap_key_checks(data):
89+
"""Drop decapsulationKeyCheck cases that provide no key.
90+
91+
Some ML-KEM sample vectors omit dk for these cases; the check cannot run
92+
without a key. Removed from prompt and expected so the comparison stays
93+
consistent, and runs normally once a key is present.
94+
"""
95+
dropped = 0
96+
for _, promptData, _, expectedData in data:
97+
drop = set()
98+
for tg in unwrap_acvts(promptData).get("testGroups", []):
99+
if tg.get("function") != "decapsulationKeyCheck":
100+
continue
101+
for tc in tg["tests"]:
102+
if "dk" not in tc:
103+
drop.add((tg["tgId"], tc["tcId"]))
104+
for d in (promptData, expectedData):
105+
if d is None:
106+
continue
107+
for tg in unwrap_acvts(d).get("testGroups", []):
108+
tg["tests"] = [
109+
tc for tc in tg["tests"] if (tg["tgId"], tc["tcId"]) not in drop
110+
]
111+
dropped += len(drop)
112+
return dropped
113+
114+
66115
def loadAcvpData(prompt, expectedResults):
67116
with open(prompt, "r") as f:
68117
promptData = json.load(f)
@@ -86,6 +135,17 @@ def loadDefaultAcvpData(version):
86135
f"{data_dir}/ML-KEM-encapDecap-FIPS203/expectedResults.json",
87136
),
88137
]
138+
139+
# FIPS203-tr1 encapDecap vectors (seed/expanded key formats) exist from
140+
# v1.1.0.43.
141+
if acvp_version_has_tr1(version):
142+
acvp_jsons_for_version.append(
143+
(
144+
f"{data_dir}/ML-KEM-encapDecap-FIPS203-tr1/prompt.json",
145+
f"{data_dir}/ML-KEM-encapDecap-FIPS203-tr1/expectedResults.json",
146+
)
147+
)
148+
89149
acvp_data = []
90150
for prompt, expectedResults in acvp_jsons_for_version:
91151
acvp_data.append(loadAcvpData(prompt, expectedResults))
@@ -139,12 +199,18 @@ def run_encapDecap_test(tg, tc):
139199
results[k] = v
140200
elif tg["function"] == "decapsulation":
141201
acvp_bin = get_acvp_binary(tg)
202+
# keyFormat 'seed' provides d and z to expand into the key; 'expanded'
203+
# (or absent) provides the expanded dk directly.
204+
if tg.get("keyFormat") == "seed":
205+
key_arg = f"seed={tc['d'] + tc['z']}"
206+
else:
207+
key_arg = f"dk={tc['dk']}"
142208
acvp_call = exec_prefix + [
143209
acvp_bin,
144210
"encapDecap",
145211
"VAL",
146212
"decapsulation",
147-
f"dk={tc['dk']}",
213+
key_arg,
148214
f"c={tc['c']}",
149215
]
150216
result = subprocess.run(acvp_call, encoding="utf-8", capture_output=True)
@@ -319,6 +385,10 @@ def test(prompt, expected, output, version):
319385
# load data from downloaded files
320386
data = loadDefaultAcvpData(version)
321387

388+
dropped = drop_keyless_decap_key_checks(data)
389+
if dropped:
390+
info(f"Skipping {dropped} decapsulationKeyCheck case(s) with no key")
391+
322392
runTest(data, output)
323393

324394

@@ -338,8 +408,8 @@ def test(prompt, expected, output, version):
338408
parser.add_argument(
339409
"--version",
340410
"-v",
341-
default="v1.1.0.41",
342-
help="ACVP test vector version (default: v1.1.0.41)",
411+
default="v1.1.0.43",
412+
help="ACVP test vector version (default: v1.1.0.43)",
343413
)
344414
args = parser.parse_args()
345415

test/acvp/acvp_mlkem.c

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,38 @@ static int decode_hex(const char *prefix, unsigned char *out, size_t out_len,
119119
return 1;
120120
}
121121

122+
/*
123+
* Decode the decapsulation-key argument into dk. It is either the expanded
124+
* key ("dk=HEX", keyFormat 'expanded') or a seed d||z to expand via keyGen
125+
* ("seed=HEX", keyFormat 'seed'). Returns 0 on success, 1 on failure.
126+
* MLK_NOINLINE keeps the keyGen scratch (ek) out of the caller's (main's)
127+
* stack frame; under -fsanitize=undefined it would not share slots and would
128+
* overflow AVR RAM.
129+
*/
130+
static MLK_NOINLINE int decode_dk(const char *arg,
131+
unsigned char dk[CRYPTO_SECRETKEYBYTES])
132+
{
133+
size_t seed_len = strlen("seed=");
134+
135+
/* Prefix check via memcmp; strncmp is unavailable on baremetal builds. */
136+
if (strlen(arg) >= seed_len && memcmp(arg, "seed=", seed_len) == 0)
137+
{
138+
unsigned char ek[CRYPTO_PUBLICKEYBYTES];
139+
unsigned char coins[2 * MLKEM_SYMBYTES];
140+
if (decode_hex("seed", coins, sizeof(coins), arg) != 0)
141+
{
142+
return 1;
143+
}
144+
if (crypto_kem_keypair_derand(ek, dk, coins) != 0)
145+
{
146+
fprintf(stderr, "Failed to expand seed into decapsulation key\n");
147+
return 1;
148+
}
149+
return 0;
150+
}
151+
return decode_hex("dk", dk, CRYPTO_SECRETKEYBYTES, arg);
152+
}
153+
122154
static void print_hex(const char *name, const unsigned char *raw, size_t len)
123155
{
124156
if (name != NULL)
@@ -321,8 +353,8 @@ int main(int argc, char *argv[])
321353
goto decaps_usage;
322354
}
323355

324-
/* Parse dk */
325-
if (argc == 0 || decode_hex("dk", dk, sizeof(dk), *argv) != 0)
356+
/* Parse dk (expanded key, or a seed to expand) */
357+
if (argc == 0 || decode_dk(*argv, dk) != 0)
326358
{
327359
goto decaps_usage;
328360
}

0 commit comments

Comments
 (0)