NIST P-256/384/521, ML-KEM/ML-DSA parameter sets, and key interoperability formats #2484
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (C) 2025-2026 Steel Security Advisors LLC | |
| # SPDX-License-Identifier: Apache-2.0 | |
| name: CI - Testing and Code Quality | |
| on: | |
| push: | |
| branches: [ main, develop, 'feature/**', 'fix/**' ] | |
| pull_request: | |
| branches: [ main, develop ] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # Collapse overlapping runs on the same ref to avoid runner-queue | |
| # saturation when rapid pushes fire both push- and pull_request-triggered | |
| # workflows in parallel. Cancelling in-flight runs is safe for feature | |
| # branches and PR heads; `main` and scheduled runs are preserved by | |
| # excluding them from ``cancel-in-progress``. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'schedule' }} | |
| jobs: | |
| test: | |
| name: Test ${{ matrix.os }} / Python ${{ matrix.python-version }} | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest] | |
| python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] | |
| include: | |
| # ARM64 (AArch64) runners — GitHub-hosted ubuntu arm64 | |
| - os: ubuntu-24.04-arm | |
| python-version: "3.11" | |
| - os: ubuntu-24.04-arm | |
| python-version: "3.13" | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| id: setup-python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| continue-on-error: true | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| cache: 'pip' | |
| # First-party retry path: actions/setup-python intermittently fails on | |
| # GitHub-hosted Windows runners during the Python 3.13 install | |
| # (toolcache / cache-restore flake; see PR #306 review). The retry | |
| # is non-fatal on the first miss and only re-runs when needed, so the | |
| # happy path remains a single setup-python call. | |
| - name: Set up Python ${{ matrix.python-version }} (retry without cache) | |
| if: steps.setup-python.outcome == 'failure' | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install system dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential cmake | |
| - name: Install system dependencies (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| # CMake is pre-installed on GitHub-hosted Windows runners | |
| # (see https://github.com/actions/runner-images/ image manifests), | |
| # so the common case is a no-op. Only install via Chocolatey — | |
| # with retries — if ``cmake`` is genuinely missing, so a transient | |
| # Chocolatey CDN outage cannot break the build. Matches the | |
| # pattern already used in ``ci-build-test.yml``; PR #258 review | |
| # fix for the flaky ``Install system dependencies (Windows)`` | |
| # step that was single-attempt ``choco install cmake``. | |
| run: | | |
| if (Get-Command cmake -ErrorAction SilentlyContinue) { | |
| Write-Host "CMake already available: $(cmake --version | Select-Object -First 1)" | |
| } else { | |
| Write-Host "CMake not found; installing via Chocolatey with retries..." | |
| $maxAttempts = 5 | |
| for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { | |
| Write-Host "Attempt ${attempt}/${maxAttempts}: choco install cmake..." | |
| choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System' -y 2>&1 | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-Host "Successfully installed cmake" | |
| break | |
| } | |
| if ($attempt -lt $maxAttempts) { | |
| $delay = $attempt * 15 | |
| Write-Host "Install failed, waiting ${delay}s before retry..." | |
| Start-Sleep -Seconds $delay | |
| } | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "All ${maxAttempts} attempts to install cmake via Chocolatey failed" | |
| exit 1 | |
| } | |
| Import-Module "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1" | |
| refreshenv | |
| } | |
| - name: Build native C library (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| cmake -B build -DAMA_USE_NATIVE_PQC=ON -DCMAKE_BUILD_TYPE=Release -DAMA_BUILD_TESTS=OFF -DAMA_BUILD_EXAMPLES=OFF | |
| cmake --build build -j$(nproc) | |
| - name: Build native C library (Windows) | |
| if: runner.os == 'Windows' | |
| run: | | |
| cmake -B build -DAMA_USE_NATIVE_PQC=ON -DCMAKE_BUILD_TYPE=Release -DAMA_BUILD_TESTS=OFF -DAMA_BUILD_EXAMPLES=OFF | |
| cmake --build build --config Release --parallel | |
| - name: Install Python dependencies | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| pip install "Cython>=3.0.0" "numpy>=1.24.0,<3.0.0" | |
| # [legacy] + [benchmark] supply PyCA cryptography / PyNaCl, and | |
| # pycryptodome backs the AES-GCM differential harness. Without them the | |
| # cross-implementation validation tests (tests/test_differential.py, | |
| # the PyCA interop cases in test_ed25519_native.py / test_aes_gcm_native.py | |
| # / test_hkdf_sha3_256.py) silently SKIP, so a divergence between AMA's | |
| # native C primitives and a reference implementation would go unnoticed. | |
| # Installing them converts those skips into real assertions. | |
| pip install -e ".[dev,legacy,benchmark]" pycryptodome | |
| - name: Verify module integrity digest is current | |
| shell: bash | |
| run: | | |
| python -c " | |
| from ama_cryptography._self_test import verify_module_integrity | |
| ok, detail = verify_module_integrity() | |
| if not ok: | |
| raise SystemExit( | |
| f'FATAL: Module integrity digest is stale or tampered: {detail}\n' | |
| 'Run: python -m ama_cryptography.integrity --update' | |
| ) | |
| print(f'Module integrity: OK ({detail})') | |
| " | |
| - name: Verify crypto backends are available | |
| shell: bash | |
| run: | | |
| python -c " | |
| from ama_cryptography.pqc_backends import ( | |
| DILITHIUM_AVAILABLE, KYBER_AVAILABLE, SPHINCS_AVAILABLE, | |
| _ED25519_NATIVE_AVAILABLE, _AES_GCM_NATIVE_AVAILABLE, | |
| ) | |
| backends = { | |
| 'Dilithium': DILITHIUM_AVAILABLE, | |
| 'Kyber': KYBER_AVAILABLE, | |
| 'SPHINCS+': SPHINCS_AVAILABLE, | |
| 'AES-GCM native': _AES_GCM_NATIVE_AVAILABLE, | |
| 'Ed25519 native': _ED25519_NATIVE_AVAILABLE, | |
| } | |
| missing = [name for name, avail in backends.items() if not avail] | |
| for name, avail in backends.items(): | |
| status = 'OK' if avail else 'MISSING' | |
| print(f' {name}: {status}') | |
| if missing: | |
| raise SystemExit( | |
| f'FATAL: Crypto backends not available in CI: {missing}. ' | |
| 'The C library must be built before running tests.' | |
| ) | |
| print('All crypto backends verified.') | |
| " | |
| - name: Verify cross-implementation reference libraries are importable | |
| shell: bash | |
| # The differential / interop suites validate AMA's native C primitives | |
| # against independent reference implementations. They are written to SKIP | |
| # when a reference library is absent, which means a missing install would | |
| # quietly delete that coverage instead of failing. Assert the imports here | |
| # so the skip path can never silently return in CI. | |
| run: | | |
| python -c " | |
| import importlib | |
| refs = { | |
| 'cryptography': 'PyCA interop (Ed25519 / AES-GCM / HKDF cross-checks)', | |
| 'nacl': 'PyNaCl differential validation', | |
| 'Crypto': 'pycryptodome AES-GCM differential validation', | |
| } | |
| missing = [] | |
| for mod, why in refs.items(): | |
| try: | |
| importlib.import_module(mod) | |
| print(f' {mod}: OK ({why})') | |
| except ImportError: | |
| missing.append(f'{mod} ({why})') | |
| print(f' {mod}: MISSING ({why})') | |
| if missing: | |
| raise SystemExit( | |
| 'FATAL: cross-implementation reference libraries missing in CI: ' | |
| f'{missing}. Interop tests would SKIP and silently drop coverage.' | |
| ) | |
| print('All cross-implementation reference libraries verified.') | |
| " | |
| - name: Run pytest test suite | |
| env: | |
| AMA_CI_REQUIRE_BACKENDS: "1" | |
| run: | | |
| python -m pytest tests/ -v --tb=short --no-cov | |
| - name: "Wycheproof corpus (vendored, offline, fail-closed)" | |
| # Google's Project Wycheproof is a corpus of the cases that *break* | |
| # implementations. Running it against this library for the first time | |
| # found a real Ed25519 malleability defect (RFC 8032 §5.1.7) in under a | |
| # minute — and then a second divergence in X25519 non-canonical | |
| # u-coordinate handling. Neither had any standing gate; this is it. | |
| # | |
| # The corpus is vendored under wycheproof_vectors/ and pinned by | |
| # manifest.json (upstream commit, per-file SHA-256, per-file vector | |
| # count), so nothing is fetched at test time and a swapped vector file | |
| # fails before a single vector runs. Every one of the 2,733 vectors | |
| # lands in a named bucket with an asserted count — there is no silent | |
| # skip, and an unrunnable vector is a red build with a reason. | |
| run: | | |
| python wycheproof_vectors/run_wycheproof.py | |
| - name: Run demonstration | |
| env: | |
| PYTHONUTF8: "1" | |
| run: | | |
| python -m ama_cryptography | |
| - name: Verify demonstration output | |
| shell: bash | |
| run: | | |
| python -c " | |
| import os | |
| import subprocess | |
| import sys | |
| env = os.environ.copy() | |
| env['PYTHONUTF8'] = '1' | |
| result = subprocess.run([sys.executable, '-m', 'ama_cryptography'], | |
| capture_output=True, text=True, timeout=60, | |
| encoding='utf-8', errors='replace', env=env) | |
| if result.returncode != 0: | |
| print('ERROR: Demonstration failed') | |
| print(result.stderr) | |
| sys.exit(1) | |
| output = result.stdout | |
| # Verify critical outputs | |
| checks = [ | |
| ('AMA Cryptography', 'Title present'), | |
| ('Generating key management system', 'Key generation'), | |
| ('Creating Omni-Code cryptographic package', 'Package creation'), | |
| ('Signing package', 'Signing process'), | |
| ('Verifying cryptographic package', 'Verification'), | |
| ('ALL VERIFICATIONS PASSED', 'Success confirmation') | |
| ] | |
| failed = [] | |
| for check, desc in checks: | |
| if check not in output: | |
| failed.append(f'{desc}: \"{check}\" not found') | |
| if failed: | |
| print('ERROR: Output validation failed:') | |
| for fail in failed: | |
| print(f' - {fail}') | |
| sys.exit(1) | |
| print('All verification checks passed') | |
| " | |
| code-quality: | |
| name: Code Quality Checks | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: "3.11" | |
| cache: 'pip' | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| # Pin the exact linter versions from requirements-lock.txt so the gate | |
| # matches the pinned dev toolchain and pre-commit. An unpinned ruff | |
| # (previously ``ruff>=0.4``) lets a routine ruff release start failing | |
| # an unrelated PR; mypy floated on 1.x while the [dev] extra and | |
| # [tool.mypy] config target 2.x, so local and CI could disagree. | |
| # types-PyYAML keeps tools/check_workflow_commands.py and its tests | |
| # inside `mypy --strict` rather than behind an ignore_missing_imports | |
| # override; tests/ imports the tool, so mypy follows into it. | |
| pip install "black==26.5.1" "ruff==0.15.20" "mypy==2.1.0" \ | |
| "PyYAML==6.0.3" "types-PyYAML==6.0.12.20260724" | |
| - name: Lint with ruff | |
| run: | | |
| ruff check . | |
| - name: Check code formatting with Black | |
| run: | | |
| black --check --diff . | |
| - name: Type checking with MyPy (--strict) | |
| continue-on-error: false | |
| run: | | |
| mypy --strict ama_cryptography/ tests/ | |
| - name: "INVARIANT-13: Suppression hygiene check" | |
| run: | | |
| python tools/check_suppression_hygiene.py | |
| - name: "Uniform license headers (SPDX)" | |
| # The tree carried five different header shapes at once — a two-line | |
| # Apache note, the full thirteen-line boilerplate block, a bare SPDX | |
| # tag on four files, a one-off "Apache License 2.0" spelling, and the | |
| # C block-comment forms of each. No machine license scanner could | |
| # read that reliably and a new file could pick any shape. This gate | |
| # pins the canonical two-line header with the *registered* SPDX | |
| # identifier (`Apache-2.0`; `Apache 2.0` is not a valid identifier). | |
| # Detection and normalization are both pinned by tests/test_headers.py, | |
| # so the gate cannot silently degrade into a no-op. | |
| run: | | |
| python tools/check_headers.py --check | |
| docs-build: | |
| # Run sphinx-build with -W on every PR / push so any docstring-format | |
| # regression (e.g. malformed Raises: blocks, dangling indent) is caught | |
| # at PR time, not on the post-merge auto-docs job whose failure does | |
| # not block the merge that introduced it. | |
| name: Sphinx Docs Build (Python API) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: "3.11" | |
| cache: 'pip' | |
| - name: Install documentation dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install sphinx sphinx_rtd_theme sphinx-autodoc-typehints | |
| - name: Build Sphinx HTML — warnings are errors | |
| env: | |
| # INVARIANT-7 docs-build gate. Lift the native-backend import | |
| # guards for autodoc only; ``--keep-going`` reports every warning | |
| # in the run while ``-W`` makes the job fail on any of them. | |
| AMA_SPHINX_BUILD: '1' | |
| run: sphinx-build -W --keep-going -b html docs docs/_build/html | |
| security-checks: | |
| name: Security Checks | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| # PyYAML is pinned to the requirements-lock.txt version because | |
| # tools/check_workflow_commands.py (INVARIANT-25) runs in this job and | |
| # parses the workflow files structurally. | |
| pip install pip-audit bandit "PyYAML==6.0.3" | |
| - name: Audit dependencies (strict, lock-file scoped) | |
| # Scope the audit to requirements-lock.txt — this project's pinned | |
| # dependency set — rather than auditing the whole runner environment. | |
| # A bare `pip-audit` reports CVEs in packages AMA does not ship (pip, | |
| # pyjwt, urllib3 and whatever else the GitHub runner image preinstalls), | |
| # so the gate turned red for reasons unrelated to this repository and | |
| # unfixable from it. Lock-file scoping makes the result reproducible | |
| # across reruns and runner-image rolls, and matches the contract already | |
| # used by security.yml. `--strict` still fails the job on any | |
| # vulnerability found in our own pinned set. | |
| run: pip-audit --strict --desc --requirement requirements-lock.txt | |
| - name: Security linting with Bandit | |
| run: | | |
| bandit -r ama_cryptography/ -l -f json -o bandit-report.json | |
| bandit -r ama_cryptography/ -l | |
| - name: Upload Bandit report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: success() | |
| with: | |
| name: bandit-security-report | |
| path: bandit-report.json | |
| - name: Semgrep security scan | |
| continue-on-error: false | |
| run: | | |
| python -m pip install semgrep==1.74.0 | |
| semgrep --config .semgrep.yml ama_cryptography/ --json -o semgrep-report.json | |
| - name: Upload Semgrep report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: success() | |
| with: | |
| name: semgrep-security-report | |
| path: semgrep-report.json | |
| - name: "INVARIANT-24: Verify pinned action SHAs resolve upstream" | |
| # A SHA-pinned action is only a supply-chain control if the SHA is real. | |
| # release.yml carried a pypa/cibuildwheel pin whose commit existed | |
| # nowhere upstream, so EVERY wheel job aborted with "unable to find | |
| # version" — which is why the v3.2.0 and v3.3.0 releases shipped with | |
| # zero binary artefacts. Nothing caught it because release.yml only | |
| # runs on a tag push. Checking here means a bad pin fails on the PR | |
| # that introduces it, not on release day. | |
| run: | | |
| python tools/check_action_pins.py --strict | |
| - name: "INVARIANT-25: Verify workflow runner labels and command strings" | |
| # release.yml runs only on a tag push, so anything wrong inside it stays | |
| # invisible until release day. Three defects shipped that way, each on | |
| # its own enough to produce a release with zero binary artefacts: a | |
| # retired `macos-13` runner label (the job queued until timeout), a | |
| # `python -c` payload broken by YAML folding (every wheel built, then | |
| # failed with IndentationError), and POSIX single-quoting handed to | |
| # cmd.exe (every Windows wheel died on `Invalid requirement: "'cmake"`). | |
| # All three are decidable without running anything, so they are decided | |
| # here — on the pull request that introduces them. Both directions are | |
| # pinned by tests/test_workflow_command_checks.py. | |
| run: | | |
| python tools/check_workflow_commands.py | |
| - name: "INVARIANT-31: Verify every pull-request job is reachable from its gate" | |
| # Branch protection requires the aggregating gate context of each | |
| # workflow, not the individual job names. A job missing from its gate's | |
| # `needs:` therefore still runs, still shows its own red X on the pull | |
| # request, and still cannot block the merge — "all required checks | |
| # passed" stays true next to a visibly failing job. | |
| # | |
| # `c-library-no-native-pqc` was exactly that: the guard for the | |
| # AMA_USE_NATIVE_PQC=OFF build, absent from ci-build-test.yml's gate | |
| # while commit f3dd0c2 had to repair that very configuration after it | |
| # broke undetected. Every gate comment in this repository asserts that | |
| # each job in the workflow must be green; this check is what makes that | |
| # true. Both directions are pinned by tests/test_gate_coverage.py. | |
| run: | | |
| python tools/check_gate_coverage.py | |
| - name: "INVARIANT-32: Verify documented install extras are declared" | |
| # pip does not fail on an extra a distribution does not provide — it | |
| # warns, installs WITHOUT it, and exits 0. A stale name in an install | |
| # instruction therefore yields an incomplete install and a success | |
| # message, surfacing much later as an ImportError from a subsystem the | |
| # reader believes they enabled. | |
| # | |
| # wiki/Installation.md — published to the public wiki by wiki-sync.yml — | |
| # shipped `pip install -e ".[secure-memory]"`, described as "libsodium | |
| # secure memory bindings", in its own "Everything at once" line. No such | |
| # extra ever existed, secure_memory is stdlib-only, and INVARIANT-1 | |
| # forbids libsodium outright. Both directions are pinned by | |
| # tests/test_documented_extras.py. | |
| run: | | |
| python tools/check_documented_extras.py | |
| - name: "INVARIANT-33: Verify every fuzz harness is registered everywhere" | |
| # A fuzz harness is registered in three independent lists: the CMake | |
| # target lists, the fuzzing.yml job matrix, and oss-fuzz/build.sh. | |
| # Nothing tied them together, and they had drifted: fuzz_agent_binding | |
| # was added to CMake and to the CI matrix when the agent-binding layer | |
| # landed, and never to build.sh — so OSS-Fuzz never built it. build.sh | |
| # skips a missing target with a warning and exits 0, which is why the | |
| # omission stayed invisible. | |
| # | |
| # A harness nobody runs is indistinguishable from one that finds | |
| # nothing. Both directions are pinned by | |
| # tests/test_fuzz_target_registration.py. | |
| run: | | |
| python tools/check_fuzz_target_registration.py | |
| - name: "INVARIANT-23: Secret scan (in-house)" | |
| # tools/check_secrets.py is written in house rather than adopting a | |
| # third-party scanner — see the module docstring for why a generic | |
| # entropy scanner is the wrong tool for a repository whose tracked | |
| # content is largely published high-entropy test vectors. Detection | |
| # and non-detection are pinned by tests/test_secret_scanner.py, so this | |
| # gate cannot silently degrade into a no-op. | |
| run: | | |
| python tools/check_secrets.py | |
| - name: Enforce safe os.fdopen usage (fd-ownership, AST-verified) | |
| # Replaces a grep-plus-filename-allowlist check. That construction could | |
| # not tell a correctly guarded call from a leaking one — it only asked | |
| # whether the file was on a list, so it was satisfied by editing the list | |
| # — and it had already rotted: the allowlist named `key_storage.py`, a | |
| # module that does not exist in this package, while omitting the module | |
| # that actually performs the call. tools/check_fdopen_safety.py instead | |
| # parses the AST and verifies the property that prevents the leak: every | |
| # os.fdopen call sits in a try whose handlers (or finally) can close the | |
| # raw descriptor when the hand-off fails. No allowlist to maintain, and | |
| # it applies to every tracked module rather than three named ones. | |
| # Both directions are pinned by tests/test_fdopen_safety.py. | |
| run: | | |
| python tools/check_fdopen_safety.py | |
| benchmark-regression: | |
| name: Benchmark Regression Detection (${{ matrix.runner_cpu_class }}) | |
| # x86_64 entry uses ubuntu-latest; arm64 entry uses ubuntu-24.04-arm | |
| # (GitHub-hosted Cortex-A78 / Graviton-class AArch64 runner). The | |
| # arm-baseline.json arm entry exercises the NEON paths wired by this | |
| # PR (AES-GCM, ChaCha20, Argon2) end-to-end so any byte-level | |
| # divergence between the NEON kernel and the scalar reference shows | |
| # up as a regression-detector hit, not a silent passing build. | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| runner_cpu_class: x86_64 | |
| baseline_path: benchmarks/baseline.json | |
| - os: ubuntu-24.04-arm | |
| runner_cpu_class: aarch64 | |
| baseline_path: benchmarks/arm-baseline.json | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: "3.11" | |
| cache: 'pip' | |
| - name: Install system dependencies | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential cmake util-linux | |
| - name: Capture runner CPU class | |
| id: cpu_class | |
| run: | | |
| # lscpu reports the canonical "Architecture:" field which we | |
| # treat as the source of truth. Pin the value into the runner | |
| # matrix entry so a benchmark JSON produced by ubuntu-24.04-arm | |
| # is unambiguously distinguishable from one produced by | |
| # ubuntu-latest x86_64 (the regression detector keys off this). | |
| ARCH=$(lscpu | awk -F: '/^Architecture:/ {gsub(/^ +/, "", $2); print $2}') | |
| echo "arch=${ARCH}" >> "$GITHUB_OUTPUT" | |
| echo "matrix_class=${{ matrix.runner_cpu_class }}" >> "$GITHUB_OUTPUT" | |
| - name: Build native C library | |
| run: | | |
| # AMA_ENABLE_AVX512=ON enables the in-house AVX-512 4-way Keccak | |
| # kernel on x86-64 hosts (gated on runtime CPUID at dispatch | |
| # init, so a no-op on AArch64). AMA_ENABLE_NATIVE_ARCH=ON adds | |
| # -march=native so the benchmark exercises the host's full | |
| # microarchitecture (AES-NI, BMI2, AVX2/AVX-512, NEON Crypto | |
| # Extensions as appropriate). Without these flags the benchmark | |
| # numbers reflect the lowest-common-denominator generic C path, | |
| # not what users actually run. | |
| cmake -B build \ | |
| -DAMA_USE_NATIVE_PQC=ON \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DAMA_BUILD_TESTS=OFF \ | |
| -DAMA_BUILD_EXAMPLES=OFF \ | |
| -DAMA_ENABLE_AVX512=ON \ | |
| -DAMA_ENABLE_NATIVE_ARCH=ON | |
| cmake --build build -j$(nproc) | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| pip install "Cython>=3.0.0" "numpy>=1.24.0,<3.0.0" | |
| pip install -e ".[dev]" | |
| - name: Run benchmark regression tests | |
| env: | |
| AMA_RUNNER_CPU_CLASS: ${{ steps.cpu_class.outputs.arch }} | |
| AMA_MATRIX_BASELINE: ${{ matrix.baseline_path }} | |
| run: | | |
| # Pin to a single core (`taskset -c 0`) and raise scheduling | |
| # priority (`nice -n -10`) so the benchmark numbers are not | |
| # contaminated by other tasks on the shared GitHub-hosted | |
| # runner. Mirrors the same harness dudect.yml uses for its | |
| # constant-time measurements at the identical priority — the | |
| # dudect t-statistic and the benchmark medians both depend on | |
| # the same noise floor. | |
| # | |
| # `--baseline "$AMA_MATRIX_BASELINE"` is the per-matrix entry | |
| # baseline path (Copilot review #3249188319): the x86 entry | |
| # compares against benchmarks/baseline.json, the AArch64 entry | |
| # against benchmarks/arm-baseline.json. Without this, both | |
| # entries default to the x86 baseline and the AArch64 job | |
| # reports spurious regressions on every PR. | |
| taskset -c 0 nice -n -10 python benchmarks/benchmark_runner.py \ | |
| --verbose \ | |
| --baseline "$AMA_MATRIX_BASELINE" \ | |
| --require-runner-class "$AMA_RUNNER_CPU_CLASS" \ | |
| --require-populated-baseline \ | |
| --output benchmarks/benchmark-results.json \ | |
| --markdown benchmark-report.md | |
| # Annotate the result JSON with the runner CPU class so the | |
| # arm and x86 artefacts are unambiguously distinguishable. | |
| # The downstream `check_baseline_justification.py` uses this | |
| # tag to compare against the matching baseline. | |
| python - <<'PY' | |
| import json, os, pathlib | |
| path = pathlib.Path("benchmarks/benchmark-results.json") | |
| if path.exists(): | |
| data = json.loads(path.read_text()) | |
| data.setdefault("metadata", {})["runner_cpu_class"] = ( | |
| os.environ.get("AMA_RUNNER_CPU_CLASS", "unknown") | |
| ) | |
| data["metadata"]["matrix_baseline_path"] = ( | |
| os.environ.get("AMA_MATRIX_BASELINE", "") | |
| ) | |
| path.write_text(json.dumps(data, indent=2) + "\n") | |
| PY | |
| - name: Upload benchmark results | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: success() | |
| with: | |
| name: benchmark-results-${{ matrix.runner_cpu_class }} | |
| path: | | |
| benchmarks/benchmark-results.json | |
| benchmark-report.md | |
| constant-time-check: | |
| name: Constant-Time Verification (Smoke Test) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Install build dependencies | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential cmake | |
| - name: Build C library | |
| run: | | |
| mkdir -p build | |
| cd build | |
| cmake .. | |
| make | |
| - name: Build and run dudect harnesses | |
| run: | | |
| cd tools/constant_time | |
| make clean | |
| make all | |
| # Run smoke tests with 50K iterations (faster for CI). | |
| # | |
| # Pin to a single core (`taskset -c 0`) and best-effort | |
| # elevate priority (`nice -n -10`) to suppress CPU-contention | |
| # noise on shared GitHub-hosted runners. Without this, the | |
| # dudect t-statistic at 50K iterations can drift past its | |
| # leakage threshold from background noise alone. | |
| # | |
| # GHA hosted runners lack CAP_SYS_NICE so the negative-nice | |
| # request prints "Permission denied" but still execs the | |
| # binary; probe once up front and drop the prefix when the | |
| # runner refuses (PR #326), so the log stays clean. The | |
| # v3.2.0 dudect setup-symmetry harness fixes made the lanes | |
| # noise-tolerant enough that taskset-only pinning is the | |
| # load-bearing CI gate; the nice prefix is opportunistic on | |
| # privileged self-hosted runners. The full dudect.yml gate | |
| # (path-filtered to src/c/** changes) remains the | |
| # authoritative constant-time check; this smoke test exists | |
| # as a fast pre-gate for every PR regardless of path filter. | |
| NICE_PREFIX="" | |
| if nice -n -10 true 2>/dev/null; then NICE_PREFIX="nice -n -10"; fi | |
| echo "=== Utility function timing analysis ===" | |
| taskset -c 0 $NICE_PREFIX ./dudect_harness 50000 | |
| echo "=== Crypto primitive timing analysis ===" | |
| taskset -c 0 $NICE_PREFIX ./dudect_crypto 50000 | |
| - name: Upload timing analysis results | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: success() | |
| with: | |
| name: constant-time-results | |
| path: tools/constant_time/ | |
| # ============================================================================ | |
| # AVX-512 SHA3 4-way KAT (PR C — 2026-04) | |
| # | |
| # Best-effort gate: GitHub-hosted ubuntu-latest currently rotates among | |
| # Cascade Lake / Ice Lake / Sapphire Rapids host CPUs, so AVX-512 hits | |
| # only some fraction of runs. We probe /proc/cpuinfo for the avx512f | |
| # feature flag and skip honestly when it's missing — the build/test | |
| # body itself never uses continue-on-error (INVARIANT-2: fail-closed | |
| # CI on the steps that actually run). Local validation ladder for | |
| # pre-merge: SDE (`sde64 -spr -- ./test_sha3_avx512_kat`) plus a | |
| # quarterly bare-metal bench post-merge. | |
| # ============================================================================ | |
| test-avx512: | |
| name: AVX-512 SHA3 4-way KAT | |
| runs-on: ubuntu-latest | |
| needs: test | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Probe /proc/cpuinfo for AVX-512F + AVX-512VL | |
| id: cpu | |
| run: | | |
| if grep -q '\bavx512f\b' /proc/cpuinfo && grep -q '\bavx512vl\b' /proc/cpuinfo; then | |
| echo "have_avx512=1" >> "$GITHUB_OUTPUT" | |
| echo "::notice::ubuntu-latest runner reports AVX-512F + AVX-512VL — running KAT" | |
| else | |
| echo "have_avx512=0" >> "$GITHUB_OUTPUT" | |
| echo "::notice::ubuntu-latest runner lacks AVX-512F or AVX-512VL — skipping KAT job" | |
| fi | |
| - name: Install build dependencies | |
| if: steps.cpu.outputs.have_avx512 == '1' | |
| run: | | |
| sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true | |
| sudo apt-get update | |
| sudo apt-get install -y cmake build-essential | |
| - name: Configure with AVX-512 enabled | |
| if: steps.cpu.outputs.have_avx512 == '1' | |
| run: cmake -S . -B build -DAMA_ENABLE_AVX512=ON -DAMA_BUILD_TESTS=ON | |
| - name: Build AVX-512 KAT test | |
| if: steps.cpu.outputs.have_avx512 == '1' | |
| run: cmake --build build --target test_sha3_avx512_kat --parallel | |
| - name: Run AVX-512 KAT test (byte-identity vs scalar + AVX2) | |
| if: steps.cpu.outputs.have_avx512 == '1' | |
| run: ctest --test-dir build -R test_sha3_avx512_kat --output-on-failure | |
| ed25519-backend-differential: | |
| name: Ed25519 backend differential (donna vs fe51) | |
| # WHY THIS JOB EXISTS | |
| # | |
| # This repository ships TWO Ed25519 implementations and picks between them | |
| # at configure time (CMakeLists.txt removes src/c/ama_ed25519.c from the | |
| # source list and substitutes src/c/ed25519_donna_shim.c whenever | |
| # AMA_ED25519_ASSEMBLY is ON, which is the default on x86-64): | |
| # | |
| # * ed25519-donna — x86-64 assembly, what every x86-64 lane builds | |
| # * fe51 — the portable path in ama_ed25519.c | |
| # | |
| # Until this job existed, no CI lane ever built fe51 on x86-64. It was | |
| # exercised only incidentally on the ubuntu-24.04-arm runners, and nothing | |
| # anywhere compared the two against each other. | |
| # | |
| # That is not a theoretical gap. INVARIANT-26 fixed an RFC 8032 §5.1.7 | |
| # canonical-S defect that was present in BOTH backends but for DIFFERENT | |
| # reasons — donna's `RS[63] & 224` test was too weak, while fe51 had no | |
| # range check at all — so a fix written against one could easily have left | |
| # the other broken. Two implementations of one public API that nothing | |
| # compares will drift; the only question is when it is noticed. | |
| # | |
| # This job builds both on the same runner and asserts they return the same | |
| # verdict for every signature in a shared corpus. It is a differential | |
| # test, not a KAT: it does not care which answer is right, only that the | |
| # two backends cannot disagree without failing the build. | |
| runs-on: ubuntu-latest | |
| # Two full CMake builds of the native library plus a C test run. Measured | |
| # at roughly 4 minutes locally; 30 leaves headroom for a cold runner | |
| # without letting a genuinely hung build sit for an hour. | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Install build toolchain | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential cmake | |
| - name: Build donna backend (x86-64 default) | |
| run: | | |
| cmake -S . -B build-donna \ | |
| -DAMA_ED25519_ASSEMBLY=ON \ | |
| -DAMA_USE_NATIVE_PQC=ON \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DAMA_BUILD_TESTS=ON \ | |
| -DAMA_BUILD_EXAMPLES=OFF | |
| cmake --build build-donna -j"$(nproc)" | |
| - name: Build fe51 backend (portable path, never built on x86-64 before) | |
| run: | | |
| cmake -S . -B build-fe51 \ | |
| -DAMA_ED25519_ASSEMBLY=OFF \ | |
| -DAMA_USE_NATIVE_PQC=ON \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DAMA_BUILD_TESTS=ON \ | |
| -DAMA_BUILD_EXAMPLES=OFF | |
| cmake --build build-fe51 -j"$(nproc)" | |
| - name: Run the C test suite against the fe51 backend | |
| # The donna backend's C tests already run in ci-build-test.yml on every | |
| # x86-64 lane. This is the half that had no coverage. | |
| working-directory: build-fe51 | |
| run: ctest --output-on-failure | |
| - name: Differential — both backends must agree on every signature | |
| run: | | |
| python tools/check_ed25519_backend_parity.py \ | |
| --donna build-donna/lib/libama_cryptography.so \ | |
| --fe51 build-fe51/lib/libama_cryptography.so | |
| ci-gate: | |
| name: CI Gate | |
| # Single aggregating status check for this workflow. Branch protection | |
| # should require ONLY this context (one per primary CI workflow) instead of | |
| # the individual job names: adding, renaming, or matrix-expanding a job then | |
| # updates this needs: list under code review rather than drifting the | |
| # branch-protection config out of sync (required-context drift). `needs:` is | |
| # workflow-local, so each of ci.yml / ci-build-test.yml / static-analysis.yml | |
| # carries its own gate. | |
| # | |
| # if: always() so the gate always resolves to a definitive red/green even | |
| # when a dependency fails — otherwise it would be reported as `skipped` and | |
| # leave the PR stuck "waiting for status". | |
| if: always() | |
| needs: | |
| - test | |
| - code-quality | |
| - docs-build | |
| - security-checks | |
| - benchmark-regression | |
| - constant-time-check | |
| - test-avx512 | |
| - ed25519-backend-differential | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 3 | |
| steps: | |
| - name: Fail unless every required job succeeded | |
| # STRICT. Every job in this workflow runs unconditionally — none carries a | |
| # job-level `if:` — so on the authoritative head-commit run each MUST be | |
| # `success`. `skipped` now fails the gate too: on this workflow it can only | |
| # mean a `needs:` dependency failed (already red) or a future job's `if:` | |
| # drifted so it silently stopped running — exactly the gap a lenient gate | |
| # hides. `cancelled` also fails, but only ever appears on a run superseded | |
| # by a newer push or a manual abort, neither of which gates the latest head | |
| # commit that branch protection evaluates. Matrix jobs are fail-fast:false | |
| # and timeouts report as `failure`. | |
| if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled') }} | |
| run: | | |
| echo "::error::CI Gate failed — a required job did not succeed." | |
| echo "Dependency results: ${{ join(needs.*.result, ', ') }}" | |
| exit 1 | |
| - name: All required jobs succeeded | |
| run: echo "CI Gate passed — all required jobs are green." |