Skip to content

Commit a982aba

Browse files
Add fuzz harness, NIST KAT cross-check, CT measurement scaffold, integration demos
- tests/PostQuantum.FileFormat.Fuzz: console-app harness over the DeterministicCborValidator and PqfStreamingPipeline parsers. Targets: cbor, header, streaming. PqfFileException and CborValidationException are the *expected* result — those count as refusals, not findings. A finding is any other exception type from the parser. - tests/PostQuantum.FileFormat.Kat: NIST FIPS 203 / FIPS 204 KAT cross- check. Verifies provider.MlKem1024Decapsulate and provider.MlDsa87Verify against the parts the current ICryptoProvider exposes. KAT files themselves are fetched on demand (gitignored); the harness fails fast and tells the operator where to drop them. - tests/.../Crypto/RecipientTrialConstantTimeTests.cs: dudect-style scaffold measuring AuthenticatedModeDecryptor.ResolveDek with the identity matching the first vs last recipient block, computing Welch's t. Skipped by default; emits the result to test output. To be promoted to a CT regression gate once baseline noise is characterized. - examples/pqf-{encrypt,decrypt}-dir.sh: tar | pqf pipeline demo so the README's "what does adoption look like" question has a concrete answer. - .sln + InternalsVisibleTo wired up for the new test projects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 69b6da1 commit a982aba

14 files changed

Lines changed: 915 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ artifacts/
2020
# OS
2121
.DS_Store
2222
Thumbs.db
23+
24+
# NIST KAT vector files (large, fetched on demand by scripts/fetch-nist-kat.sh)
25+
test-vectors/nist-kat/*.rsp
26+
test-vectors/nist-kat/*.req
27+

PostQuantum.FileFormat.sln

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostQuantum.FileFormat.Cli"
1919
EndProject
2020
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostQuantum.FileFormat.Cli.Tests", "tests\PostQuantum.FileFormat.Cli.Tests\PostQuantum.FileFormat.Cli.Tests.csproj", "{88548260-8F40-43FA-84C8-189A71978705}"
2121
EndProject
22+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostQuantum.FileFormat.Fuzz", "tests\PostQuantum.FileFormat.Fuzz\PostQuantum.FileFormat.Fuzz.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}"
23+
EndProject
24+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostQuantum.FileFormat.Kat", "tests\PostQuantum.FileFormat.Kat\PostQuantum.FileFormat.Kat.csproj", "{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}"
25+
EndProject
2226
Global
2327
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2428
Debug|Any CPU = Debug|Any CPU
@@ -89,6 +93,30 @@ Global
8993
{88548260-8F40-43FA-84C8-189A71978705}.Release|x64.Build.0 = Release|Any CPU
9094
{88548260-8F40-43FA-84C8-189A71978705}.Release|x86.ActiveCfg = Release|Any CPU
9195
{88548260-8F40-43FA-84C8-189A71978705}.Release|x86.Build.0 = Release|Any CPU
96+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
97+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
98+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x64.ActiveCfg = Debug|Any CPU
99+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x64.Build.0 = Debug|Any CPU
100+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x86.ActiveCfg = Debug|Any CPU
101+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x86.Build.0 = Debug|Any CPU
102+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
103+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|Any CPU.Build.0 = Release|Any CPU
104+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.ActiveCfg = Release|Any CPU
105+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU
106+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU
107+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU
108+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
109+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
110+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x64.ActiveCfg = Debug|Any CPU
111+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x64.Build.0 = Debug|Any CPU
112+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x86.ActiveCfg = Debug|Any CPU
113+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x86.Build.0 = Debug|Any CPU
114+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
115+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
116+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x64.ActiveCfg = Release|Any CPU
117+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x64.Build.0 = Release|Any CPU
118+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x86.ActiveCfg = Release|Any CPU
119+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x86.Build.0 = Release|Any CPU
92120
EndGlobalSection
93121
GlobalSection(SolutionProperties) = preSolution
94122
HideSolutionNode = FALSE
@@ -99,5 +127,7 @@ Global
99127
{4C78B0EC-7483-47FF-96CE-A5906FBCB4CC} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
100128
{674EA9E7-286A-4994-8F0F-B6AFBFA67886} = {342A349A-D343-8551-4064-2E2800C39E13}
101129
{88548260-8F40-43FA-84C8-189A71978705} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
130+
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
131+
{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
102132
EndGlobalSection
103133
EndGlobal

examples/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# PQF integration examples
2+
3+
Small, self-contained scripts that wrap the `pqf` CLI to do something
4+
useful. None of these are production tooling — they're meant to show
5+
that PQF composes with familiar Unix pipelines.
6+
7+
If you have a real integration in mind (a backup tool, a mail
8+
attachment, a database export), open a
9+
[discussion](https://github.com/systemslibrarian/PostQuantum.FileFormat/discussions)
10+
— concrete integrations are exactly what moves a format from "interesting
11+
spec" to "thing people use."
12+
13+
## `pqf-encrypt-dir.sh`
14+
15+
Encrypts an entire directory tree to a single `.pqf` file by piping a
16+
deterministic tar archive through `pqf encrypt`. Decryption reverses
17+
the pipeline.
18+
19+
Use it like:
20+
21+
```bash
22+
./pqf-encrypt-dir.sh /path/to/dir out.pqf alice.pub.pem
23+
./pqf-decrypt-dir.sh out.pqf /path/to/restore alice.key.json
24+
```
25+
26+
The script is intentionally short — read it before relying on it. The
27+
tar layout is `--sort=name --owner=0 --group=0 --numeric-owner --mtime=...`
28+
so two encryptions of the same tree produce byte-identical plaintext
29+
inputs, which means re-encryption is reproducible up to PQF's own
30+
randomness (KEM ciphertexts, AES-GCM nonces, etc.).

examples/pqf-decrypt-dir.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
#
3+
# examples/pqf-decrypt-dir.sh
4+
#
5+
# Decrypt a .pqf produced by pqf-encrypt-dir.sh and restore the tree.
6+
# Uses Authenticated Mode by default so the file signature (when present)
7+
# and footer are verified before any byte is written to disk.
8+
#
9+
# Usage:
10+
# pqf-decrypt-dir.sh <in.pqf> <dest-parent-dir> <identity.key.json>
11+
#
12+
# `dest-parent-dir` is the directory under which the original tree will
13+
# be restored — the inner directory name comes from the tar archive.
14+
15+
set -euo pipefail
16+
17+
if [[ $# -ne 3 ]]; then
18+
cat >&2 <<'EOF'
19+
usage: pqf-decrypt-dir.sh <in.pqf> <dest-parent-dir> <identity.key.json>
20+
EOF
21+
exit 2
22+
fi
23+
24+
IN_PQF="$1"
25+
DEST_DIR="$2"
26+
IDENTITY="$3"
27+
28+
mkdir -p "${DEST_DIR}"
29+
30+
TMP_TAR="$(mktemp -t pqf-decrypt-dir-XXXXXX.tar)"
31+
trap 'rm -f "${TMP_TAR}"' EXIT
32+
33+
pqf decrypt \
34+
--in "${IN_PQF}" \
35+
--out "${TMP_TAR}" \
36+
--identity "${IDENTITY}" \
37+
--mode authenticated
38+
39+
tar -C "${DEST_DIR}" -xf "${TMP_TAR}"
40+
echo "restored to ${DEST_DIR}"

examples/pqf-encrypt-dir.sh

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
#
3+
# examples/pqf-encrypt-dir.sh
4+
#
5+
# Encrypt a whole directory tree to a single .pqf file by piping a
6+
# deterministically-ordered tar archive into `pqf encrypt`. Optional
7+
# hybrid signature if --signing-key is provided.
8+
#
9+
# Usage:
10+
# pqf-encrypt-dir.sh <src-dir> <out.pqf> <recipient-pub.pem> [--signing-key <signer.key.json>]
11+
#
12+
# Requirements:
13+
# - GNU tar (for --sort=name, --mtime).
14+
# - pqf CLI on $PATH (install with `dotnet tool install --global PostQuantum.FileFormat.Cli --prerelease`).
15+
#
16+
# Notes:
17+
# - The tar layout is deterministic: same tree -> identical tar bytes.
18+
# PQF itself still introduces randomness (KEM ciphertexts, AEAD nonces),
19+
# so two encryptions of the same tree will produce different .pqf
20+
# bytes that decrypt to identical plaintext.
21+
22+
set -euo pipefail
23+
24+
if [[ $# -lt 3 ]]; then
25+
cat >&2 <<'EOF'
26+
usage: pqf-encrypt-dir.sh <src-dir> <out.pqf> <recipient-pub.pem> [--signing-key <signer.key.json>]
27+
EOF
28+
exit 2
29+
fi
30+
31+
SRC_DIR="$1"; shift
32+
OUT_PQF="$1"; shift
33+
RECIPIENT="$1"; shift
34+
35+
SIGN_ARGS=()
36+
while [[ $# -gt 0 ]]; do
37+
case "$1" in
38+
--signing-key)
39+
SIGN_ARGS+=(--signing-key "$2")
40+
shift 2
41+
;;
42+
*)
43+
echo "unknown flag: $1" >&2
44+
exit 2
45+
;;
46+
esac
47+
done
48+
49+
if [[ ! -d "${SRC_DIR}" ]]; then
50+
echo "not a directory: ${SRC_DIR}" >&2
51+
exit 1
52+
fi
53+
54+
# Deterministic tar: stable file order, zero owner/group, fixed mtime.
55+
TMP_TAR="$(mktemp -t pqf-encrypt-dir-XXXXXX.tar)"
56+
trap 'rm -f "${TMP_TAR}"' EXIT
57+
58+
tar \
59+
--sort=name \
60+
--owner=0 --group=0 --numeric-owner \
61+
--mtime='1970-01-01 00:00:00 UTC' \
62+
-C "$(dirname "${SRC_DIR}")" \
63+
-cf "${TMP_TAR}" \
64+
"$(basename "${SRC_DIR}")"
65+
66+
pqf encrypt \
67+
--in "${TMP_TAR}" \
68+
--out "${OUT_PQF}" \
69+
--recipient "${RECIPIENT}" \
70+
"${SIGN_ARGS[@]}"
71+
72+
echo "wrote ${OUT_PQF}"

scripts/fetch-nist-kat.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
#
3+
# scripts/fetch-nist-kat.sh
4+
#
5+
# Download NIST KAT vector files for ML-KEM-1024 (FIPS 203) and
6+
# ML-DSA-87 (FIPS 204) into test-vectors/nist-kat/.
7+
#
8+
# The exact URL of each authoritative artifact moves periodically;
9+
# update the constants below when NIST republishes. The harness in
10+
# tests/PostQuantum.FileFormat.Kat consumes the resulting .rsp files.
11+
#
12+
# This script does NOT vendor the files into the repo — it materializes
13+
# them into a gitignored directory for the local KAT run.
14+
15+
set -euo pipefail
16+
17+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
18+
TARGET_DIR="${REPO_ROOT}/test-vectors/nist-kat"
19+
mkdir -p "${TARGET_DIR}"
20+
21+
# TODO: Pin to specific NIST-published URLs once a stable mirror is
22+
# selected. Until then, this script intentionally fails fast and tells
23+
# the operator where to drop the files.
24+
cat <<'EOF' >&2
25+
fetch-nist-kat: this script is a placeholder.
26+
27+
To run the KAT harness, place the following files in:
28+
test-vectors/nist-kat/
29+
30+
- ml-kem-1024.rsp (NIST FIPS 203 KAT response file)
31+
- ml-dsa-87.rsp (NIST FIPS 204 KAT response file)
32+
33+
Once these files are present, run:
34+
35+
dotnet run --project tests/PostQuantum.FileFormat.Kat
36+
37+
EOF
38+
exit 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
using System.Runtime.CompilerServices;
22

33
[assembly: InternalsVisibleTo("PostQuantum.FileFormat.TestVectors")]
4+
[assembly: InternalsVisibleTo("PostQuantum.FileFormat.Fuzz")]

test-vectors/nist-kat/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# NIST KAT vectors
2+
3+
This directory is populated on demand by `scripts/fetch-nist-kat.sh` (and
4+
its PowerShell equivalent). The NIST-published KAT files are not
5+
committed to this repo because:
6+
7+
1. They are large.
8+
2. They are authoritative artifacts published by NIST and should be
9+
verified against NIST checksums on each fetch rather than mirrored.
10+
3. We do not want to subtly diverge from the upstream files.
11+
12+
After running the fetch script, this directory should contain at least:
13+
14+
- `ml-kem-1024.rsp` — KAT vectors for FIPS 203 (ML-KEM-1024).
15+
- `ml-dsa-87.rsp` — KAT vectors for FIPS 204 (ML-DSA-87).
16+
17+
Run the cross-check harness with:
18+
19+
```bash
20+
dotnet run --project tests/PostQuantum.FileFormat.Kat
21+
```
22+
23+
The harness uses the same `ICryptoProvider` the production code uses,
24+
so a KAT failure is direct evidence of a wrapper-level defect (wrong
25+
parameter set, byte ordering, mistaken serialization choice).
26+
27+
The KAT harness intentionally calls primitive APIs that may not exist
28+
on the current `ICryptoProvider` surface (`MlKemDeriveKeyPair`,
29+
`MlKemDecapsulate`, `MlDsaDeriveKeyPair`, `MlDsaVerify`). Wiring those
30+
through is the second half of this work item; until then the harness
31+
acts as a "build-the-surface" forcing function.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<!--
4+
Fuzz harness for the PQF reader.
5+
6+
This is a console app, not a test project: it is invoked from CI as
7+
`dotnet run -- --time <seconds> --target <header|streaming|cbor>` and
8+
treated as a sanity-check fuzz pass. Long-running campaigns happen out
9+
of band; OSS-Fuzz integration is tracked separately.
10+
11+
The harness deliberately targets the *parser* and *refusal paths*:
12+
- DeterministicCborValidator.ParseStrict
13+
- HeaderCborReader.Parse (via PqfStreamingPipeline.OpenAsync)
14+
- PqfStreamingPipeline.OpenAsync (full container)
15+
16+
A "find" is any input that triggers an exception other than
17+
PqfFileException or CborValidationException — i.e. a panic in the
18+
parser. PqfFileException is the *correct* result for malformed input;
19+
seeing one is the harness doing its job.
20+
-->
21+
22+
<PropertyGroup>
23+
<OutputType>Exe</OutputType>
24+
<TargetFramework>net8.0</TargetFramework>
25+
<ImplicitUsings>enable</ImplicitUsings>
26+
<Nullable>enable</Nullable>
27+
<RootNamespace>PostQuantum.FileFormat.Fuzz</RootNamespace>
28+
<IsPackable>false</IsPackable>
29+
</PropertyGroup>
30+
31+
<ItemGroup>
32+
<ProjectReference Include="..\..\src\PostQuantum.FileFormat\PostQuantum.FileFormat.csproj" />
33+
</ItemGroup>
34+
35+
</Project>

0 commit comments

Comments
 (0)