Skip to content

Commit 75fef55

Browse files
committed
CI: Add ACVP tests in CI
This commit adds a portable acvp_client.py that runs all the ACVP tests in parallel. This way we do not have to rely on parallel to be installed. It also adds these ACVP tests to CI. Resolves #4 Signed-off-by: Matthias J. Kannwischer <matthias@kannwischer.eu>
1 parent 6079933 commit 75fef55

6 files changed

Lines changed: 283 additions & 340 deletions

File tree

.github/workflows/all.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Copyright (c) The mlkem-native project authors
2-
# Copyright (c) The slhdsa-c project authors
2+
# Copyright (c) The slhdsa-native project authors
33
# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT
44

55
name: CI
@@ -13,6 +13,13 @@ on:
1313
branches: ["main"]
1414
types: [ "opened", "synchronize" ]
1515
jobs:
16+
base:
17+
name: Base
18+
permissions:
19+
contents: 'read'
20+
id-token: 'write'
21+
uses: ./.github/workflows/base.yml
22+
secrets: inherit
1623
nix:
1724
name: Nix
1825
permissions:
@@ -26,7 +33,6 @@ jobs:
2633
permissions:
2734
contents: 'read'
2835
id-token: 'write'
29-
needs: [ nix ]
36+
needs: [ base, nix ]
3037
uses: ./.github/workflows/cbmc.yml
3138
secrets: inherit
32-

.github/workflows/base.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) The mlkem-native project authors
2+
# Copyright (c) The slhdsa-native project authors
3+
# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT
4+
5+
name: Base
6+
permissions:
7+
contents: read
8+
on:
9+
workflow_call:
10+
workflow_dispatch:
11+
12+
jobs:
13+
quickcheck:
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
external:
18+
- ${{ github.repository_owner != 'pq-code-package' }}
19+
target:
20+
- runner: pqcp-arm64
21+
name: 'aarch64'
22+
- runner: ubuntu-latest
23+
name: 'x86_64'
24+
- runner: macos-latest
25+
name: 'macos (aarch64)'
26+
- runner: macos-13
27+
name: 'macos (x86_64)'
28+
exclude:
29+
- {external: true,
30+
target: {
31+
runner: pqcp-arm64,
32+
name: 'aarch64'
33+
}}
34+
name: Quickcheck (${{ matrix.target.name }})
35+
runs-on: ${{ matrix.target.runner }}
36+
steps:
37+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
38+
with:
39+
submodules: true
40+
- name: make test
41+
run: |
42+
make test

Makefile

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,8 @@ $(XTEST): $(OBJS)
3434
%.o: %.[cS]
3535
$(CC) $(CFLAGS) -c $^ -o $@
3636

37-
# without gnu parallel: bash test/acvp_cases.sh | tee test.log
38-
test: $(XTEST) test/acvp_cases.sh
39-
cat test/acvp_cases.sh | parallel --pipe bash | tee test.log
40-
@echo "=== test summary ==="
41-
@echo "PASS:" `grep -c PASS test.log`
42-
@echo "SKIP:" `grep -c SKIP test.log`
43-
@echo "FAIL:" `grep -c FAIL test.log`
44-
45-
test/acvp_cases.sh:
46-
cd test && $(MAKE) acvp_cases.sh
37+
test: $(XTEST)
38+
python3 test/acvp_client.py --jobs $(shell nproc)
4739

4840
clean:
4941
$(RM) -rf $(XTEST) $(OBJS) *.rsp *.req *.log

test/Makefile

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@ $(XCOUNT): $(OBJS) xcount.c my_dbg.c
2121
%.o: %.[cS]
2222
$(CC) $(CFLAGS) -c $^ -o $@
2323

24-
acvp_cases.sh: ACVP-Server/gen-val/json-files
25-
python3 test_slhdsa.py > $@
26-
27-
new_param.csv: $(XCOUNT) test_param.py new_param.txt acvp_cases.sh
24+
new_param.csv: $(XCOUNT) test_param.py new_param.txt
2825
echo "alg_id, pk, sk, sig, keygen, sign, vfy_ok, vfy_fail"> $@
2926
./$(XCOUNT) | tee /dev/tty | sort >> $@
3027
python3 test_param.py | parallel | tee /dev/tty | sort >> $@
@@ -35,6 +32,5 @@ ACVP-Server/gen-val/json-files:
3532
clean:
3633
$(RM) -rf $(XCOUNT) $(OBJS) *.log
3734
$(RM) -f *.pyc *.cprof */*.pyc *.rsp *.log
38-
$(RM) -f acvp_cases.sh
3935
$(RM) -rf __pycache__ */__pycache__
4036

test/acvp_client.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) The slhdsa-c project authors
3+
# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT
4+
5+
"""SLH-DSA ACVP client."""
6+
7+
import argparse
8+
import json
9+
import subprocess
10+
import sys
11+
from concurrent.futures import ThreadPoolExecutor, as_completed
12+
13+
# === JSON parsing functions ===
14+
15+
def slhdsa_load_keygen(req_fn, res_fn):
16+
with open(req_fn) as f:
17+
keygen_req = json.load(f)
18+
with open(res_fn) as f:
19+
keygen_res = json.load(f)
20+
21+
keygen_kat = []
22+
for qtg in keygen_req['testGroups']:
23+
alg = qtg['parameterSet']
24+
tgid = qtg['tgId']
25+
26+
rtg = None
27+
for tg in keygen_res['testGroups']:
28+
if tg['tgId'] == tgid:
29+
rtg = tg['tests']
30+
break
31+
32+
for qt in qtg['tests']:
33+
tcid = qt['tcId']
34+
for t in rtg:
35+
if t['tcId'] == tcid:
36+
qt.update(t)
37+
qt['parameterSet'] = alg
38+
keygen_kat += [qt]
39+
return keygen_kat
40+
41+
def slhdsa_load_siggen(req_fn, res_fn):
42+
with open(req_fn) as f:
43+
siggen_req = json.load(f)
44+
with open(res_fn) as f:
45+
siggen_res = json.load(f)
46+
47+
siggen_kat = []
48+
for qtg in siggen_req['testGroups']:
49+
alg = qtg['parameterSet']
50+
det = qtg['deterministic']
51+
pre = False
52+
if 'preHash' in qtg and qtg['preHash'] == 'preHash':
53+
pre = True
54+
ifc = None
55+
if 'signatureInterface' in qtg:
56+
ifc = qtg['signatureInterface']
57+
tgid = qtg['tgId']
58+
59+
rtg = None
60+
for tg in siggen_res['testGroups']:
61+
if tg['tgId'] == tgid:
62+
rtg = tg['tests']
63+
break
64+
65+
for qt in qtg['tests']:
66+
tcid = qt['tcId']
67+
for t in rtg:
68+
if t['tcId'] == tcid:
69+
qt.update(t)
70+
qt['parameterSet'] = alg
71+
qt['deterministic'] = det
72+
if 'preHash' not in qt:
73+
qt['preHash'] = pre
74+
if 'context' not in qt:
75+
qt['context'] = ''
76+
qt['signatureInterface'] = ifc
77+
siggen_kat += [qt]
78+
return siggen_kat
79+
80+
def slhdsa_load_sigver(req_fn, res_fn, int_fn):
81+
with open(req_fn) as f:
82+
sigver_req = json.load(f)
83+
with open(res_fn) as f:
84+
sigver_res = json.load(f)
85+
with open(int_fn) as f:
86+
sigver_int = json.load(f)
87+
88+
sigver_kat = []
89+
for qtg in sigver_req['testGroups']:
90+
alg = qtg['parameterSet']
91+
tgid = qtg['tgId']
92+
pre = False
93+
if 'preHash' in qtg and qtg['preHash'] == 'preHash':
94+
pre = True
95+
ifc = None
96+
if 'signatureInterface' in qtg:
97+
ifc = qtg['signatureInterface']
98+
99+
rtg = None
100+
for tg in sigver_res['testGroups']:
101+
if tg['tgId'] == tgid:
102+
rtg = tg['tests']
103+
break
104+
105+
itg = None
106+
for tg in sigver_int['testGroups']:
107+
if tg['tgId'] == tgid:
108+
itg = tg['tests']
109+
break
110+
111+
for qt in qtg['tests']:
112+
pk = qt['pk']
113+
tcid = qt['tcId']
114+
for t in rtg:
115+
if t['tcId'] == tcid:
116+
qt.update(t)
117+
# message, signature in this file overrides prompts
118+
for t in itg:
119+
if t['tcId'] == tcid:
120+
qt.update(t)
121+
qt['parameterSet'] = alg
122+
qt['pk'] = pk
123+
if 'preHash' not in qt:
124+
qt['preHash'] = pre
125+
qt['signatureInterface'] = ifc
126+
sigver_kat += [qt]
127+
return sigver_kat
128+
129+
# === Test execution ===
130+
131+
def run_command(cmd):
132+
"""Run a single test command and return result."""
133+
try:
134+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
135+
return (result.returncode, result.stdout, result.stderr)
136+
except Exception as e:
137+
return (-1, "", str(e))
138+
139+
def run_test_kat(test_type, kat, jobs, xbin='./xfips205'):
140+
"""Run test KAT in parallel."""
141+
passed = 0
142+
failed = 0
143+
144+
def build_command(x):
145+
s = xbin
146+
for t in x:
147+
if x[t] != "":
148+
s += f' -{t} "{x[t]}"'
149+
s += f' {test_type}'
150+
return s
151+
152+
# Run tests in parallel
153+
with ThreadPoolExecutor(max_workers=jobs) as executor:
154+
futures = [executor.submit(run_command, build_command(x)) for x in kat]
155+
156+
for future in as_completed(futures):
157+
returncode, stdout, stderr = future.result()
158+
159+
if returncode == 0:
160+
passed += 1
161+
if "PASS" in stdout:
162+
print(stdout.strip())
163+
else:
164+
failed += 1
165+
if stderr:
166+
print(f"Error: {stderr}")
167+
168+
return passed, failed
169+
170+
def main():
171+
parser = argparse.ArgumentParser(description="SLH-DSA ACVP test runner")
172+
parser.add_argument("--jobs", "-j", type=int, default=4, help="Number of parallel jobs")
173+
174+
args = parser.parse_args()
175+
176+
print("Generating test commands from ACVP JSON files...", file=sys.stderr)
177+
178+
try:
179+
json_path = 'test/ACVP-Server/gen-val/json-files/'
180+
181+
keygen_kat = slhdsa_load_keygen(
182+
json_path + 'SLH-DSA-keyGen-FIPS205/prompt.json',
183+
json_path + 'SLH-DSA-keyGen-FIPS205/expectedResults.json')
184+
185+
siggen_kat = slhdsa_load_siggen(
186+
json_path + 'SLH-DSA-sigGen-FIPS205/prompt.json',
187+
json_path + 'SLH-DSA-sigGen-FIPS205/expectedResults.json')
188+
189+
sigver_kat = slhdsa_load_sigver(
190+
json_path + 'SLH-DSA-sigVer-FIPS205/prompt.json',
191+
json_path + 'SLH-DSA-sigVer-FIPS205/expectedResults.json',
192+
json_path + 'SLH-DSA-sigVer-FIPS205/internalProjection.json')
193+
194+
except FileNotFoundError as e:
195+
print(f"Error: Could not find ACVP JSON files. Make sure submodule is initialized.", file=sys.stderr)
196+
print(f"Run: git submodule update --init --recursive", file=sys.stderr)
197+
return 1
198+
199+
total_tests = len(keygen_kat) + len(siggen_kat) + len(sigver_kat)
200+
print(f"Running {total_tests} tests with {args.jobs} parallel jobs", file=sys.stderr)
201+
202+
total_passed = 0
203+
total_failed = 0
204+
205+
# Run each test type
206+
passed, failed = run_test_kat('keyGen', keygen_kat, args.jobs)
207+
total_passed += passed
208+
total_failed += failed
209+
210+
passed, failed = run_test_kat('sigGen', siggen_kat, args.jobs)
211+
total_passed += passed
212+
total_failed += failed
213+
214+
passed, failed = run_test_kat('sigVer', sigver_kat, args.jobs)
215+
total_passed += passed
216+
total_failed += failed
217+
218+
print(f"\n=== test summary ===")
219+
print(f"PASS: {total_passed}")
220+
print(f"FAIL: {total_failed}")
221+
222+
if total_failed == 0:
223+
print("ALL GOOD!")
224+
return 0
225+
else:
226+
return 1
227+
228+
if __name__ == "__main__":
229+
sys.exit(main())

0 commit comments

Comments
 (0)