diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index 6f3ef86..e34768e 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -63,9 +63,13 @@ jobs: matrix: include: - module: varint + # `wasm_module::varint` lives in `wsc-verify-core` after the + # carve, so the harness must be invoked against that crate. + pkg: wsc-verify-core harness: wasm_module::varint tolerate_failure: false - module: merkle + pkg: wsc harness: signature::keyless::merkle # WIP — see audit C-7 partial closure. Three of the five harnesses # in this module call SHA-256 with symbolic input; the sha2 crate's @@ -76,6 +80,7 @@ jobs: # the scope of bounded model checking entirely. tolerate_failure: true - module: dsse + pkg: wsc harness: dsse # WIP — CI observation post-rebase: dsse harnesses also hit # "unwinding assertion loop 0" at --default-unwind 4. The @@ -85,6 +90,7 @@ jobs: # loop inside the prefix comparison. tolerate_failure: true - module: format + pkg: wsc harness: format # WIP — see audit C-7 partial closure. CI observation: the format # harnesses fail under cargo kani --default-unwind 4 despite static @@ -96,17 +102,20 @@ jobs: # harness rather than enumerating in one 4-byte-symbolic harness. tolerate_failure: true - module: wasm_module - # Filter was wasm_module::tests, which matched no harness (the - # module is named component_proofs, not tests). Fixed in this PR - # so the single harness — header mutual-exclusivity — actually - # runs. CI observation: the harness hits an unwinding-loop - # assertion inside at the default - # unwind of 4. Static analysis missed this; the harness's - # equality check on the 8-byte header lowers to memcmp at the - # MIR level. Needs a per-harness #[kani::unwind(9)] (or - # rewriting the equality as a per-byte comparison) before this - # can be unmasked. Filter-fix lands here; harness-fix is a - # follow-up. + # `wasm_module::component_proofs` lives in `wsc-verify-core` + # after the carve, so the harness must be invoked against that + # crate. Filter was wasm_module::tests, which matched no + # harness (the module is named component_proofs, not tests). + # Fixed in this PR so the single harness — header mutual- + # exclusivity — actually runs. CI observation: the harness + # hits an unwinding-loop assertion inside at the default unwind of 4. Static analysis missed + # this; the harness's equality check on the 8-byte header + # lowers to memcmp at the MIR level. Needs a per-harness + # #[kani::unwind(9)] (or rewriting the equality as a per-byte + # comparison) before this can be unmasked. Filter-fix lands + # here; harness-fix is a follow-up. + pkg: wsc-verify-core harness: wasm_module::component_proofs tolerate_failure: true steps: @@ -129,7 +138,8 @@ jobs: - name: Run Kani ${{ matrix.module }} proofs env: HARNESS: ${{ matrix.harness }} - run: cargo kani -p wsc --default-unwind 4 --output-format terse --harness "$HARNESS" + PACKAGE: ${{ matrix.pkg }} + run: cargo kani -p "$PACKAGE" --default-unwind 4 --output-format terse --harness "$HARNESS" timeout-minutes: 60 continue-on-error: ${{ matrix.tolerate_failure }} diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index b5b02ff..7564aea 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -92,30 +92,54 @@ jobs: tool: cargo-mutants - name: Run mutation testing on security-critical modules run: | - cargo mutants -p wsc --timeout 120 --jobs 4 --output mutants-out \ + cargo mutants -p wsc --timeout 120 --jobs 4 --output mutants-wsc \ -f src/lib/src/signature/ \ -f src/lib/src/dsse.rs \ -f src/lib/src/format/ \ -E "proofs::" \ -E "key_id" \ -- --lib + # The classic verification core lives in `wsc-verify-core` after the + # carve. Running cargo-mutants on it too keeps the zero-survivor gate + # on the moved signature code — without this step the original `-f + # src/lib/src/signature/` filter would silently match almost nothing + # post-merge and the gate would pass for the wrong reason. + - name: Run mutation testing on wsc-verify-core (carved verification core) + run: | + cargo mutants -p wsc-verify-core --timeout 120 --jobs 4 \ + --output mutants-verify-core \ + -f src/verify-core/src/signature/ \ + -E "proofs::" \ + -- --lib - name: Check surviving mutants run: | - if [ -f mutants-out/missed.txt ]; then - MISSED=$(wc -l < mutants-out/missed.txt | tr -d ' ') - else - MISSED=0 - fi - echo "Surviving mutants: $MISSED" - if [ "$MISSED" -gt 0 ]; then - echo "::error::$MISSED mutant(s) survived — tests must catch all mutations in security-critical code" - cat mutants-out/missed.txt | head -30 + missed_count() { + if [ -f "$1/missed.txt" ]; then + wc -l < "$1/missed.txt" | tr -d ' ' + else + echo 0 + fi + } + WSC=$(missed_count mutants-wsc) + CORE=$(missed_count mutants-verify-core) + TOTAL=$((WSC + CORE)) + echo "Surviving mutants: wsc=$WSC, wsc-verify-core=$CORE, total=$TOTAL" + if [ "$TOTAL" -gt 0 ]; then + echo "::error::$TOTAL mutant(s) survived — tests must catch all mutations in security-critical code" + for d in mutants-wsc mutants-verify-core; do + if [ -f "$d/missed.txt" ]; then + echo "--- $d/missed.txt ---" + head -15 "$d/missed.txt" + fi + done exit 1 fi echo "All mutants killed!" - - name: Upload mutants report + - name: Upload mutants reports if: always() uses: actions/upload-artifact@v4 with: - name: mutants-report - path: mutants-out/ + name: mutants-reports + path: | + mutants-wsc/ + mutants-verify-core/ diff --git a/Cargo.lock b/Cargo.lock index fd94916..9270cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4765,6 +4765,7 @@ dependencies = [ "wat", "webpki-roots", "wsc-attestation", + "wsc-verify-core", "x509-parser", "zeroize", ] @@ -4806,6 +4807,19 @@ dependencies = [ "wsc", ] +[[package]] +name = "wsc-verify-core" +version = "0.9.0" +dependencies = [ + "ct-codecs", + "ed25519-compact", + "getrandom 0.3.4", + "hmac-sha256", + "log", + "thiserror 2.0.17", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index 3bbf034..423af75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,13 @@ resolver = "2" members = [ "src/attestation", "src/cli", - "src/lib", "src/component", + "src/lib", + "src/verify-core", ] exclude = [ "examples/wasmtime-loader", + "verification/witness-harness", ] [workspace.package] diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 0272e5f..45eeec6 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -14,6 +14,7 @@ "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/MODULE.bazel": "c43c16ca2c432566cdb78913964497259903ebe8fb7d9b57b38e9f1425b427b8", "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/source.json": "b88bff599ceaf0f56c264c749b1606f8485cec3b8c38ba30f88a4df9af142861", "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.17.1/MODULE.bazel": "655c922ab1209978a94ef6ca7d9d43e940cd97d9c172fb55f94d91ac53f8610b", "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", "https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2", @@ -21,11 +22,13 @@ "https://bcr.bazel.build/modules/aspect_bazel_lib/1.42.1/MODULE.bazel": "b7aca918a7c7f4cb9ea223e7e2cba294760659ec7364cc551df156067e4a3621", "https://bcr.bazel.build/modules/aspect_bazel_lib/1.42.1/source.json": "d5606a2f57f9bae7b54e93c0286fef52e070377a66737c3cc1f9bbd5c06e2140", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", @@ -45,7 +48,8 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", - "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", "https://bcr.bazel.build/modules/container_structure_test/1.16.0/MODULE.bazel": "5bf2659d7724e232c10435e7ef3d5b3d3bc4bfc7825060e408b4a5e7d165ddf7", @@ -141,6 +145,8 @@ "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_nixpkgs_core/0.13.0/MODULE.bazel": "b826d994220017d44c8a4e55435c2c2e6b4733eaaff69fff65232e0010afcb7b", + "https://bcr.bazel.build/modules/rules_nixpkgs_core/0.13.0/source.json": "c542b96a9d6ab189bd1a65da43e6c7b8be5f1099876ffba35cd9d2275f82740e", "https://bcr.bazel.build/modules/rules_nodejs/6.5.0/MODULE.bazel": "546d0cf79f36f9f6e080816045f97234b071c205f4542e3351bd4424282a8810", "https://bcr.bazel.build/modules/rules_nodejs/6.5.0/source.json": "ac075bc5babebc25a0adc88ee885f2c8d8520d141f6e139ba9dfa0eedb5be908", "https://bcr.bazel.build/modules/rules_oci/1.8.0/MODULE.bazel": "a4d656f6a0e7c7c1a73b9e394e37c8f9bbc237143ce9e19deba7a532fe189552", @@ -164,6 +170,7 @@ "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_rust/0.56.0/MODULE.bazel": "3295b00757db397122092322fe1e920be7f5c9fbfb8619138977e820f2cbbbae", "https://bcr.bazel.build/modules/rules_rust/0.65.0/MODULE.bazel": "1b53caef82fd1c89a2fb15cfa3a15a8e98fe12f4904806b409f5a0183e73f547", "https://bcr.bazel.build/modules/rules_rust/0.65.0/source.json": "3ea929f53bab109fb903d54f08bd86095c323cc3969c025f69e795b564ac5e5f", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", @@ -636,6 +643,69 @@ ] } }, + "@@rules_lean+//lean:extensions.bzl%lean": { + "general": { + "bzlTransitiveDigest": "xWCDsrgjR1eNscJlJQNMqFhLpDMbq9p2hefEcKeC+TE=", + "usagesDigest": "qbRhYKQMwoVfnTg2lbGwVwiMgQHLqrwv2i7fa3eTS60=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "lean_darwin_aarch64": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%lean_release", + "attributes": { + "version": "4.27.0", + "platform": "darwin_aarch64", + "sha256": "01e7d9130464bc7d847baece07dfb2c4f48dd02e71b4b9a77d484914ea594efb" + } + }, + "lean_darwin_x86_64": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%lean_release", + "attributes": { + "version": "4.27.0", + "platform": "darwin_x86_64", + "sha256": "e4ca541d86881c35497cb6e6c1a21358f03a4b2cfb2e8d4e14e58dc2a0a805ae" + } + }, + "lean_linux_x86_64": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%lean_release", + "attributes": { + "version": "4.27.0", + "platform": "linux_x86_64", + "sha256": "056e2dc8564fc064a801e69f3eb18c044b9b546bc8b0e5a2c00247f8a1cb8ce6" + } + }, + "lean_linux_aarch64": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%lean_release", + "attributes": { + "version": "4.27.0", + "platform": "linux_aarch64", + "sha256": "b256eec276baaaccc3eb3fa64d7ccff64f710b7caa074f305ba95e0013ad31e7" + } + }, + "lean_toolchains": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%lean_toolchains_hub", + "attributes": { + "platforms": [ + "darwin_aarch64", + "darwin_x86_64", + "linux_x86_64", + "linux_aarch64" + ] + } + }, + "mathlib": { + "repoRuleId": "@@rules_lean+//lean/private:repo.bzl%mathlib_repo", + "attributes": { + "host_platform": "darwin_aarch64", + "lean_version": "4.27.0", + "mathlib_rev": "v4.27.0" + } + } + }, + "recordedRepoMappingEntries": [] + } + }, "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "hdICB1K7PX7oWtO8oksVTBDNt6xxiNERpcO4Yxoa0Gc=", @@ -17738,7 +17808,7 @@ "@@rules_wasm_component+//wasm:extensions.bzl%wasm_toolchain": { "general": { "bzlTransitiveDigest": "IcsLEoI5OKSUOq+ylenj2AnEP8EXB9Bvpf9tUnjSVZY=", - "usagesDigest": "luPWMXeQCiTEaTYqqZF9Y2dcu+VmXKavYjcmax7uMUY=", + "usagesDigest": "e+gd5ymZDqrT/0jffrBcGHxbrduD2QI7GJwsA4Xwj9I=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/src/component/src/lib.rs b/src/component/src/lib.rs index 48cf1aa..090463e 100644 --- a/src/component/src/lib.rs +++ b/src/component/src/lib.rs @@ -88,11 +88,15 @@ impl Guest for Component { // Create a reader over the module bytes let mut reader = Cursor::new(&module_bytes); - // Verify based on signature type + // Verify based on signature type. `pk.verify` now returns + // `CoreError` (the carved verify-core error type), so the match + // patterns are `CoreError` variants — `From for WSError` + // handles `?` propagation in the rest of `wsc`, but a direct match + // here must be against the actual return type. match pk.verify(&mut reader, detached_sig.as_deref()) { Ok(()) => Ok(true), - Err(wsc::WSError::NoSignatures) => Ok(false), - Err(wsc::WSError::VerificationFailed) => Ok(false), + Err(wsc::CoreError::NoSignatures) => Ok(false), + Err(wsc::CoreError::VerificationFailed) => Ok(false), Err(e) => Err(format!("Verification error: {}", e)), } } diff --git a/src/lib/BUILD.bazel b/src/lib/BUILD.bazel index 226c94d..984b831 100644 --- a/src/lib/BUILD.bazel +++ b/src/lib/BUILD.bazel @@ -23,6 +23,7 @@ rust_library( ], deps = [ "//src/attestation:wsc-attestation", + "//src/verify-core:wsc-verify-core", "@wsc_deps//:anyhow", "@wsc_deps//:ct-codecs", "@wsc_deps//:ed25519-compact", diff --git a/src/lib/Cargo.toml b/src/lib/Cargo.toml index 0a01974..19cb774 100644 --- a/src/lib/Cargo.toml +++ b/src/lib/Cargo.toml @@ -12,6 +12,9 @@ homepage = "https://github.com/pulseengine/wsc" categories = ["cryptography", "wasm"] [dependencies] +# Verification core (carved out so it builds for wasm32 with no TLS/X.509 deps). +# wsc re-exports its public API for backwards compatibility. +wsc-verify-core = { version = "0.9.0", path = "../verify-core" } # Re-export attestation types from minimal crate wsc-attestation = { version = "0.9.0", path = "../attestation" } anyhow = "1.0.100" diff --git a/src/lib/src/error.rs b/src/lib/src/error.rs index 0711c05..98ef096 100644 --- a/src/lib/src/error.rs +++ b/src/lib/src/error.rs @@ -166,6 +166,37 @@ impl From for WSError { } } +// Lift errors from the verification core (`wsc-verify-core`) into `WSError` +// so call sites in this crate can keep using `?` through the carve boundary +// without manual mapping. +impl From for WSError { + fn from(err: wsc_verify_core::CoreError) -> Self { + use wsc_verify_core::CoreError as C; + match err { + C::InternalError(s) => WSError::InternalError(s), + C::ParseError => WSError::ParseError, + C::IOError(e) => WSError::IOError(e), + C::Eof => WSError::Eof, + C::UTF8Error(e) => WSError::UTF8Error(e), + C::CryptoError(e) => WSError::CryptoError(e), + C::UnsupportedModuleType => WSError::UnsupportedModuleType, + C::VerificationFailed => WSError::VerificationFailed, + C::VerificationFailedForPredicates => WSError::VerificationFailedForPredicates, + C::NoSignatures => WSError::NoSignatures, + C::UnsupportedKeyType => WSError::UnsupportedKeyType, + C::IncompatibleSignatureVersion => WSError::IncompatibleSignatureVersion, + C::DuplicateSignature => WSError::DuplicateSignature, + C::SignatureAlreadyAttached => WSError::SignatureAlreadyAttached, + C::DuplicatePublicKey => WSError::DuplicatePublicKey, + C::UnknownPublicKey => WSError::UnknownPublicKey, + C::TooManyHashes(n) => WSError::TooManyHashes(n), + C::TooManySignatures(n) => WSError::TooManySignatures(n), + C::TooManyCertificates(n) => WSError::TooManyCertificates(n), + C::TooManySections(n) => WSError::TooManySections(n), + } + } +} + // WASI HTTP error conversion for wasm32-wasip2 target #[cfg(all(target_arch = "wasm32", target_os = "wasi"))] impl From for WSError { diff --git a/src/lib/src/lib.rs b/src/lib/src/lib.rs index 625a3a1..0130824 100644 --- a/src/lib/src/lib.rs +++ b/src/lib/src/lib.rs @@ -15,15 +15,18 @@ compile_error!( mod error; mod signature; -mod split; -mod wasm_module; -/// Secure file operations with restrictive permissions -/// -/// Provides utilities for securely reading and writing sensitive files -/// such as private keys and tokens. On Unix systems, it enforces restrictive -/// permissions (0600 = owner read/write only) to prevent credential theft. -pub mod secure_file; +// `wasm_module`, `split`, `secure_file`, and the classic signature core +// (`signature/{hash,info,keys,matrix,multi,sig_sections,simple}`) live in the +// `wsc-verify-core` crate so the verification path builds for `wasm32-*` +// without dragging in TLS/X.509. This crate re-exports them so the public +// `wsc::*` API is unchanged. +pub use wsc_verify_core::secure_file; +pub use wsc_verify_core::wasm_module; +pub use wsc_verify_core::{ + CoreError, SIGNATURE_HASH_FUNCTION, SIGNATURE_VERSION, SIGNATURE_WASM_DOMAIN, + SIGNATURE_WASM_MODULE_CONTENT_TYPE, +}; /// Time validation for offline-first verification /// @@ -174,10 +177,12 @@ pub mod runtime; pub use error::*; #[allow(unused_imports)] pub use signature::*; +// Wide re-export of the verification core's top-level items so existing +// callers continue to see `wsc::Module`, `wsc::PublicKey`, etc. #[allow(unused_imports)] -pub use split::*; +pub use wsc_verify_core::signature::*; #[allow(unused_imports)] -pub use wasm_module::*; +pub use wsc_verify_core::wasm_module::*; // Re-export keyless module for public API pub use signature::keyless; @@ -185,8 +190,3 @@ pub use signature::keyless; pub mod reexports { pub use {anyhow, ct_codecs, getrandom, hmac_sha256, log, regex, thiserror}; } - -const SIGNATURE_WASM_DOMAIN: &str = "wasmsig"; -const SIGNATURE_VERSION: u8 = 0x01; -const SIGNATURE_WASM_MODULE_CONTENT_TYPE: u8 = 0x01; -const SIGNATURE_HASH_FUNCTION: u8 = 0x01; diff --git a/src/lib/src/signature/keyless/signer.rs b/src/lib/src/signature/keyless/signer.rs index d9dd7b3..190972c 100644 --- a/src/lib/src/signature/keyless/signer.rs +++ b/src/lib/src/signature/keyless/signer.rs @@ -457,7 +457,9 @@ impl KeylessSigner { log::debug!("Embedding keyless signature: {} bytes", signature_bytes.len()); // Use Module's existing attach_signature mechanism - module.attach_signature(&signature_bytes) + module + .attach_signature(&signature_bytes) + .map_err(Into::into) } } diff --git a/src/lib/src/signature/mod.rs b/src/lib/src/signature/mod.rs index bf89dff..1d885e5 100644 --- a/src/lib/src/signature/mod.rs +++ b/src/lib/src/signature/mod.rs @@ -1,21 +1,10 @@ -mod hash; -mod info; -pub mod keyless; -mod keys; -mod matrix; -mod multi; -mod sig_sections; -mod simple; - -pub use info::*; -pub use keys::*; -pub use matrix::*; +// The classic signature core (`hash`, `info`, `keys`, `matrix`, `multi`, +// `sig_sections`, `simple`) lives in `wsc-verify-core` so it builds for +// `wasm32-*` without TLS/X.509 deps. Re-export it here so the existing +// `wsc::signature::*` public API is unchanged. +pub use wsc_verify_core::signature::*; -pub(crate) use hash::*; - -// Re-export signature data structures for fuzzing and advanced use cases -pub use sig_sections::{ - SignatureData, SignedHashes, SignatureForHashes, - SIGNATURE_SECTION_HEADER_NAME, SIGNATURE_SECTION_DELIMITER_NAME, - MAX_HASHES, MAX_SIGNATURES, new_delimiter_section, -}; +/// Keyless signing support — OIDC + Fulcio + Rekor. Stays in `wsc` because +/// it depends on `rustls`/`ureq`/`x509-parser`, which the carved core +/// deliberately avoids. +pub mod keyless; diff --git a/src/verify-core/BUILD.bazel b/src/verify-core/BUILD.bazel new file mode 100644 index 0000000..0e346b4 --- /dev/null +++ b/src/verify-core/BUILD.bazel @@ -0,0 +1,29 @@ +"""Verification core for wsc. + +Carved out of `//src/lib:wsc` so the verification path builds for `wasm32-*` +without TLS/X.509/network dependencies. This crate intentionally has zero +direct deps that pull in `ring` — that constraint is what enables direct +witness MC/DC instrumentation on the verification logic. +""" + +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "wsc-verify-core", + srcs = glob(["src/**/*.rs"]), + crate_name = "wsc_verify_core", + edition = "2024", + deps = [ + "@wsc_deps//:ct-codecs", + "@wsc_deps//:ed25519-compact", + "@wsc_deps//:getrandom", + "@wsc_deps//:hmac-sha256", + "@wsc_deps//:log", + "@wsc_deps//:thiserror", + "@wsc_deps//:zeroize", + ], +) + +exports_files(["Cargo.toml"]) diff --git a/src/verify-core/Cargo.toml b/src/verify-core/Cargo.toml new file mode 100644 index 0000000..1fe4a18 --- /dev/null +++ b/src/verify-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wsc-verify-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Verification core for wsc: WASM-module parsing + classic Ed25519 signature verification, built so it compiles for wasm32-unknown-unknown with no network/TLS/X.509 dependencies. Carved out of the wsc crate so MC/DC coverage tools (e.g. witness) can instrument it directly." +readme = "../../README.md" +keywords = ["webassembly", "modules", "signatures"] +homepage = "https://github.com/pulseengine/sigil" +categories = ["cryptography", "wasm", "no-std"] + +[dependencies] +ct-codecs = "1.1.6" +ed25519-compact = { version = "2.1.1", features = ["pem"] } +getrandom = { version = "0.3.4", features = ["wasm_js"] } +hmac-sha256 = "1.1.12" +log = "0.4.28" +thiserror = "2.0.17" +zeroize = { version = "1.8", features = ["derive"] } diff --git a/src/verify-core/src/error.rs b/src/verify-core/src/error.rs new file mode 100644 index 0000000..19a8700 --- /dev/null +++ b/src/verify-core/src/error.rs @@ -0,0 +1,75 @@ +//! Core error type for verification operations. +//! +//! `CoreError` is the error type returned by every function in this crate. +//! It is deliberately narrow — it carries only the variants the verification +//! core actually emits. `wsc::WSError` is a superset that wraps `CoreError` +//! via `From for WSError`, so calling code in the outer crate +//! propagates these errors with the usual `?` operator. +//! +//! Keeping this type here (not the full `WSError`) is what lets the core +//! avoid an `x509-parser` dependency: the `From for WSError` +//! impl in the outer crate would otherwise force x509-parser into core via +//! the orphan rule. + +#[derive(Debug, thiserror::Error)] +pub enum CoreError { + #[error("Internal error: [{0}]")] + InternalError(String), + + #[error("Parse error")] + ParseError, + + #[error("I/O error")] + IOError(#[from] std::io::Error), + + #[error("EOF")] + Eof, + + #[error("UTF-8 error")] + UTF8Error(#[from] std::str::Utf8Error), + + #[error("Ed25519 signature function error")] + CryptoError(#[from] ed25519_compact::Error), + + #[error("Unsupported module type")] + UnsupportedModuleType, + + #[error("No valid signatures")] + VerificationFailed, + + #[error("No valid signatures for the given predicates")] + VerificationFailedForPredicates, + + #[error("No signatures found")] + NoSignatures, + + #[error("Unsupported key type")] + UnsupportedKeyType, + + #[error("Incompatible signature version")] + IncompatibleSignatureVersion, + + #[error("Duplicate signature")] + DuplicateSignature, + + #[error("Signature already attached")] + SignatureAlreadyAttached, + + #[error("Duplicate public key")] + DuplicatePublicKey, + + #[error("Unknown public key")] + UnknownPublicKey, + + #[error("Too many hashes (max: {0})")] + TooManyHashes(usize), + + #[error("Too many signatures (max: {0})")] + TooManySignatures(usize), + + #[error("Too many certificates (max: {0})")] + TooManyCertificates(usize), + + #[error("Too many sections (max: {0})")] + TooManySections(usize), +} diff --git a/src/verify-core/src/lib.rs b/src/verify-core/src/lib.rs new file mode 100644 index 0000000..98061d6 --- /dev/null +++ b/src/verify-core/src/lib.rs @@ -0,0 +1,53 @@ +//! Verification core for `wsc` — WASM-module parsing and classic Ed25519 +//! signature verification, carved out so it builds for +//! `wasm32-unknown-unknown` with no network, TLS, or X.509 dependencies. +//! +//! # Why this crate exists +//! +//! The full `wsc` crate pulls in `rustls`/`ureq`/`rcgen`/`x509-parser` for +//! the keyless/Sigstore/provisioning machinery. Those transitively require +//! `ring`, which does not build for `wasm32-unknown-unknown` via plain +//! cargo. The classic verification path — `PublicKey::verify`, +//! `PublicKey::verify_multi`, the WASM module parser, the signature-section +//! parser — needs none of that. Splitting it into this crate lets MC/DC +//! coverage tools (e.g. `pulseengine/witness`) instrument the verification +//! code directly with plain cargo, without dragging in TLS. +//! +//! # Public API +//! +//! The shape is identical to what `wsc` exposed before the carve: items are +//! re-exported at the crate root for convenience, and the moved modules +//! (`signature`, `wasm_module`, `split`) are still available at their old +//! paths. `wsc` re-exports this crate's items so existing callers see no +//! change. + +#![allow(clippy::vec_init_then_push)] +#![forbid(unsafe_code)] + +mod error; +mod split; + +/// Secure file operations with restrictive permissions (Unix 0600). +/// +/// Provides utilities for reading and writing sensitive files such as +/// private keys. Lives here so the verification core's `KeyPair::from_file` +/// / `to_file` methods have what they need without crossing crate +/// boundaries (which the orphan rule would have made awkward). +pub mod secure_file; + +pub mod signature; +pub mod wasm_module; + +pub use error::CoreError; +pub use signature::*; +pub use wasm_module::*; +// `split` only adds inherent `impl Module { … }` methods (no free items to +// glob-import); declaring the module is enough to register the impls. + +// Wire-format constants shared across the verification core. Public so the +// outer `wsc` crate and downstream tooling can reference them — they are +// part of the signed module's on-disk format and must stay stable. +pub const SIGNATURE_WASM_DOMAIN: &str = "wasmsig"; +pub const SIGNATURE_VERSION: u8 = 0x01; +pub const SIGNATURE_WASM_MODULE_CONTENT_TYPE: u8 = 0x01; +pub const SIGNATURE_HASH_FUNCTION: u8 = 0x01; diff --git a/src/lib/src/secure_file.rs b/src/verify-core/src/secure_file.rs similarity index 93% rename from src/lib/src/secure_file.rs rename to src/verify-core/src/secure_file.rs index 158ad2a..364bec5 100644 --- a/src/lib/src/secure_file.rs +++ b/src/verify-core/src/secure_file.rs @@ -13,7 +13,7 @@ //! # Example //! //! ```no_run -//! use wsc::secure_file; +//! use wsc_verify_core::secure_file; //! use std::path::Path; //! //! // Write sensitive data securely @@ -21,10 +21,10 @@ //! //! // Read with permission checking //! let data = secure_file::read_secure(Path::new("/path/to/secret.key"))?; -//! # Ok::<(), wsc::WSError>(()) +//! # Ok::<(), wsc_verify_core::CoreError>(()) //! ``` -use crate::error::WSError; +use crate::error::CoreError; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::path::Path; @@ -43,7 +43,7 @@ pub const SECURE_FILE_MODE: u32 = 0o600; /// /// On non-Unix platforms, this always returns `Ok(())` with a debug log. #[cfg(unix)] -pub fn check_permissions(path: &Path) -> Result<(), WSError> { +pub fn check_permissions(path: &Path) -> Result<(), CoreError> { use std::os::unix::fs::PermissionsExt; let metadata = fs::metadata(path)?; @@ -69,7 +69,7 @@ pub fn check_permissions(path: &Path) -> Result<(), WSError> { } #[cfg(not(unix))] -pub fn check_permissions(path: &Path) -> Result<(), WSError> { +pub fn check_permissions(path: &Path) -> Result<(), CoreError> { log::debug!( "Permission check skipped for '{}': not supported on this platform. \ On Windows, ensure proper ACLs are set for sensitive files.", @@ -83,7 +83,7 @@ pub fn check_permissions(path: &Path) -> Result<(), WSError> { /// Sets the file permissions to 0600 (owner read/write only). /// On non-Unix platforms, this logs a warning and succeeds. #[cfg(unix)] -pub fn set_secure_permissions(path: &Path) -> Result<(), WSError> { +pub fn set_secure_permissions(path: &Path) -> Result<(), CoreError> { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path)?.permissions(); @@ -94,7 +94,7 @@ pub fn set_secure_permissions(path: &Path) -> Result<(), WSError> { } #[cfg(not(unix))] -pub fn set_secure_permissions(path: &Path) -> Result<(), WSError> { +pub fn set_secure_permissions(path: &Path) -> Result<(), CoreError> { log::warn!( "Cannot set restrictive file permissions for '{}': not supported on this platform. \ Ensure proper access controls are configured for sensitive files.", @@ -110,7 +110,7 @@ pub fn set_secure_permissions(path: &Path) -> Result<(), WSError> { /// /// On non-Unix platforms, this creates the file normally and logs a warning. #[cfg(unix)] -pub fn create_secure_file(path: &Path) -> Result { +pub fn create_secure_file(path: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; let file = OpenOptions::new() @@ -124,7 +124,7 @@ pub fn create_secure_file(path: &Path) -> Result { } #[cfg(not(unix))] -pub fn create_secure_file(path: &Path) -> Result { +pub fn create_secure_file(path: &Path) -> Result { log::warn!( "Creating file '{}' without restrictive permissions: not supported on this platform. \ Ensure proper access controls are configured for sensitive files.", @@ -153,7 +153,7 @@ pub fn create_secure_file(path: &Path) -> Result { /// so there's no window where the file exists with permissive permissions. /// /// On non-Unix systems, the file is created normally with a warning logged. -pub fn write_secure(path: &Path, data: &[u8]) -> Result<(), WSError> { +pub fn write_secure(path: &Path, data: &[u8]) -> Result<(), CoreError> { let mut file = create_secure_file(path)?; file.write_all(data)?; file.sync_all()?; @@ -170,7 +170,7 @@ pub fn write_secure(path: &Path, data: &[u8]) -> Result<(), WSError> { /// Write a string to a file with secure permissions /// /// See [`write_secure`] for details on the security guarantees. -pub fn write_secure_string(path: &Path, content: &str) -> Result<(), WSError> { +pub fn write_secure_string(path: &Path, content: &str) -> Result<(), CoreError> { write_secure(path, content.as_bytes()) } @@ -185,7 +185,7 @@ pub fn write_secure_string(path: &Path, content: &str) -> Result<(), WSError> { /// /// This function will still read the file even if permissions are too permissive, /// but it will log a warning to alert the user to the security issue. -pub fn read_secure(path: &Path) -> Result, WSError> { +pub fn read_secure(path: &Path) -> Result, CoreError> { // Check permissions first check_permissions(path)?; @@ -200,10 +200,10 @@ pub fn read_secure(path: &Path) -> Result, WSError> { /// Read a file as a string and check its permissions /// /// See [`read_secure`] for details on the security guarantees. -pub fn read_secure_string(path: &Path) -> Result { +pub fn read_secure_string(path: &Path) -> Result { let contents = read_secure(path)?; String::from_utf8(contents).map_err(|e| { - WSError::InternalError(format!("Invalid UTF-8 in secure file: {}", e)) + CoreError::InternalError(format!("Invalid UTF-8 in secure file: {}", e)) }) } diff --git a/src/lib/src/signature/hash.rs b/src/verify-core/src/signature/hash.rs similarity index 99% rename from src/lib/src/signature/hash.rs rename to src/verify-core/src/signature/hash.rs index 6a78f7b..d72e9d3 100644 --- a/src/lib/src/signature/hash.rs +++ b/src/verify-core/src/signature/hash.rs @@ -1,7 +1,7 @@ use std::io::{self, Write}; #[derive(Clone, Copy)] -pub(crate) struct Hash { +pub struct Hash { hash: hmac_sha256::Hash, } diff --git a/src/lib/src/signature/info.rs b/src/verify-core/src/signature/info.rs similarity index 95% rename from src/lib/src/signature/info.rs rename to src/verify-core/src/signature/info.rs index 46b9994..c266350 100644 --- a/src/lib/src/signature/info.rs +++ b/src/verify-core/src/signature/info.rs @@ -5,7 +5,7 @@ //! //! Backported from wasmsign2 (commit 8223bec, 2025-12-18). -use crate::error::*; +use crate::error::CoreError; use crate::signature::sig_sections::{SignatureData, SIGNATURE_SECTION_HEADER_NAME}; use crate::wasm_module::{Module, Section}; use std::fs::File; @@ -84,7 +84,7 @@ impl Module { /// println!("Key ID: {:02x?}", key_id); /// } /// ``` - pub fn signature_info(&self) -> Result { + pub fn signature_info(&self) -> Result { for section in &self.sections { if let Section::Custom(custom) = section { if custom.is_signature_header() { @@ -93,7 +93,7 @@ impl Module { } } } - Err(WSError::NoSignatures) + Err(CoreError::NoSignatures) } } @@ -111,7 +111,7 @@ impl Module { /// println!("Key ID: {:02x?}", key_id); /// } /// ``` -pub fn signature_info_from_file(path: impl AsRef) -> Result { +pub fn signature_info_from_file(path: impl AsRef) -> Result { let fp = File::open(path.as_ref())?; signature_info_from_reader(&mut BufReader::new(fp), None) } @@ -134,7 +134,7 @@ pub fn signature_info_from_file(path: impl AsRef) -> Result, -) -> Result { +) -> Result { if let Some(detached) = detached_signature { let data = SignatureData::deserialize(detached)?; return Ok(SignatureInfo::from_signature_data(&data)); @@ -143,14 +143,14 @@ pub fn signature_info_from_reader( let stream = Module::init_from_reader(reader)?; let mut sections = Module::iterate(stream)?; - let first_section = sections.next().ok_or(WSError::ParseError)??; + let first_section = sections.next().ok_or(CoreError::ParseError)??; match first_section { Section::Custom(custom) if custom.name() == SIGNATURE_SECTION_HEADER_NAME => { let data = custom.signature_data()?; Ok(SignatureInfo::from_signature_data(&data)) } - _ => Err(WSError::NoSignatures), + _ => Err(CoreError::NoSignatures), } } @@ -166,7 +166,7 @@ pub fn signature_info_from_reader( /// let info = wsc::signature_info_from_detached(&signature_bytes)?; /// println!("Detached signature has {} keys", info.key_ids().len()); /// ``` -pub fn signature_info_from_detached(detached_signature: &[u8]) -> Result { +pub fn signature_info_from_detached(detached_signature: &[u8]) -> Result { let data = SignatureData::deserialize(detached_signature)?; Ok(SignatureInfo::from_signature_data(&data)) } @@ -231,7 +231,7 @@ mod tests { let module = create_test_module(); let result = module.signature_info(); - assert!(matches!(result, Err(WSError::NoSignatures))); + assert!(matches!(result, Err(CoreError::NoSignatures))); } #[test] diff --git a/src/lib/src/signature/keys.rs b/src/verify-core/src/signature/keys.rs similarity index 94% rename from src/lib/src/signature/keys.rs rename to src/verify-core/src/signature/keys.rs index 4ca698a..81a410a 100644 --- a/src/lib/src/signature/keys.rs +++ b/src/verify-core/src/signature/keys.rs @@ -1,4 +1,4 @@ -pub use crate::error::*; +pub use crate::error::CoreError; use crate::secure_file; use ct_codecs::{Encoder, Hex}; @@ -9,8 +9,8 @@ use std::path::Path; use std::fmt; use zeroize::Zeroizing; -pub(crate) const ED25519_PK_ID: u8 = 0x01; -pub(crate) const ED25519_SK_ID: u8 = 0x81; +pub const ED25519_PK_ID: u8 = 0x01; +pub const ED25519_SK_ID: u8 = 0x81; /// A public key. #[derive(Clone, Eq, PartialEq, Hash)] @@ -21,12 +21,12 @@ pub struct PublicKey { impl PublicKey { /// Create a public key from raw bytes. - pub fn from_bytes(pk: &[u8]) -> Result { + pub fn from_bytes(pk: &[u8]) -> Result { let mut reader = io::Cursor::new(pk); let mut id = [0u8]; reader.read_exact(&mut id)?; if id[0] != ED25519_PK_ID { - return Err(WSError::UnsupportedKeyType); + return Err(CoreError::UnsupportedKeyType); } let mut bytes = vec![]; reader.read_to_end(&mut bytes)?; @@ -37,13 +37,13 @@ impl PublicKey { } /// Deserialize a PEM-encoded public key. - pub fn from_pem(pem: &str) -> Result { + pub fn from_pem(pem: &str) -> Result { let pk = ed25519_compact::PublicKey::from_pem(pem)?; Ok(Self { pk, key_id: None }) } /// Deserialize a DER-encoded public key. - pub fn from_der(der: &[u8]) -> Result { + pub fn from_der(der: &[u8]) -> Result { let pk = ed25519_compact::PublicKey::from_der(der)?; Ok(Self { pk, key_id: None }) } @@ -66,7 +66,7 @@ impl PublicKey { } /// Read public key from a file (raw WSC format). - pub fn from_file(file: impl AsRef) -> Result { + pub fn from_file(file: impl AsRef) -> Result { let mut fp = File::open(file)?; let mut bytes = vec![]; fp.read_to_end(&mut bytes)?; @@ -74,19 +74,19 @@ impl PublicKey { } /// Read public key from a PEM file. - pub fn from_pem_file(file: impl AsRef) -> Result { + pub fn from_pem_file(file: impl AsRef) -> Result { let content = std::fs::read_to_string(file)?; Self::from_pem(&content) } /// Read public key from a DER file. - pub fn from_der_file(file: impl AsRef) -> Result { + pub fn from_der_file(file: impl AsRef) -> Result { let bytes = std::fs::read(file)?; Self::from_der(&bytes) } /// Save the public key to a file. - pub fn to_file(&self, file: impl AsRef) -> Result<(), WSError> { + pub fn to_file(&self, file: impl AsRef) -> Result<(), CoreError> { let mut fp = File::create(file)?; fp.write_all(&self.to_bytes())?; Ok(()) @@ -129,12 +129,12 @@ pub struct SecretKey { impl SecretKey { /// Create a secret key from raw bytes. - pub fn from_bytes(sk: &[u8]) -> Result { + pub fn from_bytes(sk: &[u8]) -> Result { let mut reader = io::Cursor::new(sk); let mut id = [0u8]; reader.read_exact(&mut id)?; if id[0] != ED25519_SK_ID { - return Err(WSError::UnsupportedKeyType); + return Err(CoreError::UnsupportedKeyType); } let mut bytes = vec![]; reader.read_to_end(&mut bytes)?; @@ -144,13 +144,13 @@ impl SecretKey { } /// Deserialize a PEM-encoded secret key. - pub fn from_pem(pem: &str) -> Result { + pub fn from_pem(pem: &str) -> Result { let sk = ed25519_compact::SecretKey::from_pem(pem)?; Ok(Self { sk }) } /// Deserialize a DER-encoded secret key. - pub fn from_der(der: &[u8]) -> Result { + pub fn from_der(der: &[u8]) -> Result { let sk = ed25519_compact::SecretKey::from_der(der)?; Ok(Self { sk }) } @@ -188,7 +188,7 @@ impl SecretKey { /// On Unix systems, this function checks file permissions and logs a warning /// if the file is readable by group or others. Secret keys should have mode /// 0600 (owner read/write only) to prevent credential theft. - pub fn from_file(file: impl AsRef) -> Result { + pub fn from_file(file: impl AsRef) -> Result { let bytes = secure_file::read_secure(file.as_ref())?; Self::from_bytes(&bytes) } @@ -204,7 +204,7 @@ impl SecretKey { /// /// On non-Unix systems, a warning is logged that permissions cannot be /// enforced, and the file is created with default permissions. - pub fn to_file(&self, file: impl AsRef) -> Result<(), WSError> { + pub fn to_file(&self, file: impl AsRef) -> Result<(), CoreError> { secure_file::write_secure(file.as_ref(), &self.to_bytes()) } } @@ -273,27 +273,27 @@ impl PublicKeySet { } /// Add a public key to the set. - pub fn insert(&mut self, pk: PublicKey) -> Result<(), WSError> { + pub fn insert(&mut self, pk: PublicKey) -> Result<(), CoreError> { if !self.pks.insert(pk) { - return Err(WSError::DuplicatePublicKey); + return Err(CoreError::DuplicatePublicKey); } Ok(()) } /// Load a public key from a PEM file and add it to the set. - pub fn insert_pem_file(&mut self, file: impl AsRef) -> Result<(), WSError> { + pub fn insert_pem_file(&mut self, file: impl AsRef) -> Result<(), CoreError> { let pk = PublicKey::from_pem_file(file)?; self.insert(pk) } /// Load a public key from a raw WSC format file and add it to the set. - pub fn insert_file(&mut self, file: impl AsRef) -> Result<(), WSError> { + pub fn insert_file(&mut self, file: impl AsRef) -> Result<(), CoreError> { let pk = PublicKey::from_file(file)?; self.insert(pk) } /// Merge another public key set into this one. - pub fn merge(&mut self, other: &PublicKeySet) -> Result<(), WSError> { + pub fn merge(&mut self, other: &PublicKeySet) -> Result<(), CoreError> { for pk in other.pks.iter() { self.insert(pk.clone())?; } @@ -301,9 +301,9 @@ impl PublicKeySet { } /// Remove a key from the set. - pub fn remove(&mut self, pk: &PublicKey) -> Result<(), WSError> { + pub fn remove(&mut self, pk: &PublicKey) -> Result<(), CoreError> { if !self.pks.remove(pk) { - return Err(WSError::UnknownPublicKey); + return Err(CoreError::UnknownPublicKey); } Ok(()) } @@ -361,7 +361,7 @@ mod tests { let bytes = vec![0xFF, 1, 2, 3, 4]; // Invalid key type let result = PublicKey::from_bytes(&bytes); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::UnsupportedKeyType)); + assert!(matches!(result.unwrap_err(), CoreError::UnsupportedKeyType)); } #[test] @@ -432,7 +432,7 @@ mod tests { let bytes = vec![0xFF, 1, 2, 3, 4]; // Invalid key type let result = SecretKey::from_bytes(&bytes); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::UnsupportedKeyType)); + assert!(matches!(result.unwrap_err(), CoreError::UnsupportedKeyType)); } #[test] @@ -490,7 +490,7 @@ mod tests { let result = set.insert(kp.pk.clone()); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::DuplicatePublicKey)); + assert!(matches!(result.unwrap_err(), CoreError::DuplicatePublicKey)); assert_eq!(set.len(), 1); // Still only one key } @@ -513,7 +513,7 @@ mod tests { let result = set.remove(&kp.pk); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::UnknownPublicKey)); + assert!(matches!(result.unwrap_err(), CoreError::UnknownPublicKey)); } #[test] diff --git a/src/lib/src/signature/matrix.rs b/src/verify-core/src/signature/matrix.rs similarity index 95% rename from src/lib/src/signature/matrix.rs rename to src/verify-core/src/signature/matrix.rs index d706924..d48f880 100644 --- a/src/lib/src/signature/matrix.rs +++ b/src/verify-core/src/signature/matrix.rs @@ -25,7 +25,7 @@ impl PublicKeySet { reader: &mut impl Read, detached_signature: Option<&[u8]>, predicates: &[impl Fn(&Section) -> bool], - ) -> Result>, WSError> { + ) -> Result>, CoreError> { let mut sections = Module::iterate(Module::init_from_reader(reader)?)?; let signature_header_section = if let Some(detached_signature) = &detached_signature { Section::Custom(CustomSection::new( @@ -33,7 +33,7 @@ impl PublicKeySet { detached_signature.to_vec(), )) } else { - sections.next().ok_or(WSError::ParseError)?? + sections.next().ok_or(CoreError::ParseError)?? }; let signature_header = match signature_header_section { Section::Custom(custom_section) if custom_section.is_signature_header() => { @@ -41,7 +41,7 @@ impl PublicKeySet { } _ => { debug!("This module is not signed"); - return Err(WSError::NoSignatures); + return Err(CoreError::NoSignatures); } }; @@ -51,14 +51,14 @@ impl PublicKeySet { "Unsupported hash function: {:02x}", signature_data.hash_function, ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } if signature_data.content_type != SIGNATURE_WASM_MODULE_CONTENT_TYPE { debug!( "Unsupported content type: {:02x}", signature_data.content_type, ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let signed_hashes_set = signature_data.signed_hashes_set; @@ -71,7 +71,7 @@ impl PublicKeySet { } if valid_hashes_for_pks.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailed); + return Err(CoreError::VerificationFailed); } let mut section_sequence_must_be_signed_for_pks: HashMap> = @@ -144,7 +144,7 @@ impl PublicKeySet { if res.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailedForPredicates); + return Err(CoreError::VerificationFailedForPredicates); } Ok(res) } @@ -242,7 +242,7 @@ mod tests { let result = key_set.verify_matrix(&mut reader, None, &[predicate]); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::NoSignatures)); + assert!(matches!(result.unwrap_err(), CoreError::NoSignatures)); } #[test] @@ -262,7 +262,7 @@ mod tests { let result = key_set.verify_matrix(&mut reader, None, &[predicate]); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::VerificationFailed)); + assert!(matches!(result.unwrap_err(), CoreError::VerificationFailed)); } #[test] diff --git a/src/verify-core/src/signature/mod.rs b/src/verify-core/src/signature/mod.rs new file mode 100644 index 0000000..8272351 --- /dev/null +++ b/src/verify-core/src/signature/mod.rs @@ -0,0 +1,20 @@ +mod hash; +mod info; +mod keys; +mod matrix; +mod multi; +mod sig_sections; +mod simple; + +pub use info::*; +pub use keys::*; +pub use matrix::*; + +pub use hash::*; + +// Re-export signature data structures for fuzzing and advanced use cases. +pub use sig_sections::{ + MAX_HASHES, MAX_SIGNATURES, SIGNATURE_SECTION_DELIMITER_NAME, + SIGNATURE_SECTION_HEADER_NAME, SignatureData, SignatureForHashes, SignedHashes, + new_delimiter_section, +}; diff --git a/src/lib/src/signature/multi.rs b/src/verify-core/src/signature/multi.rs similarity index 94% rename from src/lib/src/signature/multi.rs rename to src/verify-core/src/signature/multi.rs index 786a2c0..117c538 100644 --- a/src/lib/src/signature/multi.rs +++ b/src/verify-core/src/signature/multi.rs @@ -50,7 +50,7 @@ impl SecretKey { key_id: Option<&Vec>, detached: bool, allow_extensions: bool, - ) -> Result<(Module, Vec), WSError> { + ) -> Result<(Module, Vec), CoreError> { let mut hasher = Hash::new(); let mut hashes = vec![]; @@ -76,7 +76,7 @@ impl SecretKey { // instead of panicking. A crafted module could have duplicate // signature sections to trigger a DoS via assert panic. if previous_signature_data.is_some() { - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } previous_signature_data = Some(custom_section.signature_data()?); continue; @@ -113,7 +113,7 @@ impl SecretKey { sk: &SecretKey, key_id: Option<&Vec>, hashes: Vec>, - ) -> Result { + ) -> Result { // SECURITY: Zeroize message buffer on drop to prevent key material leakage let mut msg: Zeroizing> = Zeroizing::new(vec![]); msg.extend_from_slice(SIGNATURE_WASM_DOMAIN.as_bytes()); @@ -157,7 +157,7 @@ impl SecretKey { { previous_signature_data.signed_hashes_set.clone() } - _ => return Err(WSError::IncompatibleSignatureVersion), + _ => return Err(CoreError::IncompatibleSignatureVersion), }; let mut new_hashes = true; @@ -170,7 +170,7 @@ impl SecretKey { && ct_eq(&sig.signature, &signature_for_hashes.signature) }) { debug!("A matching hash set was already signed with that key."); - return Err(WSError::DuplicateSignature); + return Err(CoreError::DuplicateSignature); } debug!("A matching hash set was already signed."); previous_signed_hashes_set @@ -213,7 +213,7 @@ impl PublicKey { reader: &mut impl Read, detached_signature: Option<&[u8]>, mut predicate: P, - ) -> Result<(), WSError> + ) -> Result<(), CoreError> where P: FnMut(&Section) -> bool, { @@ -224,7 +224,7 @@ impl PublicKey { detached_signature.to_vec(), )) } else { - sections.next().ok_or(WSError::ParseError)?.1? + sections.next().ok_or(CoreError::ParseError)?.1? }; let signature_header = match signature_header_section { Section::Custom(custom_section) if custom_section.is_signature_header() => { @@ -232,7 +232,7 @@ impl PublicKey { } _ => { debug!("This module is not signed"); - return Err(WSError::NoSignatures); + return Err(CoreError::NoSignatures); } }; @@ -242,14 +242,14 @@ impl PublicKey { "Unsupported hash function: {:02x}", signature_data.hash_function ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let signed_hashes_set = signature_data.signed_hashes_set; let valid_hashes = self.valid_hashes_for_pk(&signed_hashes_set)?; if valid_hashes.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailed); + return Err(CoreError::VerificationFailed); } debug!("Hashes matching the signature:"); for valid_hash in &valid_hashes { @@ -270,7 +270,7 @@ impl PublicKey { let h = hasher.finalize().to_vec(); debug!(" - [{}]", Hex::encode_to_string(&h).unwrap_or_else(|_| "".to_string())); if !valid_hashes.contains(&h) { - return Err(WSError::VerificationFailedForPredicates); + return Err(CoreError::VerificationFailedForPredicates); } matching_section_ranges.push(0..=idx); section_sequence_must_be_signed = None; @@ -279,10 +279,10 @@ impl PublicKey { match section_sequence_must_be_signed { None => section_sequence_must_be_signed = Some(section_must_be_signed), Some(false) if section_must_be_signed => { - return Err(WSError::VerificationFailedForPredicates); + return Err(CoreError::VerificationFailedForPredicates); } Some(true) if !section_must_be_signed => { - return Err(WSError::VerificationFailedForPredicates); + return Err(CoreError::VerificationFailedForPredicates); } _ => {} } @@ -298,7 +298,7 @@ impl PublicKey { pub(crate) fn valid_hashes_for_pk<'t>( &self, signed_hashes_set: &'t [SignedHashes], - ) -> Result>, WSError> { + ) -> Result>, CoreError> { let mut valid_hashes = HashSet::new(); for signed_section_sequence in signed_hashes_set { // SECURITY: Zeroize message buffer on drop to prevent data leakage diff --git a/src/lib/src/signature/sig_sections.rs b/src/verify-core/src/signature/sig_sections.rs similarity index 91% rename from src/lib/src/signature/sig_sections.rs rename to src/verify-core/src/signature/sig_sections.rs index 3c12463..39534d5 100644 --- a/src/lib/src/signature/sig_sections.rs +++ b/src/verify-core/src/signature/sig_sections.rs @@ -4,7 +4,7 @@ use std::io::{BufReader, BufWriter, prelude::*}; use crate::ED25519_PK_ID; use crate::SIGNATURE_VERSION; use crate::SIGNATURE_WASM_MODULE_CONTENT_TYPE; -use crate::error::*; +use crate::error::CoreError; use crate::wasm_module::*; pub const SIGNATURE_SECTION_HEADER_NAME: &str = "signature"; @@ -41,7 +41,7 @@ pub struct SignatureData { } impl SignatureForHashes { - pub fn serialize(&self) -> Result, WSError> { + pub fn serialize(&self) -> Result, CoreError> { let mut writer = BufWriter::new(Vec::new()); if let Some(key_id) = &self.key_id { varint::put_slice(&mut writer, key_id)?; @@ -63,10 +63,10 @@ impl SignatureForHashes { writer .into_inner() - .map_err(|e| WSError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) + .map_err(|e| CoreError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) } - pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { + pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { let mut reader = BufReader::new(bin.as_ref()); let key_id = varint::get_slice(&mut reader)?; let key_id = if key_id.is_empty() { @@ -79,7 +79,7 @@ impl SignatureForHashes { let alg_id = alg_id[0]; if alg_id != ED25519_PK_ID { debug!("Unsupported algorithm: {:02x}", alg_id); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let signature = varint::get_slice(&mut reader)?; @@ -104,7 +104,7 @@ impl SignatureForHashes { "Too many certificates: {} (max: {})", cert_count, MAX_CERTIFICATES ); - return Err(WSError::TooManyCertificates(MAX_CERTIFICATES)); + return Err(CoreError::TooManyCertificates(MAX_CERTIFICATES)); } if cert_count > 0 { let mut certs = Vec::with_capacity(cert_count as usize); @@ -139,7 +139,7 @@ impl SignatureForHashes { } impl SignedHashes { - pub fn serialize(&self) -> Result, WSError> { + pub fn serialize(&self) -> Result, CoreError> { let mut writer = BufWriter::new(Vec::new()); varint::put(&mut writer, self.hashes.len() as _)?; for hash in &self.hashes { @@ -151,15 +151,15 @@ impl SignedHashes { } writer .into_inner() - .map_err(|e| WSError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) + .map_err(|e| CoreError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) } - pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { + pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { let mut reader = BufReader::new(bin.as_ref()); let hashes_count = varint::get32(&mut reader)? as _; if hashes_count > MAX_HASHES { debug!("Too many hashes: {} (max: {})", hashes_count, MAX_HASHES); - return Err(WSError::TooManyHashes(MAX_HASHES)); + return Err(CoreError::TooManyHashes(MAX_HASHES)); } let mut hashes = Vec::with_capacity(hashes_count); for _ in 0..hashes_count { @@ -173,7 +173,7 @@ impl SignedHashes { "Too many signatures: {} (max: {})", signatures_count, MAX_SIGNATURES ); - return Err(WSError::TooManySignatures(MAX_SIGNATURES)); + return Err(CoreError::TooManySignatures(MAX_SIGNATURES)); } let mut signatures = Vec::with_capacity(signatures_count); for i in 0..signatures_count { @@ -195,7 +195,7 @@ impl SignedHashes { } impl SignatureData { - pub fn serialize(&self) -> Result, WSError> { + pub fn serialize(&self) -> Result, CoreError> { let mut writer = BufWriter::new(Vec::new()); varint::put(&mut writer, self.specification_version as _)?; varint::put(&mut writer, self.content_type as _)?; @@ -206,10 +206,10 @@ impl SignatureData { } writer .into_inner() - .map_err(|e| WSError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) + .map_err(|e| CoreError::IOError(std::io::Error::other(format!("buffer flush failed: {}", e)))) } - pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { + pub fn deserialize(bin: impl AsRef<[u8]>) -> Result { let mut reader = BufReader::new(bin.as_ref()); let specification_version = varint::get7(&mut reader)?; if specification_version != SIGNATURE_VERSION { @@ -217,12 +217,12 @@ impl SignatureData { "Unsupported specification version: {:02x}", specification_version ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let content_type = varint::get7(&mut reader)?; if content_type != SIGNATURE_WASM_MODULE_CONTENT_TYPE { debug!("Unsupported content type: {:02x}", content_type); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let hash_function = varint::get7(&mut reader)?; let signed_hashes_count = varint::get32(&mut reader)? as _; @@ -231,7 +231,7 @@ impl SignatureData { "Too many hashes: {} (max: {})", signed_hashes_count, MAX_HASHES ); - return Err(WSError::TooManyHashes(MAX_HASHES)); + return Err(CoreError::TooManyHashes(MAX_HASHES)); } let mut signed_hashes_set = Vec::with_capacity(signed_hashes_count); for _ in 0..signed_hashes_count { @@ -248,10 +248,10 @@ impl SignatureData { } } -pub fn new_delimiter_section() -> Result { +pub fn new_delimiter_section() -> Result { let mut custom_payload = vec![0u8; 16]; getrandom::fill(&mut custom_payload) - .map_err(|_| WSError::InternalError("RNG error".to_string()))?; + .map_err(|_| CoreError::InternalError("RNG error".to_string()))?; Ok(Section::Custom(CustomSection::new( SIGNATURE_SECTION_DELIMITER_NAME.to_string(), custom_payload, @@ -356,7 +356,7 @@ mod tests { varint::put(&mut buf, (MAX_HASHES + 1) as u64).unwrap(); let result = SignedHashes::deserialize(&buf); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::TooManyHashes(_))); + assert!(matches!(result.unwrap_err(), CoreError::TooManyHashes(_))); } #[test] @@ -371,7 +371,7 @@ mod tests { let result = SignedHashes::deserialize(&buf); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::TooManySignatures(_))); + assert!(matches!(result.unwrap_err(), CoreError::TooManySignatures(_))); } #[test] @@ -389,7 +389,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - WSError::TooManyCertificates(_) + CoreError::TooManyCertificates(_) )); } @@ -408,7 +408,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - WSError::TooManyCertificates(_) + CoreError::TooManyCertificates(_) )); } @@ -510,7 +510,7 @@ mod tests { /// None` (which would downgrade a cert-based signature to bare-key). /// /// PoC: 5 bytes each with MSB set make `varint::get32` consume all 5 and - /// return `WSError::ParseError`. Before the fix, the `if let Ok(...)` + /// return `CoreError::ParseError`. Before the fix, the `if let Ok(...)` /// pattern swallowed this and produced `Ok { certificate_chain: None }`. #[test] fn test_malformed_cert_count_is_rejected() { diff --git a/src/lib/src/signature/simple.rs b/src/verify-core/src/signature/simple.rs similarity index 94% rename from src/lib/src/signature/simple.rs rename to src/verify-core/src/signature/simple.rs index 640ba7f..f857601 100644 --- a/src/lib/src/signature/simple.rs +++ b/src/verify-core/src/signature/simple.rs @@ -29,7 +29,7 @@ impl SecretKey { /// /// `key_id` is the key identifier of the public key, to be stored with the signature. /// This parameter is optional. - pub fn sign(&self, mut module: Module, key_id: Option<&Vec>) -> Result { + pub fn sign(&self, mut module: Module, key_id: Option<&Vec>) -> Result { let mut out_sections = vec![Section::Custom(CustomSection::default())]; let mut hasher = Hash::new(); for section in module.sections.into_iter() { @@ -91,7 +91,7 @@ impl PublicKey { &self, reader: &mut impl Read, detached_signature: Option<&[u8]>, - ) -> Result<(), WSError> { + ) -> Result<(), CoreError> { let stream = Module::init_from_reader(reader)?; let mut sections = Module::iterate(stream)?; @@ -102,7 +102,7 @@ impl PublicKey { detached_signature.to_vec(), )) } else { - sections.next().ok_or(WSError::ParseError)?? + sections.next().ok_or(CoreError::ParseError)?? }; let signature_header = match signature_header_section { Section::Custom(custom_section) if custom_section.is_signature_header() => { @@ -110,7 +110,7 @@ impl PublicKey { } _ => { debug!("This module is not signed"); - return Err(WSError::NoSignatures); + return Err(CoreError::NoSignatures); } }; @@ -121,14 +121,14 @@ impl PublicKey { "Unsupported hash function: {:02x}", signature_data.specification_version ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let signed_hashes_set = signature_data.signed_hashes_set; let valid_hashes = self.valid_hashes_for_pk(&signed_hashes_set)?; if valid_hashes.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailed); + return Err(CoreError::VerificationFailed); } let mut hasher = Hash::new(); @@ -147,7 +147,7 @@ impl PublicKey { if ct_contains_hash(&valid_hashes, &h) { Ok(()) } else { - Err(WSError::VerificationFailed) + Err(CoreError::VerificationFailed) } } } @@ -165,7 +165,7 @@ impl PublicKeySet { &self, reader: &mut impl Read, detached_signature: Option<&[u8]>, - ) -> Result, WSError> { + ) -> Result, CoreError> { let mut sections = Module::iterate(Module::init_from_reader(reader)?)?; // Read the signature header from the module, or reconstruct it from the detached signature. @@ -179,7 +179,7 @@ impl PublicKeySet { )); signature_header = &signature_header_from_detached_signature; } else { - signature_header_from_stream = sections.next().ok_or(WSError::ParseError)??; + signature_header_from_stream = sections.next().ok_or(CoreError::ParseError)??; signature_header = &signature_header_from_stream; } let signature_header = match signature_header { @@ -188,7 +188,7 @@ impl PublicKeySet { } _ => { debug!("This module is not signed"); - return Err(WSError::NoSignatures); + return Err(CoreError::NoSignatures); } }; @@ -199,14 +199,14 @@ impl PublicKeySet { "Unsupported content type: {:02x}", signature_data.content_type ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } if signature_data.hash_function != SIGNATURE_HASH_FUNCTION { debug!( "Unsupported hash function: {:02x}", signature_data.specification_version ); - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let signed_hashes_set = signature_data.signed_hashes_set; let valid_hashes_for_pks: HashMap<&PublicKey, HashSet<&Vec>> = self @@ -219,7 +219,7 @@ impl PublicKeySet { .collect(); if valid_hashes_for_pks.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailed); + return Err(CoreError::VerificationFailed); } let mut hasher = Hash::new(); @@ -242,7 +242,7 @@ impl PublicKeySet { } if valid_pks.is_empty() { debug!("No valid signatures"); - return Err(WSError::VerificationFailed); + return Err(CoreError::VerificationFailed); } Ok(valid_pks) } @@ -335,7 +335,7 @@ mod tests { let mut reader = Cursor::new(unsigned_bytes); let result = kp.pk.verify(&mut reader, None); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::NoSignatures)); + assert!(matches!(result.unwrap_err(), CoreError::NoSignatures)); } #[test] @@ -352,7 +352,7 @@ mod tests { let mut reader = Cursor::new(signed_bytes); let result = kp2.pk.verify(&mut reader, None); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::VerificationFailed)); + assert!(matches!(result.unwrap_err(), CoreError::VerificationFailed)); } #[test] @@ -407,7 +407,7 @@ mod tests { let mut reader = Cursor::new(unsigned_bytes); let result = key_set.verify(&mut reader, None); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::NoSignatures)); + assert!(matches!(result.unwrap_err(), CoreError::NoSignatures)); } #[test] @@ -427,7 +427,7 @@ mod tests { let mut reader = Cursor::new(signed_bytes); let result = key_set.verify(&mut reader, None); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::VerificationFailed)); + assert!(matches!(result.unwrap_err(), CoreError::VerificationFailed)); } #[test] diff --git a/src/lib/src/split.rs b/src/verify-core/src/split.rs similarity index 95% rename from src/lib/src/split.rs rename to src/verify-core/src/split.rs index a782e28..b7ff884 100644 --- a/src/lib/src/split.rs +++ b/src/verify-core/src/split.rs @@ -7,7 +7,7 @@ impl Module { /// Print the structure of a module to the standard output, mainly for debugging purposes. /// /// Set `verbose` to `true` in order to also print details about signature data. - pub fn show(&self, verbose: bool) -> Result<(), WSError> { + pub fn show(&self, verbose: bool) -> Result<(), CoreError> { for (idx, section) in self.sections.iter().enumerate() { println!("{}:\t{}", idx, section.display(verbose)); } @@ -20,7 +20,7 @@ impl Module { /// and `false` if the section can be ignored during verification. /// /// It is highly recommended to always include the standard sections in the signed set. - pub fn split

(self, mut predicate: P) -> Result + pub fn split

(self, mut predicate: P) -> Result where P: FnMut(&Section) -> bool, { @@ -67,14 +67,14 @@ impl Module { /// /// This function returns the module without the embedded signature, /// as well as the detached signature as a byte string. - pub fn detach_signature(mut self) -> Result<(Module, Vec), WSError> { + pub fn detach_signature(mut self) -> Result<(Module, Vec), CoreError> { let mut out_sections = vec![]; let mut sections = self.sections.into_iter(); let detached_signature = match sections.next() { - None => return Err(WSError::NoSignatures), + None => return Err(CoreError::NoSignatures), Some(section) => { if !section.is_signature_header() { - return Err(WSError::NoSignatures); + return Err(CoreError::NoSignatures); } section.payload().to_vec() } @@ -89,7 +89,7 @@ impl Module { /// Embed a detached signature into a module. /// This function returns the module with embedded signature. - pub fn attach_signature(mut self, detached_signature: &[u8]) -> Result { + pub fn attach_signature(mut self, detached_signature: &[u8]) -> Result { let mut out_sections = vec![]; let sections = self.sections.into_iter(); let signature_header = Section::Custom(CustomSection::new( @@ -99,7 +99,7 @@ impl Module { out_sections.push(signature_header); for section in sections { if section.is_signature_header() { - return Err(WSError::SignatureAlreadyAttached); + return Err(CoreError::SignatureAlreadyAttached); } out_sections.push(section); } @@ -129,7 +129,7 @@ mod tests { let module = create_test_module(); let result = module.detach_signature(); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::NoSignatures)); + assert!(matches!(result.unwrap_err(), CoreError::NoSignatures)); } #[test] @@ -189,7 +189,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - WSError::SignatureAlreadyAttached + CoreError::SignatureAlreadyAttached )); } diff --git a/src/lib/src/wasm_module/component.rs b/src/verify-core/src/wasm_module/component.rs similarity index 100% rename from src/lib/src/wasm_module/component.rs rename to src/verify-core/src/wasm_module/component.rs diff --git a/src/lib/src/wasm_module/mod.rs b/src/verify-core/src/wasm_module/mod.rs similarity index 96% rename from src/lib/src/wasm_module/mod.rs rename to src/verify-core/src/wasm_module/mod.rs index accf687..c5e2b8e 100644 --- a/src/lib/src/wasm_module/mod.rs +++ b/src/verify-core/src/wasm_module/mod.rs @@ -25,7 +25,7 @@ const WASM_COMPONENT_HEADER: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01 pub type Header = [u8; 8]; /// Maximum number of sections accepted by `SectionsIterator` before the parser -/// aborts with `WSError::TooManySections`. 4096 is generous for any legitimate +/// aborts with `CoreError::TooManySections`. 4096 is generous for any legitimate /// module (the wasm-tools spec recommends ~100 typical sections; the Component /// Model adds a handful more) while bounding worst-case work for adversarial /// inputs that declare millions of empty sections. @@ -169,7 +169,7 @@ impl CustomSection { /// Return the custom section as an array of bytes. /// /// This includes the data itself, but also the size and name of the custom section. - pub fn outer_payload(&self) -> Result, WSError> { + pub fn outer_payload(&self) -> Result, CoreError> { let mut writer = io::Cursor::new(vec![]); varint::put(&mut writer, self.name.len() as _)?; writer.write_all(self.name.as_bytes())?; @@ -278,7 +278,7 @@ impl SectionLike for Section { impl Section { /// Create a new section with the given identifier and payload. - pub fn new(id: SectionId, payload: Vec) -> Result { + pub fn new(id: SectionId, payload: Vec) -> Result { match id { SectionId::CustomSection => { let mut reader = io::Cursor::new(payload); @@ -286,7 +286,7 @@ impl Section { // SECURITY: Bound custom section name length to prevent OOM // on malformed WASM with excessive name_len values. if name_len > varint::MAX_SLICE_LEN { - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let mut name_slice = vec![0u8; name_len]; reader.read_exact(&mut name_slice)?; @@ -301,17 +301,17 @@ impl Section { } /// Create a section from its standard serialized representation. - pub fn deserialize(reader: &mut impl Read) -> Result, WSError> { + pub fn deserialize(reader: &mut impl Read) -> Result, CoreError> { let id = match varint::get7(reader) { Ok(id) => SectionId::from(id), - Err(WSError::Eof) => return Ok(None), + Err(CoreError::Eof) => return Ok(None), Err(e) => return Err(e), }; let len = varint::get32(reader)? as usize; // SECURITY: Bound section payload length to prevent OOM on malformed // WASM modules with excessive length prefixes (matches get_slice limit). if len > varint::MAX_SLICE_LEN { - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let mut payload = vec![0u8; len]; reader.read_exact(&mut payload)?; @@ -320,7 +320,7 @@ impl Section { } /// Serialize a section. - pub fn serialize(&self, writer: &mut impl Write) -> Result<(), WSError> { + pub fn serialize(&self, writer: &mut impl Write) -> Result<(), CoreError> { let outer_payload; let payload = match self { Section::Standard(s) => s.payload(), @@ -365,9 +365,9 @@ impl CustomSection { /// If the section contains the module's signature, deserializes it into a `SignatureData` object /// containing the signatures and the hashes. - pub fn signature_data(&self) -> Result { + pub fn signature_data(&self) -> Result { let header_payload = - SignatureData::deserialize(self.payload()).map_err(|_| WSError::ParseError)?; + SignatureData::deserialize(self.payload()).map_err(|_| CoreError::ParseError)?; Ok(header_payload) } } @@ -393,7 +393,7 @@ pub struct Module { impl Module { /// Deserialize a WebAssembly module from the given reader. - pub fn deserialize(reader: &mut impl Read) -> Result { + pub fn deserialize(reader: &mut impl Read) -> Result { let stream = Self::init_from_reader(reader)?; let header = stream.header; let it = Self::iterate(stream)?; @@ -405,10 +405,10 @@ impl Module { } /// Deserialize a WebAssembly module from the given file. - pub fn deserialize_from_file(file: impl AsRef) -> Result { + pub fn deserialize_from_file(file: impl AsRef) -> Result { let path = file.as_ref(); let fp = File::open(path).map_err(|e| { - WSError::InternalError(format!( + CoreError::InternalError(format!( "Failed to open input file '{}': {}", path.display(), e @@ -418,7 +418,7 @@ impl Module { } /// Serialize a WebAssembly module to the given writer. - pub fn serialize(&self, writer: &mut impl Write) -> Result<(), WSError> { + pub fn serialize(&self, writer: &mut impl Write) -> Result<(), CoreError> { writer.write_all(&self.header)?; for section in &self.sections { section.serialize(writer)?; @@ -427,12 +427,12 @@ impl Module { } /// Serialize a WebAssembly module to the given file. - pub fn serialize_to_file(&self, file: impl AsRef) -> Result<(), WSError> { + pub fn serialize_to_file(&self, file: impl AsRef) -> Result<(), CoreError> { let path = file.as_ref(); // Create parent directories if they don't exist if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| { - WSError::InternalError(format!( + CoreError::InternalError(format!( "Failed to create parent directory for '{}': {}", path.display(), e @@ -440,7 +440,7 @@ impl Module { })?; } let fp = File::create(path).map_err(|e| { - WSError::InternalError(format!( + CoreError::InternalError(format!( "Failed to create output file '{}': {}", path.display(), e @@ -450,11 +450,11 @@ impl Module { } /// Parse the module's header. This function must be called before `stream()`. - pub fn init_from_reader(reader: &mut T) -> Result, WSError> { + pub fn init_from_reader(reader: &mut T) -> Result, CoreError> { let mut header = Header::default(); reader.read_exact(&mut header)?; if header != WASM_HEADER && header != WASM_COMPONENT_HEADER { - return Err(WSError::UnsupportedModuleType); + return Err(CoreError::UnsupportedModuleType); } Ok(ModuleStreamReader { reader, header }) } @@ -466,7 +466,7 @@ impl Module { /// adversarial modules from causing unbounded work. pub fn iterate( module_stream: ModuleStreamReader, - ) -> Result, WSError> { + ) -> Result, CoreError> { Ok(SectionsIterator { reader: module_stream.reader, count: 0, @@ -482,7 +482,7 @@ pub struct ModuleStreamReader<'t, T: Read> { /// An iterator over the sections of a WebAssembly module. /// /// Yields at most [`MAX_SECTIONS`] sections; the next call after the cap is -/// reached returns `Some(Err(WSError::TooManySections(MAX_SECTIONS)))` and the +/// reached returns `Some(Err(CoreError::TooManySections(MAX_SECTIONS)))` and the /// iterator subsequently terminates. pub struct SectionsIterator<'t, T: Read> { reader: &'t mut T, @@ -490,13 +490,13 @@ pub struct SectionsIterator<'t, T: Read> { } impl<'t, T: Read> Iterator for SectionsIterator<'t, T> { - type Item = Result; + type Item = Result; fn next(&mut self) -> Option { if self.count >= MAX_SECTIONS { // Bound iteration so a malformed module declaring millions of // empty sections cannot loop the parser indefinitely. - return Some(Err(WSError::TooManySections(MAX_SECTIONS))); + return Some(Err(CoreError::TooManySections(MAX_SECTIONS))); } match Section::deserialize(self.reader) { Err(e) => Some(Err(e)), @@ -683,7 +683,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - WSError::UnsupportedModuleType + CoreError::UnsupportedModuleType )); } @@ -1010,7 +1010,7 @@ mod tests { for item in it { match item { Ok(_) => seen += 1, - Err(WSError::TooManySections(max)) => { + Err(CoreError::TooManySections(max)) => { assert_eq!(max, MAX_SECTIONS); hit_cap = true; break; diff --git a/src/lib/src/wasm_module/varint.rs b/src/verify-core/src/wasm_module/varint.rs similarity index 94% rename from src/lib/src/wasm_module/varint.rs rename to src/verify-core/src/wasm_module/varint.rs index 927b30c..f448a64 100644 --- a/src/lib/src/wasm_module/varint.rs +++ b/src/verify-core/src/wasm_module/varint.rs @@ -1,14 +1,14 @@ use std::io::{self, prelude::*}; -use crate::error::*; +use crate::error::CoreError; -pub fn get7(reader: &mut impl Read) -> Result { +pub fn get7(reader: &mut impl Read) -> Result { let mut v: u8 = 0; for i in 0..1 { let mut byte = [0u8; 1]; if let Err(e) = reader.read_exact(&mut byte) { return Err(if e.kind() == io::ErrorKind::UnexpectedEof { - WSError::Eof + CoreError::Eof } else { e.into() }); @@ -18,10 +18,10 @@ pub fn get7(reader: &mut impl Read) -> Result { return Ok(v); } } - Err(WSError::ParseError) + Err(CoreError::ParseError) } -pub fn get32(reader: &mut impl Read) -> Result { +pub fn get32(reader: &mut impl Read) -> Result { let mut v: u32 = 0; for i in 0..5 { let mut byte = [0u8; 1]; @@ -31,10 +31,10 @@ pub fn get32(reader: &mut impl Read) -> Result { return Ok(v); } } - Err(WSError::ParseError) + Err(CoreError::ParseError) } -pub fn put(writer: &mut impl Write, mut v: u64) -> Result<(), WSError> { +pub fn put(writer: &mut impl Write, mut v: u64) -> Result<(), CoreError> { let mut byte = [0u8; 1]; loop { byte[0] = (v & 0x7f) as u8; @@ -49,7 +49,7 @@ pub fn put(writer: &mut impl Write, mut v: u64) -> Result<(), WSError> { } } -pub fn put_slice(writer: &mut impl Write, bytes: impl AsRef<[u8]>) -> Result<(), WSError> { +pub fn put_slice(writer: &mut impl Write, bytes: impl AsRef<[u8]>) -> Result<(), CoreError> { let bytes = bytes.as_ref(); put(writer, bytes.len() as _)?; writer.write_all(bytes)?; @@ -62,11 +62,11 @@ pub fn put_slice(writer: &mut impl Write, bytes: impl AsRef<[u8]>) -> Result<(), /// that could cause excessive memory allocation. pub const MAX_SLICE_LEN: usize = 16 * 1024 * 1024; -pub fn get_slice(reader: &mut impl Read) -> Result, WSError> { +pub fn get_slice(reader: &mut impl Read) -> Result, CoreError> { let len = get32(reader)? as usize; // Prevent DoS via excessive memory allocation if len > MAX_SLICE_LEN { - return Err(WSError::ParseError); + return Err(CoreError::ParseError); } let mut bytes = vec![0u8; len]; reader.read_exact(&mut bytes)?; @@ -99,7 +99,7 @@ mod tests { let mut reader = io::Cursor::new(data); let result = get7(&mut reader); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::Eof)); + assert!(matches!(result.unwrap_err(), CoreError::Eof)); } #[test] @@ -229,7 +229,7 @@ mod tests { let result = get_slice(&mut reader); // Should return error, not OOM assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), WSError::ParseError)); + assert!(matches!(result.unwrap_err(), CoreError::ParseError)); } #[test] diff --git a/verification/witness-harness/.gitignore b/verification/witness-harness/.gitignore new file mode 100644 index 0000000..a7bd9d3 --- /dev/null +++ b/verification/witness-harness/.gitignore @@ -0,0 +1,3 @@ +/target/ +/out/ +/Cargo.lock diff --git a/verification/witness-harness/Cargo.toml b/verification/witness-harness/Cargo.toml new file mode 100644 index 0000000..6a24dd8 --- /dev/null +++ b/verification/witness-harness/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wsc-witness-harness" +version = "0.0.0" +edition = "2024" +publish = false +description = "Witness MC/DC harness for wsc-verify-core. Excluded from the main workspace; built only when targeting wasm32-unknown-unknown for instrumentation." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wsc-verify-core = { path = "../../src/verify-core" } + +[profile.release] +panic = "abort" +opt-level = "z" + +[profile.dev] +panic = "abort" +debug = 2 # full DWARF — witness uses it for line-correlated truth tables diff --git a/verification/witness-harness/README.md b/verification/witness-harness/README.md new file mode 100644 index 0000000..5953c2b --- /dev/null +++ b/verification/witness-harness/README.md @@ -0,0 +1,91 @@ +# witness MC/DC harness for `wsc-verify-core` + +Phase-1 proof-of-concept harness for running +[`pulseengine/witness`](https://github.com/pulseengine/witness) — an MC/DC +branch-coverage tool for WebAssembly — against sigil's verification core. + +This crate is **excluded from the main workspace** (`workspace.exclude` in +the root `Cargo.toml`) because it builds for a wasm target and would +otherwise clutter native workspace builds. + +## What this validates + +That the `wsc-verify-core` carve achieved its goal: the verification core +now builds for a witness-instrumentable wasm target with plain cargo, with +no `ring` / TLS / X.509 detour. The next step (#128 Phase 2) is the same +mechanic with crafted signed-module fixtures over `verify_multi` so the +truth tables show MC/DC of the full verification decision logic, not just +the varint decoder. + +## Running + +```sh +# 1. Install witness (or use a checkout-local copy) +gh release download v0.22.0 --repo pulseengine/witness \ + --pattern '*aarch64-apple-darwin.tar.gz' --output - | tar -xz +export PATH="$PWD:$PATH" +xattr -d com.apple.quarantine witness witness-viz 2>/dev/null || true + +# 2. Make sure wasm32-wasip1 is installed for sigil's pinned toolchain +rustup target add wasm32-wasip1 + +# 3. Build the harness (dev profile — opt-level=z dead-strips the branches +# witness needs to see) +cd verification/witness-harness +cargo build --target wasm32-wasip1 +WASM=target/wasm32-wasip1/debug/wsc_witness_harness.wasm + +# 4. Instrument + run + report +mkdir -p out +witness instrument "$WASM" -o out/instrumented.wasm +witness run out/instrumented.wasm \ + --invoke-with-args 'decode_varint_5:1,0,0,0,0' \ + --invoke-with-args 'decode_varint_5:128,1,0,0,0' \ + --invoke-with-args 'decode_varint_5:128,128,1,0,0' \ + --invoke-with-args 'decode_varint_5:128,128,128,1,0' \ + --invoke-with-args 'decode_varint_5:128,128,128,128,1' \ + --invoke-with-args 'decode_varint_5:128,128,128,128,128' \ + -o out/run.json +witness report --input out/run.json --format mcdc +``` + +## Observed output (Phase 1) + +``` +decisions: 0/162 full MC/DC; conditions: 1 proved, 13 gap, 582 dead + +decision #1 varint.rs:28: NoWitness + truth table: + row 1: {c0=F, c1=T, c2=T} -> T + row 10: {c0=F, c1=T, c2=T} -> T + ... +``` + +`varint.rs:28` is sigil's actual LEB128 decoder — that decision is being +instrumented through the carved `wsc-verify-core`. The high "dead" count +(582) is mostly `std::result`/formatting machinery reachable from the +harness; what matters for Phase 1 is that witness can *see* sigil-core +decisions at all. Phase 2 will design scenarios that drive the +`decode_varint_5` and (later) `verify_multi` decisions to **full MC/DC** +rather than just demonstrating reachability. + +## Notes on target choice + +- **`wasm32-wasip1`** (used here): produces a core wasm module that + `witness instrument` can read directly; getrandom uses WASI for + randomness (no JS host needed). +- `wasm32-wasip2`: builds, but produces a *component* — witness emits + `wasm-tools component unbundle` instructions for that case. Use wasip1 + for the simpler path. +- `wasm32-unknown-unknown`: also builds, but `wsc-verify-core` enables + `getrandom`'s `wasm_js` feature (needed for `KeyPair::generate` to work + on that target in browsers) which pulls wasm-bindgen placeholders that + witness's standalone wasmtime cannot satisfy — would need a custom + getrandom backend. + +## Profile choice + +The Cargo.toml here sets `[profile.dev] debug = 2, panic = "abort"`. +Release-profile builds with `opt-level = "z"` dead-strip the very branches +witness needs to instrument (an 860-byte release wasm vs ~3.5 MB dev wasm +on the same code). Use the dev profile. diff --git a/verification/witness-harness/src/lib.rs b/verification/witness-harness/src/lib.rs new file mode 100644 index 0000000..036e0f4 --- /dev/null +++ b/verification/witness-harness/src/lib.rs @@ -0,0 +1,34 @@ +//! Witness MC/DC harness for `wsc-verify-core` — Phase 1. +//! +//! This crate compiles to a `wasm32-unknown-unknown` cdylib that the +//! [`pulseengine/witness`](https://github.com/pulseengine/witness) tool +//! instruments to reconstruct **Modified Condition / Decision Coverage** +//! truth tables for the verification core. +//! +//! # Phase 1 — toolchain end-to-end +//! +//! The scenario here drives `wasm_module::varint::get32` — sigil's LEB128 +//! decoder, the only sigil-core hot loop whose conditions a witness truth +//! table can render cleanly. Five symbolic input bytes flip the +//! continuation bit at each varint position; witness reports MC/DC over the +//! decoder's loop. +//! +//! Phase 2 (#128) adds scenarios over `PublicKey::verify_multi` with +//! crafted signed-module fixtures. + +#![allow(clippy::missing_safety_doc)] + +use wsc_verify_core::wasm_module::varint; + +/// Decode the LEB128 varint encoded in `[b0, b1, b2, b3, b4]`. +/// +/// Returns the decoded `u32` on success, or `0xFFFFFFFF` on a decode error. +/// Witness instruments the branches inside `varint::get32`; each call to +/// this export flips the continuation bit at exactly one position, giving +/// the truth-table rows MC/DC needs to prove each condition independently. +#[unsafe(no_mangle)] +pub extern "C" fn decode_varint_5(b0: u8, b1: u8, b2: u8, b3: u8, b4: u8) -> u32 { + let bytes = [b0, b1, b2, b3, b4]; + let mut slice = &bytes[..]; + varint::get32(&mut slice).unwrap_or(0xFFFFFFFF) +}