diff --git a/.github/workflows/house-style.yml b/.github/workflows/house-style.yml index d3db7c4..5d77bf7 100644 --- a/.github/workflows/house-style.yml +++ b/.github/workflows/house-style.yml @@ -5,6 +5,7 @@ on: branches: [ 'main', 'release/**' ] pull_request: branches: [ '*' ] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e6cc83e..c0e8dbb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -28,17 +28,24 @@ jobs: const workflows = [ 'build-test.yml', + 'c99-compliance.yml', + 'cmdline-test.yml', 'codespell.yml', 'comprehensive-tests.yml', + 'cose-hpke.yml', 'coverage.yml', 'empty-brace-scan.yml', 'examples.yml', + 'house-style.yml', + 'interop.yml', + 'lean-build.yml', 'minimal-build.yml', 'misra-2012.yml', 'misra-2023.yml', 'multi-compiler.yml', 'sanitizer.yml', 'scenarios.yml', + 'stack-bounds.yml', 'static-analysis.yml', 'wolfssl-versions.yml', ]; diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml new file mode 100644 index 0000000..8dadf91 --- /dev/null +++ b/.github/workflows/release-prep.yml @@ -0,0 +1,350 @@ +name: Release Qualification + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix + required: true + type: string + pull_request: + types: [labeled] + +concurrency: + group: release-qualification-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + checks: read + pull-requests: read + +jobs: + metadata: + name: Release metadata + if: >- + github.event_name == 'workflow_dispatch' || + github.event.label.name == 'ci:release' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} + ci_sha: ${{ steps.release.outputs.ci_sha }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Resolve release version and commit + id: release + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -eu + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION=$INPUT_VERSION + else + VERSION=$(awk '/LIBWOLFCOSE_VERSION_STRING/ { + gsub(/"/, "", $3); print $3; exit + }' include/wolfcose/version.h) + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "ci_sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + else + echo "ci_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + fi + + - name: Validate release metadata + run: | + ./scripts/release/validate.sh \ + --version "${{ steps.release.outputs.version }}" \ + --ref "${{ steps.release.outputs.commit }}" + + qualify: + name: Release-specific qualification + needs: metadata + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y autoconf automake g++ lcov libtool \ + unzip valgrind zip + + - name: Resolve wolfSSL master commit + id: wolfssl + run: | + echo "commit=$(git ls-remote https://github.com/wolfSSL/wolfssl.git HEAD | cut -f1)" >> "$GITHUB_OUTPUT" + + - name: Cache full wolfSSL backend + id: cache-wolfssl + uses: actions/cache@v4 + with: + path: ~/wolfssl-release + key: wolfssl-release-full-v1-${{ steps.wolfssl.outputs.commit }} + + - name: Build full wolfSSL backend + if: steps.cache-wolfssl.outputs.cache-hit != 'true' + env: + WOLFSSL_COMMIT: ${{ steps.wolfssl.outputs.commit }} + run: | + git init "$HOME/wolfssl-release-src" + git -C "$HOME/wolfssl-release-src" remote add origin \ + https://github.com/wolfSSL/wolfssl.git + git -C "$HOME/wolfssl-release-src" fetch --depth 1 origin \ + "$WOLFSSL_COMMIT" + git -C "$HOME/wolfssl-release-src" checkout --detach FETCH_HEAD + test "$(git -C "$HOME/wolfssl-release-src" rev-parse HEAD)" = \ + "$WOLFSSL_COMMIT" + cd "$HOME/wolfssl-release-src" + ./autogen.sh + ./configure --enable-ecc --enable-ed25519 --enable-ed448 \ + --enable-curve25519 --enable-curve448 \ + --enable-ecccustcurves=all \ + --enable-aesgcm --enable-aesccm --enable-aescbc \ + --enable-sha384 --enable-sha512 --enable-keygen \ + --enable-hkdf --enable-aeskeywrap \ + --enable-chacha --enable-poly1305 --enable-rsapss \ + --enable-mldsa --enable-lms --enable-hpke \ + --prefix="$HOME/wolfssl-release" + make -j"$(nproc)" + make install + + - name: Run baseline release commands + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: | + make clean + make + make test + make comprehensive + make scenarios + make tool-test + make cmdline-test EXPECT_PQC=true + make demos + + - name: Run advanced feature scenarios + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: make release-scenarios + + - name: Compile as C++ + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: make cxx-check + + - name: Run release scenarios under Valgrind + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: make valgrind-check + + - name: Merge release coverage profiles + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + WOLFSSL_LDFLAGS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: make release-coverage + + - name: Build and smoke-test source archives + env: + WOLFSSL_CFLAGS: -isystem /home/runner/wolfssl-release/include + WOLFSSL_LIBS: -L/home/runner/wolfssl-release/lib -lwolfssl + LD_LIBRARY_PATH: /home/runner/wolfssl-release/lib + run: | + make release-artifacts \ + VERSION="${{ needs.metadata.outputs.version }}" \ + RELEASE_REF="${{ needs.metadata.outputs.commit }}" + + - name: Verify reproducible source archives + env: + VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_REF: ${{ needs.metadata.outputs.commit }} + run: | + normal=$(mktemp -d) + restricted=$(mktemp -d) + ( + umask 022 + TZ=UTC RELEASE_SMOKE_TEST=0 make release-artifacts \ + VERSION="$VERSION" RELEASE_REF="$RELEASE_REF" \ + RELEASE_OUTPUT="$normal" + ) + ( + umask 077 + TZ=Pacific/Honolulu RELEASE_SMOKE_TEST=0 make release-artifacts \ + VERSION="$VERSION" RELEASE_REF="$RELEASE_REF" \ + RELEASE_OUTPUT="$restricted" + ) + cmp "$normal/wolfcose-$VERSION.tar.gz" \ + "$restricted/wolfcose-$VERSION.tar.gz" + cmp "$normal/wolfcose-$VERSION.zip" \ + "$restricted/wolfcose-$VERSION.zip" + cmp "$normal/wolfcose-$VERSION.sha256" \ + "$restricted/wolfcose-$VERSION.sha256" + + - name: Record backend in release manifest + run: | + python3 - <<'PY' + import json + from pathlib import Path + + path = Path("dist/release-manifest.json") + data = json.loads(path.read_text()) + data["wolfssl"] = { + "ref": "master", + "commit": "${{ steps.wolfssl.outputs.commit }}", + } + data["workflow_run"] = ( + "${{ github.server_url }}/${{ github.repository }}/actions/runs/" + "${{ github.run_id }}" + ) + path.write_text(json.dumps(data, indent=2) + "\n") + PY + + - name: Upload verified release candidate + uses: actions/upload-artifact@v4 + with: + name: wolfcose-${{ needs.metadata.outputs.version }}-${{ needs.metadata.outputs.commit }} + path: | + dist/ + release-coverage.info + retention-days: 90 + + required-ci: + name: Required CI for candidate SHA + needs: metadata + runs-on: ubuntu-latest + timeout-minutes: 125 + steps: + - name: Wait for required workflows + uses: actions/github-script@v7 + env: + CANDIDATE_SHA: ${{ needs.metadata.outputs.ci_sha }} + with: + script: | + const required = [ + 'build-test.yml', + 'c99-compliance.yml', + 'cmdline-test.yml', + 'codeql.yml', + 'codespell.yml', + 'comprehensive-tests.yml', + 'cose-hpke.yml', + 'coverage.yml', + 'empty-brace-scan.yml', + 'examples.yml', + 'house-style.yml', + 'interop.yml', + 'lean-build.yml', + 'minimal-build.yml', + 'misra-2012.yml', + 'misra-2023.yml', + 'multi-compiler.yml', + 'sanitizer.yml', + 'scenarios.yml', + 'semgrep.yml', + 'stack-bounds.yml', + 'static-analysis.yml', + 'wolfssl-versions.yml', + ]; + const wanted = new Set(required); + const candidate = process.env.CANDIDATE_SHA; + const expectedEvent = context.eventName === 'pull_request' + ? 'pull_request' + : 'push'; + const deadline = Date.now() + (120 * 60 * 1000); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const basename = (path) => (path || '').split('/').pop(); + + async function newestRuns() { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + head_sha: candidate, + event: expectedEvent, + per_page: 100, + } + ); + const newest = new Map(); + for (const run of runs) { + const file = basename(run.path); + if (!wanted.has(file)) continue; + const old = newest.get(file); + if (old === undefined || run.run_number > old.run_number) { + newest.set(file, run); + } + } + return newest; + } + + let newest = new Map(); + while (Date.now() < deadline) { + newest = await newestRuns(); + const pending = [...newest.values()].filter( + (run) => run.status !== 'completed' + ); + core.info( + `Found ${newest.size}/${required.length} workflows; ` + + `${pending.length} still running` + ); + if (newest.size === required.length && pending.length === 0) break; + await sleep(60 * 1000); + } + + let summary = `## Required CI associated with ${candidate}\n\n`; + const failures = []; + for (const file of required) { + const run = newest.get(file); + if (run === undefined) { + summary += `- ${file}: missing\n`; + failures.push(`${file}: missing`); + continue; + } + summary += `- [${file}](${run.html_url}): ${run.conclusion || run.status}\n`; + if (run.status !== 'completed' || run.conclusion !== 'success') { + failures.push(`${file}: ${run.conclusion || run.status}`); + } + } + await core.summary.addRaw(summary).write(); + if (failures.length !== 0) { + core.setFailed(failures.join('\n')); + } + + release-qualified: + name: Release qualified + if: >- + always() && + (github.event_name == 'workflow_dispatch' || + github.event.label.name == 'ci:release') + needs: [metadata, qualify, required-ci] + runs-on: ubuntu-latest + steps: + - name: Enforce all release gates + env: + METADATA: ${{ needs.metadata.result }} + QUALIFY: ${{ needs.qualify.result }} + REQUIRED_CI: ${{ needs.required-ci.result }} + run: | + test "$METADATA" = success + test "$QUALIFY" = success + test "$REQUIRED_CI" = success diff --git a/.gitignore b/.gitignore index 037c079..62acc94 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ docs/Gemfile.lock # Local fenrir tracking (do not commit) FENRIR_FIX_LOG.md fenrir-open-findings-*.md + +# Release qualification output +dist/ +release-coverage.info diff --git a/Makefile b/Makefile index 96babe1..88efca3 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,9 @@ # clean - Remove all build artifacts CC ?= gcc +CXX ?= g++ AR ?= ar +VALGRIND ?= valgrind PKG_CONFIG ?= pkg-config WOLFSSL_PACKAGE ?= wolfssl WOLFSSL_PREFIX ?= /usr/local @@ -149,6 +151,7 @@ EXTSIGN_DEMO = examples/ext_sign_demo MLDSAV_DEMO = examples/sign1_verify_mldsa LMS_DEMO = examples/sign1_lms LMSV_DEMO = examples/sign1_verify_lms +CXX_SMOKE = tests/cpp_header_smoke # Comprehensive tests (CI) COMP_SIGN = examples/comprehensive/sign_all @@ -163,7 +166,7 @@ SCEN_IOTFLEET = examples/scenarios/iot_fleet_config SCEN_SENSOR = examples/scenarios/sensor_attestation SCEN_BROADCAST = examples/scenarios/group_broadcast_mac -.PHONY: all shared test pkg-config-test ecdsa-policy-test rsapss-policy-test countersign-config-test zero-alloc-check zeroize-test deprecated-algs-test ecc-import-policy-test ext-sign-test ext-sign-demo ext-sign-force-failure coverage eat-psa-test eat-psa-float-test eat-psa-min-buffers-test eat-psa-claim-limits-test eat-psa-profile-test eat-psa-config-check eat-psa-ext-sign-test eat-psa-ext-sign-force-failure eat-psa-coverage eat-psa-coverage-force-failure generic-reduced-alg-test tool tool-test cmdline-test demo demos hpke-demo lean-verify psa-eat-lean-verify psa-eat-demo mldsa-demo mldsa-verify lms-demo lms-verify comprehensive scenarios interop-tcose tcose-upstream interop-go-cose interop-python-cwt interop-rust-coset c99-check c99-check-lms c99-hpke-check experimental-check clean FORCE +.PHONY: all shared test pkg-config-test ecdsa-policy-test rsapss-policy-test countersign-config-test zero-alloc-check zeroize-test deprecated-algs-test ecc-import-policy-test ext-sign-test ext-sign-demo ext-sign-force-failure coverage eat-psa-test eat-psa-float-test eat-psa-min-buffers-test eat-psa-claim-limits-test eat-psa-profile-test eat-psa-config-check eat-psa-ext-sign-test eat-psa-ext-sign-force-failure eat-psa-coverage eat-psa-coverage-force-failure hpke-coverage-force-failure generic-reduced-alg-test tool tool-test cmdline-test demo demos hpke-demo lean-verify psa-eat-lean-verify psa-eat-demo mldsa-demo mldsa-verify lms-demo lms-verify comprehensive scenarios release-scenarios release-coverage cxx-check valgrind-check release-validate release-artifacts interop-tcose tcose-upstream interop-go-cose interop-python-cwt interop-rust-coset c99-check c99-check-lms c99-hpke-check experimental-check clean FORCE # --- Core library --- all: $(LIB_A) @@ -878,6 +881,20 @@ eat-psa-coverage-force-failure: clean ./$(TEST_BIN) gcov src/*.c +# HPKE coverage uses the same -Os and forced-failure build as the runs above so the tracefiles merge cleanly. +HPKE_COVERAGE_FLAGS = -DWOLFCOSE_EXPERIMENTAL \ + -DWOLFCOSE_ENABLE_HPKE_0_ENCRYPT -DWOLFCOSE_ENABLE_HPKE_0_DECRYPT \ + -DWOLFCOSE_ENABLE_HPKE_0_KE_ENCRYPT -DWOLFCOSE_ENABLE_HPKE_0_KE_DECRYPT +hpke-coverage-force-failure: clean + @set -e; for f in $(SRC); do \ + $(CC) $(CFLAGS) $(HPKE_COVERAGE_FLAGS) -DWOLFCOSE_FORCE_FAILURE --coverage -fprofile-arcs -ftest-coverage -c $$f -o $${f%.c}.o; \ + done + rm -f $(LIB_A) + $(AR) rcs $(LIB_A) $(OBJ) + $(CC) $(CFLAGS) $(HPKE_COVERAGE_FLAGS) -DWOLFCOSE_FORCE_FAILURE --coverage -fprofile-arcs -ftest-coverage -o $(TEST_BIN) $(TEST_SRC) $(FORCE_FAIL_SRC) $(LIB_A) $(LDFLAGS) $(LDLIBS) + ./$(TEST_BIN) + gcov src/*.c + # --- Forced-failure coverage of the delegated seam --- # WOLF_FAIL_EXT_SIGN lives behind both WOLFCOSE_FORCE_FAILURE and # WOLFCOSE_ENABLE_EXT_SIGN, so it is unreachable unless both are set. @@ -1015,6 +1032,52 @@ lms-verify: @echo "=== Running lean HSS/LMS verify-only example ===" ./$(LMSV_DEMO) +# --- Release qualification --- +# These feature scenarios require wolfSSL built with HPKE, LMS, and the +# algorithms used by the full PSA/EAT profile. +release-scenarios: hpke-demo lms-demo lms-verify mldsa-demo psa-eat-demo + $(MAKE) eat-psa-ext-sign-test + @echo "PASS: release feature scenarios" + +release-coverage: + ./scripts/release/coverage.sh + +cxx-check: + $(MAKE) clean + $(MAKE) all CC="$(CXX) -x c++" \ + CFLAGS="-std=c++17 -Os -Wall -Wextra -Wpedantic -Wshadow -Wconversion -DHAVE_ANONYMOUS_INLINE_AGGREGATES=1 -I./include $(WOLFSSL_CFLAGS)" + $(CXX) -std=c++17 -Wall -Wextra -Wpedantic -I./include \ + $(WOLFSSL_CFLAGS) -o $(CXX_SMOKE) tests/cpp_header_smoke.cpp \ + $(LIB_A) $(LDFLAGS) $(LDLIBS) + ./$(CXX_SMOKE) + +valgrind-check: demo release-scenarios + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(DEMO_BIN) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(HPKE_DEMO) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(LMS_DEMO) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(LMSV_DEMO) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(MLDSA_DEMO) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(EAT_DEMO) + $(VALGRIND) --leak-check=full --show-leak-kinds=all \ + --errors-for-leak-kinds=all --error-exitcode=1 ./$(TEST_BIN) + +release-validate: + @test -n "$(VERSION)" || { echo "VERSION=X.Y.Z is required"; exit 2; } + ./scripts/release/validate.sh --version "$(VERSION)" \ + --ref "$(or $(RELEASE_REF),HEAD)" + +release-artifacts: + @test -n "$(VERSION)" || { echo "VERSION=X.Y.Z is required"; exit 2; } + ./scripts/release/package.sh --version "$(VERSION)" \ + --ref "$(or $(RELEASE_REF),HEAD)" \ + --output "$(or $(RELEASE_OUTPUT),dist)" + # --- Comprehensive algorithm tests (CI) --- comprehensive: $(LIB_A) @mkdir -p examples/comprehensive @@ -1335,7 +1398,7 @@ clean: rm -f $(OBJ) $(TEST_BIN) $(TOOL_BIN) $(DEMO_BIN) $(ENC_DEMO) $(HPKE_DEMO) $(MAC_DEMO) \ $(EAT_PSA_TEST_BIN) $(EAT_PSA_LIMITS_TEST_BIN) \ $(EXTSIGN_DEMO) $(SIGN1_DEMO) $(LEANV_DEMO) $(EAT_LEANV_DEMO) \ - $(EAT_DEMO) $(MLDSA_DEMO) $(MLDSAV_DEMO) $(LMS_DEMO) $(LMSV_DEMO) \ + $(EAT_DEMO) $(MLDSA_DEMO) $(MLDSAV_DEMO) $(LMS_DEMO) $(LMSV_DEMO) $(CXX_SMOKE) \ $(COMP_SIGN) $(COMP_ENCRYPT) $(COMP_MAC) $(COMP_ERRORS) \ $(SCEN_FIRMWARE) $(SCEN_MULTIPARTY) $(SCEN_IOTFLEET) $(SCEN_SENSOR) $(SCEN_BROADCAST) \ $(INTEROP_DIR)/*.o $(INTEROP_DIR)/*.su $(INTEROP_BIN) \ @@ -1344,7 +1407,8 @@ clean: $(PYTHON_CWT_BIN) \ $(RUST_COSET_C_BIN) $(RUST_COSET_BIN) \ $(LIB_A) $(LIB_SO) $(BUILD_CONFIG) $(BUILD_CONFIG).tmp src/*.su tests/*.su examples/*.su examples/comprehensive/*.su examples/scenarios/*.su \ - src/*.gcno src/*.gcda tests/*.gcno tests/*.gcda *.gcov experimental-check.err + src/*.gcno src/*.gcda tests/*.gcno tests/*.gcda *.gcov experimental-check.err \ + release-coverage.info rm -rf tests/*.dSYM tools/*.dSYM examples/*.dSYM \ examples/comprehensive/*.dSYM examples/scenarios/*.dSYM \ $(RUST_COSET_DIR)/target diff --git a/docs/Testing.md b/docs/Testing.md index 2a47c09..07030c6 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -7,6 +7,39 @@ validated on every push and PR to ensure coverage does not regress. ## Running Tests +### Release Qualification + +Release candidates add the `ci:release` label to run the read-only Release +Qualification workflow. The workflow validates version metadata, exercises +the optional feature profiles, audits every required CI workflow associated +with the pull request head, builds reproducible source archives, and +smoke-tests both archives. A manual post-merge run qualifies the exact commit +used to build the release artifacts. Like wolfProvider's label-selected jobs, +the workflow starts only when the label is added; remove and re-add the label +to qualify a newer commit on the same pull request. Apply the label only after +the candidate version and release notes are committed. + +The release-only local targets are: + +```bash +make release-scenarios +make release-coverage +make cxx-check +make valgrind-check +make release-validate VERSION=X.Y.Z RELEASE_REF=HEAD +make release-artifacts VERSION=X.Y.Z RELEASE_REF=HEAD +``` + +Replace `X.Y.Z` with the version in `include/wolfcose/version.h`. + +`release-scenarios` combines HPKE one-recipient and multi-recipient delivery, +stateful HSS/LMS signing, lean LMS verification, ML-DSA signing, RFC 9783 +device onboarding, and delegated PSA/EAT signing. The HPKE scenario rejects +modified authentication data, modified ciphertext, and a mismatched recipient. +The LMS scenario signs two messages, proves the persisted state advances, and +rejects a modified signature. The PSA/EAT scenario rejects a wrong challenge, +a modified token, and an unapproved software measurement. + ### Basic Unit Tests ```bash diff --git a/examples/hpke_demo.c b/examples/hpke_demo.c index fcc5a3d..33a76cf 100644 --- a/examples/hpke_demo.c +++ b/examples/hpke_demo.c @@ -44,12 +44,15 @@ static int demo_hpke_encrypt0(void) { static const uint8_t kid[] = "recipient-a"; static const uint8_t payload[] = "HPKE-0 integrated encryption"; + static const uint8_t aad[] = "fleet-policy-v1"; + static const uint8_t wrongAad[] = "fleet-policy-v2"; WC_RNG rng; ecc_key recipient; WOLFCOSE_KEY recipientKey; WOLFCOSE_HDR hdr; uint8_t scratch[WOLFCOSE_MAX_SCRATCH_SZ]; uint8_t cose[512]; + uint8_t tampered[512]; uint8_t plaintext[sizeof(payload)]; size_t coseLen = 0u; size_t plaintextLen = 0u; @@ -78,14 +81,14 @@ static int demo_hpke_encrypt0(void) if (ret == 0) { ret = wc_CoseHpkeEncrypt0_Encrypt(&recipientKey, kid, sizeof(kid) - 1u, payload, sizeof(payload) - 1u, - NULL, 0u, NULL, NULL, 0u, scratch, sizeof(scratch), cose, + NULL, 0u, NULL, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), cose, sizeof(cose), &coseLen, &rng); } if (ret == 0) { recipientKey.hasPrivate = 1u; ret = wc_CoseHpkeEncrypt0_Decrypt(&recipientKey, cose, coseLen, - NULL, 0u, NULL, 0u, scratch, sizeof(scratch), &hdr, plaintext, - sizeof(plaintext), &plaintextLen); + NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), &hdr, + plaintext, sizeof(plaintext), &plaintextLen); } if ((ret == 0) && ((hdr.alg != WOLFCOSE_ALG_HPKE_0) || @@ -93,6 +96,26 @@ static int demo_hpke_encrypt0(void) (memcmp(plaintext, payload, plaintextLen) != 0))) { ret = -1; } + if (ret == 0) { + (void)memcpy(tampered, cose, coseLen); + tampered[coseLen - 1u] ^= 0x01u; + plaintextLen = sizeof(plaintext); + if ((wc_CoseHpkeEncrypt0_Decrypt(&recipientKey, tampered, coseLen, + NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), + &hdr, plaintext, sizeof(plaintext), &plaintextLen) == 0) || + (plaintextLen != 0u)) { + ret = -1; + } + } + if (ret == 0) { + plaintextLen = sizeof(plaintext); + if ((wc_CoseHpkeEncrypt0_Decrypt(&recipientKey, cose, coseLen, + NULL, 0u, wrongAad, sizeof(wrongAad) - 1u, scratch, + sizeof(scratch), &hdr, plaintext, sizeof(plaintext), + &plaintextLen) == 0) || (plaintextLen != 0u)) { + ret = -1; + } + } if (eccInit != 0) { wc_ecc_free(&recipient); @@ -112,6 +135,7 @@ static int demo_hpke_key_encryption(void) "recipient-a", "recipient-b" }; static const uint8_t payload[] = "HPKE-0-KE multi-recipient encryption"; + static const uint8_t aad[] = "fleet-config-v1"; WC_RNG rng; ecc_key recipientEcc[HPKE_DEMO_RECIPIENTS]; WOLFCOSE_KEY recipientKey[HPKE_DEMO_RECIPIENTS]; @@ -120,6 +144,7 @@ static int demo_hpke_key_encryption(void) uint8_t iv[12]; uint8_t scratch[WOLFCOSE_MAX_SCRATCH_SZ]; uint8_t cose[1024]; + uint8_t tampered[1024]; uint8_t plaintext[sizeof(payload)]; size_t coseLen = 0u; size_t plaintextLen = 0u; @@ -161,7 +186,7 @@ static int demo_hpke_key_encryption(void) if (ret == 0) { ret = wc_CoseEncrypt_Encrypt(recipients, HPKE_DEMO_RECIPIENTS, WOLFCOSE_ALG_A128GCM, iv, sizeof(iv), payload, - sizeof(payload) - 1u, NULL, 0u, NULL, 0u, scratch, + sizeof(payload) - 1u, NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), cose, sizeof(cose), &coseLen, &rng); } if (ret == 0) { @@ -172,8 +197,8 @@ static int demo_hpke_key_encryption(void) for (i = 0u; (ret == 0) && (i < HPKE_DEMO_RECIPIENTS); i++) { plaintextLen = 0u; ret = wc_CoseEncrypt_Decrypt(&recipients[i], i, cose, coseLen, - NULL, 0u, NULL, 0u, scratch, sizeof(scratch), &hdr, plaintext, - sizeof(plaintext), &plaintextLen); + NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), &hdr, + plaintext, sizeof(plaintext), &plaintextLen); if ((ret == 0) && ((hdr.alg != WOLFCOSE_ALG_A128GCM) || (plaintextLen != (sizeof(payload) - 1u)) || @@ -181,6 +206,27 @@ static int demo_hpke_key_encryption(void) ret = -1; } } + if (ret == 0) { + plaintextLen = sizeof(plaintext); + if ((wc_CoseEncrypt_Decrypt(&recipients[0], 1u, cose, coseLen, + NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), + &hdr, plaintext, sizeof(plaintext), &plaintextLen) == 0) || + (plaintextLen != 0u)) { + ret = -1; + } + } + if (ret == 0) { + (void)memcpy(tampered, cose, coseLen); + /* The final byte belongs to the second recipient's wrapped CEK. */ + tampered[coseLen - 1u] ^= 0x01u; + plaintextLen = sizeof(plaintext); + if ((wc_CoseEncrypt_Decrypt(&recipients[1], 1u, tampered, coseLen, + NULL, 0u, aad, sizeof(aad) - 1u, scratch, sizeof(scratch), + &hdr, plaintext, sizeof(plaintext), &plaintextLen) == 0) || + (plaintextLen != 0u)) { + ret = -1; + } + } while (eccCount > 0u) { eccCount--; diff --git a/examples/psa_eat_demo.c b/examples/psa_eat_demo.c index e05ae87..f9f9c49 100644 --- a/examples/psa_eat_demo.c +++ b/examples/psa_eat_demo.c @@ -235,6 +235,7 @@ int main(void) DEMO_RESOLVER_CTX resolver; uint8_t challenge[32]; uint8_t wrongChallenge[32]; + uint8_t badMeasurement[WC_SHA256_DIGEST_SIZE]; uint8_t bootSeed[32]; uint8_t measurement[WC_SHA256_DIGEST_SIZE]; uint8_t publicX[32]; @@ -242,6 +243,7 @@ int main(void) uint8_t claimsBuf[768]; uint8_t scratch[WOLFCOSE_MAX_SCRATCH_SZ]; uint8_t tokenBuf[1024]; + uint8_t tamperedToken[1024]; word32 publicXLen = (word32)sizeof(publicX); word32 publicYLen = (word32)sizeof(publicY); size_t tokenLen = 0u; @@ -333,11 +335,39 @@ int main(void) ret = WOLFCOSE_E_EAT_PSA_NONCE; } } + if (ret == WOLFCOSE_SUCCESS) { + (void)XMEMCPY(tamperedToken, tokenBuf, tokenLen); + tamperedToken[tokenLen - 1u] ^= 0x01u; + ret = wc_CoseEatPsaToken_VerifyByUeid(resolve_iak, &resolver, + tamperedToken, tokenLen, challenge, sizeof(challenge), scratch, + sizeof(scratch), &verified); + if (ret != WOLFCOSE_SUCCESS) { + (void)printf("verifier: rejected a modified token\n"); + ret = WOLFCOSE_SUCCESS; + } + else { + ret = WOLFCOSE_E_EAT_PSA_CLAIM; + } + } if (ret == WOLFCOSE_SUCCESS) { ret = wc_CoseEatPsaToken_VerifyByUeid(resolve_iak, &resolver, tokenBuf, tokenLen, challenge, sizeof(challenge), scratch, sizeof(scratch), &verified); } + if (ret == WOLFCOSE_SUCCESS) { + (void)XMEMCPY(badMeasurement, kApprovedMeasurement, + sizeof(badMeasurement)); + badMeasurement[0] ^= 0x01u; + ret = appraise_token(&verified, badMeasurement, + sizeof(badMeasurement)); + if (ret == WOLFCOSE_E_EAT_PSA_CLAIM) { + (void)printf("verifier: rejected an unapproved measurement\n"); + ret = WOLFCOSE_SUCCESS; + } + else if (ret == WOLFCOSE_SUCCESS) { + ret = WOLFCOSE_E_EAT_PSA_CLAIM; + } + } if (ret == WOLFCOSE_SUCCESS) { ret = appraise_token(&verified, kApprovedMeasurement, sizeof(kApprovedMeasurement)); diff --git a/examples/sign1_lms.c b/examples/sign1_lms.c index c911b57..4c54352 100644 --- a/examples/sign1_lms.c +++ b/examples/sign1_lms.c @@ -36,6 +36,7 @@ #include static const char PAYLOAD[] = "wolfCOSE HSS-LMS payload"; +static const char PAYLOAD2[] = "wolfCOSE HSS-LMS second firmware payload"; /* This example persists the compact LMS private key. WOLFSSL_WC_LMS_SERIALIZE_STATE * instead hands the callback the full serialized working state, which is larger @@ -47,7 +48,10 @@ static const char PAYLOAD[] = "wolfCOSE HSS-LMS payload"; /* LMS signatures run ~1.5KB for L1/H10/W8; keep buffers off the stack. */ static uint8_t gScratch[8192]; static uint8_t gMsg[4096]; +static uint8_t gMsg2[4096]; static uint8_t gPrivStore[HSS_MAX_PRIVATE_KEY_LEN]; +static uint8_t gPrivBefore[HSS_MAX_PRIVATE_KEY_LEN]; +static unsigned int gWriteCount; static int lms_write_cb(const byte* priv, word32 privSz, void* context) { @@ -56,6 +60,7 @@ static int lms_write_cb(const byte* priv, word32 privSz, void* context) if ((priv != NULL) && (context != NULL) && (privSz <= (word32)sizeof(gPrivStore))) { (void)memcpy(context, priv, (size_t)privSz); + gWriteCount++; ret = (int)WC_LMS_RC_SAVED_TO_NV_MEMORY; } return ret; @@ -82,6 +87,8 @@ int main(void) const uint8_t* payload = NULL; size_t payloadLen = 0; size_t msgLen = 0; + size_t msgLen2 = 0; + unsigned int writesBefore; int ret; int rc = 1; @@ -151,6 +158,43 @@ int main(void) (void)printf("HSS-LMS verify failed (%d)\n", ret); } + writesBefore = gWriteCount; + (void)memcpy(gPrivBefore, gPrivStore, sizeof(gPrivBefore)); + if (rc == 0) { + ret = wc_CoseSign1_Sign(&key, WOLFCOSE_ALG_HSS_LMS, NULL, 0, + (const uint8_t*)PAYLOAD2, sizeof(PAYLOAD2) - 1u, NULL, 0, + NULL, 0, gScratch, sizeof(gScratch), gMsg2, sizeof(gMsg2), + &msgLen2, &rng); + if ((ret != WOLFCOSE_SUCCESS) || (gWriteCount <= writesBefore) || + (memcmp(gPrivBefore, gPrivStore, sizeof(gPrivBefore)) == 0) || + ((msgLen == msgLen2) && (memcmp(gMsg, gMsg2, msgLen) == 0))) { + (void)printf("HSS-LMS state did not advance safely\n"); + rc = 1; + } + } + if (rc == 0) { + ret = wc_CoseSign1_Verify(&key, gMsg2, msgLen2, NULL, 0, NULL, 0, + gScratch, sizeof(gScratch), &hdr, &payload, &payloadLen); + if ((ret != WOLFCOSE_SUCCESS) || + (payloadLen != (sizeof(PAYLOAD2) - 1u)) || (payload == NULL) || + (memcmp(payload, PAYLOAD2, payloadLen) != 0)) { + (void)printf("HSS-LMS second signature failed verification\n"); + rc = 1; + } + } + if (rc == 0) { + gMsg2[msgLen2 - 1u] ^= 0x01u; + ret = wc_CoseSign1_Verify(&key, gMsg2, msgLen2, NULL, 0, NULL, 0, + gScratch, sizeof(gScratch), &hdr, &payload, &payloadLen); + if (ret == WOLFCOSE_SUCCESS) { + (void)printf("HSS-LMS accepted a modified signature\n"); + rc = 1; + } + else { + (void)printf("HSS-LMS state advance and tamper checks: OK\n"); + } + } + wc_CoseKey_Free(&key); wc_LmsKey_Free(&lmsKey); wc_FreeRng(&rng); diff --git a/scripts/release/coverage.sh b/scripts/release/coverage.sh new file mode 100755 index 0000000..3e422c3 --- /dev/null +++ b/scripts/release/coverage.sh @@ -0,0 +1,68 @@ +#!/bin/sh + +set -eu + +if ! command -v lcov >/dev/null 2>&1; then + echo "lcov is required for release coverage" >&2 + exit 1 +fi + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/wolfcose-release-coverage.XXXXXX") +trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM +cd "$ROOT" + +capture() +{ + name=$1 + lcov --capture --directory . --output-file "$TMP_DIR/$name.info" \ + --ignore-errors mismatch >/dev/null + lcov --remove "$TMP_DIR/$name.info" '/usr/*' '*/tests/*' \ + '*/examples/*' --output-file "$TMP_DIR/$name.filtered.info" \ + --ignore-errors unused >/dev/null +} + +make coverage-force-failure +capture default + +make eat-psa-coverage-force-failure +capture eat-psa + +make hpke-coverage-force-failure +capture hpke + +lcov --add-tracefile "$TMP_DIR/default.filtered.info" \ + --add-tracefile "$TMP_DIR/eat-psa.filtered.info" \ + --add-tracefile "$TMP_DIR/hpke.filtered.info" \ + --output-file "$TMP_DIR/release.info" >/dev/null + +awk ' + /^SF:/ { + file = substr($0, 4) + active = (file ~ /\/src\/[^/]+\.c$/) + if (active) seen[file] = 1 + next + } + active && /^DA:/ { + split(substr($0, 4), fields, ",") + if (fields[2] == 0) uncovered[file]++ + } + END { + failed = 0 + count = 0 + for (file in seen) { + count++ + missed = uncovered[file] + 0 + printf "%s: %d uncovered line(s)\n", file, missed + if (missed != 0) failed = 1 + } + if (count == 0) { + print "no wolfCOSE source coverage found" > "/dev/stderr" + failed = 1 + } + exit failed + } +' "$TMP_DIR/release.info" + +cp "$TMP_DIR/release.info" release-coverage.info +echo "PASS: merged release coverage is 100% per source file" diff --git a/scripts/release/package.sh b/scripts/release/package.sh new file mode 100755 index 0000000..a666e93 --- /dev/null +++ b/scripts/release/package.sh @@ -0,0 +1,155 @@ +#!/bin/sh + +set -eu + +umask 022 + +TZ=UTC +export TZ + +usage() +{ + echo "usage: $0 --version X.Y.Z [--ref REF] [--output DIR]" >&2 + exit 2 +} + +VERSION= +REF=HEAD +OUTPUT=dist + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || usage + VERSION=$2 + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || usage + REF=$2 + shift 2 + ;; + --output) + [ "$#" -ge 2 ] || usage + OUTPUT=$2 + shift 2 + ;; + *) + usage + ;; + esac +done + +[ -n "$VERSION" ] || usage + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd) +cd "$REPO_DIR" + +"$SCRIPT_DIR/validate.sh" --version "$VERSION" --ref "$REF" + +COMMIT=$(git rev-parse "$REF^{commit}") +COMMIT_TIME=$(git show -s --format=%ct "$COMMIT") +PREFIX=wolfcose-$VERSION +OUTPUT_ABS=$(mkdir -p "$OUTPUT" && CDPATH='' cd -- "$OUTPUT" && pwd) +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/wolfcose-release-package.XXXXXX") +trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM + +TAR_FILE=$OUTPUT_ABS/$PREFIX.tar.gz +ZIP_FILE=$OUTPUT_ABS/$PREFIX.zip +SUM_FILE=$OUTPUT_ABS/$PREFIX.sha256 +MANIFEST=$OUTPUT_ABS/release-manifest.json +SUMMARY=$OUTPUT_ABS/release-test-summary.md + +rm -f "$TAR_FILE" "$ZIP_FILE" "$SUM_FILE" "$MANIFEST" "$SUMMARY" + +git archive --format=tar --prefix="$PREFIX/" "$COMMIT" > "$TMP_DIR/source.tar" +gzip -n -9 < "$TMP_DIR/source.tar" > "$TAR_FILE" +tar -xf "$TMP_DIR/source.tar" -C "$TMP_DIR" + +find "$TMP_DIR/$PREFIX" -exec touch -h -d "@$COMMIT_TIME" {} + 2>/dev/null || \ + find "$TMP_DIR/$PREFIX" -exec touch -h -t "$(date -u -r "$COMMIT_TIME" '+%Y%m%d%H%M.%S')" {} + + +( + cd "$TMP_DIR" + find "$PREFIX" -print | LC_ALL=C sort | zip -X -q "$ZIP_FILE" -@ +) + +if command -v sha256sum >/dev/null 2>&1; then + ( + cd "$OUTPUT_ABS" + sha256sum "$(basename "$TAR_FILE")" "$(basename "$ZIP_FILE")" > "$SUM_FILE" + ) +else + ( + cd "$OUTPUT_ABS" + shasum -a 256 "$(basename "$TAR_FILE")" "$(basename "$ZIP_FILE")" > "$SUM_FILE" + ) +fi + +TAR_SHA=$(awk -v f="$(basename "$TAR_FILE")" '$2 == f {print $1}' "$SUM_FILE") +ZIP_SHA=$(awk -v f="$(basename "$ZIP_FILE")" '$2 == f {print $1}' "$SUM_FILE") + +cat > "$MANIFEST" < "$SUMMARY" </dev/null 2>&1; then + sha256sum "$file" + else + shasum -a 256 "$file" + fi + done +) > "$TMP_DIR/tar-files" +( + cd "$TMP_DIR/from-zip" + find "$PREFIX" -type f -print | LC_ALL=C sort | while IFS= read -r file; do + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" + else + shasum -a 256 "$file" + fi + done +) > "$TMP_DIR/zip-files" +diff -u "$TMP_DIR/tar-files" "$TMP_DIR/zip-files" + +if [ "${RELEASE_SMOKE_TEST:-1}" -eq 1 ]; then + for tree in "$TMP_DIR/from-tar/$PREFIX" "$TMP_DIR/from-zip/$PREFIX"; do + make -C "$tree" clean + make -C "$tree" + make -C "$tree" test + make -C "$tree" tool-test + make -C "$tree" demos + done + printf '\n- Archive smoke tests: passed\n' >> "$SUMMARY" +else + printf '\n- Archive smoke tests: skipped by RELEASE_SMOKE_TEST=0\n' >> "$SUMMARY" +fi + +echo "PASS: release artifacts written to $OUTPUT_ABS" diff --git a/scripts/release/validate.sh b/scripts/release/validate.sh new file mode 100755 index 0000000..74d83bf --- /dev/null +++ b/scripts/release/validate.sh @@ -0,0 +1,167 @@ +#!/bin/sh + +set -eu + +usage() +{ + echo "usage: $0 --version X.Y.Z [--ref REF]" >&2 + exit 2 +} + +VERSION= +REF=HEAD + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || usage + VERSION=$2 + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || usage + REF=$2 + shift 2 + ;; + *) + usage + ;; + esac +done + +[ -n "$VERSION" ] || usage + +if ! printf '%s\n' "$VERSION" | grep -Eq \ + '^(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$'; then + echo "invalid release version: $VERSION" >&2 + exit 1 +fi + +if ! git cat-file -e "$REF^{commit}" 2>/dev/null; then + echo "release ref is not a commit: $REF" >&2 + exit 1 +fi + +if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + TAG_COMMIT=$(git rev-list -n 1 "v$VERSION") + REF_COMMIT=$(git rev-parse "$REF^{commit}") + if [ "$TAG_COMMIT" != "$REF_COMMIT" ]; then + echo "v$VERSION already points at $TAG_COMMIT, not $REF_COMMIT" >&2 + exit 1 + fi +fi + +TRACKED_BUILD_OUTPUT=$(git ls-tree -r --name-only "$REF" | \ + grep -E '\.(o|a|so|su|gcda|gcno)$' || true) +if [ -n "$TRACKED_BUILD_OUTPUT" ]; then + echo "release ref contains tracked build output:" >&2 + printf '%s\n' "$TRACKED_BUILD_OUTPUT" >&2 + exit 1 +fi + +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/wolfcose-release-validate.XXXXXX") +trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM +git archive "$REF" | tar -x -C "$TMP_DIR" + +VERSION_HEADER=$TMP_DIR/include/wolfcose/version.h +CHANGELOG=$TMP_DIR/ChangeLog.md +RELEASE_NOTES=$TMP_DIR/docs/Release-Notes.md +README=$TMP_DIR/README.md +CURRENT_CHANGELOG=$TMP_DIR/current-release-changelog.md + +for file in "$VERSION_HEADER" "$CHANGELOG" "$RELEASE_NOTES" "$README"; do + if [ ! -f "$file" ]; then + echo "release metadata file missing: $file" >&2 + exit 1 + fi +done + +awk ' + NR > 1 && /^# wolfCOSE Release / { exit } + { print } +' "$CHANGELOG" > "$CURRENT_CHANGELOG" + +HEADER_VERSION=$(awk '/LIBWOLFCOSE_VERSION_STRING/ {gsub(/\"/, "", $3); print $3; exit}' "$VERSION_HEADER") +HEADER_HEX=$(awk '/LIBWOLFCOSE_VERSION_HEX/ {print $3; exit}' "$VERSION_HEADER") + +MAJOR=${VERSION%%.*} +VERSION_REMAINDER=${VERSION#*.} +MINOR=${VERSION_REMAINDER%%.*} +PATCH=${VERSION_REMAINDER#*.} + +if [ "$MAJOR" -gt 255 ] || [ "$MINOR" -gt 4095 ] || \ + [ "$PATCH" -gt 4095 ]; then + echo "release version exceeds packed version fields: $VERSION" >&2 + exit 1 +fi + +EXPECTED_HEX=$(printf '0x%08X' "$(( (MAJOR << 24) | (MINOR << 12) | PATCH ))") +VERSION_RE="${MAJOR}\\.${MINOR}\\.${PATCH}" + +if [ "$HEADER_VERSION" != "$VERSION" ]; then + echo "version header says $HEADER_VERSION, expected $VERSION" >&2 + exit 1 +fi + +if [ "$HEADER_HEX" != "$EXPECTED_HEX" ]; then + echo "version hex says $HEADER_HEX, expected $EXPECTED_HEX" >&2 + exit 1 +fi + +CHANGELOG_HEAD=$(sed -n '1p' "$CHANGELOG") +NOTES_HEAD=$(sed -n '3p' "$RELEASE_NOTES") + +printf '%s\n' "$CHANGELOG_HEAD" | grep -Eq \ + "^# wolfCOSE Release $VERSION_RE \([A-Z][a-z]+ [0-9]{1,2}, [0-9]{4}\)$" || { + echo "invalid top ChangeLog heading: $CHANGELOG_HEAD" >&2 + exit 1 +} + +printf '%s\n' "$NOTES_HEAD" | grep -Eq \ + "^## wolfCOSE $VERSION_RE \([A-Z][a-z]+ [0-9]{1,2}, [0-9]{4}\)$" || { + echo "invalid top release-notes heading: $NOTES_HEAD" >&2 + exit 1 +} + +section_line() +{ + awk -v heading="$1" '$0 == heading {print NR; exit}' \ + "$CURRENT_CHANGELOG" +} + +VULN_LINE=$(section_line '## Vulnerabilities') +FEATURE_LINE=$(section_line '## New Feature Additions') +FIX_LINE=$(section_line '## Fixes') +IMPROVE_LINE=$(section_line '## Improvements/Optimizations') + +if [ -z "$VULN_LINE" ] || [ -z "$FEATURE_LINE" ] || [ -z "$FIX_LINE" ] || \ + [ -z "$IMPROVE_LINE" ] || [ "$VULN_LINE" -ge "$FEATURE_LINE" ] || \ + [ "$FEATURE_LINE" -ge "$FIX_LINE" ] || [ "$FIX_LINE" -ge "$IMPROVE_LINE" ]; then + echo "ChangeLog release sections are missing or out of order" >&2 + exit 1 +fi + +grep -Fq "current release is **$VERSION**" "$README" || { + echo "README current release does not match $VERSION" >&2 + exit 1 +} + +if [ "$VERSION" = "2.0.0" ]; then + grep -Fq 'No CVEs were assigned for this release.' \ + "$CURRENT_CHANGELOG" || { + echo "2.0.0 ChangeLog must record that no CVEs were assigned" >&2 + exit 1 + } + for rfc in 'RFC 9864' 'RFC 9783' 'RFC 9338' 'RFC 8778'; do + grep -Fq "$rfc" "$CURRENT_CHANGELOG" || { + echo "2.0.0 ChangeLog is missing $rfc" >&2 + exit 1 + } + done +fi + +if git rev-parse "$REF^" >/dev/null 2>&1; then + git diff --check "$REF^" "$REF" +fi + +echo "PASS: wolfCOSE $VERSION metadata at $(git rev-parse "$REF^{commit}")" diff --git a/src/wolfcose_recipient.c b/src/wolfcose_recipient.c index 230a9e8..19ef42b 100644 --- a/src/wolfcose_recipient.c +++ b/src/wolfcose_recipient.c @@ -1169,9 +1169,11 @@ int wolfCose_Hpke0SealInit(WOLFCOSE_HPKE_0_SEAL_CTX* sealCtx, } } if (ret == WOLFCOSE_SUCCESS) { - hpkeRet = wc_HpkeSerializePublicKey(&sealCtx->hpke, - &sealCtx->ephemeralKey, - enc, &encLen); + hpkeRet = -1; /* Initialize to failure for injection testing */ + INJECT_FAILURE(WOLF_FAIL_HPKE_SERIALIZE, hpkeRet, + hpkeRet = wc_HpkeSerializePublicKey(&sealCtx->hpke, + &sealCtx->ephemeralKey, + enc, &encLen)); if ((hpkeRet != 0) || (encLen != WOLFCOSE_HPKE_0_ENC_SZ)) { ret = WOLFCOSE_E_CRYPTO; } diff --git a/tests/cpp_header_smoke.cpp b/tests/cpp_header_smoke.cpp new file mode 100644 index 0000000..c740c55 --- /dev/null +++ b/tests/cpp_header_smoke.cpp @@ -0,0 +1,11 @@ +#include +#include +#include + +int main() +{ + WOLFCOSE_KEY key; + + wc_CoseKey_Init(&key); + return (LIBWOLFCOSE_VERSION_HEX == 0u) ? 1 : 0; +} diff --git a/tests/force_failure.h b/tests/force_failure.h index 88c8a1c..fe7dd62 100644 --- a/tests/force_failure.h +++ b/tests/force_failure.h @@ -104,6 +104,9 @@ typedef enum { /* ECDH failures */ WOLF_FAIL_ECDH_SHARED_SECRET, /* wc_ecc_shared_secret */ + /* HPKE failures */ + WOLF_FAIL_HPKE_SERIALIZE, /* wc_HpkeSerializePublicKey */ + /* Hash failures */ WOLF_FAIL_HASH, /* wc_Hash */ diff --git a/tests/test_cose.c b/tests/test_cose.c index 31f2c75..f2a0f3c 100644 --- a/tests/test_cose.c +++ b/tests/test_cose.c @@ -11212,6 +11212,8 @@ static void test_cose_hpke_encrypt0(void) WOLFCOSE_KEY nonP256Key; #endif WOLFCOSE_HDR hdr; + WOLFCOSE_CBOR_CTX ekCtx; + WOLFCOSE_HDR_STATE ekState; ecc_key recipientEcc; ecc_key wrongEcc; #if defined(HAVE_ECC_KOBLITZ) @@ -11474,6 +11476,50 @@ static void test_cose_hpke_encrypt0(void) } } + /* Decoding an unprotected ek label with no hpkeHdr sink hits the skip. */ + if (recipientKeyInited != 0) { + ekCtx.buf = out; + ekCtx.cbuf = out; + ekCtx.bufSz = sizeof(out); + ekCtx.idx = 0u; + ret = wc_CBOR_EncodeMapStart(&ekCtx, 1u); + if (ret == WOLFCOSE_SUCCESS) { + ret = wc_CBOR_EncodeInt(&ekCtx, WOLFCOSE_HDR_HPKE_EK); + } + if (ret == WOLFCOSE_SUCCESS) { + ret = wc_CBOR_EncodeBstr(&ekCtx, kid, sizeof(kid) - 1u); + } + TEST_ASSERT(ret == WOLFCOSE_SUCCESS, "hpke ek header encode"); + if (ret == WOLFCOSE_SUCCESS) { + ekCtx.cbuf = out; + ekCtx.bufSz = ekCtx.idx; + ekCtx.idx = 0u; + (void)XMEMSET(&hdr, 0, sizeof(hdr)); + wolfCose_HdrStateInit(&ekState); + ret = wolfCose_DecodeUnprotectedHdr(&ekCtx, &hdr, &ekState); + TEST_ASSERT(ret == WOLFCOSE_SUCCESS, "hpke ek header skipped"); + } + } + +#ifdef WOLFCOSE_FORCE_FAILURE + /* A forced serialize failure after the ephemeral key init exercises the + * seal-init and detached error-cleanup paths. */ + if (recipientKeyInited != 0) { + recipientKey.hasPrivate = 0u; + detachedLen = sizeof(detached); + wolfForceFailure_Set(WOLF_FAIL_HPKE_SERIALIZE); + ret = wc_CoseHpkeEncrypt0_Encrypt(&recipientKey, kid, + sizeof(kid) - 1u, payload, sizeof(payload) - 1u, + detached, sizeof(detached), &detachedLen, + aad, sizeof(aad) - 1u, + scratch, sizeof(scratch), out, sizeof(out), &outLen, &rng); + TEST_ASSERT(ret != WOLFCOSE_SUCCESS, + "hpke encrypt0 serialize forced failure"); + TEST_ASSERT(detachedLen == 0u, + "hpke encrypt0 detached length cleared on failure"); + } +#endif /* WOLFCOSE_FORCE_FAILURE */ + if (wrongKeyInited != 0) { wc_CoseKey_Free(&wrongKey); }