diff --git a/.github/workflows/OpenPR.yml b/.github/workflows/OpenPR.yml index 2cd179f6..e78fe5b6 100644 --- a/.github/workflows/OpenPR.yml +++ b/.github/workflows/OpenPR.yml @@ -12,4 +12,12 @@ jobs: assign-author: runs-on: ubuntu-latest steps: - - uses: toshimaru/auto-author-assign@v1.6.2 + - name: Assign PR author + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + gh pr edit "$PR_NUMBER" --add-assignee "$PR_AUTHOR" \ + --repo "${{ github.repository }}" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 68d8900b..3e9218c0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,24 +28,38 @@ jobs: - name: Check changed paths id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - native: - - 'native/**' - native_c_cpp: - - 'native/**/*.c' - - 'native/**/*.cpp' - - 'native/**/*.h' - - 'native/**/*.hpp' - dotnet: - - '**/*.cs' - - '**/*.csproj' - - '**/*.sln' - - '*.props' - - '*.targets' - - 'Directory.Build.props' - - 'Directory.Packages.props' + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + + CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null || git diff --name-only HEAD~1 HEAD) + + if echo "$CHANGED" | grep -q '^native/'; then + echo "native=true" >> "$GITHUB_OUTPUT" + else + echo "native=false" >> "$GITHUB_OUTPUT" + fi + + if echo "$CHANGED" | grep -qE '\.(c|cpp|h|hpp)$' | grep -q '^native/'; then + echo "native_c_cpp=true" >> "$GITHUB_OUTPUT" + else + # Check more carefully for C/C++ files under native/ + if echo "$CHANGED" | grep -q '^native/.*\.\(c\|cpp\|h\|hpp\)$'; then + echo "native_c_cpp=true" >> "$GITHUB_OUTPUT" + else + echo "native_c_cpp=false" >> "$GITHUB_OUTPUT" + fi + fi + + if echo "$CHANGED" | grep -qE '\.(cs|csproj|sln)$|\.props$|\.targets$|Directory\.Build|Directory\.Packages'; then + echo "dotnet=true" >> "$GITHUB_OUTPUT" + else + echo "dotnet=false" >> "$GITHUB_OUTPUT" + fi analyze-csharp: name: codeql-csharp diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c7373dd8..3f5b4674 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,6 +19,24 @@ on: release: types: [ created ] # Trigger on new releases. workflow_dispatch: # Allow manual triggering from the Actions tab. + inputs: + release_scope: + description: 'Component to release (GA)' + required: true + type: choice + options: + - 'dotnet' + - 'native' + - 'both' + default: 'dotnet' + release_type: + description: 'Version bump after GA release' + required: true + type: choice + options: + - 'minor' + - 'patch' + default: 'minor' jobs: @@ -38,21 +56,28 @@ jobs: - name: Check changed paths id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - native: - - 'native/**' - dotnet: - - '**/*.cs' - - '**/*.csproj' - - '**/*.sln' - - '*.props' - - '*.targets' - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'Nuget.config' - - 'global.json' + shell: bash + run: | + # Determine the base ref to diff against + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + + CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null || git diff --name-only HEAD~1 HEAD) + + if echo "$CHANGED" | grep -q '^native/'; then + echo "native=true" >> "$GITHUB_OUTPUT" + else + echo "native=false" >> "$GITHUB_OUTPUT" + fi + + if echo "$CHANGED" | grep -qE '\.(cs|csproj|sln)$|\.props$|\.targets$|Directory\.Build|Directory\.Packages|Nuget\.config|global\.json'; then + echo "dotnet=true" >> "$GITHUB_OUTPUT" + else + echo "dotnet=false" >> "$GITHUB_OUTPUT" + fi #### PULL REQUEST EVENTS #### @@ -144,14 +169,21 @@ jobs: } - name: Setup Rust (stable) - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt + shell: pwsh + run: | + rustup toolchain install stable --profile minimal --component clippy,rustfmt + rustup default stable - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@v2 + uses: actions/cache@v4 with: - workspaces: native/rust -> target + path: | + ~/.cargo/registry + ~/.cargo/git + native/rust/target + key: rust-${{ runner.os }}-${{ hashFiles('native/rust/Cargo.lock') }} + restore-keys: | + rust-${{ runner.os }}- - name: Rust format check shell: pwsh @@ -180,9 +212,9 @@ jobs: cargo clippy --workspace -- -D warnings - name: Setup Rust (nightly, for coverage) - uses: dtolnay/rust-toolchain@nightly - with: - components: llvm-tools-preview + shell: pwsh + run: | + rustup toolchain install nightly --profile minimal --component llvm-tools-preview - name: Cache cargo-llvm-cov uses: actions/cache@v4 @@ -249,17 +281,26 @@ jobs: } - name: Setup Rust (stable) - uses: dtolnay/rust-toolchain@stable + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable - name: Setup Rust (nightly, for coverage) - uses: dtolnay/rust-toolchain@nightly - with: - components: llvm-tools-preview + shell: pwsh + run: | + rustup toolchain install nightly --profile minimal --component llvm-tools-preview - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@v2 + uses: actions/cache@v4 with: - workspaces: native/rust -> target + path: | + ~/.cargo/registry + ~/.cargo/git + native/rust/target + key: rust-cpp-${{ runner.os }}-${{ hashFiles('native/rust/Cargo.lock') }} + restore-keys: | + rust-cpp-${{ runner.os }}- - name: Install OpenCppCoverage shell: pwsh @@ -299,10 +340,54 @@ jobs: - name: Generate changelog if: ${{ github.event_name == 'push' }} - uses: tj-actions/github-changelog-generator@v1.19 - with: - output: CHANGELOG.md - token: ${{ secrets.GITHUB_TOKEN }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Cumulative changelog using GitHub's generate-notes API. + # Produces a full history: delta for current release + bodies from all previous releases. + echo "# Changelog" > CHANGELOG.md + echo "" >> CHANGELOG.md + + PREVIOUS_TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") + echo "Previous tag: $PREVIOUS_TAG" + + if [ -n "$PREVIOUS_TAG" ]; then + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="pending" \ + -f target_commitish="${{ github.sha }}" \ + -f previous_tag_name="$PREVIOUS_TAG" \ + --jq '.body' 2>/dev/null || echo "") + else + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="pending" \ + -f target_commitish="${{ github.sha }}" \ + --jq '.body' 2>/dev/null || echo "") + fi + + if [ -n "$NOTES" ]; then + echo "$NOTES" >> CHANGELOG.md + echo "" >> CHANGELOG.md + fi + + # Append previous release bodies for cumulative history + echo "---" >> CHANGELOG.md + echo "" >> CHANGELOG.md + echo "## Previous Releases" >> CHANGELOG.md + echo "" >> CHANGELOG.md + + gh release list --limit 50 --json tagName --jq '.[].tagName' 2>/dev/null | while read -r TAG; do + BODY=$(gh release view "$TAG" --json body --jq '.body' 2>/dev/null || echo "") + if [ -n "$BODY" ]; then + echo "### $TAG" >> CHANGELOG.md + echo "" >> CHANGELOG.md + echo "$BODY" >> CHANGELOG.md + echo "" >> CHANGELOG.md + fi + done + + echo "Generated cumulative CHANGELOG.md:" + wc -l CHANGELOG.md - name: Commit changelog if: ${{ github.event_name == 'push' }} @@ -686,21 +771,479 @@ jobs: # Upload the zipped assets to the release. - name: Upload binary archives - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ./published/CoseSignTool-*.zip - file_glob: true - overwrite: true - tag: ${{ needs.create_release.outputs.tag_name }} + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.create_release.outputs.tag_name }}" + for zip in ./published/CoseSignTool-*.zip; do + echo "Uploading $zip to release $TAG..." + gh release upload "$TAG" "$zip" --clobber + done # Commented out until we decide to support publishing of nuget packages. - # Upload the NuGet packages to the release (commented out for now) + # Upload the NuGet packages to the release # - name: Upload NuGet packages - # uses: svenstaro/upload-release-action@v2 - # with: - # repo_token: ${{ secrets.GITHUB_TOKEN }} - # file: ./published/packages/*.nupkg - # file_glob: true - # overwrite: true - # tag: ${{ needs.create_release.outputs.tag_name }} + # shell: bash + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # run: | + # TAG="${{ needs.create_release.outputs.tag_name }}" + # for pkg in ./published/packages/*.nupkg; do + # gh release upload "$TAG" "$pkg" --clobber + # done + + ############################################################################## + # NATIVE RELEASE — Auto pre-release on push to main (when native/ changed) + # + # Version source: native/rust/Cargo.toml [workspace.package] version field. + # Tag format: native-v{version}-pre{N} (pre-release) + # native-v{version} (GA, via workflow_dispatch) + # + # These jobs are independent of the .NET release pipeline. Both can release + # from the same push if both native/ and dotnet files changed. + ############################################################################## + + native_pre_release: + name: Native Pre-release + if: ${{ github.event_name == 'push' && needs.detect-changes.outputs.native == 'true' }} + needs: [ detect-changes, create_changelog ] + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + tag_name: ${{ steps.create-tag.outputs.tag }} + version: ${{ steps.create-tag.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version and create tag + id: create-tag + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Read version from workspace Cargo.toml (single source of truth for native) + VERSION=$(grep -oP '(?<=^version = ")[^"]+' native/rust/Cargo.toml) + echo "Workspace version: $VERSION" + + # Find latest native pre-release tag to determine next pre-release number + LATEST=$(gh release list --limit 100 --json tagName \ + --jq '[.[].tagName | select(startswith("native-v"))] | sort_by(.) | last // empty' 2>/dev/null || echo "") + echo "Latest native tag: $LATEST" + + if [ -z "$LATEST" ]; then + NEW_TAG="native-v${VERSION}-pre1" + elif [[ "$LATEST" =~ ^native-v(.+)-pre([0-9]+)$ ]]; then + PRE=$((${BASH_REMATCH[2]} + 1)) + NEW_TAG="native-v${VERSION}-pre${PRE}" + else + # Latest was a GA release — start new pre-release series + NEW_TAG="native-v${VERSION}-pre1" + fi + + echo "New tag: $NEW_TAG" + echo "tag=$NEW_TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Generate native changelog and create pre-release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.create-tag.outputs.tag }}" + + # Find previous native release for changelog delta + PREV_TAG=$(gh release list --limit 100 --json tagName \ + --jq '[.[].tagName | select(startswith("native-v"))] | sort_by(.) | last // empty' 2>/dev/null || echo "") + + # Generate delta notes via GitHub's release notes API + if [ -n "$PREV_TAG" ]; then + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="$TAG" \ + -f target_commitish="${{ github.sha }}" \ + -f previous_tag_name="$PREV_TAG" \ + --jq '.body' 2>/dev/null || echo "") + else + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="$TAG" \ + -f target_commitish="${{ github.sha }}" \ + --jq '.body' 2>/dev/null || echo "") + fi + + # Build cumulative changelog (current delta + all previous native releases) + echo "# Native Changelog" > NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + if [ -n "$NOTES" ]; then + echo "$NOTES" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + fi + echo "---" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + echo "## Previous Native Releases" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + gh release list --limit 50 --json tagName --jq '.[].tagName' 2>/dev/null | \ + grep '^native-v' | while read -r T; do + BODY=$(gh release view "$T" --json body --jq '.body' 2>/dev/null || echo "") + if [ -n "$BODY" ]; then + echo "### $T" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + echo "$BODY" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + fi + done + + echo "Generated cumulative native changelog:" + wc -l NATIVE_CHANGELOG.md + + # Create the pre-release + gh release create "$TAG" \ + --title "Native Release $TAG" \ + --notes-file NATIVE_CHANGELOG.md \ + --prerelease + + # ── Native Pre-release Assets ────────────────────────────────────────── + # Builds Rust workspace in release mode and archives static/dynamic libraries + # plus C and C++ headers for each target platform. + native_pre_release_assets: + name: native-release-${{ matrix.target }} + needs: [ detect-changes, native_pre_release ] + if: ${{ github.event_name == 'push' && needs.detect-changes.outputs.native == 'true' }} + runs-on: windows-latest + permissions: + contents: write + env: + VCPKG_ROOT: C:\vcpkg + OPENSSL_DIR: C:\vcpkg\installed\x64-windows + strategy: + matrix: + include: + - target: win-x64 + triple: x86_64-pc-windows-msvc + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache vcpkg packages + uses: actions/cache@v4 + with: + path: C:\vcpkg\installed + key: vcpkg-openssl-x64-windows-v1 + + - name: Install OpenSSL via vcpkg + shell: pwsh + run: | + if (Test-Path "$env:VCPKG_ROOT\installed\x64-windows\lib\libssl.lib") { + Write-Host "OpenSSL already cached" -ForegroundColor Green + } else { + & "$env:VCPKG_ROOT\vcpkg" install openssl:x64-windows + } + + - name: Setup Rust (stable) + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/rust/target + key: rust-release-${{ runner.os }}-${{ hashFiles('native/rust/Cargo.lock') }} + restore-keys: | + rust-release-${{ runner.os }}- + + - name: Build Rust workspace (release) + shell: pwsh + working-directory: native/rust + run: | + $env:PATH = "$env:VCPKG_ROOT\installed\x64-windows\bin;$env:PATH" + cargo build --workspace --exclude cose-openssl --release + + - name: Archive native libraries and headers + shell: pwsh + run: | + $tag = "${{ needs.native_pre_release.outputs.tag_name }}" + $archiveDir = "native-release" + + # Create directory structure + New-Item -ItemType Directory -Force -Path "$archiveDir/lib" + New-Item -ItemType Directory -Force -Path "$archiveDir/include/c" + New-Item -ItemType Directory -Force -Path "$archiveDir/include/cpp" + + # Copy static libraries (.lib) + Get-ChildItem -Path "native/rust/target/release" -Filter "*.lib" -File | + Where-Object { $_.Name -notmatch 'build_script|deps' } | + Copy-Item -Destination "$archiveDir/lib/" + + # Copy dynamic libraries (.dll) + Get-ChildItem -Path "native/rust/target/release" -Filter "*.dll" -File | + Copy-Item -Destination "$archiveDir/lib/" + + # Copy C headers + if (Test-Path "native/c/include") { + Copy-Item -Path "native/c/include/*" -Destination "$archiveDir/include/c/" -Recurse + } + + # Copy C++ headers + if (Test-Path "native/c_pp/include") { + Copy-Item -Path "native/c_pp/include/*" -Destination "$archiveDir/include/cpp/" -Recurse + } + + # Copy license and docs + Copy-Item -Path "LICENSE" -Destination "$archiveDir/" + if (Test-Path "native/README.md") { + Copy-Item -Path "native/README.md" -Destination "$archiveDir/" + } + + # Create version file + "$tag" | Out-File -FilePath "$archiveDir/VERSION" -Encoding utf8 -NoNewline + + # Create archive + Compress-Archive -Path "$archiveDir/*" -DestinationPath "CoseSignTool-Native-${{ matrix.target }}.zip" + + Write-Host "Archive contents:" + Get-ChildItem -Path "$archiveDir" -Recurse | ForEach-Object { + Write-Host " $($_.FullName.Replace("$archiveDir\", ''))" + } + + - name: Upload native archive + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.native_pre_release.outputs.tag_name }}" + gh release upload "$TAG" "CoseSignTool-Native-${{ matrix.target }}.zip" --clobber + + ############################################################################## + # MANUAL GA RELEASE — "Mint Release" via workflow_dispatch + # + # Supports releasing Native, .NET, or both independently. + # Requires the "release_scope" input to specify what to release. + # + # Native GA release: + # 1. Reads version from native/rust/Cargo.toml + # 2. Creates GA tag: native-v{version} + # 3. Builds release-mode artifacts + # 4. Bumps version to next minor/patch, commits + # + # .NET GA release: + # Uses existing create_release + release_assets jobs. + ############################################################################## + + native_mint_release: + name: Native GA Release + if: >- + github.event_name == 'workflow_dispatch' && + (github.event.inputs.release_scope == 'native' || github.event.inputs.release_scope == 'both') + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + tag_name: ${{ steps.ga.outputs.tag }} + version: ${{ steps.ga.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine GA version + id: ga + shell: bash + run: | + VERSION=$(grep -oP '(?<=^version = ")[^"]+' native/rust/Cargo.toml) + TAG="native-v${VERSION}" + echo "GA version: $VERSION (tag: $TAG)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Generate GA changelog and create release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.ga.outputs.tag }}" + + # Find previous native release for delta + PREV_TAG=$(gh release list --limit 100 --json tagName \ + --jq '[.[].tagName | select(startswith("native-v"))] | sort_by(.) | last // empty' 2>/dev/null || echo "") + + # Generate cumulative changelog + echo "# Native Release ${{ steps.ga.outputs.version }}" > NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + + if [ -n "$PREV_TAG" ]; then + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="$TAG" \ + -f target_commitish="${{ github.sha }}" \ + -f previous_tag_name="$PREV_TAG" \ + --jq '.body' 2>/dev/null || echo "") + else + NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="$TAG" \ + -f target_commitish="${{ github.sha }}" \ + --jq '.body' 2>/dev/null || echo "") + fi + + if [ -n "$NOTES" ]; then + echo "$NOTES" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + fi + + echo "---" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + echo "## Previous Native Releases" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + gh release list --limit 50 --json tagName --jq '.[].tagName' 2>/dev/null | \ + grep '^native-v' | while read -r T; do + BODY=$(gh release view "$T" --json body --jq '.body' 2>/dev/null || echo "") + if [ -n "$BODY" ]; then + echo "### $T" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + echo "$BODY" >> NATIVE_CHANGELOG.md + echo "" >> NATIVE_CHANGELOG.md + fi + done + + # Create GA release (not a pre-release) + gh release create "$TAG" \ + --title "Native Release ${{ steps.ga.outputs.version }}" \ + --notes-file NATIVE_CHANGELOG.md + + - name: Bump to next development version + shell: bash + run: | + VERSION=$(grep -oP '(?<=^version = ")[^"]+' native/rust/Cargo.toml) + IFS='.' read -r major minor patch <<< "$VERSION" + + if [ "${{ github.event.inputs.release_type }}" == "patch" ]; then + NEXT="$major.$minor.$((patch + 1))" + else + NEXT="$major.$((minor + 1)).0" + fi + + sed -i "s/^version = \"$VERSION\"/version = \"$NEXT\"/" native/rust/Cargo.toml + + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add native/rust/Cargo.toml + git commit -m "Bump native version to $NEXT [skip ci]" + git push + + # ── Native GA Release Assets ──────────────────────────────────────────── + native_mint_release_assets: + name: native-ga-${{ matrix.target }} + needs: [ native_mint_release ] + if: >- + github.event_name == 'workflow_dispatch' && + (github.event.inputs.release_scope == 'native' || github.event.inputs.release_scope == 'both') + runs-on: windows-latest + permissions: + contents: write + env: + VCPKG_ROOT: C:\vcpkg + OPENSSL_DIR: C:\vcpkg\installed\x64-windows + strategy: + matrix: + include: + - target: win-x64 + triple: x86_64-pc-windows-msvc + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.native_mint_release.outputs.tag_name }} + + - name: Cache vcpkg packages + uses: actions/cache@v4 + with: + path: C:\vcpkg\installed + key: vcpkg-openssl-x64-windows-v1 + + - name: Install OpenSSL via vcpkg + shell: pwsh + run: | + if (Test-Path "$env:VCPKG_ROOT\installed\x64-windows\lib\libssl.lib") { + Write-Host "OpenSSL already cached" -ForegroundColor Green + } else { + & "$env:VCPKG_ROOT\vcpkg" install openssl:x64-windows + } + + - name: Setup Rust (stable) + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/rust/target + key: rust-release-${{ runner.os }}-${{ hashFiles('native/rust/Cargo.lock') }} + restore-keys: | + rust-release-${{ runner.os }}- + + - name: Build Rust workspace (release) + shell: pwsh + working-directory: native/rust + run: | + $env:PATH = "$env:VCPKG_ROOT\installed\x64-windows\bin;$env:PATH" + cargo build --workspace --exclude cose-openssl --release + + - name: Archive native libraries and headers + shell: pwsh + run: | + $tag = "${{ needs.native_mint_release.outputs.tag_name }}" + $archiveDir = "native-release" + + New-Item -ItemType Directory -Force -Path "$archiveDir/lib" + New-Item -ItemType Directory -Force -Path "$archiveDir/include/c" + New-Item -ItemType Directory -Force -Path "$archiveDir/include/cpp" + + # Copy static libraries (.lib) + Get-ChildItem -Path "native/rust/target/release" -Filter "*.lib" -File | + Where-Object { $_.Name -notmatch 'build_script|deps' } | + Copy-Item -Destination "$archiveDir/lib/" + + # Copy dynamic libraries (.dll) + Get-ChildItem -Path "native/rust/target/release" -Filter "*.dll" -File | + Copy-Item -Destination "$archiveDir/lib/" + + # Copy C headers + if (Test-Path "native/c/include") { + Copy-Item -Path "native/c/include/*" -Destination "$archiveDir/include/c/" -Recurse + } + + # Copy C++ headers + if (Test-Path "native/c_pp/include") { + Copy-Item -Path "native/c_pp/include/*" -Destination "$archiveDir/include/cpp/" -Recurse + } + + # Copy license and docs + Copy-Item -Path "LICENSE" -Destination "$archiveDir/" + if (Test-Path "native/README.md") { + Copy-Item -Path "native/README.md" -Destination "$archiveDir/" + } + + "$tag" | Out-File -FilePath "$archiveDir/VERSION" -Encoding utf8 -NoNewline + + Compress-Archive -Path "$archiveDir/*" -DestinationPath "CoseSignTool-Native-${{ matrix.target }}.zip" + + - name: Upload native archive + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ needs.native_mint_release.outputs.tag_name }}" + gh release upload "$TAG" "CoseSignTool-Native-${{ matrix.target }}.zip" --clobber diff --git a/native/CONTRIBUTING.md b/native/CONTRIBUTING.md new file mode 100644 index 00000000..53cf47f9 --- /dev/null +++ b/native/CONTRIBUTING.md @@ -0,0 +1,370 @@ + + +# Contributing to Native CoseSignTool + +Thank you for your interest in improving the native COSE Sign1 SDK. This guide +covers everything you need to build, test, and submit changes to the +`native/` directory. + +## Table of Contents + +- [Development Setup](#development-setup) +- [Building](#building) +- [Testing](#testing) +- [Coverage Requirements](#coverage-requirements) +- [Code Style](#code-style) +- [Architecture Rules](#architecture-rules) +- [Naming Conventions](#naming-conventions) +- [PR Review Checklist](#pr-review-checklist) +- [Adding a New Extension Pack](#adding-a-new-extension-pack) +- [Adding a New FFI Export](#adding-a-new-ffi-export) + +--- + +## Development Setup + +### Required Tools + +| Tool | Version | Purpose | +|------|---------|---------| +| **Rust** | stable (edition 2021) | Core implementation | +| **OpenSSL** | 3.0+ | Crypto backend (via `OPENSSL_DIR` or vcpkg) | +| **CMake** | 3.20+ | C/C++ projection builds | +| **C compiler** | C11 (MSVC / GCC / Clang) | C projection tests | +| **C++ compiler** | C++17 (MSVC 2017+ / GCC 7+ / Clang 5+) | C++ projection tests | +| **vcpkg** | Latest | Recommended C/C++ consumption path | + +### Optional Tools + +| Tool | Purpose | +|------|---------| +| OpenCppCoverage | C/C++ line coverage on Windows | +| cargo-llvm-cov | Rust line coverage (`cargo +nightly llvm-cov`) | +| GTest | C/C++ test framework (fetched automatically by CMake) | + +### OpenSSL via vcpkg (recommended on Windows) + +```powershell +vcpkg install openssl:x64-windows +$env:OPENSSL_DIR = "C:\vcpkg\installed\x64-windows" +$env:PATH = "$env:OPENSSL_DIR\bin;$env:PATH" +``` + +### First Build + +```powershell +cd native/rust +cargo build --workspace # debug build — verifies toolchain + OpenSSL +cargo test --workspace # run all Rust tests +``` + +--- + +## Building + +### Rust + +```powershell +cd native/rust +cargo build --release --workspace # release build (produces FFI .lib / .dll) +cargo check --workspace # type-check only (faster iteration) +``` + +### C Projection + +```powershell +cd native/rust && cargo build --release --workspace # build FFI libs first +cd native/c +cmake -B build -DBUILD_TESTING=ON +cmake --build build --config Release +``` + +### C++ Projection + +```powershell +cd native/rust && cargo build --release --workspace # build FFI libs first +cd native/c_pp +cmake -B build -DBUILD_TESTING=ON +cmake --build build --config Release +``` + +### Via vcpkg (builds everything) + +```powershell +vcpkg install cosesign1-validation-native[cpp,certificates,mst,signing] ` + --overlay-ports=native/vcpkg_ports +``` + +--- + +## Testing + +### Rust + +```powershell +cd native/rust +cargo test --workspace # all tests +cargo test -p cose_sign1_validation # single crate +cargo test -p cose_sign1_certificates -- --nocapture # with stdout +``` + +### C / C++ + +```powershell +# After building (see above) +ctest --test-dir native/c/build -C Release +ctest --test-dir native/c_pp/build -C Release +``` + +### Full Pipeline (build + test + coverage + ASAN) + +```powershell +./native/collect-coverage-asan.ps1 -Configuration Debug -MinimumLineCoveragePercent 90 +``` + +This single script: +1. Builds Rust FFI crates +2. Runs C projection tests with coverage + ASAN +3. Runs C++ projection tests with coverage + ASAN +4. Fails if coverage < 90% + +### Test Conventions + +- **Arrange-Act-Assert** pattern in all tests. +- **Parallel-safe**: no shared mutable state, unique temp file names. +- **Both paths**: every feature needs positive *and* negative test cases. +- **FFI null safety**: every pointer parameter in every FFI function needs a + null-pointer test. +- **Roundtrip tests**: sign → parse → validate for end-to-end confidence. + +--- + +## Coverage Requirements + +| Component | Minimum | Tool | Command | +|-----------|---------|------|---------| +| Rust library crates | ≥ 90% line | `cargo llvm-cov` | `cd native/rust && ./collect-coverage.ps1` | +| C projection | ≥ 90% line | OpenCppCoverage | `cd native/c && ./collect-coverage.ps1` | +| C++ projection | ≥ 90% line | OpenCppCoverage | `cd native/c_pp && ./collect-coverage.ps1` | + +### What May Be Excluded from Coverage + +Only FFI boundary stubs may use `#[cfg_attr(coverage_nightly, coverage(off))]`: + +| Allowed | Example | +|---------|---------| +| ✅ FFI panic handlers | `handle_panic()` | +| ✅ ABI version functions | `cose_*_abi_version()` | +| ✅ Free functions | `cose_*_free()` | +| ✅ Error accessors | `cose_last_error_*()` | + +### What Must NEVER Be Excluded + +- Business logic +- Validation / parsing +- Error handling branches +- Crypto operations +- Builder methods + +Every `coverage(off)` annotation must include a comment justifying why the code +is unreachable. + +--- + +## Code Style + +### Rust + +| Rule | Example | +|------|---------| +| Copyright header on every `.rs` file | `// Copyright (c) Microsoft Corporation.` / `// Licensed under the MIT License.` | +| Manual `Display` + `Error` impls | No `thiserror` in production crates | +| `// SAFETY:` comment on every `unsafe` block | Explains why the operation is sound | +| No `.unwrap()` / `.expect()` in production code | Tests are fine | +| Prefer `.into()` over `.to_string()` for literals | `"message".into()` not `"message".to_string()` | + +Full formatting and lint rules are in +[`.editorconfig`](../.editorconfig) and the Cargo workspace `[lints]` table. + +### C + +| Rule | Example | +|------|---------| +| `extern "C"` guards in every header | `#ifdef __cplusplus` / `extern "C" {` / `#endif` | +| Include guards (`#ifndef`) | `#ifndef COSE_SIGN1_VALIDATION_H` | +| `*const` for borrowed pointers | `const cose_sign1_message_t* msg` | +| `*mut` / non-const for ownership transfer | `cose_sign1_message_t** out_msg` | + +### C++ + +| Rule | Example | +|------|---------| +| Move-only classes | Delete copy ctor + copy assignment | +| Null-check in destructor | `if (handle_) cose_*_free(handle_);` | +| `@see` on copy methods | Point to zero-copy alternative | +| Namespace: `cose::sign1::` | Shared types in `cose::` | + +--- + +## Architecture Rules + +### Dependencies Flow DOWN Only + +``` +Primitives ← Domain Crates ← Extension Packs ← FFI Crates ← C/C++ Headers +``` + +- **Never** depend upward (e.g., primitives must not depend on validation). +- **Never** depend sideways between extension packs (e.g., certificates must + not depend on MST). + +### Single Responsibility + +| Layer | Allowed | Not Allowed | +|-------|---------|-------------| +| Primitives | Types, traits, constants | Policy, I/O, network | +| Domain crates | Business logic for one area | Cross-area dependencies | +| Extension packs | Service integration via traits | Direct domain-crate coupling | +| FFI crates | ABI translation only | Business logic | +| C/C++ headers | Inline RAII wrappers | Compiled code | + +### External Dependency Rules + +1. Every external crate must be listed in `allowed-dependencies.toml`. +2. Prefer `std` over third-party (see [DEPENDENCY-PHILOSOPHY.md](docs/DEPENDENCY-PHILOSOPHY.md)). +3. No proc-macro crates in the core dependency path. +4. Azure SDK dependencies only in extension packs, gated behind Cargo features. + +--- + +## Naming Conventions + +### Rust Crate Names + +| Pattern | Example | Purpose | +|---------|---------|---------| +| `*_primitives` | `cbor_primitives` | Zero-policy trait crates | +| `cose_sign1_*` | `cose_sign1_signing` | Domain / extension crates | +| `*_ffi` | `cose_sign1_signing_ffi` | FFI projection of parent crate | +| `*_local` | `cose_sign1_certificates_local` | Local/test utility crates | +| `*_test_utils` | `cose_sign1_validation_test_utils` | Shared test infrastructure | + +### FFI Function Prefixes + +| Prefix | Scope | Example | +|--------|-------|---------| +| `cose_` | Shared COSE types | `cose_headermap_new`, `cose_crypto_signer_free` | +| `cose_sign1_` | Sign1 operations | `cose_sign1_message_parse`, `cose_sign1_builder_sign` | +| `cose_sign1_certificates_` | Certificates pack | `cose_sign1_certificates_trust_policy_builder_*` | +| `cose_sign1_mst_` | MST pack | `cose_sign1_mst_options_new` | +| `cose_sign1_akv_` | AKV pack | `cose_sign1_akv_options_new` | +| `did_x509_` | DID:x509 | `did_x509_parse` | + +### C++ Class Names + +Classes mirror Rust types in `PascalCase` within namespaces: + +| Rust | C++ | +|------|-----| +| `CoseSign1Message` | `cose::sign1::CoseSign1Message` | +| `ValidatorBuilder` | `cose::sign1::ValidatorBuilder` | +| `CoseHeaderMap` | `cose::CoseHeaderMap` | + +--- + +## PR Review Checklist + +Every native PR is evaluated on these dimensions. Address each before +requesting review. + +### 1. Zero-Copy / No-Allocation + +- [ ] No unnecessary `.clone()`, `.to_vec()`, `.to_owned()` on large types +- [ ] FFI handle conversions use bounded lifetimes (`<'a>`), not `'static` +- [ ] C++ accessors return `ByteView` (borrowed), not `std::vector` (copied) +- [ ] `_consume` / `_to_message` variants provided where applicable + +### 2. Safety & Correctness + +- [ ] Every FFI pointer parameter is null-checked before dereference +- [ ] Every `extern "C"` function is wrapped in `catch_unwind` +- [ ] Every `unsafe` block has a `// SAFETY:` comment +- [ ] Memory ownership documented: who allocates, who frees, which `*_free()` + +### 3. API Design + +- [ ] Builder patterns are fluent (return `&mut self` or `Self`) +- [ ] Error types use manual `Display + Error` (no `thiserror`) +- [ ] C++ classes are move-only (copy deleted) +- [ ] C headers have `extern "C"` guards + +### 4. Test Quality + +- [ ] Positive and negative paths covered +- [ ] FFI null-pointer safety tests for every parameter +- [ ] Roundtrip test (sign → parse → validate) if applicable +- [ ] No shared mutable state between tests (parallel-safe) + +### 5. Documentation + +- [ ] Public Rust APIs have `///` doc comments +- [ ] FFI functions have `# Safety` sections +- [ ] C++ methods have `@see` cross-refs to zero-copy alternatives +- [ ] Module-level `//!` comment in every new `lib.rs` + +--- + +## Adding a New Extension Pack + +1. Create the crate structure: + +``` +extension_packs/my_pack/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Module docs + pub use +│ ├── signing/mod.rs # If contributing to signing +│ └── validation/ +│ ├── mod.rs +│ ├── trust_pack.rs # impl CoseSign1TrustPack +│ ├── fact_producer.rs # impl TrustFactProducer +│ └── key_resolver.rs # impl SigningKeyResolver (optional) +├── tests/ # Integration tests +└── ffi/ + ├── Cargo.toml # crate-type = ["staticlib", "cdylib"] + └── src/ + ├── lib.rs # FFI exports with catch_unwind + ├── types.rs # Opaque handle types + └── provider.rs # CBOR provider selection +``` + +2. Add to workspace `members` in `native/rust/Cargo.toml`. +3. Create C header: `native/c/include/cose/sign1/extension_packs/my_pack.h` +4. Create C++ header: `native/c_pp/include/cose/sign1/extension_packs/my_pack.hpp` +5. Add feature to vcpkg port (`native/vcpkg_ports/`). +6. Add `COSE_HAS_MY_PACK` define to CMake. + +--- + +## Adding a New FFI Export + +When you add a public API to a Rust library crate that needs C/C++ access: + +1. **Rust FFI**: Add `#[no_mangle] pub extern "C" fn cose_*()` in the FFI crate. +2. **C header**: Add matching declaration in the appropriate `.h` file. +3. **C++ header**: Add RAII wrapper method in the corresponding `.hpp` file. +4. **Null tests**: Add null-pointer safety tests for every pointer parameter. +5. **C/C++ tests**: Add GTest coverage for the new function. + +The C/C++ headers are hand-maintained (not auto-generated) — this is +intentional to preserve the header hierarchy and enable C++ RAII patterns. See +[rust/docs/ffi_guide.md](rust/docs/ffi_guide.md) for the rationale. + +--- + +## Questions? + +- Architecture questions → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- Ownership/memory questions → [docs/FFI-OWNERSHIP.md](docs/FFI-OWNERSHIP.md) +- Dependency questions → [docs/DEPENDENCY-PHILOSOPHY.md](docs/DEPENDENCY-PHILOSOPHY.md) +- Rust-specific docs → [rust/docs/](rust/docs/) \ No newline at end of file diff --git a/native/README.md b/native/README.md new file mode 100644 index 00000000..160e0999 --- /dev/null +++ b/native/README.md @@ -0,0 +1,205 @@ + + +# Native COSE Sign1 SDK + +A production-grade **Rust / C / C++** implementation of [COSE Sign1](https://datatracker.ietf.org/doc/html/rfc9052) +signing, validation, and trust-policy evaluation — with streaming support for +payloads of any size. + +``` +┌──────────────────────────────────────────────────────────┐ +│ Your Application (C, C++, or Rust) │ +├──────────────────────────────────────────────────────────┤ +│ C++ RAII Headers │ C Headers │ Rust API │ +│ (header-only) │ (ABI-stable)│ (source of │ +│ native/c_pp/ │ native/c/ │ truth) │ +├──────────────────────┴───────────────┤ native/rust/ │ +│ FFI Crates (extern "C", panic-safe)│ │ +├──────────────────────────────────────┴───────────────────┤ +│ Rust Library Crates (primitives → signing → validation)│ +└──────────────────────────────────────────────────────────┘ +``` + +## Key Properties + +| Property | How | +|----------|-----| +| **Zero unnecessary allocation** | `Arc<[u8]>` sharing, `ByteView` borrows, move-not-clone builders | +| **Streaming sign & verify** | 64 KB chunks — sign or verify a 10 GB payload in ~65 KB of memory | +| **Formally verified CBOR** | Default backend is Microsoft Research's EverParse (cborrs) | +| **Modular extension packs** | X.509, Azure Key Vault, Microsoft Transparency — link only what you need | +| **Compile-time provider selection** | CBOR and crypto providers are Cargo features, not runtime decisions | +| **Panic-safe FFI** | Every `extern "C"` function wrapped in `catch_unwind` with thread-local errors | + +## Quick Start + +### Rust + +```bash +cd native/rust +cargo test --workspace # run all tests +cargo run -p cose_sign1_validation_demo -- selftest # run the demo +``` + +### C + +```bash +# 1. Build Rust FFI libraries +cd native/rust && cargo build --release --workspace + +# 2. Build & test the C projection +cd native/c +cmake -B build -DBUILD_TESTING=ON +cmake --build build --config Release +ctest --test-dir build -C Release +``` + +### C++ + +```bash +# 1. Build Rust FFI libraries (same as above) +cd native/rust && cargo build --release --workspace + +# 2. Build & test the C++ projection +cd native/c_pp +cmake -B build -DBUILD_TESTING=ON +cmake --build build --config Release +ctest --test-dir build -C Release +``` + +### Via vcpkg (recommended for C/C++ consumers) + +```bash +vcpkg install cosesign1-validation-native[cpp,certificates,mst,signing] \ + --overlay-ports=native/vcpkg_ports +``` + +Then in your `CMakeLists.txt`: + +```cmake +find_package(cose_sign1_validation CONFIG REQUIRED) +target_link_libraries(my_app PRIVATE cosesign1_validation_native::cose_sign1) # C +target_link_libraries(my_app PRIVATE cosesign1_validation_native::cose_sign1_cpp) # C++ +``` + +## Code Examples + +### Sign a payload (C++) + +```cpp +#include +#include + +auto signer = cose::crypto::OpenSslSigner::FromDer(key_der.data(), key_der.size()); +auto factory = cose::sign1::SignatureFactory::FromCryptoSigner(signer); +auto bytes = factory.SignDirectBytes(payload.data(), payload.size(), "application/json"); +``` + +### Validate with trust policy (C++) + +```cpp +#include + +cose::ValidatorBuilder builder; +cose::WithCertificates(builder); + +cose::TrustPolicyBuilder policy(builder); +policy.RequireContentTypeNonEmpty(); +cose::RequireX509ChainTrusted(policy); + +auto plan = policy.Compile(); +cose::WithCompiledTrustPlan(builder, plan); +auto validator = builder.Build(); +auto result = validator.Validate(cose_bytes); +``` + +### Parse and inspect (C) + +```c +#include + +cose_sign1_message_t* msg = NULL; +cose_sign1_message_parse(cose_bytes, len, &msg); + +int64_t alg = 0; +cose_sign1_message_algorithm(msg, &alg); +printf("Algorithm: %lld\n", alg); + +cose_sign1_message_free(msg); +``` + +## Directory Layout + +``` +native/ +├── rust/ Rust workspace — the source of truth +│ ├── primitives/ CBOR, crypto, and COSE type layers +│ ├── signing/ Builder, factory, header contributions +│ ├── validation/ Trust engine, staged validator, demo +│ ├── extension_packs/ Certificates, AKV, MST, AAS +│ ├── did/ DID:x509 utilities +│ └── cli/ Command-line tool +├── c/ C projection +│ ├── include/cose/ C headers (mirrors Rust crate tree) +│ └── tests/ GTest-based C tests +├── c_pp/ C++ projection +│ ├── include/cose/ Header-only RAII wrappers +│ └── tests/ GTest-based C++ tests +└── docs/ Cross-cutting documentation + ├── ARCHITECTURE.md Full architecture reference + ├── FFI-OWNERSHIP.md Ownership & memory model across the FFI boundary + └── DEPENDENCY-PHILOSOPHY.md Why each dependency exists +``` + +## Dependency Philosophy + +The SDK follows a **minimal-footprint** strategy: + +- **Core crates** depend only on `openssl`, `sha2`, `x509-parser`, `base64`, and `cborrs` — each irreplaceable. +- **Azure dependencies** (`tokio`, `reqwest`, `azure_*`) exist only in extension packs and are feature-gated. +- **No proc-macro crates** in the core path — no `thiserror`, no `derive_builder`. +- **Standard library first** — `std::sync::LazyLock` replaced `once_cell`; `std::sync::Mutex` replaced `parking_lot`. + +See [docs/DEPENDENCY-PHILOSOPHY.md](docs/DEPENDENCY-PHILOSOPHY.md) for the full rationale. + +## Documentation + +| Document | What it covers | +|----------|---------------| +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Complete architecture, naming conventions, extension packs, CLI | +| [docs/FFI-OWNERSHIP.md](docs/FFI-OWNERSHIP.md) | Ownership model, handle lifecycle, zero-copy patterns | +| [docs/DEPENDENCY-PHILOSOPHY.md](docs/DEPENDENCY-PHILOSOPHY.md) | Why each dependency exists, addition guidelines | +| [rust/README.md](rust/README.md) | Crate inventory and Rust quick start | +| [rust/docs/memory-characteristics.md](rust/docs/memory-characteristics.md) | Per-operation memory profiles, streaming analysis | +| [rust/docs/ffi_guide.md](rust/docs/ffi_guide.md) | FFI crate reference, buffer patterns, build integration | +| [rust/docs/signing_flow.md](rust/docs/signing_flow.md) | Signing pipeline, factory types, post-sign verification | +| [c/README.md](c/README.md) | C API reference, examples, error handling | +| [c_pp/README.md](c_pp/README.md) | C++ RAII reference, examples, exception handling | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, testing, PR checklist | + +## Extension Packs + +| Pack | Rust Crate | Purpose | +|------|-----------|---------| +| X.509 Certificates | `cose_sign1_certificates` | `x5chain` parsing, certificate trust verification | +| Azure Key Vault | `cose_sign1_azure_key_vault` | KID-based key resolution and allow-listing | +| Microsoft Transparency | `cose_sign1_transparent_mst` | MST receipt verification (Merkle Sealed Transparency) | +| Azure Artifact Signing | `cose_sign1_azure_artifact_signing` | Azure Trusted Signing integration | +| Ephemeral Certs | `cose_sign1_certificates_local` | Test/dev certificate generation | + +Each pack is a separate Rust crate with its own FFI projection, C header, and +C++ wrapper. Link only the packs you need — the CMake build auto-discovers +available packs and sets `COSE_HAS_*` defines accordingly. + +## Quality Gates + +| Gate | Threshold | Tool | +|------|-----------|------| +| Rust line coverage | ≥ 90% | `cargo llvm-cov` | +| C/C++ line coverage | ≥ 90% | OpenCppCoverage | +| Address sanitizer | Clean | MSVC ASAN via `collect-coverage-asan.ps1` | +| Dependency allowlist | Enforced | `allowed-dependencies.toml` | + +## License + +[MIT](../LICENSE) — Copyright (c) Microsoft Corporation. \ No newline at end of file diff --git a/native/docs/DEPENDENCY-PHILOSOPHY.md b/native/docs/DEPENDENCY-PHILOSOPHY.md new file mode 100644 index 00000000..22d709ef --- /dev/null +++ b/native/docs/DEPENDENCY-PHILOSOPHY.md @@ -0,0 +1,208 @@ + + +# Dependency Philosophy + +> Why each dependency exists, what we removed, and the rules for adding new ones. + +## Table of Contents + +- [Guiding Principles](#guiding-principles) +- [Core Dependencies](#core-dependencies) +- [Azure Dependencies](#azure-dependencies) +- [Removed Dependencies](#removed-dependencies) +- [Dependency Decision Framework](#dependency-decision-framework) +- [Workspace Dependency Map](#workspace-dependency-map) + +--- + +## Guiding Principles + +1. **Every dependency must justify its existence.** If `std` can do it, use `std`. +2. **Core crates have minimal deps.** The signing/validation path should be + auditable by reading a small set of well-known crates. +3. **Heavy deps stay in extension packs.** Azure SDK, `tokio`, `reqwest` — these + are feature-gated and only compiled when an extension pack is enabled. +4. **No proc-macro crates in the core path.** Proc macros (`thiserror`, + `derive_builder`, `serde_derive` in core) increase compile times + and expand the trusted code surface. +5. **Pin major versions in `[workspace.dependencies]`.** All external crates are + declared once in the workspace root `Cargo.toml` for consistent versioning. + +--- + +## Core Dependencies + +These dependencies are on the critical path for signing and validation. Each is +irreplaceable — there is no reasonable `std`-only alternative. + +| Crate | Version | Used By | Why It Exists | +|-------|---------|---------|--------------| +| `openssl` | 0.10 | `cose_sign1_crypto_openssl` | ECDSA / RSA / ML-DSA signing and verification. OpenSSL is the crypto backend; abstracting it away is the job of `crypto_primitives`. No pure-Rust crate supports the full algorithm matrix (especially ML-DSA with OpenSSL 3.x). | +| `sha2` | 0.10 | Indirect signing, content hashing | SHA-256/384/512 for indirect signature payloads and trust subject IDs. Pure Rust, no C dependencies, widely audited. | +| `sha1` | 0.10 | Certificate thumbprints | SHA-1 thumbprints for X.509 certificates (required by the COSE x5t header). Deprecated for security, but required by the spec. | +| `x509-parser` | 0.18 | `cose_sign1_certificates` | X.509 certificate chain parsing (DER/PEM). The only mature Rust crate for full certificate parsing including extensions, SANs, and basic constraints. | +| `base64` | 0.22 | MST JWKS, PEM handling | Base64/Base64URL encoding for JWK parsing in MST receipts and PEM handling. | +| `hex` | 0.4 | Thumbprint display, debugging | Hex encoding for certificate thumbprints and diagnostic output. | +| `anyhow` | 1 | FFI crates only | Ergonomic error handling at the FFI boundary. Used in FFI crates (not library crates) because FFI errors are converted to thread-local strings anyway. Library crates use manual `Display + Error` impls. | +| `regex` | 1 | `did_x509`, trust policy | DID:x509 method-specific-id parsing and trust policy pattern matching. | +| `url` | 2 | AKV, AAS packs | URL parsing for Azure Key Vault URIs and Azure Artifact Signing endpoints. | + +### CBOR Backend + +| Crate | Version | Used By | Why It Exists | +|-------|---------|---------|--------------| +| `cborrs` (EverParse) | Vendored | `cbor_primitives_everparse` | Formally verified CBOR parser produced by Microsoft Research's EverParse toolchain. This is the default and recommended CBOR backend. The `cbor_primitives` trait crate abstracts it, allowing future backends without changing library code. | + +### Serialization (Scoped) + +| Crate | Version | Used By | Why It Exists | +|-------|---------|---------|--------------| +| `serde` | 1 | MST, AKV, AAS packs | JSON deserialization for JWKS keys (MST receipts), AKV API responses, and AAS client. Not used in core primitives or signing/validation. | +| `serde_json` | 1 | MST, AKV, AAS packs | JSON parsing companion to `serde`. Same scope restriction. | + +> **Note**: `serde` and `serde_json` are **not** used in the primitives, signing, +> or validation core crates. They appear only in extension packs that interact +> with JSON-based external services. + +### Tracing + +| Crate | Version | Used By | Why It Exists | +|-------|---------|---------|--------------| +| `tracing` | 0.1 | Library crates | Structured diagnostic logging. Instrumentation points in signing, validation, and crypto operations. Zero overhead when no subscriber is installed. | +| `tracing-subscriber` | 0.3 | CLI, demo | Console output for `tracing` events. Only in executable crates. | + +--- + +## Azure Dependencies + +These dependencies exist **only** in extension packs. They are feature-gated +in the vcpkg port and Cargo workspace — if you don't enable the `akv` or `ats` +feature, none of these crates are compiled. + +| Crate | Version | Used By | Why It Exists | +|-------|---------|---------|--------------| +| `azure_core` | 0.33 | AKV, AAS packs | Azure SDK core (HTTP pipeline, retry, auth plumbing). Required by all `azure_*` crates. Features: `reqwest` + `reqwest_native_tls` (no rustls to avoid OpenSSL + rustls conflicts). | +| `azure_identity` | 0.33 | AKV, AAS packs | Azure credential providers (DefaultAzureCredential, managed identity, CLI). | +| `azure_security_keyvault_keys` | 0.12 | AKV pack | Azure Key Vault key operations (sign with HSM-backed keys). | +| `tokio` | 1 | AKV, AAS packs | Async runtime for Azure SDK calls. Features: `rt` + `macros` only (no full runtime). | +| `reqwest` | 0.13 | MST client, AAS client | HTTP client for MST ledger queries and AAS API calls. Features: `json` + `rustls-tls`. | +| `async-trait` | 0.1 | AKV, AAS packs | `async fn` in traits (pending stabilization of async trait methods). | + +### Why Not `rustls` Everywhere? + +The `azure_core` crate uses `reqwest_native_tls` (which delegates to the +platform TLS — SChannel on Windows, OpenSSL on Linux). This avoids shipping +two TLS stacks and ensures Azure SDK authentication works with corporate +proxies that require platform certificate stores. + +The `reqwest` crate (used by MST/AAS clients) uses `rustls-tls` because these +clients don't need platform cert store integration. + +--- + +## Removed Dependencies + +These crates were previously in the dependency tree and have been intentionally +removed. Do not re-add them without a compelling justification. + +| Removed Crate | Replaced By | Rationale | +|--------------|-------------|-----------| +| `once_cell` | `std::sync::LazyLock` | Rust 1.80 stabilized `LazyLock`, eliminating the need for `once_cell::sync::Lazy`. One fewer dependency in every crate that needed lazy initialization. | +| `parking_lot` | `std::sync::Mutex` | The performance difference is negligible for our usage patterns (low-contention locks in validation pipelines). Removing it simplifies the dependency tree and eliminates platform-specific lock code. | +| `azure_security_keyvault_certificates` | Direct key operations via `azure_security_keyvault_keys` | The certificates client was unused — AKV signing only needs key operations. Removing it eliminated a large transitive dependency subtree. | +| `thiserror` | Manual `Display` + `Error` impls | Proc macros increase compile time and expand the trusted code surface. Manual impls are ~10 lines per error type — a small cost for build transparency. `anyhow` is still used in FFI crates where error types are immediately stringified. | + +--- + +## Dependency Decision Framework + +When considering a new dependency, evaluate against this checklist: + +### Must-Have Criteria + +| # | Question | Required Answer | +|---|----------|----------------| +| 1 | Can `std` do this? | No | +| 2 | Is there a simpler alternative already in the dep tree? | No | +| 3 | Is the crate actively maintained (commit in last 6 months)? | Yes | +| 4 | Is the crate widely used (>1M downloads or well-known ecosystem)? | Yes | +| 5 | Does it avoid `unsafe` or have a credible safety argument? | Yes | + +### Placement Rules + +| If the dependency is needed by... | Place it in... | +|----------------------------------|---------------| +| Primitives (`cbor_primitives`, `crypto_primitives`, `cose_primitives`) | `[workspace.dependencies]` — but think very hard first | +| Domain crates (signing, validation, headers) | `[workspace.dependencies]` | +| A single extension pack | That pack's `Cargo.toml` only | +| Azure SDK integration | Extension pack, behind a Cargo feature | +| CLI/demo only | Executable crate's `Cargo.toml` | +| Tests only | `[dev-dependencies]` in the relevant crate | + +### Red Flags + +These should trigger extra scrutiny or rejection: + +| Red Flag | Why | +|----------|-----| +| Proc-macro crate in core path | Compile-time cost, opaque code generation | +| Pulls in `tokio` or `reqwest` transitively | Async runtime in core is an architecture violation | +| Crate has `unsafe` without justification | Expands the trusted code surface | +| Crate is maintained by a single person with no recent activity | Bus-factor risk | +| Crate duplicates functionality already in `std` | Use `std` instead | +| Crate requires a specific allocator or global state | Conflicts with our zero-allocation goals | + +--- + +## Workspace Dependency Map + +Visual overview of which crate categories use which dependencies: + +``` + ┌─────────────────────────────────────────────┐ + │ Primitives Layer │ + │ cbor_primitives: (zero external deps) │ + │ crypto_primitives: (zero external deps) │ + │ cose_primitives: (zero external deps) │ + │ cose_sign1_primitives: sha2 │ + └─────────────────────┬───────────────────────┘ + │ + ┌─────────────────────▼───────────────────────┐ + │ Domain Crates │ + │ signing: sha2, tracing │ + │ validation: sha2, tracing │ + │ headers: (minimal) │ + │ factories: sha2, tracing │ + └─────────────────────┬───────────────────────┘ + │ + ┌───────────────────────────────────┼───────────────────────────┐ + │ │ │ +┌─────────▼──────────┐ ┌────────────────────▼──────────┐ ┌────────────▼───────────┐ +│ Certificates Pack │ │ MST Pack │ │ AKV / AAS Packs │ +│ x509-parser │ │ serde, serde_json │ │ azure_core │ +│ sha1 │ │ base64, reqwest │ │ azure_identity │ +│ openssl │ │ sha2 │ │ azure_security_kv_keys│ +│ base64 │ │ │ │ tokio, reqwest │ +│ │ │ │ │ async-trait │ +└────────────────────┘ └───────────────────────────────┘ └────────────────────────┘ + │ │ │ + └─────────────────────────┼────────────────────────────────┘ + │ + ┌───────────▼─────────────────────────────────┐ + │ FFI Crates │ + │ + anyhow (error stringification at ABI) │ + └───────────────────────────────────────────── ┘ +``` + +### Dependency Counts + +| Layer | Direct External Deps | Transitive Deps (approx.) | +|-------|---------------------|--------------------------| +| Primitives (no crypto) | 0–1 | < 10 | +| Domain crates | 2–3 | < 20 | +| Certificates pack | 4–5 | < 30 | +| Azure extension packs | 8–10 | < 80 | +| Full workspace | ~20 direct | ~120 total | + +The core signing + validation path (without Azure packs) has approximately +20 transitive dependencies — a fraction of typical Rust projects of this scope. \ No newline at end of file diff --git a/native/docs/FFI-OWNERSHIP.md b/native/docs/FFI-OWNERSHIP.md new file mode 100644 index 00000000..17db1f96 --- /dev/null +++ b/native/docs/FFI-OWNERSHIP.md @@ -0,0 +1,537 @@ + + +# FFI Ownership Model + +> The definitive guide to memory ownership across the Rust ↔ C ↔ C++ boundary. + +## Table of Contents + +- [Core Principle](#core-principle) +- [Handle Lifecycle](#handle-lifecycle) +- [Borrowing vs. Consuming](#borrowing-vs-consuming) +- [ByteView: Zero-Copy Access](#byteview-zero-copy-access) +- [Thread-Local Error Pattern](#thread-local-error-pattern) +- [Panic Safety](#panic-safety) +- [C++ RAII Wrappers](#c-raii-wrappers) +- [Ownership Flow Diagrams](#ownership-flow-diagrams) +- [Anti-Patterns](#anti-patterns) +- [Quick Reference](#quick-reference) + +--- + +## Core Principle + +**Rust owns all heap memory. C/C++ borrows through opaque handles.** + +Every object allocated by the SDK lives on the Rust heap. C and C++ code +receives opaque pointers (handles) that reference — but never directly access — +the Rust-side data. When C/C++ is done with a handle, it calls the +corresponding `*_free()` function, which transfers ownership back to Rust for +deallocation. + +This design ensures: + +- **No double-free** — exactly one owner at all times. +- **No use-after-free** — handles are opaque; you cannot dereference into freed memory. +- **No allocator mismatch** — Rust allocates, Rust frees. C's `malloc`/`free` are never involved for SDK objects. + +--- + +## Handle Lifecycle + +Every SDK object follows the same three-phase lifecycle: + +``` + Rust C / C++ + ──── ─────── + Box::new(value) + Box::into_raw(box) ──────────→ *mut Handle (opaque pointer) + │ + │ use via cose_*() functions + │ + Box::from_raw(ptr) ←────────── cose_*_free(handle) + drop(box) +``` + +### Phase 1: Creation + +Rust allocates the object and converts it to a raw pointer: + +```rust +// Rust FFI — creation +#[no_mangle] +pub extern "C" fn cose_sign1_validator_builder_new( + out: *mut *mut ValidatorBuilderHandle, +) -> cose_status_t { + with_catch_unwind(|| { + if out.is_null() { + anyhow::bail!("out must not be null"); + } + let builder = ValidatorBuilder::new(); + // SAFETY: out is non-null (checked above), we transfer ownership to caller + unsafe { *out = Box::into_raw(Box::new(ValidatorBuilderHandle(builder))) }; + Ok(COSE_OK) + }) +} +``` + +```c +// C — receiving the handle +cose_sign1_validator_builder_t* builder = NULL; +cose_status_t status = cose_sign1_validator_builder_new(&builder); +// builder is now a valid opaque pointer — do NOT dereference it +``` + +### Phase 2: Usage + +C/C++ passes the handle back to Rust functions. Rust converts the raw pointer +to a reference (borrow) to access the inner data: + +```rust +// Rust FFI — borrowing for read access +pub(crate) unsafe fn handle_to_inner<'a>( + handle: *const ValidatorBuilderHandle, +) -> Option<&'a ValidatorBuilder> { + // SAFETY: caller guarantees handle is valid for 'a + unsafe { handle.as_ref() }.map(|h| &h.0) +} +``` + +The `<'a>` lifetime is critical — it ties the reference lifetime to the handle's +validity, not to `'static`. + +### Phase 3: Destruction + +C/C++ calls the free function. Rust reclaims and drops the object: + +```rust +// Rust FFI — destruction +#[no_mangle] +pub extern "C" fn cose_sign1_validator_builder_free( + handle: *mut ValidatorBuilderHandle, +) { + if !handle.is_null() { + // SAFETY: handle was created by Box::into_raw in _new(), + // caller guarantees this is the last use + unsafe { drop(Box::from_raw(handle)) }; + } +} +``` + +```c +// C — releasing the handle +cose_sign1_validator_builder_free(builder); +builder = NULL; // good practice: null out after free +``` + +--- + +## Borrowing vs. Consuming + +FFI functions use pointer mutability to signal ownership semantics: + +| Pointer Type | Meaning | After Call | +|-------------|---------|------------| +| `*const Handle` | **Borrow** — Rust reads but does not take ownership | Handle remains valid; caller still owns it | +| `*mut Handle` (non-out) | **Consume** — Rust takes ownership via `Box::from_raw` | Handle is **invalidated**; caller must NOT use or free it | +| `*mut *mut Handle` | **Output** — Rust creates and transfers ownership to caller | Caller receives a new handle; must eventually free it | + +### Borrow Example (set_protected) + +```rust +// Rust: borrows headers, clones internally because handle stays valid +#[no_mangle] +pub extern "C" fn cose_sign1_builder_set_protected( + builder: *mut BuilderHandle, // borrowed (mutated but not consumed) + headers: *const HeaderMapHandle, // borrowed (read-only) +) -> cose_status_t { + with_catch_unwind(|| { + let builder = unsafe { builder.as_mut() }.context("null builder")?; + let headers = unsafe { headers.as_ref() }.context("null headers")?; + builder.0.set_protected(headers.0.clone()); // clone required — we borrow + Ok(COSE_OK) + }) +} +``` + +```c +// C: both handles remain valid after the call +cose_sign1_builder_set_protected(builder, headers); +// builder and headers are still usable +``` + +### Consume Example (consume_protected) + +```rust +// Rust: takes ownership of headers via Box::from_raw — no clone needed +#[no_mangle] +pub extern "C" fn cose_sign1_builder_consume_protected( + builder: *mut BuilderHandle, // borrowed (mutated) + headers: *mut HeaderMapHandle, // CONSUMED — ownership transferred +) -> cose_status_t { + with_catch_unwind(|| { + let builder = unsafe { builder.as_mut() }.context("null builder")?; + // SAFETY: headers was created by Box::into_raw; we are the new owner + let headers = unsafe { Box::from_raw(headers) }; + builder.0.set_protected(headers.0); // move, not clone + Ok(COSE_OK) + }) +} +``` + +```c +// C: headers is INVALIDATED after this call — do NOT use or free it +cose_sign1_builder_consume_protected(builder, headers); +headers = NULL; // must not touch headers again +``` + +### When to Provide Both Variants + +Provide both `set_*` (borrow + clone) and `consume_*` (move) when the cloned +type is non-trivial (e.g., `CoseHeaderMap`, `Vec`, `CoseSign1Message`). +For small/cheap types (integers, booleans), a single borrow variant suffices. + +--- + +## ByteView: Zero-Copy Access + +`ByteView` is a C/C++ struct that borrows bytes directly from a Rust-owned +`Arc<[u8]>` — no copy, no allocation: + +```c +// C definition +typedef struct { + const uint8_t* data; // pointer into Rust Arc<[u8]> + size_t size; // byte count +} cose_byte_view_t; +``` + +```cpp +// C++ usage — zero-copy payload access +cose::sign1::CoseSign1Message msg = /* ... */; +ByteView payload = msg.Payload(); // {data, size} pointing into Rust Arc +// Use payload.data / payload.size — valid as long as msg is alive +``` + +### Lifetime Rule + +`ByteView` data is valid **only as long as the parent handle is alive**: + +```cpp +// ✅ GOOD: use ByteView while message is alive +auto msg = cose::sign1::CoseSign1Message::FromBytes(raw); +ByteView payload = msg.Payload(); +process(payload.data, payload.size); + +// ❌ BAD: ByteView outlives the message +ByteView dangling; +{ + auto msg = cose::sign1::CoseSign1Message::FromBytes(raw); + dangling = msg.Payload(); +} // msg destroyed here — dangling.data is now invalid! +process(dangling.data, dangling.size); // use-after-free! +``` + +### When to Copy + +If you need the data to outlive the handle, copy explicitly: + +```cpp +auto msg = cose::sign1::CoseSign1Message::FromBytes(raw); +std::vector owned_payload = msg.PayloadAsVector(); // explicit copy +// owned_payload is independent of msg's lifetime +``` + +--- + +## Thread-Local Error Pattern + +FFI functions return status codes, not error messages. Detailed error +information is stored in a **thread-local** buffer: + +```rust +// Rust FFI — thread-local error storage +thread_local! { + static LAST_ERROR: RefCell> = RefCell::new(None); +} + +fn set_last_error(msg: String) { + LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg)); +} + +#[no_mangle] +pub extern "C" fn cose_last_error_message_utf8() -> *mut c_char { + LAST_ERROR.with(|e| { + match e.borrow().as_deref() { + Some(msg) => CString::new(msg).unwrap().into_raw(), + None => std::ptr::null_mut(), + } + }) +} +``` + +```c +// C — retrieving the error after a failed call +cose_status_t status = cose_sign1_validator_builder_build(builder, &validator); +if (status != COSE_OK) { + char* err = cose_last_error_message_utf8(); + fprintf(stderr, "Error: %s\n", err ? err : "(no message)"); + cose_string_free(err); // caller owns the returned string +} +``` + +### Thread Safety + +- Error messages are **per-thread** — concurrent calls on different threads + never interfere. +- The error is overwritten by the **next** FFI call on the same thread — read + it immediately after the failing call. +- The returned `char*` is Rust-allocated — always free with `cose_string_free()`, + never with C's `free()`. + +--- + +## Panic Safety + +Every `extern "C"` function is wrapped in `catch_unwind` to prevent Rust panics +from unwinding across the FFI boundary (which is undefined behavior): + +```rust +pub(crate) fn with_catch_unwind(f: F) -> cose_status_t +where + F: FnOnce() -> anyhow::Result + std::panic::UnwindSafe, +{ + match std::panic::catch_unwind(f) { + Ok(Ok(status)) => status, + Ok(Err(err)) => { + set_last_error(format!("{:#}", err)); + COSE_ERR + } + Err(_) => { + set_last_error("internal panic".into()); + COSE_PANIC + } + } +} +``` + +### Status Codes + +| Code | Constant | Meaning | +|------|----------|---------| +| 0 | `COSE_OK` | Success | +| 1 | `COSE_ERR` | Error — call `cose_last_error_message_utf8()` for details | +| 2 | `COSE_PANIC` | Rust panic caught — should not occur in normal usage | +| 3 | `COSE_INVALID_ARG` | Invalid argument (null pointer, bad length) | + +--- + +## C++ RAII Wrappers + +The C++ projection wraps every C handle in a move-only RAII class: + +```cpp +namespace cose::sign1 { + +class ValidatorBuilder { +public: + // Factory method — throws cose_error on failure + ValidatorBuilder() { + cose_status_t st = cose_sign1_validator_builder_new(&handle_); + if (st != COSE_OK) throw cose::cose_error("failed to create builder"); + } + + // Move constructor — transfers ownership + ValidatorBuilder(ValidatorBuilder&& other) noexcept + : handle_(other.handle_) { + other.handle_ = nullptr; // CRITICAL: null out source + } + + // Move assignment + ValidatorBuilder& operator=(ValidatorBuilder&& other) noexcept { + if (this != &other) { + if (handle_) cose_sign1_validator_builder_free(handle_); + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; + } + + // Copy is deleted — handles are unique owners + ValidatorBuilder(const ValidatorBuilder&) = delete; + ValidatorBuilder& operator=(const ValidatorBuilder&) = delete; + + // Destructor — automatic cleanup + ~ValidatorBuilder() { + if (handle_) cose_sign1_validator_builder_free(handle_); + } + + // Interop: access raw handle when needed + cose_sign1_validator_builder_t* native_handle() { return handle_; } + +private: + cose_sign1_validator_builder_t* handle_ = nullptr; +}; + +} // namespace cose::sign1 +``` + +### RAII Rules + +| Rule | Why | +|------|-----| +| Delete copy ctor + copy assignment | Prevents double-free | +| Null-out source in move ctor/assignment | Prevents use-after-move | +| Check `if (handle_)` before free in destructor | Allows moved-from objects to destruct safely | +| Throw `cose_error` in constructors on failure | RAII: if constructor succeeds, object is valid | +| `native_handle()` for interop | Escape hatch for mixing C and C++ APIs | + +--- + +## Ownership Flow Diagrams + +### Create → Use → Free (Happy Path) + +``` + C / C++ Rust + ─────── ──── + ┌─ new(&out) ─────────────────→ Box::new(T) + │ Box::into_raw → *mut T + │ out ← ─────────────────────── return pointer + │ + │ use(handle, ...) ──────────→ handle.as_ref() → &T + │ use(handle, ...) ──────────→ handle.as_ref() → &T + │ + └─ free(handle) ──────────────→ Box::from_raw(*mut T) + drop(T) +``` + +### Consume Pattern (Ownership Transfer) + +``` + C / C++ Rust + ─────── ──── + ┌─ new_headers(&h) ──────────→ Box::into_raw → *mut H + │ h ← ─────────────────────── return pointer + │ + │ consume(builder, h) ──────→ Box::from_raw(h) → owned H + │ h = NULL (invalidated) move H into builder + │ + └─ free(builder) ─────────────→ drops builder + contained H +``` + +### String Ownership + +``` + C / C++ Rust + ─────── ──── + ┌─ error_message_utf8() ─────→ CString::new(msg) + │ CString::into_raw → *mut c_char + │ err ← ────────────────────── return pointer + │ + │ fprintf(stderr, err) (string data lives on Rust heap) + │ + └─ string_free(err) ─────────→ CString::from_raw(*mut c_char) + drop(CString) +``` + +--- + +## Anti-Patterns + +### ❌ Using `'static` for Handle References + +```rust +// BAD: unsound — handle can be freed at any time +unsafe fn handle_to_inner(h: *const H) -> Option<&'static Inner> { ... } + +// GOOD: lifetime bounded to handle validity +unsafe fn handle_to_inner<'a>(h: *const H) -> Option<&'a Inner> { ... } +``` + +### ❌ Freeing with the Wrong Allocator + +```c +// BAD: C's free() on Rust-allocated memory +char* err = cose_last_error_message_utf8(); +free(err); // WRONG — allocated by Rust, not malloc + +// GOOD: use the SDK's free function +cose_string_free(err); +``` + +### ❌ Using a Handle After Consume + +```c +// BAD: headers was consumed — handle is invalid +cose_sign1_builder_consume_protected(builder, headers); +cose_headermap_get_int(headers, 1, &alg); // use-after-free! + +// GOOD: null out after consume +cose_sign1_builder_consume_protected(builder, headers); +headers = NULL; +``` + +### ❌ Forgetting to Null-Out in Move Constructor + +```cpp +// BAD: both objects think they own the handle +MyHandle(MyHandle&& other) : handle_(other.handle_) { } + +// GOOD: null out the source +MyHandle(MyHandle&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; +} +``` + +### ❌ Cloning When Moving is Possible + +```rust +// BAD: builder is consumed via Box::from_raw — we own the data, no need to clone +let inner = unsafe { Box::from_raw(builder) }; +rust_builder.set_protected(inner.protected.clone()); + +// GOOD: move out of the consumed box +let inner = unsafe { Box::from_raw(builder) }; +rust_builder.set_protected(inner.protected); // moved, not cloned +``` + +### ❌ ByteView Outliving Its Parent + +```cpp +// BAD: ByteView dangles after message is destroyed +ByteView payload; +{ + auto msg = CoseSign1Message::FromBytes(raw); + payload = msg.Payload(); +} // msg freed here — payload.data is dangling + +// GOOD: keep the message alive, or copy +auto msg = CoseSign1Message::FromBytes(raw); +auto payload = msg.Payload(); +process(payload.data, payload.size); // msg still alive +``` + +--- + +## Quick Reference + +| Operation | Rust | C | C++ | +|-----------|------|---|-----| +| **Create** | `Box::into_raw(Box::new(T))` | `cose_*_new(&out)` | Constructor / `T::New()` | +| **Borrow** | `handle.as_ref()` → `&T` | pass `const *handle` | method call on object | +| **Consume** | `Box::from_raw(handle)` → `T` | pass `*mut handle` + null out | `std::move(obj)` | +| **Free** | `drop(Box::from_raw(handle))` | `cose_*_free(handle)` | Destructor (automatic) | +| **Error** | `set_last_error(msg)` | `cose_last_error_message_utf8()` | `throw cose_error(...)` | +| **Zero-copy read** | `&data[range]` | `cose_byte_view_t` | `ByteView` | +| **Copy read** | `.to_vec()` | `memcpy` from `cose_byte_view_t` | `.PayloadAsVector()` | + +### Memory Ownership Summary + +| Resource Type | Created By | Freed By | +|--------------|-----------|----------| +| Handle (`cose_*_t*`) | `cose_*_new()` / `cose_*_build()` | `cose_*_free()` | +| String (`char*`) | `cose_*_utf8()` | `cose_string_free()` | +| Byte buffer (`uint8_t*`, len) | `cose_*_bytes()` | `cose_*_bytes_free()` | +| `ByteView` | Borrowed from handle | Do NOT free — valid while parent lives | +| C++ RAII object | Constructor | Destructor (automatic) | \ No newline at end of file diff --git a/native/docs/MEMORY-PRINCIPLES.md b/native/docs/MEMORY-PRINCIPLES.md new file mode 100644 index 00000000..85b9e94c --- /dev/null +++ b/native/docs/MEMORY-PRINCIPLES.md @@ -0,0 +1,624 @@ + + +# Memory Design Principles + +> **The definitive reference for memory architecture across the native Rust, C, and C++ stack.** +> +> Every design decision in this document traces back to one axiom: +> +> ***Every byte should be allocated at most once.*** + +--- + +## Table of Contents + +1. [Philosophy](#1-philosophy) +2. [Core Primitives](#2-core-primitives) +3. [Operation Memory Profiles](#3-operation-memory-profiles) +4. [Cross-Layer Patterns](#4-cross-layer-patterns) +5. [Structurally Required Allocations](#5-structurally-required-allocations) +6. [Allocation Review Checklist](#6-allocation-review-checklist) + +--- + +## 1. Philosophy + +The native COSE stack is built on five interlocking design principles. Each one +eliminates an entire class of unnecessary memory operations. + +### 1.1 — Parse Once, Share Everywhere + +When a COSE_Sign1 message is parsed, the raw CBOR bytes are wrapped in a +single `Arc<[u8]>`. Every downstream structure — headers, payload, signature — +holds a `Range` into that same allocation. Cloning a +`CoseSign1Message` increments a reference count; it never deep-copies the +backing buffer. + +``` + ┌──────────────────────────────────────────┐ + │ Arc<[u8]> (one allocation) │ + │ ┌──────┬──────────┬──────┬───────────┐ │ + │ │ tag? │protected │ pay- │ signature │ │ + │ │ │ headers │ load │ │ │ + │ └──┬───┴────┬─────┴──┬───┴─────┬─────┘ │ + └─────│────────│────────│─────────│─────────┘ + │ │ │ │ + Range Range Range Range + │ │ │ │ + ▼ ▼ ▼ ▼ + LazyHeaderMap LazyHeaderMap payload_range signature_range +``` + +*Source: `cose_primitives::data::CoseData`, `cose_sign1_primitives::CoseSign1Message`* + +### 1.2 — Lazy Parsing via `OnceLock` + +Header maps are **not decoded at parse time**. `LazyHeaderMap` stores the raw +CBOR byte range and a `OnceLock`. Parsing happens at most once, +on first access, and the decoded values share the original `Arc<[u8]>` through +`ArcSlice` and `ArcStr` — zero additional copies for byte/text string values. + +``` +LazyHeaderMap + ├── raw: Arc<[u8]> ← same allocation as parent message + ├── range: Range ← byte range of CBOR header map + └── parsed: OnceLock + │ + └─ populated on first .headers() call + │ + ├── ArcSlice { data: Arc<[u8]>, range } ← zero-copy bstr value + └── ArcStr { data: Arc<[u8]>, range } ← zero-copy tstr value +``` + +**Why this matters:** A validation pipeline that only inspects the algorithm +header and content type will never decode the KID, CWT claims, or any other +header field. For messages with large unprotected headers (e.g., embedded +receipts), this avoids substantial CBOR decoding work entirely. + +*Source: `cose_primitives::lazy_headers::LazyHeaderMap`* + +### 1.3 — Streaming for Large Payloads + +For payloads that exceed available memory (multi-GB files), the stack uses +streaming modes that keep peak memory independent of payload size: + +| Operation | Streaming API | Peak Memory | +|-----------|---------------|-------------| +| Parse | `parse_stream()` | `O(header_size + sig_size)` — typically < 1 KB | +| Sign | `sign_streaming()` via `SigStructureHasher` | `O(64 KB)` — one chunk buffer | +| Verify | `verify_payload_streaming()` | `O(64 KB)` — one chunk buffer | + +The payload never touches Rust heap memory. It flows from disk/stream directly +through the cryptographic hasher or verifier in 64 KB chunks. + +``` + ┌─────────┐ 64 KB chunks ┌──────────────┐ digest ┌──────────┐ + │ File / │ ──────────────────▶ │ SigStructure │ ────────────▶ │ Signer │ + │ Stream │ │ Hasher │ │ /Verifier│ + └─────────┘ └──────────────┘ └──────────┘ + stack-allocated + hash output: + [u8; 32] (SHA-256) + [u8; 48] (SHA-384) + [u8; 64] (SHA-512) +``` + +*Source: `cose_sign1_primitives::sig_structure`, `CoseData::Streamed`* + +### 1.4 — Error Paths Use `Cow<'static, str>` + +All error types use `Cow<'static, str>` for message fields. The critical +insight: **most error messages are static string literals known at compile +time**. They borrow directly from the read-only data segment — zero heap +allocation on the error path. + +```rust +// Static literal → borrows from binary, zero alloc +Cow::Borrowed("payload must not be empty") + +// Dynamic message → allocates only when needed (rare path) +Cow::Owned(format!("expected algorithm {}, got {}", expected, actual)) +``` + +This pattern appears throughout the stack: + +| Type | Location | Fields using `Cow<'static, str>` | +|------|----------|----------------------------------| +| `SigningError` | `signing/core/src/error.rs` | `detail` in all variants | +| `ValidationFailure` | `validation/core/src/validator.rs` | `message`, `error_code`, `property_name`, `attempted_value`, `exception` | +| `ValidationResult` | `validation/core/src/validator.rs` | `validator_name` | +| `ValidatorError` | `validation/core/src/validator.rs` | `CoseDecode`, `Trust` variants | + +### 1.5 — Facts Use `Arc` for Shared Immutable Strings + +The trust-fact engine produces facts that may be queried by multiple trust plan +rules. String-valued facts (certificate thumbprints, subjects, issuers, DID +components) use `Arc` — a reference-counted immutable string — so that +fact lookups never clone the underlying string data. + +```rust +// Arc created once during fact production +let thumbprint: Arc = Arc::from(hex_encode_upper(&sha256_hasher.finalize())); + +// Every rule that reads this fact gets a cheap Arc clone (pointer + refcount) +let t = facts.get::(); // Arc clone, not String clone +``` + +*Source: `validation/primitives/src/facts.rs`, `extension_packs/certificates/src/validation/facts.rs`* + +--- + +## 2. Core Primitives + +### 2.1 — Type Reference Table + +| Type | Heap Allocs | Copy Cost | Use Case | +|------|-------------|-----------|----------| +| `Arc<[u8]>` | 1 (backing buffer) | Refcount increment | Message backing store — all parsed fields index into this | +| `ArcSlice` | 0 (borrows `Arc<[u8]>`) | Refcount increment | Zero-copy sub-range: payload, signature, header bstr values | +| `ArcStr` | 0 (borrows `Arc<[u8]>`) | Refcount increment | Zero-copy UTF-8 sub-range: header tstr values | +| `Arc` | 1 (small string) | Refcount increment | Immutable shared strings: fact values, content types | +| `Cow<'static, str>` | 0 (static) or 1 (dynamic) | Borrow or clone | Error messages: static literals borrow, dynamic strings own | +| `LazyHeaderMap` | 0 until accessed | OnceLock init cost | Deferred CBOR deserialization of header maps | +| `GenericArray` | 0 (stack) | `memcpy` on stack | Hash digests: SHA-256 (32B), SHA-384 (48B), SHA-512 (64B) | +| `[u8; 32]` | 0 (stack) | `memcpy` on stack | Fixed-size hash digests for known algorithms | +| `CoseData::Buffered` | 1 (`Arc<[u8]>`) | Refcount increment | In-memory COSE message bytes | +| `CoseData::Streamed` | 1 (small `header_buf`) | Refcount increment | Large payloads: headers buffered, payload on disk | +| `Range` | 0 (2 × `usize`) | Trivial copy | Byte range into backing `Arc<[u8]>` | + +### 2.2 — `ArcSlice`: Zero-Copy Byte Window + +`ArcSlice` holds a shared reference to the parent `Arc<[u8]>` and a +`Range` describing the sub-region it represents. Dereferencing an +`ArcSlice` returns `&[u8]` — a borrow into the original allocation. + +``` + ArcSlice Arc<[u8]> + ┌──────────────┐ ┌──────────────────────────────┐ + │ data ─────────────────────▶ │ 0xD8 0x12 0xA1 0x01 0x26 … │ + │ range: 3..7 │ └──────────────────────────────┘ + └──────────────┘ ▲▲▲▲ + ││││ + .as_bytes() returns &[0x01, 0x26, …, …] +``` + +**Construction paths:** + +| Path | How | Allocates? | +|------|-----|------------| +| **Parse path** | `ArcSlice::new(arc, range)` — shares parent's `Arc` | No | +| **Parse path** | `ArcSlice::from_sub_slice(parent, sub_slice)` — pointer arithmetic | No | +| **Builder path** | `ArcSlice::from(vec)` — wraps `Vec` in new `Arc` | Yes (small) | + +*Source: `cose_primitives::arc_types::ArcSlice`* + +### 2.3 — `ArcStr`: Zero-Copy UTF-8 String Window + +Identical layout to `ArcSlice`, but guarantees UTF-8 validity. Constructed from +CBOR tstr values during header map decoding — shares the message's `Arc<[u8]>` +buffer with no additional allocation. + +*Source: `cose_primitives::arc_types::ArcStr`* + +### 2.4 — `LazyHeaderMap`: Deferred CBOR Deserialization + +| Method | Behavior | Triggers parse? | +|--------|----------|-----------------| +| `as_bytes()` | Returns raw CBOR `&[u8]` | No | +| `range()` | Returns byte range | No | +| `arc()` | Returns `&Arc<[u8]>` | No | +| `is_parsed()` | Check if parsed | No | +| `headers()` | Decode and cache; return `&CoseHeaderMap` | Yes (once) | +| `try_headers()` | Same, propagating CBOR errors | Yes (once) | +| `get(label)` | Delegate to `headers().get(label)` | Yes (once) | +| `insert(label, value)` | Mutate parsed map | Yes (once) | + +The `OnceLock` ensures thread-safe one-time initialization. Concurrent callers +block on the first parse; all subsequent calls return the cached result. + +*Source: `cose_primitives::lazy_headers::LazyHeaderMap`* + +### 2.5 — `CoseData`: The Ownership Root + +`CoseData` is an enum with two variants that govern the memory model for the +entire message: + +``` +CoseData::Buffered CoseData::Streamed +┌────────────────────────┐ ┌─────────────────────────────┐ +│ raw: Arc<[u8]> │ │ header_buf: Arc<[u8]> │ +│ (entire CBOR msg) │ │ (headers + sig only) │ +│ range: 0..len │ │ protected_range, unprotected│ +│ (sub-messages may │ │ _range, signature_range │ +│ use a sub-range) │ │ source: Arc>│ +└────────────────────────┘ │ payload_offset: u64 │ + │ payload_len: u64 │ + └─────────────────────────────┘ +``` + +For `Streamed`, the payload is *never* loaded into memory. It lives on the +underlying `ReadSeek` source (typically a file) and is accessed by seeking to +`payload_offset` and reading `payload_len` bytes in chunks. + +*Source: `cose_primitives::data::CoseData`* + +--- + +## 3. Operation Memory Profiles + +### 3.1 — Parse + +| Mode | API | Peak Memory | Allocations | Description | +|------|-----|-------------|-------------|-------------| +| **Buffered** | `CoseSign1Message::parse()` | `O(n)` where n = message size | 1 × `Arc<[u8]>` | Entire CBOR in one allocation; all fields are ranges | +| **Streamed** | `CoseSign1Message::parse_stream()` | `O(h + s)` where h = headers, s = signature | 1 × small `Arc<[u8]>` | Typically < 1 KB; payload stays on disk | + +**Buffered parse — allocation sequence:** + +``` +Input bytes ──▶ Arc::from(bytes) ──▶ CoseSign1Message + │ ├── protected: LazyHeaderMap { arc, 4..47 } + │ ├── unprotected: LazyHeaderMap { arc, 47..52 } + │ ├── payload_range: Some(52..1052) + │ └── signature_range: 1052..1116 + │ + └── ONE heap allocation. Everything else is Range. +``` + +### 3.2 — Sign + +| Mode | API | Peak Memory | Description | +|------|-----|-------------|-------------| +| **Buffered** | `CoseSign1Builder::sign()` | `O(p + s)` | p = payload, s = Sig_structure | +| **Streaming** | `sign_streaming()` | `O(64 KB + prefix)` | Payload streamed through hasher in 64 KB chunks | + +**Streaming sign — memory timeline:** + +``` +Time ─────────────────────────────────────────────────────────▶ + +1. Sig_structure prefix ┌─ ~200 bytes (CBOR array header + protected bytes) + └─ stack-allocated, written to hasher + +2. Payload streaming ┌─ 64 KB chunk buffer (reused) + (10 GB file) │ read → hash → read → hash → ... + └─ 64 KB constant, regardless of payload size + +3. Hash finalization ┌─ 32–64 bytes (stack GenericArray or [u8; N]) + └─ no heap allocation + +4. Signing ┌─ ~72–132 bytes (ECDSA/RSA signature) + └─ one Vec allocation for the signature output + +Peak total: ~65 KB +``` + +### 3.3 — Verify + +| Mode | API | Peak Memory | Description | +|------|-----|-------------|-------------| +| **Buffered** | `verify()` / `verify_detached()` | `O(p + s)` | Full Sig_structure materialized | +| **Streaming** | `verify_payload_streaming()` | `O(64 KB)` | Prefix + payload chunks fed to `VerifyingContext` | +| **Fallback** | (non-streaming verifier) | `O(p + s)` | Ed25519/ML-DSA must buffer entire payload | + +### 3.4 — Algorithm Streaming Support Matrix + +| Algorithm | COSE ID | Streaming? | Reason | +|-----------|---------|------------|--------| +| ES256 | -7 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| ES384 | -35 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| ES512 | -36 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| PS256 | -37 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| PS384 | -38 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| PS512 | -39 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| RS256 | -257 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| RS384 | -258 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| RS512 | -259 | ✅ | OpenSSL `EVP_DigestVerify` — incremental | +| EdDSA | -8 | ❌ | Ed25519 requires full message before sign/verify | +| ML-DSA-* | TBD | ❌ | Post-quantum; requires full message | + +> **Design implication:** `verify_payload_streaming()` queries +> `supports_streaming()` on the verifier. When it returns `false`, the +> function falls back to full materialization. For a 10 GB payload +> with Ed25519, you need 10 GB of RAM. + +### 3.5 — Scenario Profiles + +#### Small Payload (100 bytes) + +All modes are equivalent. Overhead is dominated by Sig_structure CBOR +framing (~200 bytes) and signature size (~64–132 bytes). + +**Total peak: ~500 bytes.** Use `parse()` + `verify()` for simplicity. + +#### Large Streaming Verify (10 GB payload, ECDSA) + +``` +parse_stream(file) → ~1 KB (headers + signature in header_buf) +verify_payload_streaming() → ~65 KB (64 KB chunk buffer + prefix) + ───────── +Peak total: ~66 KB +``` + +The 10 GB payload is never loaded into memory. + +#### Large Streaming Sign (10 GB payload) + +``` +SigStructureHasher::init() → ~200 B (CBOR prefix) +stream 10 GB in 64 KB chunks → 64 KB (reused buffer) +hasher.finalize() → 32–64 B (stack-allocated hash) +signer.sign(&hash) → ~100 B (signature output) + ───────── +Peak total: ~65 KB +``` + +--- + +## 4. Cross-Layer Patterns + +### 4.1 — Data Flow Through the Stack + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ C++ Application │ +│ │ +│ auto msg = CoseSign1Message::Parse(bytes); │ +│ ByteView payload = msg.Payload(); ← borrowed pointer into Rust Arc │ +│ auto vec = msg.PayloadAsVector(); ← copies only if caller needs it │ +│ builder.ConsumeProtected(std::move(h)); ← release() transfers ownership │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ C Headers │ +│ │ +│ cose_sign1_message_payload(handle, &ptr, &len); ← ptr borrows from Arc │ +│ // ptr valid until handle is freed — caller never allocates │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ FFI Boundary (extern "C") │ +│ │ +│ // Borrow: return pointer into Arc-backed data │ +│ *out_ptr = inner.payload().as_ptr(); ← zero copy │ +│ *out_len = inner.payload().len(); │ +│ │ +│ // Ownership transfer: .to_vec() only when C must own the bytes │ +│ let vec = inner.encode(); ← allocates caller-owned copy │ +│ *out_ptr = Box::into_raw(vec); │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Rust Library Layer │ +│ │ +│ CoseSign1Message::parse(bytes) ← one Arc<[u8]>, everything shared │ +│ message.protected().headers() ← OnceLock parse, ArcSlice values │ +│ validator.validate(&message, &arc) ← Arc clones only (refcount bump) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 — Layer-by-Layer Rules + +#### Rust Library Layer + +| Pattern | Rule | Example | +|---------|------|---------| +| Message fields | `Range` into `Arc<[u8]>` | `payload_range: Option>` | +| Header values | `ArcSlice` / `ArcStr` from shared buffer | `CoseHeaderValue::Bytes(ArcSlice)` | +| Fact strings | `Arc` for shared immutable strings | `thumbprint: Arc` | +| Error messages | `Cow<'static, str>` | `Cow::Borrowed("missing content type")` | +| Message sharing | `Arc` | Validator and fact producers share same `Arc` | +| Builder consumption | Move fields out of `self`, never clone | `builder.protected` → moved into message | + +#### FFI Boundary + +| Operation | Technique | Allocates? | +|-----------|-----------|------------| +| **Borrow data to C** | Return `*const u8` + `u32` length pointing into `Arc` | No | +| **Transfer ownership to C** | `.to_vec()` → `Box::into_raw()` | Yes (required) | +| **Borrow handle from C** | `*const Handle` → `handle.as_ref()` | No | +| **Consume handle from C** | `*mut Handle` → `Box::from_raw()` | No | +| **Receive C callback output** | `slice::from_raw_parts()` → `.to_vec()` | Yes (required) | + +#### C Projection + +| Pattern | Rule | +|---------|------| +| **Byte access** | Always `const uint8_t* + uint32_t len` (borrowed from Rust handle) | +| **Caller never allocates** | All output buffers are Rust-allocated; C receives pointers | +| **Lifetime** | Borrowed pointers valid until the owning handle is freed | +| **Ownership transfer** | `*_free()` function documented on every handle | + +#### C++ Projection + +| Type | What It Does | Allocates? | +|------|-------------|------------| +| `ByteView` | `{const uint8_t* data, size_t size}` — borrows from Rust handle | No | +| `std::vector` return | Copies bytes out — caller owns the vector | Yes | +| `release()` | Transfers handle ownership to another wrapper | No | +| `std::move()` | C++ move semantics → calls `release()` internally | No | + +> **Design rule:** Every C++ method that returns `std::vector` (a copy) +> must have a `@see` comment pointing to the zero-copy `ByteView` or +> `ToMessage` alternative. + +### 4.3 — Ownership Transfer Patterns + +``` + Borrow (zero-copy) Consume (zero-copy move) + ───────────────────── ───────────────────────── + C++: headers.GetBytes(label) C++: builder.ConsumeProtected(move(h)) + → ByteView (borrowed) → h.release() transfers handle + FFI: *const HeaderMapHandle FFI: *mut HeaderMapHandle + → handle.as_ref() → Box::from_raw(handle) + Rust: &CoseHeaderMap Rust: CoseHeaderMap (moved into builder) + → ArcSlice from shared Arc → no clone needed + + Copy (when ownership transfer Copy-on-write (amortized) + to C caller is required) ───────────────────────── + ───────────────────────── C++: builder.SetProtected(headers) + C++: msg.PayloadAsVector() → copies headers (handle still valid) + → std::vector FFI: *const HeaderMapHandle + FFI: inner.encode().to_vec() → headers.clone() inside Rust + → Box::into_raw(boxed_vec) Rust: CoseHeaderMap::clone() + Caller: must free with *_free() → deep copy of map entries +``` + +--- + +## 5. Structurally Required Allocations + +These allocations **cannot be eliminated** without fundamental architecture +changes. Each one is documented here to prevent well-intentioned "optimization" +attempts that would cascade breakage through the stack. + +### 5.1 — Allocation Inventory + +| # | Allocation | Location | Why Required | Zero-Copy Alternative | +|---|-----------|----------|--------------|----------------------| +| 1 | `payload.to_vec()` in factory | `signing/factories/` | `SigningContext` takes ownership of payload bytes. Changing to borrowed would cascade lifetime parameters through `SigningService` trait, all factory implementations, and the FFI boundary. | None — ownership boundary | +| 2 | `.to_vec()` on FFI callback return | `signing/core/ffi/` | C callbacks allocate with `malloc`; Rust must copy to its own heap before the C caller can `free()` the original. Two allocators cannot share ownership. | None — allocator boundary | +| 3 | `message.clone()` in `validate()` | `validation/core/src/validator.rs` | Backward-compatible API. `validate()` takes `&CoseSign1Message` and must clone internally for the pipeline. | **`validate_arc()`** — takes `Arc`, zero-copy sharing | +| 4 | `headers.clone()` in `set_protected()` | `signing/core/ffi/` | FFI handle is borrowed (`*const`), so Rust must clone the headers to own them. | **`consume_protected()`** — takes `*mut`, moves via `Box::from_raw` | +| 5 | `ContentType` as `String` | `validation/core/src/message_facts.rs` | The `ContentType` field in the `ContentTypeFact` uses `String` because the trust plan engine's `Field` binding requires an owned string for type erasure. | `Arc` used for fact values; `String` at plan binding boundary | +| 6 | Post-sign verification reparse | `signing/factories/` | After signing, the factory calls `CoseSign1Message::parse()` on the output bytes to verify the signature. This is an `O(n)` CBOR reparse on top of the `O(1000×n)` crypto cost — negligible. The reparse catches serialization bugs before the bytes escape the factory. | None — defense-in-depth requirement | +| 7 | `ArcSlice::from(vec)` on builder path | `cose_primitives::arc_types` | Builder-constructed header values are typically small (`Vec` from CWT claim encoding). Each wraps in its own `Arc`. Acceptable because builder values are small header fields (< 1 KB), not megabyte payloads. | None for builder path — parse path is zero-copy | + +### 5.2 — Decision Diagram + +When encountering a `.clone()`, `.to_vec()`, or `.to_owned()` call, use this +decision tree to determine if it's justified: + +``` + Is the data crossing an FFI boundary? + ┌───── YES ────────────────────┐ + │ │ + ▼ │ + Is C caller taking Is it a callback return + ownership of bytes? from C → Rust? + ┌─── YES ──┐ ┌─── YES ──┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + .to_vec() Return *const .to_vec() Return ref + REQUIRED (zero-copy REQUIRED (zero-copy + borrow) (allocator borrow) + boundary) + │ + ▼ NO + │ + Is there a zero-copy alternative API? + ┌─── YES ──────────────────────┐ + │ │ + ▼ ▼ NO + Use it: Document in this table + validate_arc() (Section 5.1) and add + consume_protected() a code comment explaining + SignDirectToMessage() why the allocation exists. +``` + +--- + +## 6. Allocation Review Checklist + +Use this checklist when reviewing PRs that touch native code. Any unchecked +item is a potential review blocker. + +### 6.1 — Rust Code + +- [ ] **No gratuitous `.clone()` on `Arc<[u8]>`, `ArcSlice`, `Vec`, or `CoseSign1Message`.** + If a clone exists, it must be in the [Structurally Required](#5-structurally-required-allocations) + table or have a `// clone required because: ...` comment. + +- [ ] **Error types use `Cow<'static, str>`, not `String`.** + Static error messages must use `Cow::Borrowed("...")`, not `"...".to_string()`. + +- [ ] **Fact values use `Arc`, not `String`.** + Trust fact fields that are shared across rules must be `Arc` to avoid + cloning on each rule evaluation. + +- [ ] **No `.to_string()` on string literals in error paths.** + Use `.into()` instead, which resolves to `Cow::Borrowed` for `&'static str`. + +- [ ] **FFI handle-to-inner functions use bounded `<'a>`, not `'static`.** + A `'static` lifetime on a handle reference is unsound — the handle can be + freed at any time. + + ```rust + // ✅ Correct: bounded lifetime + unsafe fn handle_to_inner<'a>(h: *const H) -> Option<&'a Inner> + + // ❌ Unsound: 'static on heap-allocated handle + unsafe fn handle_to_inner(h: *const H) -> Option<&'static Inner> + ``` + +- [ ] **Builder patterns move fields, not clone them.** + When a builder is consumed (`Box::from_raw` on FFI side, or `self` consumption + in Rust), fields should be moved out of the struct, not cloned. + +- [ ] **New `LazyHeaderMap` access does not trigger unnecessary parsing.** + If only raw bytes are needed (e.g., for Sig_structure), use `.as_bytes()` + not `.headers()`. + +- [ ] **Streaming APIs use fixed-size buffers.** + Chunk buffers in sign/verify streaming paths must be constant-size (64 KB), + never proportional to payload size. + +- [ ] **Hash digests are stack-allocated.** + SHA-256/384/512 outputs use `GenericArray` or `[u8; N]`, not `Vec`. + +### 6.2 — FFI Code + +- [ ] **Borrow vs. own is explicit in pointer types.** + `*const` = borrowed (caller may reuse handle). `*mut` = consumed (handle + invalidated after call). + +- [ ] **Every `Box::into_raw()` has a documented `*_free()` counterpart.** + +- [ ] **Null checks on ALL pointer parameters before dereference.** + +- [ ] **`catch_unwind` wraps all `extern "C"` function bodies.** + +- [ ] **String ownership is documented.** `*mut c_char` = caller must free. + `*const c_char` = borrowed from Rust, valid until handle is freed. + +### 6.3 — C/C++ Projection Code + +- [ ] **Byte accessors return `ByteView` (borrowed), not `std::vector` (copied).** + If a copy method exists, it must have a `@see` pointing to the zero-copy alternative. + +- [ ] **C++ classes are move-only.** Copy constructor and copy assignment are + `= delete`. Move constructor nulls the source handle. + + ```cpp + // ✅ Correct + MyHandle(MyHandle&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + ``` + +- [ ] **Destructors guard against double-free.** `if (handle_)` before calling + the Rust `*_free()` function. + +- [ ] **`release()` is used for ownership transfer**, not raw pointer + extraction followed by manual free. + +### 6.4 — Quick Reference: Preferred vs. Avoided + +| Context | ✅ Preferred | ❌ Avoided | +|---------|-------------|-----------| +| Error detail | `Cow::Borrowed("msg")` | `"msg".to_string()` | +| Error detail (dynamic) | `Cow::Owned(format!(...))` | `format!(...).to_string()` | +| Fact string | `Arc::::from(s)` | `s.to_string()` stored as `String` | +| Header byte value | `ArcSlice::new(arc, range)` | `arc[range].to_vec()` | +| Message sharing | `Arc::new(message)` then `.clone()` | `message.clone()` (deep copy) | +| Builder field transfer | `std::mem::take(&mut self.field)` | `self.field.clone()` | +| Hash output | `GenericArray` (stack) | `Vec` (heap) | +| C++ byte access | `ByteView payload = msg.Payload()` | `std::vector p = msg.PayloadAsVector()` | +| FFI handle borrow | `handle.as_ref()` (`*const`) | `Box::from_raw()` on `*const` (unsound) | +| FFI handle consume | `Box::from_raw(handle)` (`*mut`) | `handle.as_ref()` then `.clone()` | + +--- + +## Further Reading + +- [Memory Characteristics](../rust/docs/memory-characteristics.md) — per-crate memory breakdown and scenario analysis +- [Architecture](ARCHITECTURE.md) — full native stack architecture and layer diagram +- [Zero-Copy Design Instructions](../.github/instructions/zero-copy-design.instructions.md) — Copilot agent instructions for maintaining zero-copy patterns \ No newline at end of file diff --git a/native/rust/Cargo.lock b/native/rust/Cargo.lock index 1f3fa701..6e74823d 100644 --- a/native/rust/Cargo.lock +++ b/native/rust/Cargo.lock @@ -174,6 +174,22 @@ dependencies = [ "url", ] +[[package]] +name = "azure_security_keyvault_certificates" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f278ea7890a007301a5669496e7f740a51c9a15a5f7ad5ae8dd0302f36ba6c59" +dependencies = [ + "async-lock", + "async-trait", + "azure_core", + "futures", + "rustc_version", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "azure_security_keyvault_keys" version = "0.12.0" @@ -381,12 +397,12 @@ dependencies = [ "cose_sign1_validation_primitives", "crypto_primitives", "did_x509", - "once_cell", "openssl", "rcgen", "serde_json", "sha2", "tokio", + "x509-parser", ] [[package]] @@ -407,6 +423,7 @@ dependencies = [ "async-trait", "azure_core", "azure_identity", + "azure_security_keyvault_certificates", "azure_security_keyvault_keys", "base64", "cbor_primitives", @@ -418,7 +435,6 @@ dependencies = [ "cose_sign1_validation", "cose_sign1_validation_primitives", "crypto_primitives", - "once_cell", "regex", "serde_json", "sha2", @@ -554,7 +570,6 @@ dependencies = [ "cose_sign1_signing", "crypto_primitives", "libc", - "once_cell", "openssl", "tempfile", ] @@ -623,7 +638,6 @@ dependencies = [ "cose_sign1_signing", "crypto_primitives", "libc", - "once_cell", "openssl", "tempfile", ] @@ -644,7 +658,6 @@ dependencies = [ "cose_sign1_validation", "cose_sign1_validation_primitives", "crypto_primitives", - "once_cell", "openssl", "serde", "serde_json", @@ -705,7 +718,6 @@ dependencies = [ "cbor_primitives", "cbor_primitives_everparse", "cose_sign1_primitives", - "once_cell", "regex", "sha2", ] diff --git a/native/rust/Cargo.toml b/native/rust/Cargo.toml index 01543b3b..6b86f712 100644 --- a/native/rust/Cargo.toml +++ b/native/rust/Cargo.toml @@ -40,6 +40,7 @@ members = [ [workspace.package] edition = "2021" license = "MIT" +version = "0.1.0" [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } @@ -73,8 +74,8 @@ x509-parser = "0.18" openssl = "0.10" # Concurrency + plumbing -once_cell = "1" -parking_lot = "0.12" +# once_cell removed — migrated to std::sync::LazyLock (Rust 1.80+) +# parking_lot removed — unused (std::sync::Mutex suffices) regex = "1" url = "2" @@ -82,7 +83,7 @@ url = "2" azure_core = { version = "0.33", default-features = false, features = ["reqwest", "reqwest_native_tls"] } azure_identity = "0.33" azure_security_keyvault_keys = "0.12" -azure_security_keyvault_certificates = "0.11" +azure_security_keyvault_certificates = "0.11" # Planned: proper cert fetch in AKV certificate source tokio = { version = "1", features = ["rt", "macros"] } reqwest = { version = "0.13", features = ["json", "rustls-tls"] } async-trait = "0.1" diff --git a/native/rust/allowed-dependencies.toml b/native/rust/allowed-dependencies.toml index a95bb7e0..da8e5805 100644 --- a/native/rust/allowed-dependencies.toml +++ b/native/rust/allowed-dependencies.toml @@ -108,6 +108,7 @@ ring = "Local hashing for message digests" azure_core = "Azure SDK HTTP pipeline with retry, telemetry, credentials" azure_identity = "Azure identity credentials (DeveloperToolsCredential, ManagedIdentity, ClientSecret)" azure_security_keyvault_keys = "Azure Key Vault Keys client (sign, verify, get_key)" +azure_security_keyvault_certificates = "Azure Key Vault Certificates client (fetch X.509 leaf cert)" tokio = "Async runtime for Azure SDK (block_on at FFI boundary)" [crate.cose_sign1_azure_artifact_signing] @@ -115,6 +116,8 @@ azure_core = "Azure SDK HTTP pipeline for AAS certificate/signing API calls" azure_identity = "Azure identity credentials for authenticating to AAS" tokio = "Async runtime for Azure SDK (block_on at FFI boundary)" once_cell = "Lazy initialization of AAS clients" +openssl = "X.509 certificate parsing for PKCS#7 chain extraction and EKU inspection" +x509-parser = "Direct X.509 extension parsing for EKU OID extraction" [crate.x509] x509-parser = "X.509 certificate parsing" @@ -161,6 +164,7 @@ ring = "Local hashing for message digests" azure_core = "Azure SDK HTTP pipeline with retry, telemetry, credentials" azure_identity = "Azure identity credentials (DeveloperToolsCredential, ManagedIdentity, ClientSecret)" azure_security_keyvault_keys = "Azure Key Vault Keys client (sign, verify, get_key)" +azure_security_keyvault_certificates = "Azure Key Vault Certificates client (fetch X.509 leaf cert)" tokio = "Async runtime for Azure SDK (block_on at FFI boundary)" [crate.azure_artifact_signing] @@ -169,6 +173,8 @@ azure_identity = "Azure identity credentials for authenticating to AAS" tokio = "Async runtime for Azure SDK (block_on at FFI boundary)" once_cell = "Lazy initialization of AAS clients" base64 = "Base64 encoding/decoding for digest and cert bytes" +openssl = "X.509 certificate parsing for PKCS#7 chain extraction and EKU inspection" +x509-parser = "Direct X.509 extension parsing for EKU OID extraction" [crate.client] azure_core = "Azure SDK HTTP pipeline" diff --git a/native/rust/did/x509/Cargo.toml b/native/rust/did/x509/Cargo.toml index eb092b1b..159d4cc0 100644 --- a/native/rust/did/x509/Cargo.toml +++ b/native/rust/did/x509/Cargo.toml @@ -20,5 +20,8 @@ hex = "0.4" sha2.workspace = true openssl = { workspace = true } -[lints.rust] +[[example]] +name = "did_x509_basics" + +[lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } diff --git a/native/rust/did/x509/examples/did_x509_basics.rs b/native/rust/did/x509/examples/did_x509_basics.rs new file mode 100644 index 00000000..6731eaaf --- /dev/null +++ b/native/rust/did/x509/examples/did_x509_basics.rs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! DID:x509 basics — parse, build, validate, and resolve workflows. +//! +//! Run with: +//! cargo run --example did_x509_basics -p did_x509 + +use std::borrow::Cow; + +use did_x509::{ + DidX509Builder, DidX509Parser, DidX509Policy, DidX509Resolver, DidX509Validator, SanType, +}; +use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair}; +use sha2::{Digest, Sha256}; + +fn main() { + // ── 1. Generate an ephemeral CA + leaf certificate chain ────────── + println!("=== Step 1: Create ephemeral certificate chain ===\n"); + + let (ca_der, leaf_der) = create_cert_chain(); + let chain: Vec<&[u8]> = vec![leaf_der.as_slice(), ca_der.as_slice()]; + + let ca_thumbprint = hex::encode(Sha256::digest(&ca_der)); + println!(" CA thumbprint (SHA-256): {}", ca_thumbprint); + + // ── 2. Build a DID:x509 identifier from the chain ──────────────── + println!("\n=== Step 2: Build DID:x509 identifiers ===\n"); + + // Build with an EKU policy (code-signing OID 1.3.6.1.5.5.7.3.3) + let eku_policy = DidX509Policy::Eku(vec![Cow::Borrowed("1.3.6.1.5.5.7.3.3")]); + let did_eku = + DidX509Builder::build_sha256(&ca_der, &[eku_policy.clone()]).expect("build EKU DID"); + println!(" DID (EKU): {}", did_eku); + + // Build with a subject policy + let subject_policy = + DidX509Policy::Subject(vec![("CN".to_string(), "Example Leaf".to_string())]); + let did_subject = DidX509Builder::build_sha256(&ca_der, &[subject_policy.clone()]) + .expect("build subject DID"); + println!(" DID (Subject): {}", did_subject); + + // Build with a SAN policy + let san_policy = DidX509Policy::San(SanType::Dns, "leaf.example.com".to_string()); + let did_san = + DidX509Builder::build_sha256(&ca_der, &[san_policy.clone()]).expect("build SAN DID"); + println!(" DID (SAN): {}", did_san); + + // ── 3. Parse DID:x509 identifiers back into components ─────────── + println!("\n=== Step 3: Parse DID:x509 identifiers ===\n"); + + let parsed = DidX509Parser::parse(&did_eku).expect("parse DID"); + println!(" Hash algorithm: {}", parsed.hash_algorithm); + println!(" CA fingerprint hex: {}", parsed.ca_fingerprint_hex); + println!(" Has EKU policy: {}", parsed.has_eku_policy()); + println!(" Has subject policy: {}", parsed.has_subject_policy()); + + if let Some(eku_oids) = parsed.get_eku_policy() { + println!(" EKU OIDs: {:?}", eku_oids); + } + + // ── 4. Validate DID against the certificate chain ──────────────── + println!("\n=== Step 4: Validate DID against certificate chain ===\n"); + + // Validate the SAN-based DID (leaf cert has SAN: dns:leaf.example.com) + let result = DidX509Validator::validate(&did_san, &chain).expect("validate DID"); + println!(" DID (SAN) valid: {}", result.is_valid); + println!(" Matched CA index: {:?}", result.matched_ca_index); + + // Validate subject-based DID (leaf cert has CN=Example Leaf) + let result = DidX509Validator::validate(&did_subject, &chain).expect("validate subject DID"); + println!(" DID (Subject) valid: {}", result.is_valid); + + // Demonstrate a failing validation with a wrong subject + let wrong_subject = DidX509Policy::Subject(vec![("CN".to_string(), "Wrong Name".to_string())]); + let did_wrong = + DidX509Builder::build_sha256(&ca_der, &[wrong_subject]).expect("build wrong DID"); + let result = DidX509Validator::validate(&did_wrong, &chain).expect("validate wrong DID"); + println!( + " DID (wrong CN) valid: {} (expected false)", + result.is_valid + ); + if !result.errors.is_empty() { + println!(" Validation errors: {:?}", result.errors); + } + + // ── 5. Resolve DID to a DID Document ───────────────────────────── + println!("\n=== Step 5: Resolve DID to DID Document ===\n"); + + let doc = DidX509Resolver::resolve(&did_san, &chain).expect("resolve DID"); + let doc_json = doc.to_json(true).expect("serialize DID Document"); + println!("{}", doc_json); + + println!("\n=== All steps completed successfully! ==="); +} + +/// Create an ephemeral CA and leaf certificate chain using rcgen. +/// Returns (ca_der, leaf_der) — both DER-encoded. +fn create_cert_chain() -> (Vec, Vec) { + // CA certificate + let mut ca_params = CertificateParams::new(vec!["Example CA".to_string()]).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Example CA"); + ca_params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "Example Org"); + + let ca_key = KeyPair::generate().unwrap(); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + let ca_der = ca_cert.der().to_vec(); + + // Create an Issuer from the CA params + key for signing the leaf. + // Note: Issuer::new consumes the params, so we rebuild them. + let mut ca_issuer_params = CertificateParams::new(vec!["Example CA".to_string()]).unwrap(); + ca_issuer_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_issuer_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Example CA"); + ca_issuer_params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "Example Org"); + let ca_issuer = Issuer::new(ca_issuer_params, ca_key); + + // Leaf certificate signed by CA + let mut leaf_params = CertificateParams::new(vec!["leaf.example.com".to_string()]).unwrap(); + leaf_params.is_ca = IsCa::NoCa; + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Example Leaf"); + leaf_params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "Example Org"); + // Add code-signing EKU + leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::CodeSigning]; + + let leaf_key = KeyPair::generate().unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_issuer).unwrap(); + let leaf_der = leaf_cert.der().to_vec(); + + (ca_der, leaf_der) +} diff --git a/native/rust/did/x509/ffi/README.md b/native/rust/did/x509/ffi/README.md new file mode 100644 index 00000000..ab4b75e3 --- /dev/null +++ b/native/rust/did/x509/ffi/README.md @@ -0,0 +1,68 @@ + + +# did_x509_ffi + +C/C++ FFI projection for DID:x509 identifier operations. + +## Overview + +This crate provides C-compatible FFI exports for parsing, building, validating, and resolving +DID:x509 identifiers against X.509 certificate chains. It wraps the `did_x509` crate for +core functionality. + +## Exported Functions + +### ABI & Error Handling + +| Function | Description | +|----------|-------------| +| `did_x509_abi_version` | ABI version check | +| `did_x509_error_message` | Get error description string | +| `did_x509_error_code` | Get error code | +| `did_x509_error_free` | Free an error handle | +| `did_x509_string_free` | Free a string returned by this library | + +### Parsing + +| Function | Description | +|----------|-------------| +| `did_x509_parse` | Parse a DID:x509 identifier string | +| `did_x509_parsed_get_fingerprint` | Get the certificate fingerprint | +| `did_x509_parsed_get_hash_algorithm` | Get the hash algorithm used | +| `did_x509_parsed_get_policy_count` | Get the number of policies | +| `did_x509_parsed_free` | Free a parsed DID handle | + +### Building + +| Function | Description | +|----------|-------------| +| `did_x509_build_with_eku` | Build a DID:x509 with EKU policy | +| `did_x509_build_from_chain` | Build a DID:x509 from a certificate chain | + +### Validation & Resolution + +| Function | Description | +|----------|-------------| +| `did_x509_validate` | Validate a DID:x509 against a certificate chain | +| `did_x509_resolve` | Resolve a DID:x509 to a public key | + +## Handle Types + +| Type | Description | +|------|-------------| +| `DidX509ParsedHandle` | Opaque parsed DID:x509 identifier | +| `DidX509ErrorHandle` | Opaque error handle | + +## C Header + +`` + +## Parent Library + +[`did_x509`](../../x509/) — DID:x509 implementation. + +## Build + +```bash +cargo build --release -p did_x509_ffi +``` diff --git a/native/rust/did/x509/ffi/src/lib.rs b/native/rust/did/x509/ffi/src/lib.rs index 4c954c57..ca8efb8a 100644 --- a/native/rust/did/x509/ffi/src/lib.rs +++ b/native/rust/did/x509/ffi/src/lib.rs @@ -5,29 +5,45 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! C/C++ FFI for DID:x509 parsing, building, validation and resolution. +//! C-ABI projection for `did_x509`. //! -//! This crate (`did_x509_ffi`) provides FFI-safe wrappers for working with DID:x509 -//! identifiers from C and C++ code. It uses the `did_x509` crate for core functionality. +//! This crate provides C-compatible FFI exports for DID:x509 identifier +//! operations. It wraps the `did_x509` crate, enabling C and C++ code to +//! parse, build, validate, and resolve DID:x509 identifiers against X.509 +//! certificate chains. //! -//! ## Error Handling +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `did_x509_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling //! //! All functions follow a consistent error handling pattern: //! - Return value: 0 = success, negative = error code //! - `out_error` parameter: Set to error handle on failure (caller must free) //! - Output parameters: Only valid if return is 0 //! -//! ## Memory Management +//! # Memory Ownership //! -//! Handles and strings returned by this library must be freed using the corresponding `*_free` function: -//! - `did_x509_parsed_free` for parsed identifier handles -//! - `did_x509_error_free` for error handles -//! - `did_x509_string_free` for string pointers +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `did_x509_parsed_free` for parsed identifier handles +//! - `did_x509_error_free` for error handles +//! - `did_x509_string_free` for string pointers //! -//! ## Thread Safety +//! # Thread Safety //! -//! All handles are thread-safe and can be used from multiple threads. However, handles -//! are not internally synchronized, so concurrent mutation requires external synchronization. +//! All functions are thread-safe. Handles are not internally synchronized, +//! so concurrent mutation requires external synchronization. pub mod error; pub mod types; diff --git a/native/rust/extension_packs/azure_artifact_signing/Cargo.toml b/native/rust/extension_packs/azure_artifact_signing/Cargo.toml index 98df3c5c..3e10300f 100644 --- a/native/rust/extension_packs/azure_artifact_signing/Cargo.toml +++ b/native/rust/extension_packs/azure_artifact_signing/Cargo.toml @@ -23,9 +23,10 @@ did_x509 = { path = "../../did/x509" } azure_core = { workspace = true } azure_identity = { workspace = true } tokio = { workspace = true, features = ["rt"] } -once_cell = { workspace = true } base64 = { workspace = true } sha2 = { workspace = true } +openssl = { workspace = true } +x509-parser = { workspace = true } [dev-dependencies] cose_sign1_validation_primitives = { path = "../../validation/primitives" } diff --git a/native/rust/extension_packs/azure_artifact_signing/ffi/src/lib.rs b/native/rust/extension_packs/azure_artifact_signing/ffi/src/lib.rs index 1089b5b7..29211dbe 100644 --- a/native/rust/extension_packs/azure_artifact_signing/ffi/src/lib.rs +++ b/native/rust/extension_packs/azure_artifact_signing/ffi/src/lib.rs @@ -3,14 +3,49 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -//! Azure Artifact Signing pack FFI bindings. +//! C-ABI projection for `cose_sign1_azure_artifact_signing`. +//! +//! This crate provides C-compatible FFI exports for the Azure Artifact Signing +//! (AAS) extension pack. It enables C/C++ consumers to register the AAS trust +//! pack with a validator builder, with support for both default and custom +//! trust options (endpoint URL, account name, certificate profile name). +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_sign1_ats_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for details. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function +//! +//! # Thread Safety +//! +//! All functions are thread-safe. Error state is thread-local. #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] use cose_sign1_azure_artifact_signing::options::AzureArtifactSigningOptions; +use cose_sign1_azure_artifact_signing::validation::fluent_ext::AasPrimarySigningKeyScopeRulesExt; use cose_sign1_azure_artifact_signing::validation::AzureArtifactSigningTrustPack; use cose_sign1_validation_ffi::{cose_sign1_validator_builder_t, cose_status_t, with_catch_unwind}; +use cose_sign1_validation_ffi::{cose_trust_policy_builder_t, with_trust_policy_builder_mut}; use std::ffi::{c_char, CStr}; use std::sync::Arc; @@ -106,6 +141,46 @@ pub extern "C" fn cose_sign1_validator_builder_with_ats_pack_ex( }) } -// TODO: Add trust policy builder helpers once the fact types are stabilized: -// cose_sign1_ats_trust_policy_builder_require_ats_identified -// cose_sign1_ats_trust_policy_builder_require_ats_compliant +/// Trust-policy helper: require that the signing certificate was issued by +/// Azure Artifact Signing. +/// +/// Adds a requirement on `AasSigningServiceIdentifiedFact.is_ats_issued == true` +/// to the primary signing key scope of the trust policy. +/// +/// # Safety +/// +/// `policy_builder` must be a valid, non-null pointer to a `cose_trust_policy_builder_t`. +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub extern "C" fn cose_sign1_ats_trust_policy_builder_require_ats_identified( + policy_builder: *mut cose_trust_policy_builder_t, +) -> cose_status_t { + with_catch_unwind(|| { + with_trust_policy_builder_mut(policy_builder, |b| { + b.for_primary_signing_key(|s| s.require_ats_identified()) + })?; + Ok(cose_status_t::COSE_OK) + }) +} + +/// Trust-policy helper: require that the signing operation is SCITT compliant +/// (AAS-issued with SCITT headers present). +/// +/// Adds a requirement on `AasComplianceFact.scitt_compliant == true` +/// to the primary signing key scope of the trust policy. +/// +/// # Safety +/// +/// `policy_builder` must be a valid, non-null pointer to a `cose_trust_policy_builder_t`. +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub extern "C" fn cose_sign1_ats_trust_policy_builder_require_ats_compliant( + policy_builder: *mut cose_trust_policy_builder_t, +) -> cose_status_t { + with_catch_unwind(|| { + with_trust_policy_builder_mut(policy_builder, |b| { + b.for_primary_signing_key(|s| s.require_ats_compliant()) + })?; + Ok(cose_status_t::COSE_OK) + }) +} diff --git a/native/rust/extension_packs/azure_artifact_signing/src/signing/did_x509_helper.rs b/native/rust/extension_packs/azure_artifact_signing/src/signing/did_x509_helper.rs index 304e1c68..430380f0 100644 --- a/native/rust/extension_packs/azure_artifact_signing/src/signing/did_x509_helper.rs +++ b/native/rust/extension_packs/azure_artifact_signing/src/signing/did_x509_helper.rs @@ -1,110 +1,131 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! DID:x509 identifier construction for Azure Artifact Signing certificates. -//! -//! Maps V2 `AzureArtifactSigningDidX509` — generates DID:x509 identifiers -//! using the "deepest greatest" Microsoft EKU from the leaf certificate. -//! -//! Format: `did:x509:0:sha256:{base64url-hash}::eku:{oid}` - -use crate::error::AasError; - -/// Microsoft reserved EKU OID prefix used by Azure Artifact Signing certificates. -const MICROSOFT_EKU_PREFIX: &str = "1.3.6.1.4.1.311"; - -/// Build a DID:x509 identifier from an AAS-issued certificate chain. -/// -/// Uses AAS-specific logic: -/// 1. Extract EKU OIDs from the leaf certificate -/// 2. Filter to Microsoft EKUs (prefix `1.3.6.1.4.1.311`) -/// 3. Select the "deepest greatest" Microsoft EKU (most segments, then highest last segment) -/// 4. Build DID:x509 with that specific EKU policy -/// -/// Falls back to generic `build_from_chain_with_eku()` if no Microsoft EKU is found. -pub fn build_did_x509_from_ats_chain(chain_ders: &[&[u8]]) -> Result { - // Try AAS-specific Microsoft EKU selection first - if let Some(microsoft_eku) = find_deepest_greatest_microsoft_eku(chain_ders) { - // Build DID:x509 with the specific Microsoft EKU - let policy = did_x509::DidX509Policy::Eku(vec![microsoft_eku.into()]); - did_x509::DidX509Builder::build_from_chain(chain_ders, &[policy]) - .map_err(|e| AasError::DidX509Error(e.to_string())) - } else { - // No Microsoft EKU found — use generic EKU-based builder - did_x509::DidX509Builder::build_from_chain_with_eku(chain_ders) - .map_err(|e| AasError::DidX509Error(e.to_string())) - } -} - -/// Find the "deepest greatest" Microsoft EKU from the leaf certificate. -/// -/// Maps V2 `AzureArtifactSigningDidX509.GetDeepestGreatestMicrosoftEku()`. -/// -/// Selection criteria: -/// 1. Filter to Microsoft EKUs (starting with `1.3.6.1.4.1.311`) -/// 2. Select the OID with the most segments (deepest) -/// 3. If tied, select the one with the greatest last segment value -fn find_deepest_greatest_microsoft_eku(chain_ders: &[&[u8]]) -> Option { - if chain_ders.is_empty() { - return None; - } - - // Parse the leaf certificate to extract EKU OIDs - let leaf_der = chain_ders[0]; - let ekus = extract_eku_oids(leaf_der)?; - - // Filter to Microsoft EKUs - let microsoft_ekus: Vec<&String> = ekus - .iter() - .filter(|oid| oid.starts_with(MICROSOFT_EKU_PREFIX)) - .collect(); - - if microsoft_ekus.is_empty() { - return None; - } - - // Select deepest (most segments), then greatest (highest last segment) - microsoft_ekus - .into_iter() - .max_by(|a, b| { - let segments_a = a.split('.').count(); - let segments_b = b.split('.').count(); - segments_a - .cmp(&segments_b) - .then_with(|| last_segment_value(a).cmp(&last_segment_value(b))) - }) - .cloned() -} - -/// Extract EKU OIDs from a DER-encoded X.509 certificate. -/// -/// Returns None if parsing fails or no EKU extension is present. -fn extract_eku_oids(cert_der: &[u8]) -> Option> { - // Use x509-parser if available, or fall back to a simple approach - // For now, try the did_x509 crate's parsing which already handles this - // The did_x509 crate extracts EKUs internally — we need a way to access them. - // - // TODO: When x509-parser is available as a dep, use: - // let (_, cert) = x509_parser::parse_x509_certificate(cert_der).ok()?; - // let eku = cert.extended_key_usage().ok()??; - // Some(eku.value.other.iter().map(|oid| oid.to_id_string()).collect()) - // - // For now, delegate to did_x509's internal parsing by attempting to build - // and extracting the EKU from the resulting DID string. - let chain = &[cert_der]; - if let Ok(did) = did_x509::DidX509Builder::build_from_chain_with_eku(chain) { - // Parse the DID to extract the EKU OID: did:x509:0:sha256:...::eku:{oid} - if let Some(eku_part) = did.split("::eku:").nth(1) { - return Some(vec![eku_part.to_string()]); - } - } - None -} - -/// Get the numeric value of the last segment of an OID. -fn last_segment_value(oid: &str) -> u64 { - oid.rsplit('.') - .next() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0) -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! DID:x509 identifier construction for Azure Artifact Signing certificates. +//! +//! Maps V2 `AzureArtifactSigningDidX509` — generates DID:x509 identifiers +//! using the "deepest greatest" Microsoft EKU from the leaf certificate. +//! +//! Format: `did:x509:0:sha256:{base64url-hash}::eku:{oid}` + +use crate::error::AasError; + +/// Microsoft reserved EKU OID prefix used by Azure Artifact Signing certificates. +const MICROSOFT_EKU_PREFIX: &str = "1.3.6.1.4.1.311"; + +/// Build a DID:x509 identifier from an AAS-issued certificate chain. +/// +/// Uses AAS-specific logic: +/// 1. Extract EKU OIDs from the leaf certificate +/// 2. Filter to Microsoft EKUs (prefix `1.3.6.1.4.1.311`) +/// 3. Select the "deepest greatest" Microsoft EKU (most segments, then highest last segment) +/// 4. Build DID:x509 with that specific EKU policy +/// +/// Falls back to generic `build_from_chain_with_eku()` if no Microsoft EKU is found. +pub fn build_did_x509_from_ats_chain(chain_ders: &[&[u8]]) -> Result { + // Try AAS-specific Microsoft EKU selection first + if let Some(microsoft_eku) = find_deepest_greatest_microsoft_eku(chain_ders) { + // Build DID:x509 with the specific Microsoft EKU + let policy = did_x509::DidX509Policy::Eku(vec![microsoft_eku.into()]); + did_x509::DidX509Builder::build_from_chain(chain_ders, &[policy]) + .map_err(|e| AasError::DidX509Error(e.to_string())) + } else { + // No Microsoft EKU found — use generic EKU-based builder + did_x509::DidX509Builder::build_from_chain_with_eku(chain_ders) + .map_err(|e| AasError::DidX509Error(e.to_string())) + } +} + +/// Find the "deepest greatest" Microsoft EKU from the leaf certificate. +/// +/// Maps V2 `AzureArtifactSigningDidX509.GetDeepestGreatestMicrosoftEku()`. +/// +/// Selection criteria: +/// 1. Filter to Microsoft EKUs (starting with `1.3.6.1.4.1.311`) +/// 2. Select the OID with the most segments (deepest) +/// 3. If tied, select the one with the greatest last segment value +fn find_deepest_greatest_microsoft_eku(chain_ders: &[&[u8]]) -> Option { + if chain_ders.is_empty() { + return None; + } + + // Parse the leaf certificate to extract EKU OIDs + let leaf_der = chain_ders[0]; + let ekus = extract_eku_oids(leaf_der)?; + + // Filter to Microsoft EKUs + let microsoft_ekus: Vec<&String> = ekus + .iter() + .filter(|oid| oid.starts_with(MICROSOFT_EKU_PREFIX)) + .collect(); + + if microsoft_ekus.is_empty() { + return None; + } + + // Select deepest (most segments), then greatest (highest last segment) + microsoft_ekus + .into_iter() + .max_by(|a, b| { + let segments_a = a.split('.').count(); + let segments_b = b.split('.').count(); + segments_a + .cmp(&segments_b) + .then_with(|| last_segment_value(a).cmp(&last_segment_value(b))) + }) + .cloned() +} + +/// Extract EKU OIDs from a DER-encoded X.509 certificate. +/// +/// Uses `x509-parser` to parse the certificate and extract Extended Key Usage +/// OIDs from the EKU extension. +/// +/// Returns None if parsing fails or no EKU extension is present. +fn extract_eku_oids(cert_der: &[u8]) -> Option> { + use x509_parser::prelude::*; + + let (_, cert) = X509Certificate::from_der(cert_der).ok()?; + let eku_ext = cert.extended_key_usage().ok().flatten()?; + let eku = &eku_ext.value; + + let mut oids = Vec::new(); + if eku.any { + oids.push("2.5.29.37.0".to_string()); + } + if eku.server_auth { + oids.push("1.3.6.1.5.5.7.3.1".to_string()); + } + if eku.client_auth { + oids.push("1.3.6.1.5.5.7.3.2".to_string()); + } + if eku.code_signing { + oids.push("1.3.6.1.5.5.7.3.3".to_string()); + } + if eku.email_protection { + oids.push("1.3.6.1.5.5.7.3.4".to_string()); + } + if eku.time_stamping { + oids.push("1.3.6.1.5.5.7.3.8".to_string()); + } + if eku.ocsp_signing { + oids.push("1.3.6.1.5.5.7.3.9".to_string()); + } + for other_oid in &eku.other { + oids.push(other_oid.to_id_string()); + } + + if oids.is_empty() { + None + } else { + Some(oids) + } +} + +/// Get the numeric value of the last segment of an OID. +fn last_segment_value(oid: &str) -> u64 { + oid.rsplit('.') + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) +} diff --git a/native/rust/extension_packs/azure_artifact_signing/src/signing/signing_service.rs b/native/rust/extension_packs/azure_artifact_signing/src/signing/signing_service.rs index 0af36a86..806d89db 100644 --- a/native/rust/extension_packs/azure_artifact_signing/src/signing/signing_service.rs +++ b/native/rust/extension_packs/azure_artifact_signing/src/signing/signing_service.rs @@ -54,18 +54,29 @@ impl AasCertificateSourceAdapter { return Ok(()); } - // Fetch root cert as the chain (PKCS#7 parsing TODO — for now use root as single cert) - let root_der = self + // Fetch the PKCS#7 certificate chain from AAS + let pkcs7_bytes = self .inner - .fetch_root_certificate() + .fetch_certificate_chain_pkcs7() .map_err(|e| CertificateError::ChainBuildFailed(e.to_string()))?; - // For now, we use the root cert as a placeholder leaf cert. - // In production, the sign response returns the signing certificate. - let _ = self.leaf_cert.set(root_der.clone()); + // Parse PKCS#7 DER to extract individual certificates + let certs = parse_pkcs7_chain(&pkcs7_bytes).map_err(|e| { + CertificateError::ChainBuildFailed(format!("PKCS#7 parse failed: {}", e)) + })?; + + if certs.is_empty() { + return Err(CertificateError::ChainBuildFailed( + "PKCS#7 chain contains no certificates".into(), + )); + } + + // First certificate is the leaf (signing cert), rest are intermediates/root + let leaf_cert = certs[0].clone(); + let _ = self.leaf_cert.set(leaf_cert); let _ = self .chain_builder - .set(ExplicitCertificateChainBuilder::new(vec![root_der])); + .set(ExplicitCertificateChainBuilder::new(certs)); Ok(()) } @@ -269,3 +280,118 @@ impl SigningService for AzureArtifactSigningService { self.inner.verify_signature(message_bytes, ctx) } } + +/// Parse a DER-encoded PKCS#7 (SignedData) bundle or single certificate to +/// extract individual DER-encoded X.509 certificates, ordered leaf-first. +/// +/// AAS returns certificate chains as `application/pkcs7-mime` DER or as a +/// single `application/x-x509-ca-cert` DER certificate. +/// +/// Extraction strategy: +/// 1. Try parsing as a single X.509 DER certificate (simplest case) +/// 2. Try parsing as PKCS#7 DER and scan for embedded X.509 certificates +/// using ASN.1 SEQUENCE tag markers within the structure +fn parse_pkcs7_chain(response_bytes: &[u8]) -> Result>, String> { + // Strategy 1: Single X.509 DER certificate + if let Ok(x509) = openssl::x509::X509::from_der(response_bytes) { + return Ok(vec![x509 + .to_der() + .map_err(|e| format!("cert to DER: {}", e))?]); + } + + // Strategy 2: PKCS#7 signed-data — extract certs via ASN.1 scanning. + // + // PKCS#7 SignedData contains a SET OF Certificate in its `certificates` + // field. Each certificate is an ASN.1 SEQUENCE. We verify the outer + // structure is valid PKCS#7 first, then scan for embedded certificates + // by trying X509::from_der at each SEQUENCE tag offset. + let _pkcs7 = openssl::pkcs7::Pkcs7::from_der(response_bytes) + .map_err(|e| format!("invalid PKCS#7 DER: {}", e))?; + + // Scan the DER bytes for embedded X.509 certificate SEQUENCE structures. + // This is a robust approach that works regardless of the openssl crate's + // level of PKCS#7 API support. + let certs = extract_embedded_certificates(response_bytes); + + if certs.is_empty() { + Err("no certificates found in PKCS#7 bundle".into()) + } else { + Ok(certs) + } +} + +/// Scan DER bytes for embedded X.509 certificate structures. +/// +/// Walks the byte buffer looking for ASN.1 SEQUENCE tags (0x30) followed by +/// valid multi-byte lengths, and attempts to parse each candidate region as +/// an X.509 certificate. This handles both PKCS#7 and raw DER cert bundles. +fn extract_embedded_certificates(der: &[u8]) -> Vec> { + let mut certs = Vec::new(); + let mut offset = 0; + + while offset < der.len() { + // Look for ASN.1 SEQUENCE tag (0x30) + if der[offset] != 0x30 { + offset += 1; + continue; + } + + // Determine the length of this SEQUENCE + if let Some(seq_len) = read_asn1_length(der, offset + 1) { + let header_len = asn1_header_length(der, offset + 1); + let total_len = 1 + header_len + seq_len; + + if offset + total_len <= der.len() { + let candidate = &der[offset..offset + total_len]; + if let Ok(x509) = openssl::x509::X509::from_der(candidate) { + if let Ok(cert_der) = x509.to_der() { + certs.push(cert_der); + offset += total_len; + continue; + } + } + } + } + offset += 1; + } + certs +} + +/// Read an ASN.1 length value starting at `offset` in `der`. +fn read_asn1_length(der: &[u8], offset: usize) -> Option { + if offset >= der.len() { + return None; + } + let first = der[offset] as usize; + if first < 0x80 { + // Short form + Some(first) + } else if first == 0x80 { + // Indefinite length — not supported for certificates + None + } else { + // Long form: first byte = 0x80 | num_length_bytes + let num_bytes = first & 0x7F; + if num_bytes > 4 || offset + 1 + num_bytes > der.len() { + return None; + } + let mut length = 0usize; + for i in 0..num_bytes { + length = (length << 8) | (der[offset + 1 + i] as usize); + } + Some(length) + } +} + +/// Calculate the number of bytes used by the ASN.1 length encoding. +fn asn1_header_length(der: &[u8], offset: usize) -> usize { + if offset >= der.len() { + return 0; + } + let first = der[offset] as usize; + if first < 0x80 { + 1 + } else { + 1 + (first & 0x7F) + } +} diff --git a/native/rust/extension_packs/azure_artifact_signing/src/validation/facts.rs b/native/rust/extension_packs/azure_artifact_signing/src/validation/facts.rs index 11d44503..2ba25b8d 100644 --- a/native/rust/extension_packs/azure_artifact_signing/src/validation/facts.rs +++ b/native/rust/extension_packs/azure_artifact_signing/src/validation/facts.rs @@ -43,3 +43,32 @@ impl FactProperties for AasComplianceFact { } } } + +/// Field-name constants for declarative trust policies. +pub mod fields { + pub mod aas_identified { + pub const IS_ATS_ISSUED: &str = "is_ats_issued"; + } + + pub mod aas_compliance { + pub const SCITT_COMPLIANT: &str = "scitt_compliant"; + } +} + +/// Typed fields for fluent trust-policy authoring. +pub mod typed_fields { + use super::{AasComplianceFact, AasSigningServiceIdentifiedFact}; + use cose_sign1_validation_primitives::field::Field; + + pub mod aas_identified { + use super::*; + pub const IS_ATS_ISSUED: Field = + Field::new(crate::validation::facts::fields::aas_identified::IS_ATS_ISSUED); + } + + pub mod aas_compliance { + use super::*; + pub const SCITT_COMPLIANT: Field = + Field::new(crate::validation::facts::fields::aas_compliance::SCITT_COMPLIANT); + } +} diff --git a/native/rust/extension_packs/azure_artifact_signing/src/validation/fluent_ext.rs b/native/rust/extension_packs/azure_artifact_signing/src/validation/fluent_ext.rs new file mode 100644 index 00000000..d499fea8 --- /dev/null +++ b/native/rust/extension_packs/azure_artifact_signing/src/validation/fluent_ext.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fluent trust policy builder extensions for Azure Artifact Signing facts. +//! +//! Provides ergonomic methods to add AAS-specific requirements to trust policies +//! via the fluent `TrustPlanBuilder` API. + +use crate::validation::facts::{ + typed_fields as aas_typed, AasComplianceFact, AasSigningServiceIdentifiedFact, +}; +use cose_sign1_validation_primitives::fluent::{PrimarySigningKeyScope, ScopeRules, Where}; + +// ============================================================================ +// Where<> extensions for individual fact types +// ============================================================================ + +/// Fluent helpers for `Where`. +pub trait AasIdentifiedWhereExt { + /// Require that the signing certificate was issued by Azure Artifact Signing. + fn require_ats_issued(self) -> Self; + + /// Require that the signing certificate was NOT issued by Azure Artifact Signing. + fn require_not_ats_issued(self) -> Self; +} + +impl AasIdentifiedWhereExt for Where { + fn require_ats_issued(self) -> Self { + self.r#true(aas_typed::aas_identified::IS_ATS_ISSUED) + } + + fn require_not_ats_issued(self) -> Self { + self.r#false(aas_typed::aas_identified::IS_ATS_ISSUED) + } +} + +/// Fluent helpers for `Where`. +pub trait AasComplianceWhereExt { + /// Require that the signing operation is SCITT compliant. + fn require_scitt_compliant(self) -> Self; + + /// Require that the signing operation is NOT SCITT compliant. + fn require_not_scitt_compliant(self) -> Self; +} + +impl AasComplianceWhereExt for Where { + fn require_scitt_compliant(self) -> Self { + self.r#true(aas_typed::aas_compliance::SCITT_COMPLIANT) + } + + fn require_not_scitt_compliant(self) -> Self { + self.r#false(aas_typed::aas_compliance::SCITT_COMPLIANT) + } +} + +// ============================================================================ +// Primary signing key scope extensions +// ============================================================================ + +/// Fluent helper methods for AAS-specific trust policy requirements on +/// the primary signing key scope. +/// +/// Usage: +/// ```ignore +/// plan.for_primary_signing_key(|key| key.require_ats_identified()) +/// ``` +pub trait AasPrimarySigningKeyScopeRulesExt { + /// Require that the signing certificate was issued by Azure Artifact Signing. + fn require_ats_identified(self) -> Self; + + /// Require that the signing operation is SCITT compliant (AAS-issued + SCITT headers). + fn require_ats_compliant(self) -> Self; +} + +impl AasPrimarySigningKeyScopeRulesExt for ScopeRules { + fn require_ats_identified(self) -> Self { + self.require::(|w| w.require_ats_issued()) + } + + fn require_ats_compliant(self) -> Self { + self.require::(|w| w.require_scitt_compliant()) + } +} diff --git a/native/rust/extension_packs/azure_artifact_signing/src/validation/mod.rs b/native/rust/extension_packs/azure_artifact_signing/src/validation/mod.rs index 6f9be600..e5ea00ea 100644 --- a/native/rust/extension_packs/azure_artifact_signing/src/validation/mod.rs +++ b/native/rust/extension_packs/azure_artifact_signing/src/validation/mod.rs @@ -15,6 +15,7 @@ use cose_sign1_validation_primitives::{ use crate::validation::facts::{AasComplianceFact, AasSigningServiceIdentifiedFact}; pub mod facts; +pub mod fluent_ext; /// Produces AAS-specific facts. pub struct AasFactProducer; diff --git a/native/rust/extension_packs/azure_key_vault/Cargo.toml b/native/rust/extension_packs/azure_key_vault/Cargo.toml index d55ee9f6..13ebf9ba 100644 --- a/native/rust/extension_packs/azure_key_vault/Cargo.toml +++ b/native/rust/extension_packs/azure_key_vault/Cargo.toml @@ -19,11 +19,11 @@ crypto_primitives = { path = "../../primitives/crypto" } cose_sign1_crypto_openssl = { path = "../../primitives/crypto/openssl" } sha2 = { workspace = true } regex = { workspace = true } -once_cell = { workspace = true } url = { workspace = true } azure_core = { workspace = true, features = ["reqwest", "reqwest_native_tls"] } azure_identity = { workspace = true } azure_security_keyvault_keys = { workspace = true } +azure_security_keyvault_certificates = { workspace = true } tokio = { workspace = true, features = ["rt"] } [dev-dependencies] diff --git a/native/rust/extension_packs/azure_key_vault/ffi/README.md b/native/rust/extension_packs/azure_key_vault/ffi/README.md new file mode 100644 index 00000000..32f6f99f --- /dev/null +++ b/native/rust/extension_packs/azure_key_vault/ffi/README.md @@ -0,0 +1,68 @@ + + +# cose_sign1_azure_key_vault_ffi + +C/C++ FFI projection for the Azure Key Vault extension pack. + +## Overview + +This crate provides C-compatible FFI exports for the Azure Key Vault trust pack. +It enables C/C++ consumers to register the AKV trust pack with a validator builder, +author trust policies that constrain Key Vault KID properties, and create signing +keys and signing services backed by Azure Key Vault. + +## Exported Functions + +### Pack Registration + +| Function | Description | +|----------|-------------| +| `cose_sign1_validator_builder_with_akv_pack` | Add AKV pack (default options) | +| `cose_sign1_validator_builder_with_akv_pack_ex` | Add AKV pack (custom options) | + +### KID Trust Policies + +| Function | Description | +|----------|-------------| +| `..._require_azure_key_vault_kid` | Require AKV KID detected | +| `..._require_not_azure_key_vault_kid` | Require AKV KID not detected | +| `..._require_azure_key_vault_kid_allowed` | Require KID is in allowed list | +| `..._require_azure_key_vault_kid_not_allowed` | Require KID is not in allowed list | + +### Key Client Lifecycle + +| Function | Description | +|----------|-------------| +| `cose_akv_key_client_new_dev` | Create key client (dev credentials) | +| `cose_akv_key_client_new_client_secret` | Create key client (client secret) | +| `cose_akv_key_client_free` | Free a key client handle | + +### Signing Operations + +| Function | Description | +|----------|-------------| +| `cose_sign1_akv_create_signing_key` | Create a signing key from AKV | +| `cose_sign1_akv_create_signing_service` | Create a signing service from AKV | +| `cose_sign1_akv_signing_service_free` | Free a signing service handle | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_akv_trust_options_t` | C ABI options struct for AKV trust configuration | +| `AkvKeyClientHandle` | Opaque Azure Key Vault key client | +| `AkvSigningServiceHandle` | Opaque AKV-backed signing service | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_azure_key_vault`](../../azure_key_vault/) — Azure Key Vault trust pack implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_azure_key_vault_ffi +``` diff --git a/native/rust/extension_packs/azure_key_vault/ffi/src/lib.rs b/native/rust/extension_packs/azure_key_vault/ffi/src/lib.rs index c54cf745..2604202f 100644 --- a/native/rust/extension_packs/azure_key_vault/ffi/src/lib.rs +++ b/native/rust/extension_packs/azure_key_vault/ffi/src/lib.rs @@ -1,6 +1,42 @@ -//! Azure Key Vault pack FFI bindings. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! C-ABI projection for `cose_sign1_azure_key_vault`. +//! +//! This crate provides C-compatible FFI exports for the Azure Key Vault +//! extension pack. It enables C/C++ consumers to register the Azure Key Vault +//! trust pack with a validator builder, author trust policies that constrain +//! Key Vault KID properties (detection, allowed/denied lists), and create +//! signing keys and signing services backed by Azure Key Vault. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for details. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_akv_key_client_free` for key client handles +//! - `cose_sign1_akv_signing_service_free` for signing service handles +//! +//! # Thread Safety //! -//! This crate exposes the Azure Key Vault KID validation pack and signing key creation to C/C++ consumers. +//! All functions are thread-safe. Error state is thread-local. #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![deny(unsafe_op_in_unsafe_fn)] diff --git a/native/rust/extension_packs/azure_key_vault/src/signing/akv_certificate_source.rs b/native/rust/extension_packs/azure_key_vault/src/signing/akv_certificate_source.rs index 0477ead9..5d3afa75 100644 --- a/native/rust/extension_packs/azure_key_vault/src/signing/akv_certificate_source.rs +++ b/native/rust/extension_packs/azure_key_vault/src/signing/akv_certificate_source.rs @@ -37,8 +37,9 @@ impl AzureKeyVaultCertificateSource { /// Fetch the signing certificate from AKV. /// - /// Retrieves the certificate associated with the key by constructing the - /// certificate URL from the key URL and making a GET request. + /// Uses the Azure Key Vault Certificates SDK to retrieve the certificate + /// associated with the key. Constructs the certificate name from the key + /// URL pattern: `https://{vault}/keys/{name}/{version}`. /// /// Returns `(leaf_cert_der, chain_ders)` where chain_ders is ordered leaf-first. /// Currently returns the leaf certificate only — full chain extraction @@ -50,54 +51,54 @@ impl AzureKeyVaultCertificateSource { cert_name: &str, credential: std::sync::Arc, ) -> Result<(Vec, Vec>), AkvError> { - use azure_security_keyvault_keys::KeyClient; + use azure_security_keyvault_certificates::CertificateClient; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|e| AkvError::General(e.to_string()))?; - // Use the KeyClient to access the vault's HTTP pipeline, then - // construct the certificate URL manually. - // AKV certificates API: GET {vault}/certificates/{name}?api-version=7.4 - let cert_url = format!( - "{}/certificates/{}?api-version=7.4", - vault_url.trim_end_matches('/'), - cert_name, - ); - - let client = KeyClient::new(vault_url, credential, None) + let client = CertificateClient::new(vault_url, credential, None) .map_err(|e| AkvError::CertificateSourceError(e.to_string()))?; - // Use the key client's get_key to at least verify connectivity, - // then the certificate DER is obtained from the response. - // For a proper implementation, we'd use the certificates API directly. - // For now, return the public key bytes as a placeholder certificate. - let key_bytes = self.crypto_client.public_key_bytes().map_err(|e| { + let response = runtime + .block_on(client.get_certificate(cert_name, None)) + .map_err(|e| { + AkvError::CertificateSourceError(format!( + "failed to get certificate '{}': {}", + cert_name, e + )) + })?; + + let certificate = response.into_model().map_err(|e| { AkvError::CertificateSourceError(format!( - "failed to get public key for certificate: {}", - e + "failed to deserialize certificate '{}': {}", + cert_name, e )) })?; - // The public key bytes are not a valid certificate, but this - // unblocks the initialization path. A full implementation would - // parse the x5c chain from the JWT token or fetch via Azure Certs API. - let _ = (runtime, cert_url, client); // suppress unused warnings - Ok((key_bytes, Vec::new())) + // The `cer` field contains the DER-encoded X.509 certificate + let cert_der: Vec = certificate.cer.ok_or_else(|| { + AkvError::CertificateSourceError( + "certificate response missing 'cer' (DER) field".into(), + ) + })?; + + // Return leaf cert with empty chain — full chain extraction from + // PKCS#12 secret would require an additional get_secret() call. + // Callers should use initialize() with the full chain when available. + Ok((cert_der, Vec::new())) } /// Initialize with pre-fetched certificate and chain data. /// - /// This is the primary initialization path — call either this method - /// or use `fetch_certificate()` + `initialize()` together. + /// Use either `fetch_certificate()` to retrieve from AKV, or call this + /// method directly with certificate data obtained through another source. pub fn initialize( &mut self, certificate_der: Vec, chain: Vec>, ) -> Result<(), CertificateError> { - // In a real impl, this would fetch from AKV. - // For now, accept pre-fetched data (enables mock testing). self.certificate_der = certificate_der.clone(); self.chain = chain.clone(); let mut full_chain = vec![certificate_der]; diff --git a/native/rust/extension_packs/azure_key_vault/src/validation/pack.rs b/native/rust/extension_packs/azure_key_vault/src/validation/pack.rs index 589c842d..aef3b3b8 100644 --- a/native/rust/extension_packs/azure_key_vault/src/validation/pack.rs +++ b/native/rust/extension_packs/azure_key_vault/src/validation/pack.rs @@ -1,254 +1,254 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::validation::facts::{AzureKeyVaultKidAllowedFact, AzureKeyVaultKidDetectedFact}; -use cose_sign1_primitives::{CoseHeaderLabel, CoseHeaderValue}; -use cose_sign1_validation::fluent::*; -use cose_sign1_validation_primitives::error::TrustError; -use cose_sign1_validation_primitives::facts::{FactKey, TrustFactContext, TrustFactProducer}; -use cose_sign1_validation_primitives::plan::CompiledTrustPlan; -use once_cell::sync::Lazy; -use regex::Regex; -use url::Url; - -pub mod fluent_ext { - pub use crate::validation::fluent_ext::*; -} - -pub const KID_HEADER_LABEL: i64 = 4; - -#[derive(Debug, Clone)] -pub struct AzureKeyVaultTrustOptions { - pub allowed_kid_patterns: Vec, - pub require_azure_key_vault_kid: bool, -} - -impl Default for AzureKeyVaultTrustOptions { - /// Default AKV policy options. - /// - /// This is intended to be secure-by-default: - /// - only allow Microsoft-owned Key Vault namespaces by default - /// - require that the `kid` looks like an AKV key identifier - fn default() -> Self { - // Secure-by-default: only allow Microsoft-owned Key Vault namespaces. - Self { - allowed_kid_patterns: vec![ - "https://*.vault.azure.net/keys/*".into(), - "https://*.managedhsm.azure.net/keys/*".into(), - ], - require_azure_key_vault_kid: true, - } - } -} - -#[derive(Debug, Clone)] -pub struct AzureKeyVaultTrustPack { - options: AzureKeyVaultTrustOptions, - compiled_patterns: Option>, -} - -impl AzureKeyVaultTrustPack { - /// Create an AKV trust pack with precompiled allow-list patterns. - /// - /// Patterns support: - /// - wildcard `*` and `?` matching - /// - `regex:` prefix for raw regular expressions - pub fn new(options: AzureKeyVaultTrustOptions) -> Self { - let mut compiled = Vec::new(); - - for pattern in &options.allowed_kid_patterns { - let pattern = pattern.trim(); - if pattern.is_empty() { - continue; - } - - if pattern.to_ascii_lowercase().starts_with("regex:") { - let re = Regex::new(&pattern["regex:".len()..]) - .map_err(|e| TrustError::FactProduction(format!("invalid_regex: {e}"))); - if let Ok(re) = re { - compiled.push(re); - } - continue; - } - - let escaped = regex::escape(pattern) - .replace("\\*", ".*") - .replace("\\?", "."); - - let re = Regex::new(&format!("^{escaped}(/.*)?$")) - .map_err(|e| TrustError::FactProduction(format!("invalid_pattern_regex: {e}"))); - if let Ok(re) = re { - compiled.push(re); - } - } - - let compiled_patterns = if compiled.is_empty() { - None - } else { - Some(compiled) - }; - Self { - options, - compiled_patterns, - } - } - - /// Try to read the COSE `kid` header as UTF-8 text. - /// - /// Prefers protected headers but will also check unprotected headers if present. - fn try_get_kid_utf8(ctx: &TrustFactContext<'_>) -> Option { - let msg = ctx.cose_sign1_message()?; - let kid_label = CoseHeaderLabel::Int(KID_HEADER_LABEL); - - if let Some(CoseHeaderValue::Bytes(b)) = msg.protected.headers().get(&kid_label) { - if let Ok(s) = std::str::from_utf8(b) { - if !s.trim().is_empty() { - return Some(s.to_string()); - } - } - } - - if let Some(CoseHeaderValue::Bytes(b)) = msg.unprotected.get(&kid_label) { - if let Ok(s) = std::str::from_utf8(b) { - if !s.trim().is_empty() { - return Some(s.to_string()); - } - } - } - - None - } - - /// Heuristic check for an AKV key identifier URL. - /// - /// This validates: - /// - URL parses successfully - /// - host ends with `.vault.azure.net` or `.managedhsm.azure.net` - /// - path contains `/keys/` - fn looks_like_azure_key_vault_key_id(kid: &str) -> bool { - if kid.trim().is_empty() { - return false; - } - - let Ok(uri) = Url::parse(kid) else { - return false; - }; - - let host = uri.host_str().unwrap_or("").to_ascii_lowercase(); - (host.ends_with(".vault.azure.net") || host.ends_with(".managedhsm.azure.net")) - && uri.path().to_ascii_lowercase().contains("/keys/") - } -} - -impl CoseSign1TrustPack for AzureKeyVaultTrustPack { - /// Short display name for this trust pack. - fn name(&self) -> &'static str { - "AzureKeyVaultTrustPack" - } - - /// Return a `TrustFactProducer` instance for this pack. - fn fact_producer(&self) -> std::sync::Arc { - std::sync::Arc::new(self.clone()) - } - - /// Return the default AKV trust plan. - /// - /// This plan requires that the message `kid` looks like an AKV key id and is allowlisted. - fn default_trust_plan(&self) -> Option { - use crate::validation::fluent_ext::{ - AzureKeyVaultKidAllowedWhereExt, AzureKeyVaultKidDetectedWhereExt, - }; - - // Secure-by-default AKV policy: - // - kid must look like an AKV key id - // - kid must match allowed patterns (defaults cover Microsoft Key Vault namespaces) - let bundled = TrustPlanBuilder::new(vec![std::sync::Arc::new(self.clone())]) - .for_message(|m| { - m.require::(|f| f.require_azure_key_vault_kid()) - .and() - .require::(|f| f.require_kid_allowed()) - }) - .compile() - .expect("default trust plan should be satisfiable by the AKV trust pack"); - - Some(bundled.plan().clone()) - } -} - -impl TrustFactProducer for AzureKeyVaultTrustPack { - /// Stable producer name used for diagnostics/audit. - fn name(&self) -> &'static str { - "cose_sign1_azure_key_vault::AzureKeyVaultTrustPack" - } - - /// Produce AKV-related facts. - /// - /// This pack only produces facts for the `Message` subject. - fn produce(&self, ctx: &mut TrustFactContext<'_>) -> Result<(), TrustError> { - if ctx.subject().kind != "Message" { - ctx.mark_produced(FactKey::of::()); - ctx.mark_produced(FactKey::of::()); - return Ok(()); - } - - if ctx.cose_sign1_message().is_none() { - ctx.mark_missing::("MissingMessage"); - ctx.mark_missing::("MissingMessage"); - ctx.mark_produced(FactKey::of::()); - ctx.mark_produced(FactKey::of::()); - return Ok(()); - } - - let Some(kid) = Self::try_get_kid_utf8(ctx) else { - ctx.mark_missing::("MissingKid"); - ctx.mark_missing::("MissingKid"); - ctx.mark_produced(FactKey::of::()); - ctx.mark_produced(FactKey::of::()); - return Ok(()); - }; - - let is_akv = Self::looks_like_azure_key_vault_key_id(&kid); - ctx.observe(AzureKeyVaultKidDetectedFact { - is_azure_key_vault_key: is_akv, - })?; - - let (is_allowed, details) = if self.options.require_azure_key_vault_kid && !is_akv { - (false, Some("NoPatternMatch".into())) - } else if self.compiled_patterns.is_none() { - (false, Some("NoAllowedPatterns".into())) - } else { - let matched = self - .compiled_patterns - .as_ref() - .is_some_and(|patterns| patterns.iter().any(|re| re.is_match(&kid))); - ( - matched, - Some(if matched { - "PatternMatched".into() - } else { - "NoPatternMatch".into() - }), - ) - }; - - ctx.observe(AzureKeyVaultKidAllowedFact { - is_allowed, - details, - })?; - - ctx.mark_produced(FactKey::of::()); - ctx.mark_produced(FactKey::of::()); - Ok(()) - } - - /// Return the set of fact keys this producer can emit. - fn provides(&self) -> &'static [FactKey] { - static PROVIDED: Lazy<[FactKey; 2]> = Lazy::new(|| { - [ - FactKey::of::(), - FactKey::of::(), - ] - }); - &*PROVIDED - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::validation::facts::{AzureKeyVaultKidAllowedFact, AzureKeyVaultKidDetectedFact}; +use cose_sign1_primitives::{CoseHeaderLabel, CoseHeaderValue}; +use cose_sign1_validation::fluent::*; +use cose_sign1_validation_primitives::error::TrustError; +use cose_sign1_validation_primitives::facts::{FactKey, TrustFactContext, TrustFactProducer}; +use cose_sign1_validation_primitives::plan::CompiledTrustPlan; +use regex::Regex; +use std::sync::LazyLock; +use url::Url; + +pub mod fluent_ext { + pub use crate::validation::fluent_ext::*; +} + +pub const KID_HEADER_LABEL: i64 = 4; + +#[derive(Debug, Clone)] +pub struct AzureKeyVaultTrustOptions { + pub allowed_kid_patterns: Vec, + pub require_azure_key_vault_kid: bool, +} + +impl Default for AzureKeyVaultTrustOptions { + /// Default AKV policy options. + /// + /// This is intended to be secure-by-default: + /// - only allow Microsoft-owned Key Vault namespaces by default + /// - require that the `kid` looks like an AKV key identifier + fn default() -> Self { + // Secure-by-default: only allow Microsoft-owned Key Vault namespaces. + Self { + allowed_kid_patterns: vec![ + "https://*.vault.azure.net/keys/*".into(), + "https://*.managedhsm.azure.net/keys/*".into(), + ], + require_azure_key_vault_kid: true, + } + } +} + +#[derive(Debug, Clone)] +pub struct AzureKeyVaultTrustPack { + options: AzureKeyVaultTrustOptions, + compiled_patterns: Option>, +} + +impl AzureKeyVaultTrustPack { + /// Create an AKV trust pack with precompiled allow-list patterns. + /// + /// Patterns support: + /// - wildcard `*` and `?` matching + /// - `regex:` prefix for raw regular expressions + pub fn new(options: AzureKeyVaultTrustOptions) -> Self { + let mut compiled = Vec::new(); + + for pattern in &options.allowed_kid_patterns { + let pattern = pattern.trim(); + if pattern.is_empty() { + continue; + } + + if pattern.to_ascii_lowercase().starts_with("regex:") { + let re = Regex::new(&pattern["regex:".len()..]) + .map_err(|e| TrustError::FactProduction(format!("invalid_regex: {e}"))); + if let Ok(re) = re { + compiled.push(re); + } + continue; + } + + let escaped = regex::escape(pattern) + .replace("\\*", ".*") + .replace("\\?", "."); + + let re = Regex::new(&format!("^{escaped}(/.*)?$")) + .map_err(|e| TrustError::FactProduction(format!("invalid_pattern_regex: {e}"))); + if let Ok(re) = re { + compiled.push(re); + } + } + + let compiled_patterns = if compiled.is_empty() { + None + } else { + Some(compiled) + }; + Self { + options, + compiled_patterns, + } + } + + /// Try to read the COSE `kid` header as UTF-8 text. + /// + /// Prefers protected headers but will also check unprotected headers if present. + fn try_get_kid_utf8(ctx: &TrustFactContext<'_>) -> Option { + let msg = ctx.cose_sign1_message()?; + let kid_label = CoseHeaderLabel::Int(KID_HEADER_LABEL); + + if let Some(CoseHeaderValue::Bytes(b)) = msg.protected.headers().get(&kid_label) { + if let Ok(s) = std::str::from_utf8(b) { + if !s.trim().is_empty() { + return Some(s.to_string()); + } + } + } + + if let Some(CoseHeaderValue::Bytes(b)) = msg.unprotected.get(&kid_label) { + if let Ok(s) = std::str::from_utf8(b) { + if !s.trim().is_empty() { + return Some(s.to_string()); + } + } + } + + None + } + + /// Heuristic check for an AKV key identifier URL. + /// + /// This validates: + /// - URL parses successfully + /// - host ends with `.vault.azure.net` or `.managedhsm.azure.net` + /// - path contains `/keys/` + fn looks_like_azure_key_vault_key_id(kid: &str) -> bool { + if kid.trim().is_empty() { + return false; + } + + let Ok(uri) = Url::parse(kid) else { + return false; + }; + + let host = uri.host_str().unwrap_or("").to_ascii_lowercase(); + (host.ends_with(".vault.azure.net") || host.ends_with(".managedhsm.azure.net")) + && uri.path().to_ascii_lowercase().contains("/keys/") + } +} + +impl CoseSign1TrustPack for AzureKeyVaultTrustPack { + /// Short display name for this trust pack. + fn name(&self) -> &'static str { + "AzureKeyVaultTrustPack" + } + + /// Return a `TrustFactProducer` instance for this pack. + fn fact_producer(&self) -> std::sync::Arc { + std::sync::Arc::new(self.clone()) + } + + /// Return the default AKV trust plan. + /// + /// This plan requires that the message `kid` looks like an AKV key id and is allowlisted. + fn default_trust_plan(&self) -> Option { + use crate::validation::fluent_ext::{ + AzureKeyVaultKidAllowedWhereExt, AzureKeyVaultKidDetectedWhereExt, + }; + + // Secure-by-default AKV policy: + // - kid must look like an AKV key id + // - kid must match allowed patterns (defaults cover Microsoft Key Vault namespaces) + let bundled = TrustPlanBuilder::new(vec![std::sync::Arc::new(self.clone())]) + .for_message(|m| { + m.require::(|f| f.require_azure_key_vault_kid()) + .and() + .require::(|f| f.require_kid_allowed()) + }) + .compile() + .expect("default trust plan should be satisfiable by the AKV trust pack"); + + Some(bundled.plan().clone()) + } +} + +impl TrustFactProducer for AzureKeyVaultTrustPack { + /// Stable producer name used for diagnostics/audit. + fn name(&self) -> &'static str { + "cose_sign1_azure_key_vault::AzureKeyVaultTrustPack" + } + + /// Produce AKV-related facts. + /// + /// This pack only produces facts for the `Message` subject. + fn produce(&self, ctx: &mut TrustFactContext<'_>) -> Result<(), TrustError> { + if ctx.subject().kind != "Message" { + ctx.mark_produced(FactKey::of::()); + ctx.mark_produced(FactKey::of::()); + return Ok(()); + } + + if ctx.cose_sign1_message().is_none() { + ctx.mark_missing::("MissingMessage"); + ctx.mark_missing::("MissingMessage"); + ctx.mark_produced(FactKey::of::()); + ctx.mark_produced(FactKey::of::()); + return Ok(()); + } + + let Some(kid) = Self::try_get_kid_utf8(ctx) else { + ctx.mark_missing::("MissingKid"); + ctx.mark_missing::("MissingKid"); + ctx.mark_produced(FactKey::of::()); + ctx.mark_produced(FactKey::of::()); + return Ok(()); + }; + + let is_akv = Self::looks_like_azure_key_vault_key_id(&kid); + ctx.observe(AzureKeyVaultKidDetectedFact { + is_azure_key_vault_key: is_akv, + })?; + + let (is_allowed, details) = if self.options.require_azure_key_vault_kid && !is_akv { + (false, Some("NoPatternMatch".into())) + } else if self.compiled_patterns.is_none() { + (false, Some("NoAllowedPatterns".into())) + } else { + let matched = self + .compiled_patterns + .as_ref() + .is_some_and(|patterns| patterns.iter().any(|re| re.is_match(&kid))); + ( + matched, + Some(if matched { + "PatternMatched".into() + } else { + "NoPatternMatch".into() + }), + ) + }; + + ctx.observe(AzureKeyVaultKidAllowedFact { + is_allowed, + details, + })?; + + ctx.mark_produced(FactKey::of::()); + ctx.mark_produced(FactKey::of::()); + Ok(()) + } + + /// Return the set of fact keys this producer can emit. + fn provides(&self) -> &'static [FactKey] { + static PROVIDED: LazyLock<[FactKey; 2]> = LazyLock::new(|| { + [ + FactKey::of::(), + FactKey::of::(), + ] + }); + &*PROVIDED + } +} diff --git a/native/rust/extension_packs/certificates/Cargo.toml b/native/rust/extension_packs/certificates/Cargo.toml index 1d757771..082eed66 100644 --- a/native/rust/extension_packs/certificates/Cargo.toml +++ b/native/rust/extension_packs/certificates/Cargo.toml @@ -35,5 +35,8 @@ cbor_primitives_everparse = { path = "../../primitives/cbor/everparse" } cose_sign1_crypto_openssl = { path = "../../primitives/crypto/openssl" } openssl = { workspace = true } +[[example]] +name = "certificate_trust_validation" + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/native/rust/extension_packs/certificates/examples/certificate_trust_validation.rs b/native/rust/extension_packs/certificates/examples/certificate_trust_validation.rs new file mode 100644 index 00000000..b1987315 --- /dev/null +++ b/native/rust/extension_packs/certificates/examples/certificate_trust_validation.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Certificate-based trust validation — create an ephemeral certificate chain, +//! construct a COSE_Sign1 message with an embedded x5chain header, then validate +//! using the X.509 certificate trust pack. +//! +//! Run with: +//! cargo run --example certificate_trust_validation -p cose_sign1_certificates + +use std::sync::Arc; + +use cbor_primitives::{CborEncoder, CborProvider}; +use cbor_primitives_everparse::EverParseCborProvider; +use cose_sign1_certificates::validation::pack::{ + CertificateTrustOptions, X509CertificateTrustPack, +}; +use cose_sign1_validation::fluent::*; +use cose_sign1_validation_primitives::CoseHeaderLocation; + +fn main() { + // ── 1. Generate an ephemeral self-signed certificate ───────────── + println!("=== Step 1: Generate ephemeral certificate ===\n"); + + let rcgen::CertifiedKey { cert, .. } = + rcgen::generate_simple_self_signed(vec!["example-leaf".to_string()]).expect("rcgen failed"); + let leaf_der = cert.der().to_vec(); + println!(" Leaf cert DER size: {} bytes", leaf_der.len()); + + // ── 2. Build a minimal COSE_Sign1 with x5chain header ─────────── + println!("\n=== Step 2: Build COSE_Sign1 with x5chain ===\n"); + + let payload = b"Hello, COSE world!"; + let cose_bytes = build_cose_sign1_with_x5chain(&leaf_der, payload); + println!(" COSE message size: {} bytes", cose_bytes.len()); + println!(" Payload: {:?}", std::str::from_utf8(payload).unwrap()); + + // ── 3. Set up the certificate trust pack ───────────────────────── + println!("\n=== Step 3: Configure certificate trust pack ===\n"); + + // For this example, treat the embedded x5chain as trusted. + // In production, configure actual trust roots and revocation checks. + let cert_pack = Arc::new(X509CertificateTrustPack::new(CertificateTrustOptions { + trust_embedded_chain_as_trusted: true, + ..Default::default() + })); + println!(" Trust pack: embedded x5chain treated as trusted"); + + let trust_packs: Vec> = vec![cert_pack]; + + // ── 4. Build a validator with bypass trust + signature bypass ───── + // (We bypass the actual crypto check because the COSE message's + // signature is a dummy — in a real scenario the signing service + // would produce a valid signature.) + println!("\n=== Step 4: Validate with trust bypass ===\n"); + + let validator = CoseSign1Validator::new(trust_packs.clone()).with_options(|o| { + o.certificate_header_location = CoseHeaderLocation::Any; + o.trust_evaluation_options.bypass_trust = true; + }); + + let result = validator + .validate_bytes( + EverParseCborProvider, + Arc::from(cose_bytes.clone().into_boxed_slice()), + ) + .expect("validation pipeline error"); + + println!(" resolution: {:?}", result.resolution.kind); + println!(" trust: {:?}", result.trust.kind); + println!(" signature: {:?}", result.signature.kind); + println!(" overall: {:?}", result.overall.kind); + + // ── 5. Demonstrate custom trust plan ───────────────────────────── + println!("\n=== Step 5: Custom trust plan (advanced) ===\n"); + + use cose_sign1_certificates::validation::fluent_ext::PrimarySigningKeyScopeRulesExt; + + let cert_pack2 = Arc::new(X509CertificateTrustPack::new(CertificateTrustOptions { + trust_embedded_chain_as_trusted: true, + ..Default::default() + })); + let packs: Vec> = vec![cert_pack2]; + + let plan = TrustPlanBuilder::new(packs) + .for_primary_signing_key(|key| { + key.require_x509_chain_trusted() + .and() + .require_signing_certificate_present() + }) + .compile() + .expect("plan compile"); + + let validator2 = CoseSign1Validator::new(plan).with_options(|o| { + o.certificate_header_location = CoseHeaderLocation::Any; + }); + + let result2 = validator2 + .validate_bytes( + EverParseCborProvider, + Arc::from(cose_bytes.into_boxed_slice()), + ) + .expect("validation pipeline error"); + + println!(" resolution: {:?}", result2.resolution.kind); + println!(" trust: {:?}", result2.trust.kind); + println!(" signature: {:?}", result2.signature.kind); + println!(" overall: {:?}", result2.overall.kind); + + // Print failures if any + let stages = [ + ("resolution", &result2.resolution), + ("trust", &result2.trust), + ("signature", &result2.signature), + ("overall", &result2.overall), + ]; + for (name, stage) in stages { + if !stage.failures.is_empty() { + println!("\n {} failures:", name); + for f in &stage.failures { + println!(" - {}", f.message); + } + } + } + + println!("\n=== Example completed! ==="); +} + +/// Build a minimal COSE_Sign1 byte sequence with an embedded x5chain header. +/// +/// The message structure is: +/// [protected_headers_bstr, unprotected_headers_map, payload_bstr, signature_bstr] +/// +/// Protected headers contain: +/// { 1 (alg): -7 (ES256), 33 (x5chain): bstr(cert_der) } +fn build_cose_sign1_with_x5chain(leaf_der: &[u8], payload: &[u8]) -> Vec { + let p = EverParseCborProvider; + let mut enc = p.encoder(); + + // COSE_Sign1 is a 4-element CBOR array + enc.encode_array(4).unwrap(); + + // Protected headers: CBOR bstr wrapping a CBOR map + let mut hdr_enc = p.encoder(); + hdr_enc.encode_map(2).unwrap(); + hdr_enc.encode_i64(1).unwrap(); // label: alg + hdr_enc.encode_i64(-7).unwrap(); // value: ES256 + hdr_enc.encode_i64(33).unwrap(); // label: x5chain + hdr_enc.encode_bstr(leaf_der).unwrap(); + let protected_bytes = hdr_enc.into_bytes(); + enc.encode_bstr(&protected_bytes).unwrap(); + + // Unprotected headers: empty map + enc.encode_map(0).unwrap(); + + // Payload: embedded byte string + enc.encode_bstr(payload).unwrap(); + + // Signature: dummy (not cryptographically valid) + enc.encode_bstr(b"example-signature-placeholder").unwrap(); + + enc.into_bytes() +} diff --git a/native/rust/extension_packs/certificates/ffi/README.md b/native/rust/extension_packs/certificates/ffi/README.md new file mode 100644 index 00000000..46799a08 --- /dev/null +++ b/native/rust/extension_packs/certificates/ffi/README.md @@ -0,0 +1,95 @@ + + +# cose_sign1_certificates_ffi + +C/C++ FFI projection for the X.509 certificate validation extension pack. + +## Overview + +This crate provides C-compatible FFI exports for registering the X.509 certificate trust pack +with a validator builder and authoring trust policies that constrain X.509 chain properties. +Supported constraints include chain trust status, chain element identity and validity, public key +algorithms, and signing certificate identity. + +## Exported Functions + +### Pack Registration + +| Function | Description | +|----------|-------------| +| `cose_sign1_validator_builder_with_certificates_pack` | Add certificate pack (default options) | +| `cose_sign1_validator_builder_with_certificates_pack_ex` | Add certificate pack (custom options) | + +### Chain Trust Policies + +| Function | Description | +|----------|-------------| +| `..._require_x509_chain_trusted` | Require chain is trusted | +| `..._require_x509_chain_not_trusted` | Require chain is not trusted | +| `..._require_x509_chain_built` | Require chain was successfully built | +| `..._require_x509_chain_not_built` | Require chain was not built | +| `..._require_x509_chain_element_count_eq` | Require specific chain length | +| `..._require_x509_chain_status_flags_eq` | Require specific chain status flags | +| `..._require_leaf_chain_thumbprint_present` | Require leaf thumbprint present | +| `..._require_leaf_subject_eq` | Require leaf subject matches | +| `..._require_issuer_subject_eq` | Require issuer subject matches | +| `..._require_leaf_issuer_is_next_chain_subject_optional` | Require leaf-to-chain issuer linkage | + +### Signing Certificate Policies + +| Function | Description | +|----------|-------------| +| `..._require_signing_certificate_present` | Require signing cert present | +| `..._require_signing_certificate_subject_issuer_matches_*` | Require subject-issuer match | +| `..._require_signing_certificate_thumbprint_eq` | Require specific thumbprint | +| `..._require_signing_certificate_thumbprint_present` | Require thumbprint present | +| `..._require_signing_certificate_subject_eq` | Require specific subject | +| `..._require_signing_certificate_issuer_eq` | Require specific issuer | +| `..._require_signing_certificate_serial_number_eq` | Require specific serial number | +| `..._require_signing_certificate_*` (validity) | Time-based validity constraints | + +### Chain Element Policies + +| Function | Description | +|----------|-------------| +| `..._require_chain_element_subject_eq` | Require element subject matches | +| `..._require_chain_element_issuer_eq` | Require element issuer matches | +| `..._require_chain_element_thumbprint_eq` | Require element thumbprint matches | +| `..._require_chain_element_thumbprint_present` | Require element thumbprint present | +| `..._require_chain_element_*` (validity) | Element time-based validity constraints | + +### Public Key Algorithm Policies + +| Function | Description | +|----------|-------------| +| `..._require_not_pqc_algorithm_or_missing` | Require non-PQC algorithm | +| `..._require_x509_public_key_algorithm_thumbprint_eq` | Require specific algorithm thumbprint | +| `..._require_x509_public_key_algorithm_oid_eq` | Require specific algorithm OID | +| `..._require_x509_public_key_algorithm_is_pqc` | Require PQC algorithm | +| `..._require_x509_public_key_algorithm_is_not_pqc` | Require non-PQC algorithm | + +### Key Utilities + +| Function | Description | +|----------|-------------| +| `cose_sign1_certificates_key_from_cert_der` | Create key handle from DER certificate | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_certificate_trust_options_t` | C ABI options struct for certificate trust configuration | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_certificates`](../../certificates/) — X.509 certificate trust pack implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_certificates_ffi +``` diff --git a/native/rust/extension_packs/certificates/ffi/src/lib.rs b/native/rust/extension_packs/certificates/ffi/src/lib.rs index ecf589f3..c8e5c229 100644 --- a/native/rust/extension_packs/certificates/ffi/src/lib.rs +++ b/native/rust/extension_packs/certificates/ffi/src/lib.rs @@ -1,9 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! X.509 certificates pack FFI bindings. +//! C-ABI projection for `cose_sign1_certificates`. +//! +//! This crate provides C-compatible FFI exports for the X.509 certificate +//! validation extension pack. It enables C/C++ consumers to register the +//! certificate trust pack with a validator builder and to author trust policies +//! that constrain X.509 chain properties such as trust anchor, chain element +//! identity, validity periods, public key algorithms, and signing certificate +//! identity. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for details. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function +//! +//! # Thread Safety //! -//! This crate exposes the X.509 certificate validation pack to C/C++ consumers. +//! All functions are thread-safe. Error state is thread-local. use cose_sign1_certificates::validation::facts::{ X509ChainElementIdentityFact, X509ChainElementValidityFact, X509ChainTrustedFact, diff --git a/native/rust/extension_packs/certificates/local/ffi/README.md b/native/rust/extension_packs/certificates/local/ffi/README.md new file mode 100644 index 00000000..7201529c --- /dev/null +++ b/native/rust/extension_packs/certificates/local/ffi/README.md @@ -0,0 +1,76 @@ + + +# cose_sign1_certificates_local_ffi + +C/C++ FFI projection for local certificate creation and loading. + +## Overview + +This crate provides C-compatible FFI exports for creating ephemeral certificates, +building certificate chains, and loading certificates from PEM or DER encoded files. +It is primarily used for testing and development scenarios where real CA-issued +certificates are not available. + +## Exported Functions + +### ABI & Error Handling + +| Function | Description | +|----------|-------------| +| `cose_cert_local_ffi_abi_version` | ABI version check | +| `cose_cert_local_last_error_message_utf8` | Get thread-local error message | +| `cose_cert_local_last_error_clear` | Clear thread-local error state | +| `cose_cert_local_string_free` | Free a string returned by this library | + +### Certificate Factory + +| Function | Description | +|----------|-------------| +| `cose_cert_local_factory_new` | Create a new certificate factory | +| `cose_cert_local_factory_free` | Free a certificate factory | +| `cose_cert_local_factory_create_cert` | Create a certificate signed by an issuer | +| `cose_cert_local_factory_create_self_signed` | Create a self-signed certificate | + +### Certificate Chain + +| Function | Description | +|----------|-------------| +| `cose_cert_local_chain_new` | Create a new certificate chain factory | +| `cose_cert_local_chain_free` | Free a chain factory | +| `cose_cert_local_chain_create` | Create a complete certificate chain | + +### Certificate Loading + +| Function | Description | +|----------|-------------| +| `cose_cert_local_load_pem` | Load certificate from PEM-encoded data | +| `cose_cert_local_load_der` | Load certificate from DER-encoded data | + +### Memory Management + +| Function | Description | +|----------|-------------| +| `cose_cert_local_bytes_free` | Free a byte buffer | +| `cose_cert_local_array_free` | Free an array of byte buffer pointers | +| `cose_cert_local_lengths_array_free` | Free an array of lengths | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_cert_local_factory_t` | Opaque ephemeral certificate factory | +| `cose_cert_local_chain_t` | Opaque certificate chain factory | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_certificates_local`](../../local/) — Local certificate creation utilities. + +## Build + +```bash +cargo build --release -p cose_sign1_certificates_local_ffi +``` diff --git a/native/rust/extension_packs/certificates/local/ffi/src/lib.rs b/native/rust/extension_packs/certificates/local/ffi/src/lib.rs index 46a93a2f..cbcc6fc6 100644 --- a/native/rust/extension_packs/certificates/local/ffi/src/lib.rs +++ b/native/rust/extension_packs/certificates/local/ffi/src/lib.rs @@ -5,10 +5,47 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! FFI bindings for local certificate creation and loading. +//! C-ABI projection for `cose_sign1_certificates_local`. //! -//! This crate provides C-compatible FFI exports for the `cose_sign1_certificates_local` crate, -//! enabling certificate creation, chain building, and certificate loading from C/C++ code. +//! This crate provides C-compatible FFI exports for local certificate creation +//! and loading. It wraps the `cose_sign1_certificates_local` crate, enabling +//! C/C++ code to create ephemeral certificates, build certificate chains, and +//! load certificates from PEM or DER encoded files. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_cert_local_ffi_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_cert_local_last_error_message_utf8()` for a +//! thread-local error description. Call `cose_cert_local_last_error_clear()` +//! to reset error state. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_cert_local_factory_free` for factory handles +//! - `cose_cert_local_chain_free` for chain handles +//! - `cose_cert_local_bytes_free` for byte buffers +//! - `cose_cert_local_array_free` for array pointers +//! - `cose_cert_local_lengths_array_free` for length array pointers +//! - `cose_cert_local_string_free` for error message strings +//! +//! # Thread Safety +//! +//! All functions are thread-safe. Error state is thread-local. use cose_sign1_certificates_local::{ CertificateChainFactory, CertificateChainOptions, CertificateFactory, CertificateOptions, diff --git a/native/rust/extension_packs/certificates/local/src/loaders/mod.rs b/native/rust/extension_packs/certificates/local/src/loaders/mod.rs index 826d2403..77b5c9d9 100644 --- a/native/rust/extension_packs/certificates/local/src/loaders/mod.rs +++ b/native/rust/extension_packs/certificates/local/src/loaders/mod.rs @@ -9,7 +9,7 @@ //! - **DER** - Binary X.509 certificate format //! - **PEM** - Base64-encoded X.509 with BEGIN/END markers //! - **PFX** - PKCS#12 archives (password-protected, feature-gated) -//! - **Windows Store** - Windows certificate store (platform-specific, stub) +//! - **Windows Store** - Windows certificate store (platform-specific, feature-gated) //! //! ## Format Support //! diff --git a/native/rust/extension_packs/certificates/local/src/loaders/pfx.rs b/native/rust/extension_packs/certificates/local/src/loaders/pfx.rs index 89054168..47a4d4f3 100644 --- a/native/rust/extension_packs/certificates/local/src/loaders/pfx.rs +++ b/native/rust/extension_packs/certificates/local/src/loaders/pfx.rs @@ -211,7 +211,7 @@ pub fn load_from_pfx_no_password>(path: P) -> Result Result { - // TODO: Implement post-sign verification - Ok(true) + // Parse the COSE_Sign1 message + let msg = cose_sign1_primitives::CoseSign1Message::parse(message_bytes).map_err(|e| { + SigningError::VerificationFailed { + detail: format!("failed to parse COSE_Sign1: {}", e).into(), + } + })?; + + // Extract the public key from the signing certificate + let cert_der = self + .certificate_source + .get_signing_certificate() + .map_err(|e| SigningError::VerificationFailed { + detail: format!("certificate source: {}", e).into(), + })?; + + let x509 = openssl::x509::X509::from_der(cert_der).map_err(|e| { + SigningError::VerificationFailed { + detail: format!("failed to parse certificate: {}", e).into(), + } + })?; + + let public_key_der = x509 + .public_key() + .map_err(|e| SigningError::VerificationFailed { + detail: format!("failed to extract public key: {}", e).into(), + })? + .public_key_to_der() + .map_err(|e| SigningError::VerificationFailed { + detail: format!("failed to encode public key: {}", e).into(), + })?; + + // Determine algorithm from the signing key provider + let algorithm = self.signing_key_provider.algorithm(); + + // Create verifier from the certificate's public key + let verifier = cose_sign1_crypto_openssl::evp_verifier::EvpVerifier::from_der( + &public_key_der, + algorithm, + ) + .map_err(|e| SigningError::VerificationFailed { + detail: format!("verifier creation: {}", e).into(), + })?; + + // Build Sig_structure and verify + let payload = msg.payload().unwrap_or_default(); + let sig_structure = msg.sig_structure_bytes(payload, None).map_err(|e| { + SigningError::VerificationFailed { + detail: format!("sig_structure: {}", e).into(), + } + })?; + + verifier + .verify(&sig_structure, msg.signature()) + .map_err(|e| SigningError::VerificationFailed { + detail: format!("verify: {}", e).into(), + }) } } diff --git a/native/rust/extension_packs/certificates/tests/certificate_signing_service_tests.rs b/native/rust/extension_packs/certificates/tests/certificate_signing_service_tests.rs index 676f14c5..a85a8d33 100644 --- a/native/rust/extension_packs/certificates/tests/certificate_signing_service_tests.rs +++ b/native/rust/extension_packs/certificates/tests/certificate_signing_service_tests.rs @@ -323,6 +323,75 @@ fn test_get_cose_signer_certificate_source_failure() { #[test] fn test_verify_signature_returns_true() { + // Generate a real EC P-256 key pair and self-signed certificate + let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let cert_params = rcgen::CertificateParams::new(vec!["test.example.com".to_string()]).unwrap(); + let cert = cert_params.self_signed(&key_pair).unwrap(); + let cert_der = cert.der().to_vec(); + + // Build a COSE_Sign1 message signed by this key + let payload = b"test payload for verification"; + + // Create an OpenSSL signer from the private key DER + let private_key_der = key_pair.serialize_der(); + let signer = + cose_sign1_crypto_openssl::evp_signer::EvpSigner::from_der(&private_key_der, -7).unwrap(); + + // Build and sign a tagged COSE_Sign1 message + let builder = cose_sign1_primitives::CoseSign1Builder::new().tagged(true); + let signed_bytes = builder.sign(&signer, payload).expect("sign"); + + // Now set up CertificateSigningService with the real cert + let source = Box::new(MockCertificateSource::new(cert_der, vec![])); + let provider = Arc::new(MockSigningKeyProvider::new(false)); + let options = CertificateSigningOptions::default(); + + let service = CertificateSigningService::new(source, provider, options); + let context = SigningContext::from_bytes(vec![]); + + let result = service.verify_signature(&signed_bytes, &context); + assert!(result.is_ok(), "verify_signature failed: {:?}", result); + assert!(result.unwrap(), "signature should be valid"); +} + +#[test] +fn test_verify_signature_rejects_tampered_message() { + // Generate a real EC P-256 key pair and self-signed certificate + let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let cert_params = rcgen::CertificateParams::new(vec!["test.example.com".to_string()]).unwrap(); + let cert = cert_params.self_signed(&key_pair).unwrap(); + let cert_der = cert.der().to_vec(); + + // Build a COSE_Sign1 message signed by this key + let payload = b"original payload"; + let private_key_der = key_pair.serialize_der(); + let signer = + cose_sign1_crypto_openssl::evp_signer::EvpSigner::from_der(&private_key_der, -7).unwrap(); + + let builder = cose_sign1_primitives::CoseSign1Builder::new().tagged(true); + let mut signed_bytes = builder.sign(&signer, payload).expect("sign"); + + // Tamper with the last byte of the signature + let len = signed_bytes.len(); + signed_bytes[len - 1] ^= 0xFF; + + let source = Box::new(MockCertificateSource::new(cert_der, vec![])); + let provider = Arc::new(MockSigningKeyProvider::new(false)); + let options = CertificateSigningOptions::default(); + let service = CertificateSigningService::new(source, provider, options); + let context = SigningContext::from_bytes(vec![]); + + let result = service.verify_signature(&signed_bytes, &context); + // Either returns Ok(false) or Err — both indicate invalid signature + match result { + Ok(false) => {} // Verification correctly returned false + Err(_) => {} // Verification error is also acceptable for tampered data + Ok(true) => panic!("tampered message should not verify as valid"), + } +} + +#[test] +fn test_verify_signature_invalid_message_returns_error() { let cert = create_test_cert(); let source = Box::new(MockCertificateSource::new(cert, vec![])); let provider = Arc::new(MockSigningKeyProvider::new(false)); @@ -331,10 +400,9 @@ fn test_verify_signature_returns_true() { let service = CertificateSigningService::new(source, provider, options); let context = SigningContext::from_bytes(vec![]); - // Currently returns true (TODO implementation) + // Garbage bytes are not a valid COSE_Sign1 message let result = service.verify_signature(&[1, 2, 3, 4], &context); - assert!(result.is_ok()); - assert!(result.unwrap()); + assert!(result.is_err(), "invalid message bytes should return Err"); } #[test] diff --git a/native/rust/extension_packs/certificates/tests/signing_key_resolver_pqc_resolution.rs b/native/rust/extension_packs/certificates/tests/signing_key_resolver_pqc_resolution.rs index 15d79858..a8d26a98 100644 --- a/native/rust/extension_packs/certificates/tests/signing_key_resolver_pqc_resolution.rs +++ b/native/rust/extension_packs/certificates/tests/signing_key_resolver_pqc_resolution.rs @@ -62,10 +62,9 @@ fn signing_key_resolver_can_resolve_non_p256_ec_keys_without_failing_resolution( } #[test] -fn signing_key_resolver_reports_key_mismatch_for_es256_instead_of_parse_failure() { - // If the leaf certificate's public key is not compatible with ES256, verification should - // report a clean mismatch/unsupported error (not an x509 parse error). - // The OpenSSL provider defaults to ES256 for all EC keys (curve detection is a TODO). +fn signing_key_resolver_detects_p384_curve_and_assigns_es384() { + // The OpenSSL provider detects the EC curve from the leaf certificate's public key + // and assigns the correct COSE algorithm: P-384 → ES384 (-35). let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P384_SHA384).unwrap(); let params = CertificateParams::new(vec!["resolver-pqc-smoke".to_string()]).unwrap(); @@ -85,11 +84,11 @@ fn signing_key_resolver_reports_key_mismatch_for_es256_instead_of_parse_failure( assert!(res.is_success); let key = res.cose_key.unwrap(); - // OpenSSL provider defaults to ES256 for all EC keys (P-384 detection not implemented) - assert_eq!(key.algorithm(), -7, "EC key defaults to ES256"); + // P-384 curve correctly detected → ES384 (COSE algorithm -35) + assert_eq!(key.algorithm(), -35, "P-384 key should be assigned ES384"); - // P-384 key with ES256 algorithm: garbage signature returns false or error - let result = key.verify(b"sig_structure", &[0u8; 64]); + // Garbage signature against correct algorithm should not verify + let result = key.verify(b"sig_structure", &[0u8; 96]); match result { Ok(false) => {} // Expected - signature doesn't verify Err(_) => {} // Also acceptable - verification error diff --git a/native/rust/extension_packs/certificates/tests/signing_key_resolver_tests.rs b/native/rust/extension_packs/certificates/tests/signing_key_resolver_tests.rs index 12f46ec9..57123060 100644 --- a/native/rust/extension_packs/certificates/tests/signing_key_resolver_tests.rs +++ b/native/rust/extension_packs/certificates/tests/signing_key_resolver_tests.rs @@ -222,9 +222,9 @@ fn verify_es256_oid_mismatch_returns_invalid_key() { } #[test] -fn verify_es256_wrong_key_length_returns_invalid_key() { +fn verify_es384_wrong_key_with_garbage_signature() { // Use a P-384 cert (97-byte public key) with id-ecPublicKey OID. - // OpenSSL provider defaults to ES256 for all EC keys (curve detection not implemented). + // OpenSSL provider correctly detects EC curve: P-384 → ES384 (-35). let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); let params = rcgen::CertificateParams::new(vec!["p384-test.example.com".to_string()]).unwrap(); let cert = params.self_signed(&key_pair).unwrap(); @@ -233,11 +233,11 @@ fn verify_es256_wrong_key_length_returns_invalid_key() { let protected = protected_x5chain_bstr(&cert_der); let key = resolve_key(&protected).cose_key.unwrap(); - // OpenSSL provider defaults to ES256 for all EC keys - assert_eq!(key.algorithm(), -7, "EC key defaults to ES256"); + // P-384 curve correctly detected → ES384 (COSE algorithm -35) + assert_eq!(key.algorithm(), -35, "P-384 key should be assigned ES384"); - // P-384 key with ES256 algorithm: verification may error or return false - let result = key.verify(b"sig_structure", &[0u8; 64]); + // Garbage signature against correct algorithm should not verify + let result = key.verify(b"sig_structure", &[0u8; 96]); match result { Ok(false) => {} // Expected - signature doesn't verify Err(_) => {} // Also acceptable - verification error diff --git a/native/rust/extension_packs/certificates/tests/signing_key_verify_more.rs b/native/rust/extension_packs/certificates/tests/signing_key_verify_more.rs index 0add9347..8a14c464 100644 --- a/native/rust/extension_packs/certificates/tests/signing_key_verify_more.rs +++ b/native/rust/extension_packs/certificates/tests/signing_key_verify_more.rs @@ -265,8 +265,8 @@ fn signing_key_verify_es256_returns_true_for_valid_signature() { } #[test] -fn signing_key_verify_returns_err_for_unsupported_alg() { - // Use a P-384 certificate. OpenSSL provider defaults to ES256 for all EC keys. +fn signing_key_verify_p384_resolves_to_es384_and_rejects_garbage() { + // Use a P-384 certificate. OpenSSL provider detects EC curve: P-384 → ES384 (-35). let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P384_SHA384).unwrap(); let params = CertificateParams::new(vec!["verify-unsupported-alg".to_string()]).unwrap(); let cert = params.self_signed(&key_pair).unwrap(); @@ -286,11 +286,11 @@ fn signing_key_verify_returns_err_for_unsupported_alg() { assert!(res.is_success); let key = res.cose_key.unwrap(); - // OpenSSL provider defaults to ES256 for all EC keys - assert_eq!(key.algorithm(), -7, "EC key defaults to ES256"); + // P-384 curve correctly detected → ES384 (COSE algorithm -35) + assert_eq!(key.algorithm(), -35, "P-384 key should be assigned ES384"); - // P-384 key with ES256 algorithm: verification may error or return false - let result = key.verify(b"sig_structure", &[0u8; 64]); + // Garbage signature should not verify + let result = key.verify(b"sig_structure", &[0u8; 96]); match result { Ok(false) => {} // Expected - signature doesn't verify Err(_) => {} // Also acceptable - verification error @@ -299,8 +299,8 @@ fn signing_key_verify_returns_err_for_unsupported_alg() { } #[test] -fn signing_key_verify_es256_rejects_non_p256_certificate_key() { - // Use a P-384 leaf. OpenSSL provider defaults to ES256 for all EC keys. +fn signing_key_verify_es384_rejects_non_matching_signature() { + // Use a P-384 leaf. OpenSSL provider detects EC curve: P-384 → ES384 (-35). let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P384_SHA384).unwrap(); let params = CertificateParams::new(vec!["verify-es256-alg-mismatch".to_string()]).unwrap(); let cert = params.self_signed(&key_pair).unwrap(); @@ -320,11 +320,11 @@ fn signing_key_verify_es256_rejects_non_p256_certificate_key() { assert!(res.is_success); let key = res.cose_key.unwrap(); - // OpenSSL provider defaults to ES256 for all EC keys - assert_eq!(key.algorithm(), -7, "EC key defaults to ES256"); + // P-384 curve correctly detected → ES384 (COSE algorithm -35) + assert_eq!(key.algorithm(), -35, "P-384 key should be assigned ES384"); - // P-384 key with ES256 algorithm: verification may error or return false - let result = key.verify(b"sig_structure", &[0u8; 64]); + // Garbage signature against correct algorithm should fail verification + let result = key.verify(b"sig_structure", &[0u8; 96]); match result { Ok(false) => {} // Expected - signature doesn't verify Err(_) => {} // Also acceptable - verification error diff --git a/native/rust/extension_packs/mst/Cargo.toml b/native/rust/extension_packs/mst/Cargo.toml index 72a77320..e3cdbba9 100644 --- a/native/rust/extension_packs/mst/Cargo.toml +++ b/native/rust/extension_packs/mst/Cargo.toml @@ -13,7 +13,6 @@ test-utils = [] [dependencies] sha2.workspace = true -once_cell.workspace = true url.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/native/rust/extension_packs/mst/client/README.md b/native/rust/extension_packs/mst/client/README.md new file mode 100644 index 00000000..62065d6c --- /dev/null +++ b/native/rust/extension_packs/mst/client/README.md @@ -0,0 +1,216 @@ + + +# code_transparency_client + +Rust REST client for the Azure Code Transparency Service. + +## Overview + +This crate provides a high-level HTTP client for interacting with the +[Azure Code Transparency](https://learn.microsoft.com/en-us/azure/confidential-ledger/code-transparency-overview) +service (formerly Microsoft Supply-chain Transparency, MST). It follows +canonical Azure SDK patterns — pipeline policies, long-running operation +polling, and structured error handling — to submit COSE_Sign1 messages for +transparent registration and retrieve receipts. + +Key capabilities: + +- **Entry submission** — `create_entry()` submits COSE_Sign1 messages and + returns a `Poller` for async tracking +- **Convenience signing** — `make_transparent()` submits and polls to + completion in a single call +- **Entry retrieval** — `get_entry()` / `get_entry_statement()` fetch + registered entries and their original statements +- **Key management** — `get_public_keys()` / `resolve_signing_key()` fetch + and resolve JWKS for receipt verification +- **Pipeline policies** — `ApiKeyAuthPolicy` for Bearer-token injection, + `TransactionNotCachedPolicy` for fast 503 retries +- **CBOR error handling** — Parses RFC 9290 CBOR Problem Details from + service error responses + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ code_transparency_client │ +├─────────────────────────┬────────────────────────────┤ +│ client │ models │ +│ ┌────────────────────┐ │ ┌────────────────────────┐ │ +│ │CodeTransparency │ │ │JsonWebKey │ │ +│ │ Client │ │ │JwksDocument │ │ +│ │ │ │ └────────────────────────┘ │ +│ │ • create_entry() │ │ │ +│ │ • make_transparent │ │ operation_status │ +│ │ • get_entry() │ │ ┌────────────────────────┐ │ +│ │ • get_public_keys()│ │ │OperationStatus │ │ +│ │ • resolve_signing │ │ │ (StatusMonitor) │ │ +│ │ _key() │ │ └────────────────────────┘ │ +│ └────────────────────┘ │ │ +├─────────────────────────┼────────────────────────────┤ +│ Pipeline Policies │ Error Handling │ +│ ┌────────────────────┐ │ ┌────────────────────────┐ │ +│ │ApiKeyAuthPolicy │ │ │CodeTransparencyError │ │ +│ │TransactionNot │ │ │CborProblemDetails │ │ +│ │ CachedPolicy │ │ └────────────────────────┘ │ +│ └────────────────────┘ │ │ +├─────────────────────────┴────────────────────────────┤ +│ polling (DelayStrategy, MstPollingOptions) │ +│ mock_transport (SequentialMockTransport) [test-utils] │ +└──────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + azure_core cbor_primitives + (Pipeline, Poller, cose_sign1_primitives + StatusMonitor) +``` + +## Modules + +| Module | Description | +|--------|-------------| +| `client` | `CodeTransparencyClient` — main HTTP client with entry submission, retrieval, and key management | +| `models` | `JsonWebKey`, `JwksDocument` — JWKS types for receipt verification key resolution | +| `operation_status` | `OperationStatus` — `StatusMonitor` implementation for long-running operation polling | +| `polling` | `DelayStrategy` (fixed / exponential), `MstPollingOptions` — configurable polling behavior | +| `api_key_auth_policy` | `ApiKeyAuthPolicy` — pipeline policy injecting `Authorization: Bearer {key}` headers | +| `transaction_not_cached_policy` | `TransactionNotCachedPolicy` — fast-retry policy (250 ms × 8) for `TransactionNotCached` 503 errors | +| `cbor_problem_details` | `CborProblemDetails` — RFC 9290 CBOR Problem Details parser for structured error responses | +| `error` | `CodeTransparencyError` — structured errors with HTTP status codes and service messages | +| `mock_transport` | `SequentialMockTransport` — mock HTTP transport for unit tests (behind `test-utils` feature) | + +## Key Types + +### CodeTransparencyClient + +```rust +use code_transparency_client::{CodeTransparencyClient, MstPollingOptions}; + +// Create a client with API key authentication +let client = CodeTransparencyClient::new( + "https://my-instance.confidential-ledger.azure.com", + Some("my-api-key".into()), + None, // default options +)?; + +// Submit a COSE_Sign1 message and wait for receipt +let transparent_bytes = client + .make_transparent(&cose_sign1_bytes, None) + .await?; +``` + +### Entry Submission with Polling + +```rust +use code_transparency_client::CodeTransparencyClient; + +let client = CodeTransparencyClient::new(endpoint, api_key, None)?; + +// Start the long-running operation +let poller = client.create_entry(&cose_sign1_bytes).await?; + +// Poll until complete (uses default delay strategy) +let status = poller.wait().await?; +let entry_id = status.entry_id.expect("entry registered"); + +// Retrieve the transparent entry +let entry_bytes = client.get_entry(&entry_id).await?; +``` + +### Receipt Key Resolution + +```rust +use code_transparency_client::CodeTransparencyClient; + +// Resolve a signing key by key ID (checks cache first, then fetches JWKS) +let jwk = client.resolve_signing_key("key-id-123", &jwks_cache).await?; +``` + +### Custom Polling Options + +```rust +use code_transparency_client::{MstPollingOptions, DelayStrategy}; +use std::time::Duration; + +let options = MstPollingOptions { + delay_strategy: DelayStrategy::Exponential { + initial: Duration::from_secs(1), + max: Duration::from_secs(30), + }, + max_retries: Some(20), +}; + +let transparent = client + .make_transparent(&cose_bytes, Some(options)) + .await?; +``` + +## Error Handling + +All operations return `CodeTransparencyError`: + +```rust +pub enum CodeTransparencyError { + /// HTTP or network error from the Azure pipeline. + HttpError(azure_core::Error), + /// Service returned a structured CBOR Problem Details response. + ServiceError { + status: u16, + details: Option, + message: String, + }, + /// Operation timed out or exceeded max retries. + PollingTimeout, + /// CBOR/COSE deserialization failure. + DeserializationError(String), +} +``` + +The `TransactionNotCachedPolicy` automatically retries 503 responses with +a `TransactionNotCached` error code up to 8 times at 250 ms intervals before +surfacing the error to the caller. + +## Memory Design + +- **Pipeline-based I/O**: HTTP requests flow through an `azure_core::http::Pipeline` + with configurable policies. Response bodies are read once and owned by the caller. +- **COSE bytes are borrowed**: `create_entry()` and `make_transparent()` accept + `&[u8]`, avoiding copies of potentially large COSE_Sign1 messages. +- **JWKS caching**: `resolve_signing_key()` checks an in-memory cache before + making network requests, avoiding redundant fetches. + +## Dependencies + +- `azure_core` — HTTP pipeline, `Poller`, `StatusMonitor`, retry policies +- `cbor_primitives` — CBOR decoding for problem details and configuration +- `cose_sign1_primitives` — COSE types shared with the signing/validation stack +- `serde` / `serde_json` — JSON deserialization for JWKS responses +- `tokio` — Async runtime for HTTP operations + +## Testing + +Enable the `test-utils` feature to access `SequentialMockTransport` for +unit tests without network access: + +```toml +[dev-dependencies] +code_transparency_client = { path = ".", features = ["test-utils"] } +``` + +```rust +use code_transparency_client::mock_transport::SequentialMockTransport; + +let transport = SequentialMockTransport::new(vec![ + mock_response(200, cose_bytes), + mock_response(200, receipt_bytes), +]); +``` + +## See Also + +- [extension_packs/mst/](../) — MST trust pack using this client for receipt validation +- [extension_packs/certificates/](../../certificates/) — Certificate trust pack +- [Azure Code Transparency docs](https://learn.microsoft.com/en-us/azure/confidential-ledger/code-transparency-overview) + +## License + +Licensed under the [MIT License](../../../../../LICENSE). \ No newline at end of file diff --git a/native/rust/extension_packs/mst/ffi/README.md b/native/rust/extension_packs/mst/ffi/README.md new file mode 100644 index 00000000..a1ab4544 --- /dev/null +++ b/native/rust/extension_packs/mst/ffi/README.md @@ -0,0 +1,70 @@ + + +# cose_sign1_transparent_mst_ffi + +C/C++ FFI projection for the Microsoft Secure Transparency (MST) extension pack. + +## Overview + +This crate provides C-compatible FFI exports for the MST receipt verification trust pack. +It enables C/C++ consumers to register the MST trust pack with a validator builder, author +trust policies that constrain MST receipt properties, and interact with the MST transparency +service for creating and retrieving entries. + +## Exported Functions + +### Pack Registration + +| Function | Description | +|----------|-------------| +| `cose_sign1_validator_builder_with_mst_pack` | Add MST pack (default options) | +| `cose_sign1_validator_builder_with_mst_pack_ex` | Add MST pack (custom options) | + +### Receipt Trust Policies + +| Function | Description | +|----------|-------------| +| `..._require_receipt_present` | Require receipt is present | +| `..._require_receipt_not_present` | Require receipt is not present | +| `..._require_receipt_signature_verified` | Require receipt signature verified | +| `..._require_receipt_signature_not_verified` | Require receipt signature not verified | +| `..._require_receipt_issuer_contains` | Require receipt issuer contains substring | +| `..._require_receipt_issuer_eq` | Require receipt issuer equals value | +| `..._require_receipt_kid_eq` | Require receipt KID equals value | +| `..._require_receipt_kid_contains` | Require receipt KID contains substring | +| `..._require_receipt_trusted` | Require receipt is trusted | +| `..._require_receipt_not_trusted` | Require receipt is not trusted | +| `..._require_receipt_trusted_from_issuer_contains` | Require trusted receipt from issuer | +| `..._require_receipt_statement_sha256_eq` | Require receipt statement SHA-256 hash | +| `..._require_receipt_statement_coverage_eq` | Require receipt statement coverage equals | +| `..._require_receipt_statement_coverage_contains` | Require receipt statement coverage contains | + +### MST Service Operations + +| Function | Description | +|----------|-------------| +| `cose_mst_client_new` | Create a new MST service client | +| `cose_sign1_mst_make_transparent` | Make a COSE message transparent via MST | +| `cose_sign1_mst_create_entry` | Create an MST transparency entry | +| `cose_sign1_mst_get_entry_statement` | Retrieve an MST entry statement | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_mst_trust_options_t` | C ABI options struct for MST trust configuration | +| `MstClientHandle` | Opaque MST service client | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_transparent_mst`](../../mst/) — Microsoft Secure Transparency trust pack implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_transparent_mst_ffi +``` diff --git a/native/rust/extension_packs/mst/ffi/src/lib.rs b/native/rust/extension_packs/mst/ffi/src/lib.rs index 46e9d1fe..758f2a49 100644 --- a/native/rust/extension_packs/mst/ffi/src/lib.rs +++ b/native/rust/extension_packs/mst/ffi/src/lib.rs @@ -1,6 +1,41 @@ -//! Transparent MST pack FFI bindings. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! C-ABI projection for `cose_sign1_transparent_mst`. +//! +//! This crate provides C-compatible FFI exports for the Microsoft Secure +//! Transparency (MST) extension pack. It enables C/C++ consumers to register +//! the MST trust pack with a validator builder, author trust policies that +//! constrain MST receipt properties (presence, KID, signature verification, +//! statement coverage, statement SHA-256 hash, and trust status), and interact +//! with the MST transparency service for creating and retrieving entries. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for details. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function +//! +//! # Thread Safety //! -//! This crate exposes the Microsoft Secure Transparency (MST) receipt verification pack to C/C++ consumers. +//! All functions are thread-safe. Error state is thread-local. #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![deny(unsafe_op_in_unsafe_fn)] diff --git a/native/rust/extension_packs/mst/src/validation/pack.rs b/native/rust/extension_packs/mst/src/validation/pack.rs index ecfe0ad0..95a808bb 100644 --- a/native/rust/extension_packs/mst/src/validation/pack.rs +++ b/native/rust/extension_packs/mst/src/validation/pack.rs @@ -1,372 +1,372 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::validation::facts::{ - MstReceiptIssuerFact, MstReceiptKidFact, MstReceiptPresentFact, - MstReceiptSignatureVerifiedFact, MstReceiptStatementCoverageFact, - MstReceiptStatementSha256Fact, MstReceiptTrustedFact, -}; -use cose_sign1_crypto_openssl::jwk_verifier::OpenSslJwkVerifierFactory; -use cose_sign1_primitives::{ArcSlice, CoseHeaderLabel, CoseHeaderValue}; -use cose_sign1_validation::fluent::*; -use cose_sign1_validation_primitives::error::TrustError; -use cose_sign1_validation_primitives::facts::{FactKey, TrustFactContext, TrustFactProducer}; -use cose_sign1_validation_primitives::ids::sha256_of_bytes; -use cose_sign1_validation_primitives::plan::CompiledTrustPlan; -use cose_sign1_validation_primitives::subject::TrustSubject; -use once_cell::sync::Lazy; -use std::borrow::Cow; -use std::collections::HashSet; -use std::sync::Arc; - -use crate::validation::receipt_verify::{ - verify_mst_receipt, ReceiptVerifyError, ReceiptVerifyInput, -}; - -pub mod fluent_ext { - pub use crate::validation::fluent_ext::*; -} - -/// Encode bytes as lowercase hex string. -fn hex_encode(bytes: &[u8]) -> String { - bytes - .iter() - .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { - use std::fmt::Write; - // write! to a String is infallible; this expect is defensive. - write!(s, "{:02x}", b).expect("hex formatting to String cannot fail"); - s - }) -} - -/// COSE header label used by MST receipts (matches .NET): 394. -pub const MST_RECEIPT_HEADER_LABEL: i64 = 394; - -#[derive(Clone, Debug, Default)] -pub struct MstTrustPack { - /// If true, allow the verifier to fetch JWKS online when offline keys are missing or do not - /// contain the required `kid`. - /// - /// This is an operational switch. Trust decisions (e.g., issuer allowlisting) belong in policy. - pub allow_network: bool, - - /// Offline JWKS JSON used to resolve receipt signing keys by `kid`. - /// - /// This enables deterministic verification for test vectors without requiring network access. - pub offline_jwks_json: Option, - - /// Optional api-version to use for the CodeTransparency `/jwks` endpoint. - /// If not set, the verifier will try without an api-version parameter. - pub jwks_api_version: Option, -} - -impl MstTrustPack { - /// Create an MST pack with the given options. - pub fn new( - allow_network: bool, - offline_jwks_json: Option, - jwks_api_version: Option, - ) -> Self { - Self { - allow_network, - offline_jwks_json, - jwks_api_version, - } - } - - /// Create an MST pack configured for offline-only verification. - /// - /// This disables network fetching and uses the provided JWKS JSON to resolve receipt signing - /// keys. - pub fn offline_with_jwks(jwks_json: impl Into) -> Self { - Self { - allow_network: false, - offline_jwks_json: Some(jwks_json.into()), - jwks_api_version: None, - } - } - - /// Create an MST pack configured to allow online JWKS fetching. - /// - /// This is an operational switch only; issuer allowlisting should still be expressed via trust - /// policy. - pub fn online() -> Self { - Self { - allow_network: true, - offline_jwks_json: None, - jwks_api_version: None, - } - } -} - -impl TrustFactProducer for MstTrustPack { - /// Stable producer name used for diagnostics/audit. - fn name(&self) -> &'static str { - "cose_sign1_transparent_mst::MstTrustPack" - } - - /// Produce MST-related facts for the current subject. - /// - /// - On `Message` subjects: projects each receipt into a derived `CounterSignature` subject. - /// - On `CounterSignature` subjects: verifies the receipt and emits MST facts. - fn produce(&self, ctx: &mut TrustFactContext<'_>) -> Result<(), TrustError> { - // MST receipts are modeled as counter-signatures: - // - On the Message subject, we *project* each receipt into a derived CounterSignature subject. - // - On the CounterSignature subject, we produce MST-specific facts (present/trusted). - - match ctx.subject().kind { - "Message" => { - // If the COSE message is unavailable, counter-signature discovery is Missing. - if ctx.cose_sign1_message().is_none() && ctx.cose_sign1_bytes().is_none() { - ctx.mark_missing::("MissingMessage"); - ctx.mark_missing::("MissingMessage"); - ctx.mark_missing::("MissingMessage"); - - for k in self.provides() { - ctx.mark_produced(*k); - } - return Ok(()); - } - - let receipts = read_receipts(ctx)?; - - let message_subject = match ctx.cose_sign1_bytes() { - Some(bytes) => TrustSubject::message(bytes), - None => TrustSubject::message(b"seed"), - }; - - let mut seen: HashSet = - HashSet::new(); - - for r in receipts { - let cs_subject = TrustSubject::counter_signature(&message_subject, &r); - let cs_key_subject = TrustSubject::counter_signature_signing_key(&cs_subject); - - ctx.observe(CounterSignatureSubjectFact { - subject: cs_subject, - is_protected_header: false, - })?; - ctx.observe(CounterSignatureSigningKeySubjectFact { - subject: cs_key_subject, - is_protected_header: false, - })?; - - let id = sha256_of_bytes(&r); - if seen.insert(id) { - ctx.observe(UnknownCounterSignatureBytesFact { - counter_signature_id: id, - raw_counter_signature_bytes: std::sync::Arc::from(r.as_bytes()), - })?; - } - } - - for k in self.provides() { - ctx.mark_produced(*k); - } - Ok(()) - } - "CounterSignature" => { - // If the COSE message is unavailable, we can't map this subject to a receipt. - if ctx.cose_sign1_message().is_none() && ctx.cose_sign1_bytes().is_none() { - ctx.mark_missing::("MissingMessage"); - ctx.mark_missing::("MissingMessage"); - for k in self.provides() { - ctx.mark_produced(*k); - } - return Ok(()); - } - - let receipts = read_receipts(ctx)?; - - let Some(message_bytes) = ctx.cose_sign1_bytes() else { - // Fallback: without bytes we can't compute the same subject IDs. - for k in self.provides() { - ctx.mark_produced(*k); - } - return Ok(()); - }; - - let message_subject = TrustSubject::message(message_bytes); - - let mut matched_receipt: Option = None; - for r in receipts { - let cs = TrustSubject::counter_signature(&message_subject, &r); - if cs.id == ctx.subject().id { - matched_receipt = Some(r); - break; - } - } - - let Some(receipt_bytes) = matched_receipt else { - // Not an MST receipt counter-signature; leave as Available(empty). - for k in self.provides() { - ctx.mark_produced(*k); - } - return Ok(()); - }; - - // Receipt identified. - ctx.observe(MstReceiptPresentFact { present: true })?; - - // Get provider from message (required for receipt verification) - let Some(_msg) = ctx.cose_sign1_message() else { - ctx.observe(MstReceiptTrustedFact { - trusted: false, - details: Some("no message in context for verification".into()), - })?; - for k in self.provides() { - ctx.mark_produced(*k); - } - return Ok(()); - }; - - let jwks_json = self.offline_jwks_json.as_deref(); - let factory = OpenSslJwkVerifierFactory; - let out = verify_mst_receipt(ReceiptVerifyInput { - statement_bytes_with_receipts: message_bytes, - receipt_bytes: &receipt_bytes, - offline_jwks_json: jwks_json, - allow_network_fetch: self.allow_network, - jwks_api_version: self.jwks_api_version.as_deref(), - client: None, // Creates temporary client per-issuer - jwk_verifier_factory: &factory, - }); - - match out { - Ok(v) => { - ctx.observe(MstReceiptTrustedFact { - trusted: v.trusted, - details: v.details.clone(), - })?; - - ctx.observe(MstReceiptIssuerFact { - issuer: v.issuer.clone(), - })?; - ctx.observe(MstReceiptKidFact { kid: v.kid.clone() })?; - ctx.observe(MstReceiptStatementSha256Fact { - sha256_hex: Arc::from(hex_encode(&v.statement_sha256)), - })?; - ctx.observe(MstReceiptStatementCoverageFact { - coverage: "sha256(COSE_Sign1 bytes with unprotected headers cleared)", - })?; - ctx.observe(MstReceiptSignatureVerifiedFact { verified: true })?; - - ctx.observe(CounterSignatureEnvelopeIntegrityFact { - sig_structure_intact: v.trusted, - details: Some(Cow::Borrowed( - "covers: sha256(COSE_Sign1 bytes with unprotected headers cleared)", - )), - })?; - } - Err(e @ ReceiptVerifyError::UnsupportedVds(_)) => { - // Non-Microsoft receipts can coexist with MST receipts. - // Make the fact Available(false) so AnyOf semantics can still succeed. - ctx.observe(MstReceiptTrustedFact { - trusted: false, - details: Some(e.to_string().into()), - })?; - } - Err(e) => ctx.observe(MstReceiptTrustedFact { - trusted: false, - details: Some(e.to_string().into()), - })?, - } - - for k in self.provides() { - ctx.mark_produced(*k); - } - Ok(()) - } - _ => { - for k in self.provides() { - ctx.mark_produced(*k); - } - Ok(()) - } - } - } - - /// Return the set of fact keys this pack can produce. - fn provides(&self) -> &'static [FactKey] { - static PROVIDED: Lazy<[FactKey; 11]> = Lazy::new(|| { - [ - // Counter-signature projection (message-scoped) - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - // MST-specific facts (counter-signature scoped) - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - FactKey::of::(), - ] - }); - &*PROVIDED - } -} - -impl CoseSign1TrustPack for MstTrustPack { - /// Short display name for this trust pack. - fn name(&self) -> &'static str { - "MstTrustPack" - } - - /// Return a `TrustFactProducer` instance for this pack. - fn fact_producer(&self) -> std::sync::Arc { - std::sync::Arc::new(self.clone()) - } - - /// Return the default trust plan for MST-only validation. - /// - /// This plan requires that a counter-signature receipt is trusted. - fn default_trust_plan(&self) -> Option { - use crate::validation::fluent_ext::MstReceiptTrustedWhereExt; - - // Secure-by-default MST policy: - // - require a receipt to be trusted (verification must be enabled) - let bundled = TrustPlanBuilder::new(vec![std::sync::Arc::new(self.clone())]) - .for_counter_signature(|cs| { - cs.require::(|f| f.require_receipt_trusted()) - }) - .compile() - .expect("default trust plan should be satisfiable by the MST trust pack"); - - Some(bundled.plan().clone()) - } -} - -/// Read all MST receipt blobs from the current message. -/// -/// Returns `ArcSlice` values for zero-copy sharing — cloning is a refcount bump. -fn read_receipts(ctx: &TrustFactContext<'_>) -> Result, TrustError> { - if let Some(msg) = ctx.cose_sign1_message() { - let label = CoseHeaderLabel::Int(MST_RECEIPT_HEADER_LABEL); - match msg.unprotected.get(&label) { - None => return Ok(Vec::new()), - Some(CoseHeaderValue::Array(arr)) => { - let mut result = Vec::new(); - for v in arr { - if let CoseHeaderValue::Bytes(b) = v { - result.push(b.clone()); - } else { - return Err(TrustError::FactProduction("invalid header".to_string())); - } - } - return Ok(result); - } - Some(CoseHeaderValue::Bytes(_)) => { - return Err(TrustError::FactProduction("invalid header".to_string())); - } - Some(_) => { - return Err(TrustError::FactProduction("invalid header".to_string())); - } - } - } - - // Without a parsed message, we cannot read receipts - Ok(Vec::new()) -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::validation::facts::{ + MstReceiptIssuerFact, MstReceiptKidFact, MstReceiptPresentFact, + MstReceiptSignatureVerifiedFact, MstReceiptStatementCoverageFact, + MstReceiptStatementSha256Fact, MstReceiptTrustedFact, +}; +use cose_sign1_crypto_openssl::jwk_verifier::OpenSslJwkVerifierFactory; +use cose_sign1_primitives::{ArcSlice, CoseHeaderLabel, CoseHeaderValue}; +use cose_sign1_validation::fluent::*; +use cose_sign1_validation_primitives::error::TrustError; +use cose_sign1_validation_primitives::facts::{FactKey, TrustFactContext, TrustFactProducer}; +use cose_sign1_validation_primitives::ids::sha256_of_bytes; +use cose_sign1_validation_primitives::plan::CompiledTrustPlan; +use cose_sign1_validation_primitives::subject::TrustSubject; +use std::borrow::Cow; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::LazyLock; + +use crate::validation::receipt_verify::{ + verify_mst_receipt, ReceiptVerifyError, ReceiptVerifyInput, +}; + +pub mod fluent_ext { + pub use crate::validation::fluent_ext::*; +} + +/// Encode bytes as lowercase hex string. +fn hex_encode(bytes: &[u8]) -> String { + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + use std::fmt::Write; + // write! to a String is infallible; this expect is defensive. + write!(s, "{:02x}", b).expect("hex formatting to String cannot fail"); + s + }) +} + +/// COSE header label used by MST receipts (matches .NET): 394. +pub const MST_RECEIPT_HEADER_LABEL: i64 = 394; + +#[derive(Clone, Debug, Default)] +pub struct MstTrustPack { + /// If true, allow the verifier to fetch JWKS online when offline keys are missing or do not + /// contain the required `kid`. + /// + /// This is an operational switch. Trust decisions (e.g., issuer allowlisting) belong in policy. + pub allow_network: bool, + + /// Offline JWKS JSON used to resolve receipt signing keys by `kid`. + /// + /// This enables deterministic verification for test vectors without requiring network access. + pub offline_jwks_json: Option, + + /// Optional api-version to use for the CodeTransparency `/jwks` endpoint. + /// If not set, the verifier will try without an api-version parameter. + pub jwks_api_version: Option, +} + +impl MstTrustPack { + /// Create an MST pack with the given options. + pub fn new( + allow_network: bool, + offline_jwks_json: Option, + jwks_api_version: Option, + ) -> Self { + Self { + allow_network, + offline_jwks_json, + jwks_api_version, + } + } + + /// Create an MST pack configured for offline-only verification. + /// + /// This disables network fetching and uses the provided JWKS JSON to resolve receipt signing + /// keys. + pub fn offline_with_jwks(jwks_json: impl Into) -> Self { + Self { + allow_network: false, + offline_jwks_json: Some(jwks_json.into()), + jwks_api_version: None, + } + } + + /// Create an MST pack configured to allow online JWKS fetching. + /// + /// This is an operational switch only; issuer allowlisting should still be expressed via trust + /// policy. + pub fn online() -> Self { + Self { + allow_network: true, + offline_jwks_json: None, + jwks_api_version: None, + } + } +} + +impl TrustFactProducer for MstTrustPack { + /// Stable producer name used for diagnostics/audit. + fn name(&self) -> &'static str { + "cose_sign1_transparent_mst::MstTrustPack" + } + + /// Produce MST-related facts for the current subject. + /// + /// - On `Message` subjects: projects each receipt into a derived `CounterSignature` subject. + /// - On `CounterSignature` subjects: verifies the receipt and emits MST facts. + fn produce(&self, ctx: &mut TrustFactContext<'_>) -> Result<(), TrustError> { + // MST receipts are modeled as counter-signatures: + // - On the Message subject, we *project* each receipt into a derived CounterSignature subject. + // - On the CounterSignature subject, we produce MST-specific facts (present/trusted). + + match ctx.subject().kind { + "Message" => { + // If the COSE message is unavailable, counter-signature discovery is Missing. + if ctx.cose_sign1_message().is_none() && ctx.cose_sign1_bytes().is_none() { + ctx.mark_missing::("MissingMessage"); + ctx.mark_missing::("MissingMessage"); + ctx.mark_missing::("MissingMessage"); + + for k in self.provides() { + ctx.mark_produced(*k); + } + return Ok(()); + } + + let receipts = read_receipts(ctx)?; + + let message_subject = match ctx.cose_sign1_bytes() { + Some(bytes) => TrustSubject::message(bytes), + None => TrustSubject::message(b"seed"), + }; + + let mut seen: HashSet = + HashSet::new(); + + for r in receipts { + let cs_subject = TrustSubject::counter_signature(&message_subject, &r); + let cs_key_subject = TrustSubject::counter_signature_signing_key(&cs_subject); + + ctx.observe(CounterSignatureSubjectFact { + subject: cs_subject, + is_protected_header: false, + })?; + ctx.observe(CounterSignatureSigningKeySubjectFact { + subject: cs_key_subject, + is_protected_header: false, + })?; + + let id = sha256_of_bytes(&r); + if seen.insert(id) { + ctx.observe(UnknownCounterSignatureBytesFact { + counter_signature_id: id, + raw_counter_signature_bytes: std::sync::Arc::from(r.as_bytes()), + })?; + } + } + + for k in self.provides() { + ctx.mark_produced(*k); + } + Ok(()) + } + "CounterSignature" => { + // If the COSE message is unavailable, we can't map this subject to a receipt. + if ctx.cose_sign1_message().is_none() && ctx.cose_sign1_bytes().is_none() { + ctx.mark_missing::("MissingMessage"); + ctx.mark_missing::("MissingMessage"); + for k in self.provides() { + ctx.mark_produced(*k); + } + return Ok(()); + } + + let receipts = read_receipts(ctx)?; + + let Some(message_bytes) = ctx.cose_sign1_bytes() else { + // Fallback: without bytes we can't compute the same subject IDs. + for k in self.provides() { + ctx.mark_produced(*k); + } + return Ok(()); + }; + + let message_subject = TrustSubject::message(message_bytes); + + let mut matched_receipt: Option = None; + for r in receipts { + let cs = TrustSubject::counter_signature(&message_subject, &r); + if cs.id == ctx.subject().id { + matched_receipt = Some(r); + break; + } + } + + let Some(receipt_bytes) = matched_receipt else { + // Not an MST receipt counter-signature; leave as Available(empty). + for k in self.provides() { + ctx.mark_produced(*k); + } + return Ok(()); + }; + + // Receipt identified. + ctx.observe(MstReceiptPresentFact { present: true })?; + + // Get provider from message (required for receipt verification) + let Some(_msg) = ctx.cose_sign1_message() else { + ctx.observe(MstReceiptTrustedFact { + trusted: false, + details: Some("no message in context for verification".into()), + })?; + for k in self.provides() { + ctx.mark_produced(*k); + } + return Ok(()); + }; + + let jwks_json = self.offline_jwks_json.as_deref(); + let factory = OpenSslJwkVerifierFactory; + let out = verify_mst_receipt(ReceiptVerifyInput { + statement_bytes_with_receipts: message_bytes, + receipt_bytes: &receipt_bytes, + offline_jwks_json: jwks_json, + allow_network_fetch: self.allow_network, + jwks_api_version: self.jwks_api_version.as_deref(), + client: None, // Creates temporary client per-issuer + jwk_verifier_factory: &factory, + }); + + match out { + Ok(v) => { + ctx.observe(MstReceiptTrustedFact { + trusted: v.trusted, + details: v.details.clone(), + })?; + + ctx.observe(MstReceiptIssuerFact { + issuer: v.issuer.clone(), + })?; + ctx.observe(MstReceiptKidFact { kid: v.kid.clone() })?; + ctx.observe(MstReceiptStatementSha256Fact { + sha256_hex: Arc::from(hex_encode(&v.statement_sha256)), + })?; + ctx.observe(MstReceiptStatementCoverageFact { + coverage: "sha256(COSE_Sign1 bytes with unprotected headers cleared)", + })?; + ctx.observe(MstReceiptSignatureVerifiedFact { verified: true })?; + + ctx.observe(CounterSignatureEnvelopeIntegrityFact { + sig_structure_intact: v.trusted, + details: Some(Cow::Borrowed( + "covers: sha256(COSE_Sign1 bytes with unprotected headers cleared)", + )), + })?; + } + Err(e @ ReceiptVerifyError::UnsupportedVds(_)) => { + // Non-Microsoft receipts can coexist with MST receipts. + // Make the fact Available(false) so AnyOf semantics can still succeed. + ctx.observe(MstReceiptTrustedFact { + trusted: false, + details: Some(e.to_string().into()), + })?; + } + Err(e) => ctx.observe(MstReceiptTrustedFact { + trusted: false, + details: Some(e.to_string().into()), + })?, + } + + for k in self.provides() { + ctx.mark_produced(*k); + } + Ok(()) + } + _ => { + for k in self.provides() { + ctx.mark_produced(*k); + } + Ok(()) + } + } + } + + /// Return the set of fact keys this pack can produce. + fn provides(&self) -> &'static [FactKey] { + static PROVIDED: LazyLock<[FactKey; 11]> = LazyLock::new(|| { + [ + // Counter-signature projection (message-scoped) + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + // MST-specific facts (counter-signature scoped) + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + FactKey::of::(), + ] + }); + &*PROVIDED + } +} + +impl CoseSign1TrustPack for MstTrustPack { + /// Short display name for this trust pack. + fn name(&self) -> &'static str { + "MstTrustPack" + } + + /// Return a `TrustFactProducer` instance for this pack. + fn fact_producer(&self) -> std::sync::Arc { + std::sync::Arc::new(self.clone()) + } + + /// Return the default trust plan for MST-only validation. + /// + /// This plan requires that a counter-signature receipt is trusted. + fn default_trust_plan(&self) -> Option { + use crate::validation::fluent_ext::MstReceiptTrustedWhereExt; + + // Secure-by-default MST policy: + // - require a receipt to be trusted (verification must be enabled) + let bundled = TrustPlanBuilder::new(vec![std::sync::Arc::new(self.clone())]) + .for_counter_signature(|cs| { + cs.require::(|f| f.require_receipt_trusted()) + }) + .compile() + .expect("default trust plan should be satisfiable by the MST trust pack"); + + Some(bundled.plan().clone()) + } +} + +/// Read all MST receipt blobs from the current message. +/// +/// Returns `ArcSlice` values for zero-copy sharing — cloning is a refcount bump. +fn read_receipts(ctx: &TrustFactContext<'_>) -> Result, TrustError> { + if let Some(msg) = ctx.cose_sign1_message() { + let label = CoseHeaderLabel::Int(MST_RECEIPT_HEADER_LABEL); + match msg.unprotected.get(&label) { + None => return Ok(Vec::new()), + Some(CoseHeaderValue::Array(arr)) => { + let mut result = Vec::new(); + for v in arr { + if let CoseHeaderValue::Bytes(b) = v { + result.push(b.clone()); + } else { + return Err(TrustError::FactProduction("invalid header".to_string())); + } + } + return Ok(result); + } + Some(CoseHeaderValue::Bytes(_)) => { + return Err(TrustError::FactProduction("invalid header".to_string())); + } + Some(_) => { + return Err(TrustError::FactProduction("invalid header".to_string())); + } + } + } + + // Without a parsed message, we cannot read receipts + Ok(Vec::new()) +} diff --git a/native/rust/primitives/cose/Cargo.toml b/native/rust/primitives/cose/Cargo.toml index 78322e1a..2cf9565e 100644 --- a/native/rust/primitives/cose/Cargo.toml +++ b/native/rust/primitives/cose/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_primitives" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" # Required for std::sync::OnceLock +rust-version = "1.80" # Required for std::sync::OnceLock + LazyLock description = "RFC 9052 COSE types and constants — headers, algorithms, and CBOR provider" [lib] diff --git a/native/rust/primitives/cose/README.md b/native/rust/primitives/cose/README.md new file mode 100644 index 00000000..e3eb4612 --- /dev/null +++ b/native/rust/primitives/cose/README.md @@ -0,0 +1,147 @@ + + +# cose_primitives + +RFC 9052 COSE generic building blocks for Rust. + +## Overview + +This crate provides the foundational types for working with CBOR Object Signing +and Encryption (COSE) messages as defined in [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052). +It is designed as a **zero-copy**, **streaming-capable** layer that all +higher-level COSE message types (Sign1, Encrypt, MAC, etc.) build upon. + +Key capabilities: + +- **Header management** — `CoseHeaderMap`, `CoseHeaderLabel`, `CoseHeaderValue`, + `ProtectedHeader` for encoding and decoding COSE headers +- **Lazy header parsing** — `LazyHeaderMap` defers CBOR decoding until first access +- **Zero-copy data model** — `ArcSlice` and `ArcStr` reference a shared `Arc<[u8]>` + backing buffer without copying +- **Streaming support** — `CoseData` enum supports both fully-buffered and + stream-backed message payloads +- **IANA algorithm constants** — Re-exports from `crypto_primitives` (ES256, ES384, + RS256, EdDSA, etc.) +- **CBOR provider abstraction** — Compile-time selection of the CBOR backend + (currently EverParse) + +## Architecture + +``` +┌───────────────────────────────────────────────────┐ +│ cose_primitives │ +├───────────┬───────────┬───────────┬───────────────┤ +│ headers │ data │ arc_types │ lazy_headers │ +│ ┌────────┐│ ┌────────┐│ ┌───────┐│ ┌────────────┐│ +│ │HeaderMap││ │CoseData││ │ArcSlice│ │LazyHeaderMap││ +│ │Label ││ │Buffered││ │ArcStr ││ │ OnceLock ││ +│ │Value ││ │Streamed││ └───────┘│ └────────────┘│ +│ │Protected│ └────────┘│ │ │ +│ └────────┘│ │ │ │ +├───────────┴───────────┴──────────┴────────────────┤ +│ algorithms (re-exports) │ error │ provider │ +└───────────────────────────┴─────────┴──────────────┘ + │ │ + ▼ ▼ + crypto_primitives cbor_primitives + (IANA algorithm IDs) (CBOR encode/decode) +``` + +## Modules + +| Module | Description | +|--------|-------------| +| `headers` | `CoseHeaderMap`, `CoseHeaderLabel`, `CoseHeaderValue`, `ProtectedHeader` — full CBOR-backed header management | +| `lazy_headers` | `LazyHeaderMap` — lazy-parsed headers cached via `OnceLock` | +| `arc_types` | `ArcSlice` and `ArcStr` — zero-copy shared-ownership byte/string references into an `Arc<[u8]>` buffer | +| `data` | `CoseData` enum — `Buffered` (in-memory) and `Streamed` (seekable reader) message data | +| `algorithms` | Re-exported IANA algorithm constants from `crypto_primitives` | +| `error` | `CoseError` — CBOR, structural, and I/O error variants | +| `provider` | Compile-time CBOR provider singleton selection | + +## Key Types + +### CoseHeaderMap + +The primary type for reading and writing COSE headers: + +```rust +use cose_primitives::headers::{CoseHeaderMap, CoseHeaderLabel, CoseHeaderValue}; + +let mut headers = CoseHeaderMap::new(); + +// Set algorithm (label 1) to ES256 (-7) +headers.set(CoseHeaderLabel::Int(1), CoseHeaderValue::Int(-7)); + +// Read a header value +if let Some(CoseHeaderValue::Int(alg)) = headers.get(&CoseHeaderLabel::Int(1)) { + assert_eq!(*alg, -7); +} +``` + +### ArcSlice / ArcStr + +Zero-copy shared-ownership byte slices backed by `Arc<[u8]>`: + +```rust +use cose_primitives::arc_types::ArcSlice; +use std::sync::Arc; + +// Create from raw bytes — one allocation shared across sub-slices +let buffer: Arc<[u8]> = Arc::from(b"hello world".as_slice()); +let slice = ArcSlice::new(buffer.clone(), 0..5); // "hello" + +assert_eq!(slice.as_ref(), b"hello"); +``` + +### CoseData + +Supports both in-memory and stream-backed message payloads: + +```rust +use cose_primitives::data::CoseData; + +// Fully buffered payload +let data = CoseData::Buffered { bytes: payload_bytes }; + +// Streaming payload (headers in memory, body in a seekable reader) +let data = CoseData::Streamed { headers, reader }; +``` + +### LazyHeaderMap + +Defers CBOR header parsing until first access: + +```rust +use cose_primitives::lazy_headers::LazyHeaderMap; + +let lazy = LazyHeaderMap::from_bytes(raw_cbor_bytes); + +// No parsing happens until you call .get() or .map() +let map = lazy.map()?; // parsed on first call, cached thereafter +``` + +## Memory Design + +- **Zero-copy throughout**: All decoded data references a shared `Arc<[u8]>` backing + buffer. Sub-structures (headers, payload, signature) hold `ArcSlice` ranges into + the original bytes — no heap allocations for parsed fields. +- **Lazy evaluation**: `LazyHeaderMap` uses `OnceLock` to parse headers exactly + once, on demand. +- **Streaming**: `CoseData::Streamed` keeps only headers in memory while the payload + remains in a seekable stream, enabling large-file processing. + +## Dependencies + +- `cbor_primitives` — CBOR encoding/decoding trait and EverParse backend +- `crypto_primitives` — IANA algorithm constants and crypto trait definitions + +## See Also + +- [primitives/cose/sign1/](sign1/) — COSE_Sign1 message type and builder +- [primitives/cbor/](../cbor/) — CBOR provider abstraction +- [primitives/crypto/](../crypto/) — Cryptographic trait definitions + +## License + +Licensed under the [MIT License](../../../../LICENSE). \ No newline at end of file diff --git a/native/rust/primitives/cose/sign1/Cargo.toml b/native/rust/primitives/cose/sign1/Cargo.toml index 956608d6..26d5ed69 100644 --- a/native/rust/primitives/cose/sign1/Cargo.toml +++ b/native/rust/primitives/cose/sign1/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_primitives" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" # Required for std::sync::OnceLock +rust-version = "1.80" # Required for std::sync::OnceLock + LazyLock description = "Core types and traits for CoseSign1 signing and verification with pluggable CBOR" [lib] diff --git a/native/rust/primitives/cose/sign1/ffi/Cargo.toml b/native/rust/primitives/cose/sign1/ffi/Cargo.toml index 426ff961..8249898d 100644 --- a/native/rust/primitives/cose/sign1/ffi/Cargo.toml +++ b/native/rust/primitives/cose/sign1/ffi/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_primitives_ffi" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "C/C++ FFI projections for cose_sign1_primitives types and message verification" [lib] diff --git a/native/rust/primitives/cose/sign1/ffi/src/lib.rs b/native/rust/primitives/cose/sign1/ffi/src/lib.rs index 5a7ab599..b485eead 100644 --- a/native/rust/primitives/cose/sign1/ffi/src/lib.rs +++ b/native/rust/primitives/cose/sign1/ffi/src/lib.rs @@ -5,36 +5,51 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! C/C++ FFI projections for cose_sign1_primitives types and message verification. +//! C-ABI projection for `cose_sign1_primitives`. //! -//! This crate provides FFI-safe wrappers around the `cose_sign1_primitives` types, -//! allowing C and C++ code to parse and verify COSE_Sign1 messages. +//! This crate provides C-compatible FFI exports for parsing and verifying COSE_Sign1 +//! messages. It wraps the `cose_sign1_primitives` types, allowing C and C++ code to +//! parse COSE_Sign1 messages, access headers and payloads, and verify signatures. //! -//! ## Error Handling +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_sign1_ffi_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling //! //! All functions follow a consistent error handling pattern: //! - Return value: 0 = success, negative = error code //! - `out_error` parameter: Set to error handle on failure (caller must free) //! - Output parameters: Only valid if return is 0 //! -//! ## Memory Management +//! # Memory Ownership //! -//! Handles returned by this library must be freed using the corresponding `*_free` function: -//! - `cose_sign1_message_free` for message handles -//! - `cose_sign1_error_free` for error handles -//! - `cose_sign1_string_free` for string pointers -//! - `cose_headermap_free` for header map handles -//! - `cose_key_free` for key handles +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_sign1_message_free` for message handles +//! - `cose_sign1_error_free` for error handles +//! - `cose_sign1_string_free` for string pointers +//! - `cose_headermap_free` for header map handles +//! - `cose_key_free` for key handles //! //! Pointers to internal data (e.g., from `cose_sign1_message_protected_bytes`) are valid //! only as long as the parent handle is valid. //! -//! ## Thread Safety +//! # Thread Safety //! -//! All handles are thread-safe and can be used from multiple threads. However, handles -//! are not internally synchronized, so concurrent mutation requires external synchronization. +//! All functions are thread-safe. Handles are not internally synchronized, +//! so concurrent mutation requires external synchronization. //! -//! ## Example (C) +//! # Example (C) //! //! ```c //! #include "cose_sign1_primitives_ffi.h" diff --git a/native/rust/primitives/cose/sign1/src/crypto_provider.rs b/native/rust/primitives/cose/sign1/src/crypto_provider.rs index 42b3dea6..22f062b5 100644 --- a/native/rust/primitives/cose/sign1/src/crypto_provider.rs +++ b/native/rust/primitives/cose/sign1/src/crypto_provider.rs @@ -3,9 +3,9 @@ //! Crypto provider singleton. //! -//! This is a stub that always returns NullCryptoProvider. -//! Callers that need real crypto should use crypto_primitives directly -//! and construct their own signers/verifiers from keys. +//! Returns a `NullCryptoProvider` (Null Object pattern) that rejects all +//! operations. Callers that need real crypto should use `crypto_primitives` +//! directly and construct their own signers/verifiers from keys. use crypto_primitives::provider::NullCryptoProvider; use std::sync::OnceLock; @@ -17,8 +17,9 @@ static PROVIDER: OnceLock = OnceLock::new(); /// Returns a reference to the crypto provider singleton (NullCryptoProvider). /// -/// This is a stub. Real crypto implementations should use crypto_primitives -/// directly to construct signers/verifiers from keys. +/// This uses the Null Object pattern — all operations return +/// `UnsupportedOperation` errors. Real crypto implementations should use +/// `crypto_primitives` directly to construct signers/verifiers from keys. pub fn crypto_provider() -> &'static CryptoProviderImpl { PROVIDER.get_or_init(CryptoProviderImpl::default) } diff --git a/native/rust/primitives/crypto/openssl/Cargo.toml b/native/rust/primitives/crypto/openssl/Cargo.toml index 84b2d010..d926ebed 100644 --- a/native/rust/primitives/crypto/openssl/Cargo.toml +++ b/native/rust/primitives/crypto/openssl/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_crypto_openssl" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "OpenSSL-based cryptographic provider for COSE operations (safe Rust bindings)" [lib] diff --git a/native/rust/primitives/crypto/openssl/ffi/Cargo.toml b/native/rust/primitives/crypto/openssl/ffi/Cargo.toml index bc051ef2..b80dc137 100644 --- a/native/rust/primitives/crypto/openssl/ffi/Cargo.toml +++ b/native/rust/primitives/crypto/openssl/ffi/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_crypto_openssl_ffi" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "C/C++ FFI projections for OpenSSL crypto provider" [lib] diff --git a/native/rust/primitives/crypto/openssl/ffi/README.md b/native/rust/primitives/crypto/openssl/ffi/README.md new file mode 100644 index 00000000..aa828260 --- /dev/null +++ b/native/rust/primitives/crypto/openssl/ffi/README.md @@ -0,0 +1,56 @@ + + +# cose_sign1_crypto_openssl_ffi + +C/C++ FFI projection for the OpenSSL crypto provider. + +## Overview + +This crate provides C-compatible FFI exports for creating cryptographic signers and verifiers +backed by OpenSSL. It supports DER- and PEM-encoded keys, JWK-based EC and RSA verifiers, and +provides the core signing and verification primitives used by the COSE_Sign1 signing pipeline. + +## Exported Functions + +| Function | Description | +|----------|-------------| +| `cose_crypto_openssl_abi_version` | ABI version check | +| `cose_last_error_message_utf8` | Get thread-local error message | +| `cose_last_error_clear` | Clear thread-local error state | +| `cose_string_free` | Free a string returned by this library | +| `cose_crypto_openssl_provider_new` | Create a new OpenSSL provider | +| `cose_crypto_openssl_provider_free` | Free an OpenSSL provider | +| `cose_crypto_openssl_signer_from_der` | Create signer from DER-encoded private key | +| `cose_crypto_openssl_signer_from_pem` | Create signer from PEM-encoded private key | +| `cose_crypto_signer_sign` | Sign data with a signer | +| `cose_crypto_signer_algorithm` | Get the algorithm of a signer | +| `cose_crypto_signer_free` | Free a signer handle | +| `cose_crypto_openssl_verifier_from_pem` | Create verifier from PEM-encoded public key | +| `cose_crypto_openssl_verifier_from_der` | Create verifier from DER-encoded public key | +| `cose_crypto_verifier_verify` | Verify a signature | +| `cose_crypto_verifier_free` | Free a verifier handle | +| `cose_crypto_openssl_jwk_verifier_from_ec` | Create verifier from JWK EC key | +| `cose_crypto_openssl_jwk_verifier_from_rsa` | Create verifier from JWK RSA key | +| `cose_crypto_bytes_free` | Free a byte buffer returned by this library | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_crypto_provider_t` | Opaque OpenSSL crypto provider | +| `cose_crypto_signer_t` | Opaque cryptographic signer | +| `cose_crypto_verifier_t` | Opaque cryptographic verifier | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_crypto_openssl`](../../../primitives/crypto/openssl/) — OpenSSL crypto provider implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_crypto_openssl_ffi +``` diff --git a/native/rust/primitives/crypto/openssl/ffi/src/lib.rs b/native/rust/primitives/crypto/openssl/ffi/src/lib.rs index e78231af..65da5ca2 100644 --- a/native/rust/primitives/crypto/openssl/ffi/src/lib.rs +++ b/native/rust/primitives/crypto/openssl/ffi/src/lib.rs @@ -5,33 +5,49 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! C/C++ FFI projections for OpenSSL crypto provider. +//! C-ABI projection for `cose_sign1_crypto_openssl`. //! -//! This crate provides FFI-safe wrappers around the `cose_sign1_crypto_openssl` crypto provider, -//! allowing C and C++ code to create signers and verifiers backed by OpenSSL. +//! This crate provides C-compatible FFI exports for the OpenSSL crypto provider, +//! allowing C and C++ code to create cryptographic signers and verifiers backed by +//! OpenSSL. It supports DER- and PEM-encoded keys, JWK-based EC and RSA verifiers, +//! and provides the core signing and verification primitives used by the COSE_Sign1 +//! signing pipeline. //! -//! ## Error Handling +//! # ABI Stability //! -//! All functions follow a consistent error handling pattern: -//! - Return value: `cose_status_t` (0 = success, non-zero = error) -//! - Thread-local error storage: retrieve via `cose_last_error_message_utf8()` -//! - Output parameters: Only valid if return is `COSE_OK` +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_crypto_openssl_abi_version()`. //! -//! ## Memory Management +//! # Panic Safety //! -//! Handles returned by this library must be freed using the corresponding `*_free` function: -//! - `cose_crypto_openssl_provider_free` for provider handles -//! - `cose_crypto_signer_free` for signer handles -//! - `cose_crypto_verifier_free` for verifier handles -//! - `cose_crypto_bytes_free` for byte buffers -//! - `cose_string_free` for error message strings +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. //! -//! ## Thread Safety +//! # Error Handling //! -//! All handles are thread-safe and can be used from multiple threads. However, handles -//! are not internally synchronized, so concurrent mutation requires external synchronization. +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! Thread-local error storage: retrieve via `cose_last_error_message_utf8()`. +//! Call `cose_last_error_clear()` to reset error state. +//! Output parameters are only valid if the return value is `COSE_OK`. //! -//! ## Example (C) +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_crypto_openssl_provider_free` for provider handles +//! - `cose_crypto_signer_free` for signer handles +//! - `cose_crypto_verifier_free` for verifier handles +//! - `cose_crypto_bytes_free` for byte buffers +//! - `cose_string_free` for error message strings +//! +//! # Thread Safety +//! +//! All functions are thread-safe. Error state is thread-local. +//! +//! # Example (C) //! //! ```c //! #include "cose_crypto_openssl_ffi.h" diff --git a/native/rust/primitives/crypto/openssl/src/provider.rs b/native/rust/primitives/crypto/openssl/src/provider.rs index a4553c3c..a8fc9459 100644 --- a/native/rust/primitives/crypto/openssl/src/provider.rs +++ b/native/rust/primitives/crypto/openssl/src/provider.rs @@ -18,7 +18,7 @@ impl CryptoProvider for OpenSslCryptoProvider { &self, private_key_der: &[u8], ) -> Result, CryptoError> { - // Parse DER to detect algorithm, default to ES256 for EC keys + // Parse DER to detect algorithm based on key type and EC curve let pkey = openssl::pkey::PKey::private_key_from_der(private_key_der) .map_err(|e| CryptoError::InvalidKey(format!("Failed to parse private key: {}", e)))?; @@ -91,11 +91,7 @@ fn detect_algorithm_from_private_key( use openssl::pkey::Id; match pkey.id() { - Id::EC => { - // Default to ES256 for EC keys - // TODO: Detect curve and choose appropriate algorithm - Ok(-7) // ES256 - } + Id::EC => detect_ec_algorithm_from_private_key(pkey), Id::RSA => { // Default to RS256 for RSA keys Ok(-257) // RS256 @@ -136,10 +132,7 @@ fn detect_algorithm_from_public_key( use openssl::pkey::Id; match pkey.id() { - Id::EC => { - // Default to ES256 for EC keys - Ok(-7) // ES256 - } + Id::EC => detect_ec_algorithm_from_public_key(pkey), Id::RSA => { // Default to RS256 for RSA keys when algorithm not specified. // When used via x5chain resolution, the resolver overrides this @@ -174,3 +167,50 @@ fn detect_algorithm_from_public_key( ))), } } + +/// Detects the COSE EC algorithm from an EC private key by inspecting the curve. +/// +/// Maps NIST curves to COSE algorithm identifiers: +/// - P-256 (prime256v1 / secp256r1) -> ES256 (-7) +/// - P-384 (secp384r1) -> ES384 (-35) +/// - P-521 (secp521r1) -> ES512 (-36) +fn detect_ec_algorithm_from_private_key( + pkey: &openssl::pkey::PKey, +) -> Result { + let ec_key = pkey + .ec_key() + .map_err(|e| CryptoError::InvalidKey(format!("Failed to extract EC key: {}", e)))?; + let nid = ec_key + .group() + .curve_name() + .ok_or_else(|| CryptoError::UnsupportedOperation("EC key has unnamed curve".into()))?; + ec_nid_to_cose_algorithm(nid) +} + +/// Detects the COSE EC algorithm from an EC public key by inspecting the curve. +fn detect_ec_algorithm_from_public_key( + pkey: &openssl::pkey::PKey, +) -> Result { + let ec_key = pkey + .ec_key() + .map_err(|e| CryptoError::InvalidKey(format!("Failed to extract EC key: {}", e)))?; + let nid = ec_key + .group() + .curve_name() + .ok_or_else(|| CryptoError::UnsupportedOperation("EC key has unnamed curve".into()))?; + ec_nid_to_cose_algorithm(nid) +} + +/// Maps an OpenSSL EC curve NID to the corresponding COSE algorithm identifier. +fn ec_nid_to_cose_algorithm(nid: openssl::nid::Nid) -> Result { + use openssl::nid::Nid; + match nid { + Nid::X9_62_PRIME256V1 => Ok(-7), // ES256 + Nid::SECP384R1 => Ok(-35), // ES384 + Nid::SECP521R1 => Ok(-36), // ES512 + _ => Err(CryptoError::UnsupportedOperation(format!( + "Unsupported EC curve: {:?}", + nid + ))), + } +} diff --git a/native/rust/primitives/crypto/openssl/tests/coverage_90_boost.rs b/native/rust/primitives/crypto/openssl/tests/coverage_90_boost.rs index 1fda4387..58cb27b7 100644 --- a/native/rust/primitives/crypto/openssl/tests/coverage_90_boost.rs +++ b/native/rust/primitives/crypto/openssl/tests/coverage_90_boost.rs @@ -242,7 +242,7 @@ fn streaming_sign_verify_ec384() { let priv_der = pkey.private_key_to_der().unwrap(); let pub_der = pkey.public_key_to_der().unwrap(); - // Explicitly pass ES384 algorithm (-35) since provider defaults EC to ES256 + // Explicitly pass ES384 algorithm (-35) matching the P-384 key curve let signer = EvpSigner::from_der(&priv_der, -35).unwrap(); let verifier = EvpVerifier::from_der(&pub_der, -35).unwrap(); diff --git a/native/rust/primitives/crypto/src/provider.rs b/native/rust/primitives/crypto/src/provider.rs index c2b2a530..2c8fe1af 100644 --- a/native/rust/primitives/crypto/src/provider.rs +++ b/native/rust/primitives/crypto/src/provider.rs @@ -25,7 +25,7 @@ pub trait CryptoProvider: Send + Sync { fn name(&self) -> &str; } -/// Stub provider when no crypto feature is enabled. +/// Null Object provider when no crypto feature is enabled. /// /// All operations return `UnsupportedOperation` errors. /// This allows compilation when no crypto backend is selected. diff --git a/native/rust/signing/core/ffi/Cargo.toml b/native/rust/signing/core/ffi/Cargo.toml index 4eb5eaba..1e4dea9c 100644 --- a/native/rust/signing/core/ffi/Cargo.toml +++ b/native/rust/signing/core/ffi/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_signing_ffi" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "C/C++ FFI for COSE_Sign1 message signing operations. Provides builder pattern and callback-based key support for C/C++ consumers." [lib] @@ -21,7 +21,6 @@ crypto_primitives = { path = "../../../primitives/crypto" } cbor_primitives_everparse = { path = "../../../primitives/cbor/everparse", optional = true } libc = "0.2" -once_cell.workspace = true [features] default = ["cbor-everparse"] diff --git a/native/rust/signing/core/ffi/src/lib.rs b/native/rust/signing/core/ffi/src/lib.rs index 8a158ccb..961b0ef7 100644 --- a/native/rust/signing/core/ffi/src/lib.rs +++ b/native/rust/signing/core/ffi/src/lib.rs @@ -5,38 +5,54 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! C/C++ FFI for COSE_Sign1 message signing operations. +//! C-ABI projection for `cose_sign1_signing`. //! -//! This crate (`cose_sign1_signing_ffi`) provides FFI-safe wrappers for creating and signing -//! COSE_Sign1 messages from C and C++ code. It uses `cose_sign1_primitives` for types and -//! `cbor_primitives_everparse` for CBOR encoding. +//! This crate provides C-compatible FFI exports for COSE_Sign1 message signing +//! operations. It wraps the `cose_sign1_signing` crate, enabling C and C++ code +//! to build COSE_Sign1 messages with custom headers, sign payloads using callback +//! keys or crypto signers, and manage signing services and factories for direct +//! and indirect signatures (including file-based and streaming variants). //! //! For verification operations, see `cose_sign1_primitives_ffi`. //! -//! ## Error Handling +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_sign1_signing_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling //! //! All functions follow a consistent error handling pattern: //! - Return value: 0 = success, negative = error code //! - `out_error` parameter: Set to error handle on failure (caller must free) //! - Output parameters: Only valid if return is 0 //! -//! ## Memory Management +//! # Memory Ownership //! -//! Handles returned by this library must be freed using the corresponding `*_free` function: -//! - `cose_sign1_builder_free` for builder handles -//! - `cose_headermap_free` for header map handles -//! - `cose_key_free` for key handles -//! - `cose_sign1_signing_service_free` for signing service handles -//! - `cose_sign1_factory_free` for factory handles -//! - `cose_sign1_signing_error_free` for error handles -//! - `cose_sign1_string_free` for string pointers -//! - `cose_sign1_bytes_free` for byte buffer pointers -//! - `cose_sign1_cose_bytes_free` for COSE message bytes returned by factory functions +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_sign1_builder_free` for builder handles +//! - `cose_headermap_free` for header map handles +//! - `cose_key_free` for key handles +//! - `cose_sign1_signing_service_free` for signing service handles +//! - `cose_sign1_factory_free` for factory handles +//! - `cose_sign1_signing_error_free` for error handles +//! - `cose_sign1_string_free` for string pointers +//! - `cose_sign1_bytes_free` for byte buffer pointers +//! - `cose_sign1_cose_bytes_free` for COSE message bytes returned by factory functions //! -//! ## Thread Safety +//! # Thread Safety //! -//! All handles are thread-safe and can be used from multiple threads. However, handles -//! are not internally synchronized, so concurrent mutation requires external synchronization. +//! All functions are thread-safe. Handles are not internally synchronized, +//! so concurrent mutation requires external synchronization. pub mod error; pub mod provider; @@ -1562,10 +1578,10 @@ impl std::io::Read for CallbackReader { let result = unsafe { (self.callback)(buf.as_mut_ptr(), to_read, self.user_data) }; if result < 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("callback read error: {}", result), - )); + return Err(std::io::Error::other(format!( + "callback read error: {}", + result + ))); } let bytes_read = result as usize; @@ -2892,8 +2908,8 @@ impl cose_sign1_signing::SigningService for SimpleSigningService { } fn service_metadata(&self) -> &cose_sign1_signing::SigningServiceMetadata { - static METADATA: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { + static METADATA: std::sync::LazyLock = + std::sync::LazyLock::new(|| { cose_sign1_signing::SigningServiceMetadata::new( "FFI Signing Service".to_string(), "1.0.0".to_string(), diff --git a/native/rust/signing/core/ffi/tests/unit_test_internal_types.rs b/native/rust/signing/core/ffi/tests/unit_test_internal_types.rs index 8434272f..7560009d 100644 --- a/native/rust/signing/core/ffi/tests/unit_test_internal_types.rs +++ b/native/rust/signing/core/ffi/tests/unit_test_internal_types.rs @@ -144,8 +144,8 @@ impl cose_sign1_signing::SigningService for TestableSimpleSigningService { } fn service_metadata(&self) -> &cose_sign1_signing::SigningServiceMetadata { - static METADATA: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { + static METADATA: std::sync::LazyLock = + std::sync::LazyLock::new(|| { cose_sign1_signing::SigningServiceMetadata::new( "FFI Signing Service".to_string(), "1.0.0".to_string(), diff --git a/native/rust/signing/factories/ffi/Cargo.toml b/native/rust/signing/factories/ffi/Cargo.toml index a4ac490b..60a48808 100644 --- a/native/rust/signing/factories/ffi/Cargo.toml +++ b/native/rust/signing/factories/ffi/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_factories_ffi" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "C/C++ FFI for COSE_Sign1 message factory. Provides direct and indirect signature creation for C/C++ consumers." [lib] @@ -21,7 +21,6 @@ crypto_primitives = { path = "../../../primitives/crypto" } cbor_primitives_everparse = { path = "../../../primitives/cbor/everparse", optional = true } libc = "0.2" -once_cell.workspace = true [features] default = ["cbor-everparse"] diff --git a/native/rust/signing/factories/ffi/src/lib.rs b/native/rust/signing/factories/ffi/src/lib.rs index 215c2cf5..918294c3 100644 --- a/native/rust/signing/factories/ffi/src/lib.rs +++ b/native/rust/signing/factories/ffi/src/lib.rs @@ -1,2000 +1,2019 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] -#![deny(unsafe_op_in_unsafe_fn)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] - -//! C/C++ FFI for COSE_Sign1 message factories. -//! -//! This crate (`cose_sign1_factories_ffi`) provides FFI-safe wrappers for creating -//! COSE_Sign1 messages using the factory pattern. It supports both direct and indirect -//! signatures, with streaming and file-based payloads. -//! -//! ## Error Handling -//! -//! All functions follow a consistent error handling pattern: -//! - Return value: 0 = success, negative = error code -//! - `out_error` parameter: Set to error handle on failure (caller must free) -//! - Output parameters: Only valid if return is 0 -//! -//! ## Memory Management -//! -//! Handles returned by this library must be freed using the corresponding `*_free` function: -//! - `cose_sign1_factories_free` for factory handles -//! - `cose_sign1_factories_error_free` for error handles -//! - `cose_sign1_factories_string_free` for string pointers -//! - `cose_sign1_factories_bytes_free` for byte buffer pointers - -pub mod error; -pub mod provider; -pub mod types; - -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::ptr; -use std::slice; -use std::sync::Arc; - -use cose_sign1_primitives::CryptoSigner; - -use crate::error::{ - set_error, ErrorInner, FFI_ERR_FACTORY_FAILED, FFI_ERR_INVALID_ARGUMENT, FFI_ERR_NULL_POINTER, - FFI_ERR_PANIC, FFI_OK, -}; -use crate::types::{ - factory_handle_to_inner, factory_inner_to_handle, message_inner_to_handle, - signing_service_handle_to_inner, FactoryInner, MessageInner, SigningServiceInner, -}; - -// Re-export handle types for library users -pub use crate::types::{ - CoseSign1FactoriesHandle, CoseSign1FactoriesSigningServiceHandle, - CoseSign1FactoriesTransparencyProviderHandle, CoseSign1MessageHandle, -}; - -// Re-export error types for library users -pub use crate::error::{ - CoseSign1FactoriesErrorHandle, - FFI_ERR_FACTORY_FAILED as COSE_SIGN1_FACTORIES_ERR_FACTORY_FAILED, - FFI_ERR_INVALID_ARGUMENT as COSE_SIGN1_FACTORIES_ERR_INVALID_ARGUMENT, - FFI_ERR_NULL_POINTER as COSE_SIGN1_FACTORIES_ERR_NULL_POINTER, - FFI_ERR_PANIC as COSE_SIGN1_FACTORIES_ERR_PANIC, FFI_OK as COSE_SIGN1_FACTORIES_OK, -}; - -pub use crate::error::{ - cose_sign1_factories_error_code, cose_sign1_factories_error_free, - cose_sign1_factories_error_message, cose_sign1_factories_string_free, -}; - -/// ABI version for this library. -/// -/// Increment when making breaking changes to the FFI interface. -pub const ABI_VERSION: u32 = 1; - -/// Returns the ABI version for this library. -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub extern "C" fn cose_sign1_factories_abi_version() -> u32 { - ABI_VERSION -} - -// ============================================================================ -// Inner implementation functions (testable from Rust) -// ============================================================================ - -/// Inner implementation for cose_sign1_factories_create_from_signing_service. -pub fn impl_create_from_signing_service_inner( - service: &SigningServiceInner, -) -> Result { - let factory = cose_sign1_factories::CoseSign1MessageFactory::new(service.service.clone()); - Ok(FactoryInner { factory }) -} - -/// Inner implementation for cose_sign1_factories_create_from_crypto_signer. -pub fn impl_create_from_crypto_signer_inner( - signer: Arc, -) -> Result { - let service = SimpleSigningService::new(signer); - let factory = cose_sign1_factories::CoseSign1MessageFactory::new(Arc::new(service)); - Ok(FactoryInner { factory }) -} - -/// Inner implementation for cose_sign1_factories_create_with_transparency. -pub fn impl_create_with_transparency_inner( - service: &SigningServiceInner, - providers: Vec>, -) -> Result { - let factory = cose_sign1_factories::CoseSign1MessageFactory::with_transparency( - service.service.clone(), - providers, - ); - Ok(FactoryInner { factory }) -} - -/// Inner implementation for cose_sign1_factories_sign_direct. -pub fn impl_sign_direct_inner( - factory: &FactoryInner, - payload: &[u8], - content_type: &str, -) -> Result, ErrorInner> { - factory - .factory - .create_direct_bytes(payload, content_type, None) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_direct_detached. -pub fn impl_sign_direct_detached_inner( - factory: &FactoryInner, - payload: &[u8], - content_type: &str, -) -> Result, ErrorInner> { - let options = cose_sign1_factories::direct::DirectSignatureOptions { - embed_payload: false, - ..Default::default() - }; - - factory - .factory - .create_direct_bytes(payload, content_type, Some(options)) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_direct_file. -pub fn impl_sign_direct_file_inner( - factory: &FactoryInner, - file_path: &str, - content_type: &str, -) -> Result, ErrorInner> { - // Create FilePayload - let file_payload = cose_sign1_primitives::FilePayload::new(file_path).map_err(|e| { - ErrorInner::new( - format!("failed to open file: {}", e), - FFI_ERR_INVALID_ARGUMENT, - ) - })?; - - let payload_arc: Arc = Arc::new(file_payload); - - // Create options with detached=true for streaming - let options = cose_sign1_factories::direct::DirectSignatureOptions { - embed_payload: false, // Force detached for streaming - ..Default::default() - }; - - factory - .factory - .create_direct_streaming_bytes(payload_arc, content_type, Some(options)) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_direct_streaming. -pub fn impl_sign_direct_streaming_inner( - factory: &FactoryInner, - payload: Arc, - content_type: &str, -) -> Result, ErrorInner> { - // Create options with detached=true - let options = cose_sign1_factories::direct::DirectSignatureOptions { - embed_payload: false, - ..Default::default() - }; - - factory - .factory - .create_direct_streaming_bytes(payload, content_type, Some(options)) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_indirect. -pub fn impl_sign_indirect_inner( - factory: &FactoryInner, - payload: &[u8], - content_type: &str, -) -> Result, ErrorInner> { - factory - .factory - .create_indirect_bytes(payload, content_type, None) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_indirect_file. -pub fn impl_sign_indirect_file_inner( - factory: &FactoryInner, - file_path: &str, - content_type: &str, -) -> Result, ErrorInner> { - // Create FilePayload - let file_payload = cose_sign1_primitives::FilePayload::new(file_path).map_err(|e| { - ErrorInner::new( - format!("failed to open file: {}", e), - FFI_ERR_INVALID_ARGUMENT, - ) - })?; - - let payload_arc: Arc = Arc::new(file_payload); - - factory - .factory - .create_indirect_streaming_bytes(payload_arc, content_type, None) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -/// Inner implementation for cose_sign1_factories_sign_indirect_streaming. -pub fn impl_sign_indirect_streaming_inner( - factory: &FactoryInner, - payload: Arc, - content_type: &str, -) -> Result, ErrorInner> { - factory - .factory - .create_indirect_streaming_bytes(payload, content_type, None) - .map_err(|err| ErrorInner::from_factory_error(&err)) -} - -// ============================================================================ -// CryptoSigner handle type (imported from crypto layer) -// ============================================================================ - -/// Opaque handle to a CryptoSigner from crypto_primitives. -/// -/// This type is defined in the crypto layer and is used to create factories. -#[repr(C)] -pub struct CryptoSignerHandle { - _private: [u8; 0], -} - -/// Parses signed COSE bytes into a `CoseSign1MessageHandle` and writes it to the -/// caller's output pointer. -/// -/// On success the handle owns the parsed message; free it with -/// `cose_sign1_message_free` from `cose_sign1_primitives_ffi`. -#[cfg_attr(coverage_nightly, coverage(off))] -unsafe fn write_signed_message( - bytes: Vec, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let _provider = crate::provider::get_provider(); - match cose_sign1_primitives::CoseSign1Message::parse(&bytes) { - Ok(message) => { - unsafe { - *out_message = message_inner_to_handle(MessageInner { message }); - } - FFI_OK - } - Err(err) => { - set_error( - out_error, - ErrorInner::new( - format!("failed to parse signed message: {}", err), - FFI_ERR_FACTORY_FAILED, - ), - ); - FFI_ERR_FACTORY_FAILED - } - } -} - -// ============================================================================ -// Factory creation functions -// ============================================================================ - -/// Creates a factory from a signing service handle. -/// -/// # Safety -/// -/// - `service` must be a valid signing service handle -/// - `out_factory` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_create_from_signing_service( - service: *const CoseSign1FactoriesSigningServiceHandle, - out_factory: *mut *mut CoseSign1FactoriesHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_factory.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_factory")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_factory = ptr::null_mut(); - } - - let Some(service_inner) = (unsafe { signing_service_handle_to_inner(service) }) else { - set_error(out_error, ErrorInner::null_pointer("service")); - return FFI_ERR_NULL_POINTER; - }; - - match impl_create_from_signing_service_inner(service_inner) { - Ok(inner) => { - unsafe { - *out_factory = factory_inner_to_handle(inner); - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during factory creation", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Creates a factory from a CryptoSigner handle in a single call. -/// -/// This is a convenience function that wraps the signer in a SimpleSigningService -/// and creates a factory. Ownership of the signer handle is transferred to the factory. -/// -/// # Safety -/// -/// - `signer_handle` must be a valid CryptoSigner handle (from crypto layer) -/// - `out_factory` must be valid for writes -/// - `signer_handle` must not be used after this call (ownership transferred) -/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_create_from_crypto_signer( - signer_handle: *mut CryptoSignerHandle, - out_factory: *mut *mut CoseSign1FactoriesHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_factory.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_factory")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_factory = ptr::null_mut(); - } - - if signer_handle.is_null() { - set_error(out_error, ErrorInner::null_pointer("signer_handle")); - return FFI_ERR_NULL_POINTER; - } - - let signer_box = unsafe { - Box::from_raw(signer_handle as *mut Box) - }; - let signer_arc: std::sync::Arc = (*signer_box).into(); - - match impl_create_from_crypto_signer_inner(signer_arc) { - Ok(inner) => { - unsafe { - *out_factory = factory_inner_to_handle(inner); - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new( - "panic during factory creation from crypto signer", - FFI_ERR_PANIC, - ), - ); - FFI_ERR_PANIC - } - } -} - -/// Creates a factory with transparency providers. -/// -/// # Safety -/// -/// - `service` must be a valid signing service handle -/// - `providers` must be valid for reads of `providers_len` elements -/// - `out_factory` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` -/// - Ownership of provider handles is transferred (caller must not free them) -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_create_with_transparency( - service: *const CoseSign1FactoriesSigningServiceHandle, - providers: *const *mut CoseSign1FactoriesTransparencyProviderHandle, - providers_len: usize, - out_factory: *mut *mut CoseSign1FactoriesHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_factory.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_factory")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_factory = ptr::null_mut(); - } - - let Some(service_inner) = (unsafe { signing_service_handle_to_inner(service) }) else { - set_error(out_error, ErrorInner::null_pointer("service")); - return FFI_ERR_NULL_POINTER; - }; - - if providers.is_null() && providers_len > 0 { - set_error(out_error, ErrorInner::null_pointer("providers")); - return FFI_ERR_NULL_POINTER; - } - - // Convert provider handles to Vec> - let mut provider_vec = Vec::new(); - if !providers.is_null() { - let providers_slice = unsafe { slice::from_raw_parts(providers, providers_len) }; - for &provider_handle in providers_slice { - if provider_handle.is_null() { - set_error( - out_error, - ErrorInner::new("provider handle must not be null", FFI_ERR_NULL_POINTER), - ); - return FFI_ERR_NULL_POINTER; - } - // Take ownership of the provider - let provider_inner = unsafe { - Box::from_raw(provider_handle as *mut crate::types::TransparencyProviderInner) - }; - provider_vec.push(provider_inner.provider); - } - } - - match impl_create_with_transparency_inner(service_inner, provider_vec) { - Ok(inner) => { - unsafe { - *out_factory = factory_inner_to_handle(inner); - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new( - "panic during factory creation with transparency", - FFI_ERR_PANIC, - ), - ); - FFI_ERR_PANIC - } - } -} - -/// Frees a factory handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle or NULL -/// - The handle must not be used after this call -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_free(factory: *mut CoseSign1FactoriesHandle) { - if factory.is_null() { - return; - } - unsafe { - drop(Box::from_raw(factory as *mut FactoryInner)); - } -} - -// ============================================================================ -// Direct signature functions -// ============================================================================ - -/// Signs payload with direct signature (embedded payload). -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during direct signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs payload with direct signature in detached mode (payload not embedded). -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_detached( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_detached_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during detached direct signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a file directly without loading it into memory (direct signature, detached). -/// -/// Creates a detached COSE_Sign1 signature over the file content. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `file_path` must be a valid null-terminated UTF-8 string -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_file( - factory: *const CoseSign1FactoriesHandle, - file_path: *const libc::c_char, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if file_path.is_null() { - set_error(out_error, ErrorInner::null_pointer("file_path")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; - let path_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_file_inner(factory_inner, path_str, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during file signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Callback type for streaming payload reading. -/// -/// The callback is invoked repeatedly with a buffer to fill. -/// Returns the number of bytes read (0 = EOF), or negative on error. -/// -/// # Safety -/// -/// - `buffer` must be valid for writes of `buffer_len` bytes -/// - `user_data` is the opaque pointer passed to the signing function -pub type CoseReadCallback = - unsafe extern "C" fn(buffer: *mut u8, buffer_len: usize, user_data: *mut libc::c_void) -> i64; - -/// Adapter for callback-based streaming payload. -pub struct CallbackStreamingPayload { - pub callback: CoseReadCallback, - pub user_data: *mut libc::c_void, - pub total_len: u64, -} - -// SAFETY: The callback is assumed to be thread-safe. -// FFI callers are responsible for ensuring thread safety. -unsafe impl Send for CallbackStreamingPayload {} -unsafe impl Sync for CallbackStreamingPayload {} - -impl cose_sign1_primitives::StreamingPayload for CallbackStreamingPayload { - fn size(&self) -> u64 { - self.total_len - } - - fn open( - &self, - ) -> Result< - Box, - cose_sign1_primitives::error::PayloadError, - > { - Ok(Box::new(CallbackReader { - callback: self.callback, - user_data: self.user_data, - total_len: self.total_len, - bytes_read: 0, - })) - } -} - -/// Reader implementation that wraps the callback. -pub struct CallbackReader { - pub callback: CoseReadCallback, - pub user_data: *mut libc::c_void, - pub total_len: u64, - pub bytes_read: u64, -} - -// SAFETY: The callback is assumed to be thread-safe. -// FFI callers are responsible for ensuring thread safety. -unsafe impl Send for CallbackReader {} - -impl std::io::Read for CallbackReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - if self.bytes_read >= self.total_len { - return Ok(0); - } - - let remaining = (self.total_len - self.bytes_read) as usize; - let to_read = buf.len().min(remaining); - - let result = unsafe { (self.callback)(buf.as_mut_ptr(), to_read, self.user_data) }; - - if result < 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("callback read error: {}", result), - )); - } - - let bytes_read = result as usize; - self.bytes_read += bytes_read as u64; - Ok(bytes_read) - } -} - -impl cose_sign1_primitives::sig_structure::SizedRead for CallbackReader { - fn len(&self) -> Result { - Ok(self.total_len) - } -} - -/// Signs a streaming payload with direct signature (detached). -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `read_callback` must be a valid function pointer -/// - `user_data` will be passed to the callback (can be NULL) -/// - `total_len` must be the total size of the payload -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_streaming( - factory: *const CoseSign1FactoriesHandle, - read_callback: CoseReadCallback, - user_data: *mut libc::c_void, - total_len: u64, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let payload = CallbackStreamingPayload { - callback: read_callback, - user_data, - total_len, - }; - - let payload_arc: Arc = Arc::new(payload); - - match impl_sign_direct_streaming_inner(factory_inner, payload_arc, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during streaming signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -// ============================================================================ -// Indirect signature functions -// ============================================================================ - -/// Signs payload with indirect signature (hash envelope). -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_indirect_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a file with indirect signature (hash envelope) without loading it into memory. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `file_path` must be a valid null-terminated UTF-8 string -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_file( - factory: *const CoseSign1FactoriesHandle, - file_path: *const libc::c_char, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if file_path.is_null() { - set_error(out_error, ErrorInner::null_pointer("file_path")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; - let path_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_indirect_file_inner(factory_inner, path_str, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect file signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a streaming payload with indirect signature (hash envelope). -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `read_callback` must be a valid function pointer -/// - `user_data` will be passed to the callback (can be NULL) -/// - `total_len` must be the total size of the payload -/// - `content_type` must be a valid null-terminated C string -/// - `out_cose_bytes` and `out_cose_len` must be valid for writes -/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_streaming( - factory: *const CoseSign1FactoriesHandle, - read_callback: CoseReadCallback, - user_data: *mut libc::c_void, - total_len: u64, - content_type: *const libc::c_char, - out_cose_bytes: *mut *mut u8, - out_cose_len: *mut u32, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_cose_bytes.is_null() || out_cose_len.is_null() { - set_error( - out_error, - ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), - ); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_cose_bytes = ptr::null_mut(); - *out_cose_len = 0; - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let payload = CallbackStreamingPayload { - callback: read_callback, - user_data, - total_len, - }; - - let payload_arc: Arc = Arc::new(payload); - - match impl_sign_indirect_streaming_inner(factory_inner, payload_arc, content_type_str) { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let raw = Box::into_raw(boxed); - unsafe { - *out_cose_bytes = raw as *mut u8; - *out_cose_len = len as u32; - } - FFI_OK - } - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect streaming signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -// ============================================================================ -// Factory _to_message variants — return CoseSign1MessageHandle -// ============================================================================ - -/// Signs payload with direct signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_to_message( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during direct signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs payload with direct detached signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_detached_to_message( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_detached_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during detached direct signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a file directly, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `file_path` must be a valid null-terminated UTF-8 string -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_file_to_message( - factory: *const CoseSign1FactoriesHandle, - file_path: *const libc::c_char, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if file_path.is_null() { - set_error(out_error, ErrorInner::null_pointer("file_path")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; - let path_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_direct_file_inner(factory_inner, path_str, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during file signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a streaming payload with direct signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `read_callback` must be a valid function pointer -/// - `user_data` will be passed to the callback (can be NULL) -/// - `total_len` must be the total size of the payload -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_direct_streaming_to_message( - factory: *const CoseSign1FactoriesHandle, - read_callback: CoseReadCallback, - user_data: *mut libc::c_void, - total_len: u64, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let payload = CallbackStreamingPayload { - callback: read_callback, - user_data, - total_len, - }; - - let payload_arc: Arc = Arc::new(payload); - - match impl_sign_direct_streaming_inner(factory_inner, payload_arc, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during streaming signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs payload with indirect signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `payload` must be valid for reads of `payload_len` bytes -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_to_message( - factory: *const CoseSign1FactoriesHandle, - payload: *const u8, - payload_len: u32, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if payload.is_null() && payload_len > 0 { - set_error(out_error, ErrorInner::null_pointer("payload")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let payload_bytes = if payload.is_null() { - &[] as &[u8] - } else { - unsafe { slice::from_raw_parts(payload, payload_len as usize) } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_indirect_inner(factory_inner, payload_bytes, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a file with indirect signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `file_path` must be a valid null-terminated UTF-8 string -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_file_to_message( - factory: *const CoseSign1FactoriesHandle, - file_path: *const libc::c_char, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if file_path.is_null() { - set_error(out_error, ErrorInner::null_pointer("file_path")); - return FFI_ERR_NULL_POINTER; - } - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; - let path_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - match impl_sign_indirect_file_inner(factory_inner, path_str, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect file signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -/// Signs a streaming payload with indirect signature, returning an opaque message handle. -/// -/// # Safety -/// -/// - `factory` must be a valid factory handle -/// - `read_callback` must be a valid function pointer -/// - `user_data` will be passed to the callback (can be NULL) -/// - `total_len` must be the total size of the payload -/// - `content_type` must be a valid null-terminated C string -/// - `out_message` must be valid for writes -/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` -#[no_mangle] -pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_streaming_to_message( - factory: *const CoseSign1FactoriesHandle, - read_callback: CoseReadCallback, - user_data: *mut libc::c_void, - total_len: u64, - content_type: *const libc::c_char, - out_message: *mut *mut CoseSign1MessageHandle, - out_error: *mut *mut CoseSign1FactoriesErrorHandle, -) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - if out_message.is_null() { - set_error(out_error, ErrorInner::null_pointer("out_message")); - return FFI_ERR_NULL_POINTER; - } - - unsafe { - *out_message = ptr::null_mut(); - } - - let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { - set_error(out_error, ErrorInner::null_pointer("factory")); - return FFI_ERR_NULL_POINTER; - }; - - if content_type.is_null() { - set_error(out_error, ErrorInner::null_pointer("content_type")); - return FFI_ERR_NULL_POINTER; - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; - let content_type_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - set_error( - out_error, - ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), - ); - return FFI_ERR_INVALID_ARGUMENT; - } - }; - - let payload = CallbackStreamingPayload { - callback: read_callback, - user_data, - total_len, - }; - - let payload_arc: Arc = Arc::new(payload); - - match impl_sign_indirect_streaming_inner(factory_inner, payload_arc, content_type_str) { - Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, - Err(err) => { - set_error(out_error, err); - FFI_ERR_FACTORY_FAILED - } - } - })); - - match result { - Ok(code) => code, - Err(_) => { - set_error( - out_error, - ErrorInner::new("panic during indirect streaming signing", FFI_ERR_PANIC), - ); - FFI_ERR_PANIC - } - } -} - -// ============================================================================ -// Memory management functions -// ============================================================================ - -/// Frees COSE bytes allocated by factory functions. -/// -/// # Safety -/// -/// - `ptr` must have been returned by a factory signing function or be NULL -/// - `len` must be the length returned alongside the bytes -/// - The bytes must not be used after this call -#[no_mangle] -#[cfg_attr(coverage_nightly, coverage(off))] -pub unsafe extern "C" fn cose_sign1_factories_bytes_free(ptr: *mut u8, len: u32) { - if ptr.is_null() { - return; - } - unsafe { - drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( - ptr, - len as usize, - ))); - } -} - -// ============================================================================ -// Internal: Simple signing service implementation -// ============================================================================ - -/// Simple signing service that wraps a single key. -/// -/// Used to bridge between the key-based FFI and the factory pattern. -pub struct SimpleSigningService { - key: std::sync::Arc, - metadata: cose_sign1_signing::SigningServiceMetadata, -} - -impl SimpleSigningService { - pub fn new(key: std::sync::Arc) -> Self { - let metadata = cose_sign1_signing::SigningServiceMetadata::new( - "Simple Signing Service".to_string(), - "FFI-based signing service wrapping a CryptoSigner".to_string(), - ); - Self { key, metadata } - } -} - -impl cose_sign1_signing::SigningService for SimpleSigningService { - fn get_cose_signer( - &self, - _context: &cose_sign1_signing::SigningContext, - ) -> Result { - use cose_sign1_primitives::CoseHeaderMap; - - // Convert Arc to Box for the signer - let key_box: Box = Box::new(SimpleKeyWrapper { - key: self.key.clone(), - }); - - // Create a CoseSigner with empty header maps - let signer = cose_sign1_signing::CoseSigner::new( - key_box, - CoseHeaderMap::new(), - CoseHeaderMap::new(), - ); - Ok(signer) - } - - fn is_remote(&self) -> bool { - false - } - - fn service_metadata(&self) -> &cose_sign1_signing::SigningServiceMetadata { - &self.metadata - } - - fn verify_signature( - &self, - _message_bytes: &[u8], - _context: &cose_sign1_signing::SigningContext, - ) -> Result { - // Simple service doesn't support verification - Ok(true) - } -} - -/// Wrapper to convert Arc to Box. -pub struct SimpleKeyWrapper { - pub key: std::sync::Arc, -} - -impl CryptoSigner for SimpleKeyWrapper { - fn sign(&self, data: &[u8]) -> Result, cose_sign1_primitives::CryptoError> { - self.key.sign(data) - } - - fn algorithm(&self) -> i64 { - self.key.algorithm() - } - - fn key_type(&self) -> &str { - self.key.key_type() - } - - fn key_id(&self) -> Option<&[u8]> { - self.key.key_id() - } - - fn supports_streaming(&self) -> bool { - self.key.supports_streaming() - } - - fn sign_init( - &self, - ) -> Result, cose_sign1_primitives::CryptoError> - { - self.key.sign_init() - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![deny(unsafe_op_in_unsafe_fn)] +#![allow(clippy::not_unsafe_ptr_arg_deref)] + +//! C-ABI projection for `cose_sign1_factories`. +//! +//! This crate provides C-compatible FFI exports for creating COSE_Sign1 messages +//! using the factory pattern. It supports both direct and indirect signatures, +//! with streaming and file-based payloads, and transparency provider integration. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_sign1_factories_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! All functions follow a consistent error handling pattern: +//! - Return value: 0 = success, negative = error code +//! - `out_error` parameter: Set to error handle on failure (caller must free) +//! - Output parameters: Only valid if return is 0 +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_sign1_factories_free` for factory handles +//! - `cose_sign1_factories_error_free` for error handles +//! - `cose_sign1_factories_string_free` for string pointers +//! - `cose_sign1_factories_bytes_free` for byte buffer pointers +//! +//! # Thread Safety +//! +//! All functions are thread-safe. Handles are not internally synchronized, +//! so concurrent mutation requires external synchronization. + +pub mod error; +pub mod provider; +pub mod types; + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::slice; +use std::sync::Arc; + +use cose_sign1_primitives::CryptoSigner; + +use crate::error::{ + set_error, ErrorInner, FFI_ERR_FACTORY_FAILED, FFI_ERR_INVALID_ARGUMENT, FFI_ERR_NULL_POINTER, + FFI_ERR_PANIC, FFI_OK, +}; +use crate::types::{ + factory_handle_to_inner, factory_inner_to_handle, message_inner_to_handle, + signing_service_handle_to_inner, FactoryInner, MessageInner, SigningServiceInner, +}; + +// Re-export handle types for library users +pub use crate::types::{ + CoseSign1FactoriesHandle, CoseSign1FactoriesSigningServiceHandle, + CoseSign1FactoriesTransparencyProviderHandle, CoseSign1MessageHandle, +}; + +// Re-export error types for library users +pub use crate::error::{ + CoseSign1FactoriesErrorHandle, + FFI_ERR_FACTORY_FAILED as COSE_SIGN1_FACTORIES_ERR_FACTORY_FAILED, + FFI_ERR_INVALID_ARGUMENT as COSE_SIGN1_FACTORIES_ERR_INVALID_ARGUMENT, + FFI_ERR_NULL_POINTER as COSE_SIGN1_FACTORIES_ERR_NULL_POINTER, + FFI_ERR_PANIC as COSE_SIGN1_FACTORIES_ERR_PANIC, FFI_OK as COSE_SIGN1_FACTORIES_OK, +}; + +pub use crate::error::{ + cose_sign1_factories_error_code, cose_sign1_factories_error_free, + cose_sign1_factories_error_message, cose_sign1_factories_string_free, +}; + +/// ABI version for this library. +/// +/// Increment when making breaking changes to the FFI interface. +pub const ABI_VERSION: u32 = 1; + +/// Returns the ABI version for this library. +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub extern "C" fn cose_sign1_factories_abi_version() -> u32 { + ABI_VERSION +} + +// ============================================================================ +// Inner implementation functions (testable from Rust) +// ============================================================================ + +/// Inner implementation for cose_sign1_factories_create_from_signing_service. +pub fn impl_create_from_signing_service_inner( + service: &SigningServiceInner, +) -> Result { + let factory = cose_sign1_factories::CoseSign1MessageFactory::new(service.service.clone()); + Ok(FactoryInner { factory }) +} + +/// Inner implementation for cose_sign1_factories_create_from_crypto_signer. +pub fn impl_create_from_crypto_signer_inner( + signer: Arc, +) -> Result { + let service = SimpleSigningService::new(signer); + let factory = cose_sign1_factories::CoseSign1MessageFactory::new(Arc::new(service)); + Ok(FactoryInner { factory }) +} + +/// Inner implementation for cose_sign1_factories_create_with_transparency. +pub fn impl_create_with_transparency_inner( + service: &SigningServiceInner, + providers: Vec>, +) -> Result { + let factory = cose_sign1_factories::CoseSign1MessageFactory::with_transparency( + service.service.clone(), + providers, + ); + Ok(FactoryInner { factory }) +} + +/// Inner implementation for cose_sign1_factories_sign_direct. +pub fn impl_sign_direct_inner( + factory: &FactoryInner, + payload: &[u8], + content_type: &str, +) -> Result, ErrorInner> { + factory + .factory + .create_direct_bytes(payload, content_type, None) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_direct_detached. +pub fn impl_sign_direct_detached_inner( + factory: &FactoryInner, + payload: &[u8], + content_type: &str, +) -> Result, ErrorInner> { + let options = cose_sign1_factories::direct::DirectSignatureOptions { + embed_payload: false, + ..Default::default() + }; + + factory + .factory + .create_direct_bytes(payload, content_type, Some(options)) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_direct_file. +pub fn impl_sign_direct_file_inner( + factory: &FactoryInner, + file_path: &str, + content_type: &str, +) -> Result, ErrorInner> { + // Create FilePayload + let file_payload = cose_sign1_primitives::FilePayload::new(file_path).map_err(|e| { + ErrorInner::new( + format!("failed to open file: {}", e), + FFI_ERR_INVALID_ARGUMENT, + ) + })?; + + let payload_arc: Arc = Arc::new(file_payload); + + // Create options with detached=true for streaming + let options = cose_sign1_factories::direct::DirectSignatureOptions { + embed_payload: false, // Force detached for streaming + ..Default::default() + }; + + factory + .factory + .create_direct_streaming_bytes(payload_arc, content_type, Some(options)) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_direct_streaming. +pub fn impl_sign_direct_streaming_inner( + factory: &FactoryInner, + payload: Arc, + content_type: &str, +) -> Result, ErrorInner> { + // Create options with detached=true + let options = cose_sign1_factories::direct::DirectSignatureOptions { + embed_payload: false, + ..Default::default() + }; + + factory + .factory + .create_direct_streaming_bytes(payload, content_type, Some(options)) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_indirect. +pub fn impl_sign_indirect_inner( + factory: &FactoryInner, + payload: &[u8], + content_type: &str, +) -> Result, ErrorInner> { + factory + .factory + .create_indirect_bytes(payload, content_type, None) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_indirect_file. +pub fn impl_sign_indirect_file_inner( + factory: &FactoryInner, + file_path: &str, + content_type: &str, +) -> Result, ErrorInner> { + // Create FilePayload + let file_payload = cose_sign1_primitives::FilePayload::new(file_path).map_err(|e| { + ErrorInner::new( + format!("failed to open file: {}", e), + FFI_ERR_INVALID_ARGUMENT, + ) + })?; + + let payload_arc: Arc = Arc::new(file_payload); + + factory + .factory + .create_indirect_streaming_bytes(payload_arc, content_type, None) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +/// Inner implementation for cose_sign1_factories_sign_indirect_streaming. +pub fn impl_sign_indirect_streaming_inner( + factory: &FactoryInner, + payload: Arc, + content_type: &str, +) -> Result, ErrorInner> { + factory + .factory + .create_indirect_streaming_bytes(payload, content_type, None) + .map_err(|err| ErrorInner::from_factory_error(&err)) +} + +// ============================================================================ +// CryptoSigner handle type (imported from crypto layer) +// ============================================================================ + +/// Opaque handle to a CryptoSigner from crypto_primitives. +/// +/// This type is defined in the crypto layer and is used to create factories. +#[repr(C)] +pub struct CryptoSignerHandle { + _private: [u8; 0], +} + +/// Parses signed COSE bytes into a `CoseSign1MessageHandle` and writes it to the +/// caller's output pointer. +/// +/// On success the handle owns the parsed message; free it with +/// `cose_sign1_message_free` from `cose_sign1_primitives_ffi`. +#[cfg_attr(coverage_nightly, coverage(off))] +unsafe fn write_signed_message( + bytes: Vec, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let _provider = crate::provider::get_provider(); + match cose_sign1_primitives::CoseSign1Message::parse(&bytes) { + Ok(message) => { + unsafe { + *out_message = message_inner_to_handle(MessageInner { message }); + } + FFI_OK + } + Err(err) => { + set_error( + out_error, + ErrorInner::new( + format!("failed to parse signed message: {}", err), + FFI_ERR_FACTORY_FAILED, + ), + ); + FFI_ERR_FACTORY_FAILED + } + } +} + +// ============================================================================ +// Factory creation functions +// ============================================================================ + +/// Creates a factory from a signing service handle. +/// +/// # Safety +/// +/// - `service` must be a valid signing service handle +/// - `out_factory` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_create_from_signing_service( + service: *const CoseSign1FactoriesSigningServiceHandle, + out_factory: *mut *mut CoseSign1FactoriesHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_factory.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_factory")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_factory = ptr::null_mut(); + } + + let Some(service_inner) = (unsafe { signing_service_handle_to_inner(service) }) else { + set_error(out_error, ErrorInner::null_pointer("service")); + return FFI_ERR_NULL_POINTER; + }; + + match impl_create_from_signing_service_inner(service_inner) { + Ok(inner) => { + unsafe { + *out_factory = factory_inner_to_handle(inner); + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during factory creation", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Creates a factory from a CryptoSigner handle in a single call. +/// +/// This is a convenience function that wraps the signer in a SimpleSigningService +/// and creates a factory. Ownership of the signer handle is transferred to the factory. +/// +/// # Safety +/// +/// - `signer_handle` must be a valid CryptoSigner handle (from crypto layer) +/// - `out_factory` must be valid for writes +/// - `signer_handle` must not be used after this call (ownership transferred) +/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_create_from_crypto_signer( + signer_handle: *mut CryptoSignerHandle, + out_factory: *mut *mut CoseSign1FactoriesHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_factory.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_factory")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_factory = ptr::null_mut(); + } + + if signer_handle.is_null() { + set_error(out_error, ErrorInner::null_pointer("signer_handle")); + return FFI_ERR_NULL_POINTER; + } + + let signer_box = unsafe { + Box::from_raw(signer_handle as *mut Box) + }; + let signer_arc: std::sync::Arc = (*signer_box).into(); + + match impl_create_from_crypto_signer_inner(signer_arc) { + Ok(inner) => { + unsafe { + *out_factory = factory_inner_to_handle(inner); + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new( + "panic during factory creation from crypto signer", + FFI_ERR_PANIC, + ), + ); + FFI_ERR_PANIC + } + } +} + +/// Creates a factory with transparency providers. +/// +/// # Safety +/// +/// - `service` must be a valid signing service handle +/// - `providers` must be valid for reads of `providers_len` elements +/// - `out_factory` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_factories_free` +/// - Ownership of provider handles is transferred (caller must not free them) +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_create_with_transparency( + service: *const CoseSign1FactoriesSigningServiceHandle, + providers: *const *mut CoseSign1FactoriesTransparencyProviderHandle, + providers_len: usize, + out_factory: *mut *mut CoseSign1FactoriesHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_factory.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_factory")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_factory = ptr::null_mut(); + } + + let Some(service_inner) = (unsafe { signing_service_handle_to_inner(service) }) else { + set_error(out_error, ErrorInner::null_pointer("service")); + return FFI_ERR_NULL_POINTER; + }; + + if providers.is_null() && providers_len > 0 { + set_error(out_error, ErrorInner::null_pointer("providers")); + return FFI_ERR_NULL_POINTER; + } + + // Convert provider handles to Vec> + let mut provider_vec = Vec::new(); + if !providers.is_null() { + let providers_slice = unsafe { slice::from_raw_parts(providers, providers_len) }; + for &provider_handle in providers_slice { + if provider_handle.is_null() { + set_error( + out_error, + ErrorInner::new("provider handle must not be null", FFI_ERR_NULL_POINTER), + ); + return FFI_ERR_NULL_POINTER; + } + // Take ownership of the provider + let provider_inner = unsafe { + Box::from_raw(provider_handle as *mut crate::types::TransparencyProviderInner) + }; + provider_vec.push(provider_inner.provider); + } + } + + match impl_create_with_transparency_inner(service_inner, provider_vec) { + Ok(inner) => { + unsafe { + *out_factory = factory_inner_to_handle(inner); + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new( + "panic during factory creation with transparency", + FFI_ERR_PANIC, + ), + ); + FFI_ERR_PANIC + } + } +} + +/// Frees a factory handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle or NULL +/// - The handle must not be used after this call +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_free(factory: *mut CoseSign1FactoriesHandle) { + if factory.is_null() { + return; + } + unsafe { + drop(Box::from_raw(factory as *mut FactoryInner)); + } +} + +// ============================================================================ +// Direct signature functions +// ============================================================================ + +/// Signs payload with direct signature (embedded payload). +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during direct signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs payload with direct signature in detached mode (payload not embedded). +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_detached( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_detached_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during detached direct signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a file directly without loading it into memory (direct signature, detached). +/// +/// Creates a detached COSE_Sign1 signature over the file content. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `file_path` must be a valid null-terminated UTF-8 string +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_file( + factory: *const CoseSign1FactoriesHandle, + file_path: *const libc::c_char, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if file_path.is_null() { + set_error(out_error, ErrorInner::null_pointer("file_path")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; + let path_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_file_inner(factory_inner, path_str, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during file signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Callback type for streaming payload reading. +/// +/// The callback is invoked repeatedly with a buffer to fill. +/// Returns the number of bytes read (0 = EOF), or negative on error. +/// +/// # Safety +/// +/// - `buffer` must be valid for writes of `buffer_len` bytes +/// - `user_data` is the opaque pointer passed to the signing function +pub type CoseReadCallback = + unsafe extern "C" fn(buffer: *mut u8, buffer_len: usize, user_data: *mut libc::c_void) -> i64; + +/// Adapter for callback-based streaming payload. +pub struct CallbackStreamingPayload { + pub callback: CoseReadCallback, + pub user_data: *mut libc::c_void, + pub total_len: u64, +} + +// SAFETY: The callback is assumed to be thread-safe. +// FFI callers are responsible for ensuring thread safety. +unsafe impl Send for CallbackStreamingPayload {} +unsafe impl Sync for CallbackStreamingPayload {} + +impl cose_sign1_primitives::StreamingPayload for CallbackStreamingPayload { + fn size(&self) -> u64 { + self.total_len + } + + fn open( + &self, + ) -> Result< + Box, + cose_sign1_primitives::error::PayloadError, + > { + Ok(Box::new(CallbackReader { + callback: self.callback, + user_data: self.user_data, + total_len: self.total_len, + bytes_read: 0, + })) + } +} + +/// Reader implementation that wraps the callback. +pub struct CallbackReader { + pub callback: CoseReadCallback, + pub user_data: *mut libc::c_void, + pub total_len: u64, + pub bytes_read: u64, +} + +// SAFETY: The callback is assumed to be thread-safe. +// FFI callers are responsible for ensuring thread safety. +unsafe impl Send for CallbackReader {} + +impl std::io::Read for CallbackReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.bytes_read >= self.total_len { + return Ok(0); + } + + let remaining = (self.total_len - self.bytes_read) as usize; + let to_read = buf.len().min(remaining); + + let result = unsafe { (self.callback)(buf.as_mut_ptr(), to_read, self.user_data) }; + + if result < 0 { + return Err(std::io::Error::other(format!( + "callback read error: {}", + result + ))); + } + + let bytes_read = result as usize; + self.bytes_read += bytes_read as u64; + Ok(bytes_read) + } +} + +impl cose_sign1_primitives::sig_structure::SizedRead for CallbackReader { + fn len(&self) -> Result { + Ok(self.total_len) + } +} + +/// Signs a streaming payload with direct signature (detached). +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `read_callback` must be a valid function pointer +/// - `user_data` will be passed to the callback (can be NULL) +/// - `total_len` must be the total size of the payload +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_streaming( + factory: *const CoseSign1FactoriesHandle, + read_callback: CoseReadCallback, + user_data: *mut libc::c_void, + total_len: u64, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let payload = CallbackStreamingPayload { + callback: read_callback, + user_data, + total_len, + }; + + let payload_arc: Arc = Arc::new(payload); + + match impl_sign_direct_streaming_inner(factory_inner, payload_arc, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during streaming signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +// ============================================================================ +// Indirect signature functions +// ============================================================================ + +/// Signs payload with indirect signature (hash envelope). +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_indirect_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a file with indirect signature (hash envelope) without loading it into memory. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `file_path` must be a valid null-terminated UTF-8 string +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_file( + factory: *const CoseSign1FactoriesHandle, + file_path: *const libc::c_char, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if file_path.is_null() { + set_error(out_error, ErrorInner::null_pointer("file_path")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; + let path_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_indirect_file_inner(factory_inner, path_str, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect file signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a streaming payload with indirect signature (hash envelope). +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `read_callback` must be a valid function pointer +/// - `user_data` will be passed to the callback (can be NULL) +/// - `total_len` must be the total size of the payload +/// - `content_type` must be a valid null-terminated C string +/// - `out_cose_bytes` and `out_cose_len` must be valid for writes +/// - Caller must free the returned bytes with `cose_sign1_factories_bytes_free` +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_streaming( + factory: *const CoseSign1FactoriesHandle, + read_callback: CoseReadCallback, + user_data: *mut libc::c_void, + total_len: u64, + content_type: *const libc::c_char, + out_cose_bytes: *mut *mut u8, + out_cose_len: *mut u32, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_cose_bytes.is_null() || out_cose_len.is_null() { + set_error( + out_error, + ErrorInner::null_pointer("out_cose_bytes/out_cose_len"), + ); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_cose_bytes = ptr::null_mut(); + *out_cose_len = 0; + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let payload = CallbackStreamingPayload { + callback: read_callback, + user_data, + total_len, + }; + + let payload_arc: Arc = Arc::new(payload); + + match impl_sign_indirect_streaming_inner(factory_inner, payload_arc, content_type_str) { + Ok(bytes) => { + let len = bytes.len(); + let boxed = bytes.into_boxed_slice(); + let raw = Box::into_raw(boxed); + unsafe { + *out_cose_bytes = raw as *mut u8; + *out_cose_len = len as u32; + } + FFI_OK + } + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect streaming signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +// ============================================================================ +// Factory _to_message variants — return CoseSign1MessageHandle +// ============================================================================ + +/// Signs payload with direct signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_to_message( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during direct signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs payload with direct detached signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_detached_to_message( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_detached_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during detached direct signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a file directly, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `file_path` must be a valid null-terminated UTF-8 string +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_file_to_message( + factory: *const CoseSign1FactoriesHandle, + file_path: *const libc::c_char, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if file_path.is_null() { + set_error(out_error, ErrorInner::null_pointer("file_path")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; + let path_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_direct_file_inner(factory_inner, path_str, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during file signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a streaming payload with direct signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `read_callback` must be a valid function pointer +/// - `user_data` will be passed to the callback (can be NULL) +/// - `total_len` must be the total size of the payload +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_direct_streaming_to_message( + factory: *const CoseSign1FactoriesHandle, + read_callback: CoseReadCallback, + user_data: *mut libc::c_void, + total_len: u64, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let payload = CallbackStreamingPayload { + callback: read_callback, + user_data, + total_len, + }; + + let payload_arc: Arc = Arc::new(payload); + + match impl_sign_direct_streaming_inner(factory_inner, payload_arc, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during streaming signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs payload with indirect signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `payload` must be valid for reads of `payload_len` bytes +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_to_message( + factory: *const CoseSign1FactoriesHandle, + payload: *const u8, + payload_len: u32, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if payload.is_null() && payload_len > 0 { + set_error(out_error, ErrorInner::null_pointer("payload")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let payload_bytes = if payload.is_null() { + &[] as &[u8] + } else { + unsafe { slice::from_raw_parts(payload, payload_len as usize) } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_indirect_inner(factory_inner, payload_bytes, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a file with indirect signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `file_path` must be a valid null-terminated UTF-8 string +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_file_to_message( + factory: *const CoseSign1FactoriesHandle, + file_path: *const libc::c_char, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if file_path.is_null() { + set_error(out_error, ErrorInner::null_pointer("file_path")); + return FFI_ERR_NULL_POINTER; + } + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(file_path) }; + let path_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid file_path UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + match impl_sign_indirect_file_inner(factory_inner, path_str, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect file signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +/// Signs a streaming payload with indirect signature, returning an opaque message handle. +/// +/// # Safety +/// +/// - `factory` must be a valid factory handle +/// - `read_callback` must be a valid function pointer +/// - `user_data` will be passed to the callback (can be NULL) +/// - `total_len` must be the total size of the payload +/// - `content_type` must be a valid null-terminated C string +/// - `out_message` must be valid for writes +/// - Caller owns the returned handle and must free it with `cose_sign1_message_free` +#[no_mangle] +pub unsafe extern "C" fn cose_sign1_factories_sign_indirect_streaming_to_message( + factory: *const CoseSign1FactoriesHandle, + read_callback: CoseReadCallback, + user_data: *mut libc::c_void, + total_len: u64, + content_type: *const libc::c_char, + out_message: *mut *mut CoseSign1MessageHandle, + out_error: *mut *mut CoseSign1FactoriesErrorHandle, +) -> i32 { + let result = catch_unwind(AssertUnwindSafe(|| { + if out_message.is_null() { + set_error(out_error, ErrorInner::null_pointer("out_message")); + return FFI_ERR_NULL_POINTER; + } + + unsafe { + *out_message = ptr::null_mut(); + } + + let Some(factory_inner) = (unsafe { factory_handle_to_inner(factory) }) else { + set_error(out_error, ErrorInner::null_pointer("factory")); + return FFI_ERR_NULL_POINTER; + }; + + if content_type.is_null() { + set_error(out_error, ErrorInner::null_pointer("content_type")); + return FFI_ERR_NULL_POINTER; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(content_type) }; + let content_type_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + set_error( + out_error, + ErrorInner::new("invalid content_type UTF-8", FFI_ERR_INVALID_ARGUMENT), + ); + return FFI_ERR_INVALID_ARGUMENT; + } + }; + + let payload = CallbackStreamingPayload { + callback: read_callback, + user_data, + total_len, + }; + + let payload_arc: Arc = Arc::new(payload); + + match impl_sign_indirect_streaming_inner(factory_inner, payload_arc, content_type_str) { + Ok(bytes) => unsafe { write_signed_message(bytes, out_message, out_error) }, + Err(err) => { + set_error(out_error, err); + FFI_ERR_FACTORY_FAILED + } + } + })); + + match result { + Ok(code) => code, + Err(_) => { + set_error( + out_error, + ErrorInner::new("panic during indirect streaming signing", FFI_ERR_PANIC), + ); + FFI_ERR_PANIC + } + } +} + +// ============================================================================ +// Memory management functions +// ============================================================================ + +/// Frees COSE bytes allocated by factory functions. +/// +/// # Safety +/// +/// - `ptr` must have been returned by a factory signing function or be NULL +/// - `len` must be the length returned alongside the bytes +/// - The bytes must not be used after this call +#[no_mangle] +#[cfg_attr(coverage_nightly, coverage(off))] +pub unsafe extern "C" fn cose_sign1_factories_bytes_free(ptr: *mut u8, len: u32) { + if ptr.is_null() { + return; + } + unsafe { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ptr, + len as usize, + ))); + } +} + +// ============================================================================ +// Internal: Simple signing service implementation +// ============================================================================ + +/// Simple signing service that wraps a single key. +/// +/// Used to bridge between the key-based FFI and the factory pattern. +pub struct SimpleSigningService { + key: std::sync::Arc, + metadata: cose_sign1_signing::SigningServiceMetadata, +} + +impl SimpleSigningService { + pub fn new(key: std::sync::Arc) -> Self { + let metadata = cose_sign1_signing::SigningServiceMetadata::new( + "Simple Signing Service".to_string(), + "FFI-based signing service wrapping a CryptoSigner".to_string(), + ); + Self { key, metadata } + } +} + +impl cose_sign1_signing::SigningService for SimpleSigningService { + fn get_cose_signer( + &self, + _context: &cose_sign1_signing::SigningContext, + ) -> Result { + use cose_sign1_primitives::CoseHeaderMap; + + // Convert Arc to Box for the signer + let key_box: Box = Box::new(SimpleKeyWrapper { + key: self.key.clone(), + }); + + // Create a CoseSigner with empty header maps + let signer = cose_sign1_signing::CoseSigner::new( + key_box, + CoseHeaderMap::new(), + CoseHeaderMap::new(), + ); + Ok(signer) + } + + fn is_remote(&self) -> bool { + false + } + + fn service_metadata(&self) -> &cose_sign1_signing::SigningServiceMetadata { + &self.metadata + } + + fn verify_signature( + &self, + _message_bytes: &[u8], + _context: &cose_sign1_signing::SigningContext, + ) -> Result { + // Simple service doesn't support verification + Ok(true) + } +} + +/// Wrapper to convert Arc to Box. +pub struct SimpleKeyWrapper { + pub key: std::sync::Arc, +} + +impl CryptoSigner for SimpleKeyWrapper { + fn sign(&self, data: &[u8]) -> Result, cose_sign1_primitives::CryptoError> { + self.key.sign(data) + } + + fn algorithm(&self) -> i64 { + self.key.algorithm() + } + + fn key_type(&self) -> &str { + self.key.key_type() + } + + fn key_id(&self) -> Option<&[u8]> { + self.key.key_id() + } + + fn supports_streaming(&self) -> bool { + self.key.supports_streaming() + } + + fn sign_init( + &self, + ) -> Result, cose_sign1_primitives::CryptoError> + { + self.key.sign_init() + } +} diff --git a/native/rust/signing/headers/Cargo.toml b/native/rust/signing/headers/Cargo.toml index 1ddd9a4d..e538ca67 100644 --- a/native/rust/signing/headers/Cargo.toml +++ b/native/rust/signing/headers/Cargo.toml @@ -17,5 +17,8 @@ did_x509 = { path = "../../did/x509" } [dev-dependencies] cbor_primitives_everparse = { path = "../../primitives/cbor/everparse" } -[lints.rust] +[[example]] +name = "cwt_claims_basics" + +[lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } diff --git a/native/rust/signing/headers/README.md b/native/rust/signing/headers/README.md new file mode 100644 index 00000000..641b38a0 --- /dev/null +++ b/native/rust/signing/headers/README.md @@ -0,0 +1,165 @@ + + +# cose_sign1_headers + +CWT (CBOR Web Token) claims and header management for COSE_Sign1 messages. + +## Overview + +This crate provides CWT Claims support as defined in +[RFC 8392](https://www.rfc-editor.org/rfc/rfc8392) with +[SCITT](https://datatracker.ietf.org/wg/scitt/about/) compliance. It is a +port of the V2 `CoseSign1.Headers` package and supplies the types needed to +attach structured claims to COSE_Sign1 protected headers. + +Key capabilities: + +- **CWT Claims builder** — Fluent construction of standard and custom claims + (issuer, subject, audience, expiration, etc.) +- **SCITT-compliant defaults** — Default subject `"unknown.intent"` per the + SCITT specification +- **Header contributor** — `CwtClaimsHeaderContributor` implements the + `HeaderContributor` trait to inject claims into protected headers at label 15 +- **Multi-value claim types** — `CwtClaimValue` supports text, integers, byte + strings, booleans, and floats +- **FFI projection** — Companion `cose_sign1_headers_ffi` crate exposes the + full API over C-ABI + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ cose_sign1_headers │ +├──────────────┬──────────────┬────────────────────────┤ +│ cwt_claims │ cwt_claims_ │ cwt_claims_header_ │ +│ │ labels │ contributor │ +│ ┌──────────┐ │ ┌──────────┐ │ ┌────────────────────┐ │ +│ │CwtClaims │ │ │ISSUER │ │ │CwtClaimsHeader │ │ +│ │CwtClaim │ │ │SUBJECT │ │ │ Contributor │ │ +│ │ Value │ │ │AUDIENCE │ │ │ (HeaderContributor) │ │ +│ └──────────┘ │ │EXP / NBF │ │ └────────────────────┘ │ +│ │ │IAT / CID │ │ │ +│ │ └──────────┘ │ │ +├──────────────┴──────────────┴────────────────────────┤ +│ error (HeaderError) │ +└──────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + cose_sign1_signing cose_sign1_primitives + (HeaderContributor) (CoseHeaderMap) + │ + ▼ + cbor_primitives + (CBOR encode/decode) +``` + +## Modules + +| Module | Description | +|--------|-------------| +| `cwt_claims` | `CwtClaims` builder and `CwtClaimValue` enum — fluent claim construction with CBOR serialization | +| `cwt_claims_labels` | Constants for standard CWT claim labels per RFC 8392 (issuer = 1, subject = 2, etc.) | +| `cwt_claims_header_contributor` | `CwtClaimsHeaderContributor` — injects CWT claims into protected headers at label 15 | +| `cwt_claims_contributor` | Lower-level claim contributor utilities | +| `error` | `HeaderError` — CBOR encoding/decoding and claim validation errors | + +## Key Types + +### CwtClaims + +Structured CBOR Web Token claims with builder methods: + +```rust +use cose_sign1_headers::CwtClaims; + +let claims = CwtClaims::new() + .with_issuer("did:x509:0:sha256:abc123::subject:CN:My Issuer") + .with_subject("my.artifact.intent") + .with_issued_at(1700000000) + .with_expiration_time(1700086400); + +// Serialize to CBOR bytes for embedding in COSE headers +let cbor_bytes = claims.to_cbor_bytes()?; +``` + +### CwtClaimValue + +Multi-type claim values for standard and custom claims: + +```rust +use cose_sign1_headers::CwtClaimValue; + +let text_val = CwtClaimValue::Text("example".into()); +let int_val = CwtClaimValue::Int(42); +let bytes_val = CwtClaimValue::Bytes(vec![0xDE, 0xAD]); +let bool_val = CwtClaimValue::Bool(true); +let float_val = CwtClaimValue::Float(3.14); +``` + +### CwtClaimsHeaderContributor + +Implements `HeaderContributor` to inject CWT claims into COSE protected +headers: + +```rust +use cose_sign1_headers::CwtClaimsHeaderContributor; +use cose_sign1_signing::HeaderContributor; + +let claims = CwtClaims::new() + .with_issuer("did:x509:...") + .with_subject("my.intent"); + +let contributor = CwtClaimsHeaderContributor::new(claims); + +// Used by the signing pipeline — injects claims at protected header label 15 +// with a Replace merge strategy +contributor.contribute_protected_headers(&mut headers, &context); +``` + +### Standard Claim Labels + +```rust +use cose_sign1_headers::cwt_claims_labels::*; + +assert_eq!(ISSUER, 1); +assert_eq!(SUBJECT, 2); +assert_eq!(AUDIENCE, 3); +assert_eq!(EXPIRATION_TIME, 4); +assert_eq!(NOT_BEFORE, 5); +assert_eq!(ISSUED_AT, 6); +assert_eq!(CWT_ID, 7); +``` + +## Memory Design + +- **Owned claim values**: `CwtClaims` owns its claim data as `String` / `Vec` + / primitive types. Claims are typically small and constructed once per signing + operation. +- **CBOR serialization**: Claims serialize to a compact CBOR map. The serialized + bytes are embedded directly into the protected header at label 15 — no + intermediate copies. +- **Header contributor pattern**: The contributor borrows the `CwtClaims` via + `Arc` so multiple signers can share the same claims without cloning. + +## Dependencies + +- `cose_sign1_primitives` — Core COSE header types +- `cose_sign1_signing` — `HeaderContributor` trait +- `cbor_primitives` — CBOR encoding/decoding +- `did_x509` — DID:X509 issuer generation for SCITT compliance + +## FFI + +The companion [`cose_sign1_headers_ffi`](ffi/) crate exposes this +functionality over C-ABI with opaque handle types and thread-local error +reporting. See the FFI crate for C/C++ integration details. + +## See Also + +- [signing/core/](../core/) — `HeaderContributor` trait and signing pipeline +- [extension_packs/certificates/](../../extension_packs/certificates/) — Certificate trust pack that uses CWT claims for SCITT +- [did/x509/](../../did/x509/) — DID:X509 identifier utilities + +## License + +Licensed under the [MIT License](../../../../LICENSE). \ No newline at end of file diff --git a/native/rust/signing/headers/examples/cwt_claims_basics.rs b/native/rust/signing/headers/examples/cwt_claims_basics.rs new file mode 100644 index 00000000..0a0592b3 --- /dev/null +++ b/native/rust/signing/headers/examples/cwt_claims_basics.rs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CWT (CBOR Web Token) claims builder — construct, serialize, and +//! deserialize CWT claims for COSE protected headers. +//! +//! Run with: +//! cargo run --example cwt_claims_basics -p cose_sign1_headers + +use cose_sign1_headers::{CWTClaimsHeaderLabels, CwtClaimValue, CwtClaims}; + +fn main() { + // ── 1. Build CWT claims using the fluent API ───────────────────── + println!("=== Step 1: Build CWT claims ===\n"); + + let claims = CwtClaims::new() + .with_issuer("https://example.com/issuer") + .with_subject("software-artifact-v2.1") + .with_audience("https://transparency.example.com") + .with_issued_at(1_700_000_000) // 2023-11-14T22:13:20Z + .with_not_before(1_700_000_000) + .with_expiration_time(1_731_536_000) // ~1 year later + .with_cwt_id(b"unique-claim-id-001".to_vec()) + .with_custom_claim(100, CwtClaimValue::Text("build-pipeline-A".into())) + .with_custom_claim(101, CwtClaimValue::Integer(42)) + .with_custom_claim(102, CwtClaimValue::Bool(true)); + + println!(" Issuer: {:?}", claims.issuer); + println!(" Subject: {:?}", claims.subject); + println!(" Audience: {:?}", claims.audience); + println!(" Issued At: {:?}", claims.issued_at); + println!(" Not Before: {:?}", claims.not_before); + println!(" Expires: {:?}", claims.expiration_time); + println!( + " CWT ID: {:?}", + claims.cwt_id.as_ref().map(|b| String::from_utf8_lossy(b)) + ); + println!(" Custom: {} claim(s)", claims.custom_claims.len()); + + // ── 2. Serialize to CBOR bytes ─────────────────────────────────── + println!("\n=== Step 2: Serialize to CBOR ===\n"); + + let cbor_bytes = claims.to_cbor_bytes().expect("CBOR serialization"); + println!(" CBOR size: {} bytes", cbor_bytes.len()); + println!(" CBOR hex: {}", to_hex(&cbor_bytes)); + + // ── 3. Deserialize back from CBOR ──────────────────────────────── + println!("\n=== Step 3: Deserialize from CBOR ===\n"); + + let decoded = CwtClaims::from_cbor_bytes(&cbor_bytes).expect("CBOR deserialization"); + assert_eq!(decoded.issuer, claims.issuer); + assert_eq!(decoded.subject, claims.subject); + assert_eq!(decoded.audience, claims.audience); + assert_eq!(decoded.expiration_time, claims.expiration_time); + assert_eq!(decoded.not_before, claims.not_before); + assert_eq!(decoded.issued_at, claims.issued_at); + assert_eq!(decoded.cwt_id, claims.cwt_id); + assert_eq!(decoded.custom_claims.len(), claims.custom_claims.len()); + + println!(" Round-trip: all fields match ✓"); + println!(" Decoded issuer: {:?}", decoded.issuer); + println!(" Decoded subject: {:?}", decoded.subject); + + // ── 4. Show the CWT Claims header label ────────────────────────── + println!("\n=== Step 4: Header integration info ===\n"); + + println!( + " CWT Claims is placed in protected header label {}", + CWTClaimsHeaderLabels::CWT_CLAIMS_HEADER + ); + println!(" Standard claim labels:"); + println!(" Issuer (iss): {}", CWTClaimsHeaderLabels::ISSUER); + println!(" Subject (sub): {}", CWTClaimsHeaderLabels::SUBJECT); + println!(" Audience (aud): {}", CWTClaimsHeaderLabels::AUDIENCE); + println!( + " Expiration (exp): {}", + CWTClaimsHeaderLabels::EXPIRATION_TIME + ); + println!( + " Not Before (nbf): {}", + CWTClaimsHeaderLabels::NOT_BEFORE + ); + println!(" Issued At (iat): {}", CWTClaimsHeaderLabels::ISSUED_AT); + println!(" CWT ID (cti): {}", CWTClaimsHeaderLabels::CWT_ID); + + // ── 5. Build minimal claims (SCITT default subject) ────────────── + println!("\n=== Step 5: Minimal SCITT claims ===\n"); + + let minimal = CwtClaims::new() + .with_subject(CwtClaims::DEFAULT_SUBJECT) + .with_issuer("did:x509:0:sha256:example::eku:1.3.6.1.5.5.7.3.3"); + + let minimal_bytes = minimal.to_cbor_bytes().expect("minimal CBOR"); + println!(" Default subject: {:?}", CwtClaims::DEFAULT_SUBJECT); + println!(" Minimal CBOR: {} bytes", minimal_bytes.len()); + + let roundtrip = CwtClaims::from_cbor_bytes(&minimal_bytes).expect("minimal decode"); + assert_eq!( + roundtrip.subject.as_deref(), + Some(CwtClaims::DEFAULT_SUBJECT) + ); + println!(" Minimal round-trip: ✓"); + + println!("\n=== All steps completed successfully! ==="); +} + +/// Simple hex encoder for display purposes. +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} diff --git a/native/rust/signing/headers/ffi/Cargo.toml b/native/rust/signing/headers/ffi/Cargo.toml index 9d5efc72..b3065850 100644 --- a/native/rust/signing/headers/ffi/Cargo.toml +++ b/native/rust/signing/headers/ffi/Cargo.toml @@ -3,7 +3,7 @@ name = "cose_sign1_headers_ffi" version = "0.1.0" edition = { workspace = true } license = { workspace = true } -rust-version = "1.70" +rust-version = "1.80" description = "C/C++ FFI for COSE Sign1 CWT Claims. Provides CWT Claims creation, serialization, and deserialization for C/C++ consumers." [lib] diff --git a/native/rust/signing/headers/ffi/README.md b/native/rust/signing/headers/ffi/README.md new file mode 100644 index 00000000..f57f52fd --- /dev/null +++ b/native/rust/signing/headers/ffi/README.md @@ -0,0 +1,55 @@ + + +# cose_sign1_headers_ffi + +C/C++ FFI projection for CWT (CBOR Web Token) Claims operations. + +## Overview + +This crate provides C-compatible FFI exports for creating and managing CWT Claims from +C and C++ code. It supports building claims with standard fields (issuer, subject, audience, +issued-at, not-before, expiration), serializing to/from CBOR, and extracting individual fields. + +## Exported Functions + +| Function | Description | +|----------|-------------| +| `cose_cwt_claims_abi_version` | ABI version check | +| `cose_cwt_claims_create` | Create a new empty CWT claims set | +| `cose_cwt_claims_set_issuer` | Set the `iss` claim | +| `cose_cwt_claims_set_subject` | Set the `sub` claim | +| `cose_cwt_claims_set_audience` | Set the `aud` claim | +| `cose_cwt_claims_set_issued_at` | Set the `iat` claim | +| `cose_cwt_claims_set_not_before` | Set the `nbf` claim | +| `cose_cwt_claims_set_expiration` | Set the `exp` claim | +| `cose_cwt_claims_to_cbor` | Serialize claims to CBOR bytes | +| `cose_cwt_claims_from_cbor` | Deserialize claims from CBOR bytes | +| `cose_cwt_claims_get_issuer` | Get the `iss` claim value | +| `cose_cwt_claims_get_subject` | Get the `sub` claim value | +| `cose_cwt_claims_free` | Free a CWT claims handle | +| `cose_cwt_error_message` | Get error description string | +| `cose_cwt_error_code` | Get error code | +| `cose_cwt_error_free` | Free an error handle | +| `cose_cwt_string_free` | Free a string returned by this library | +| `cose_cwt_bytes_free` | Free a byte buffer returned by this library | + +## Handle Types + +| Type | Description | +|------|-------------| +| `CoseCwtClaimsHandle` | Opaque CWT claims builder/container | +| `CoseCwtErrorHandle` | Opaque error handle | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_headers`](../../headers/) — CWT Claims implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_headers_ffi +``` diff --git a/native/rust/signing/headers/ffi/src/lib.rs b/native/rust/signing/headers/ffi/src/lib.rs index b871ccc6..f372bded 100644 --- a/native/rust/signing/headers/ffi/src/lib.rs +++ b/native/rust/signing/headers/ffi/src/lib.rs @@ -5,31 +5,45 @@ #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! C/C++ FFI for COSE Sign1 CWT Claims operations. +//! C-ABI projection for `cose_sign1_headers`. //! -//! This crate (`cose_sign1_headers_ffi`) provides FFI-safe wrappers for creating and managing -//! CWT (CBOR Web Token) Claims from C and C++ code. It uses `cose_sign1_headers` for types and -//! `cbor_primitives_everparse` for CBOR encoding/decoding. +//! This crate provides C-compatible FFI exports for creating and managing CWT +//! (CBOR Web Token) Claims from C and C++ code. It wraps the `cose_sign1_headers` +//! crate and uses `cbor_primitives_everparse` for CBOR encoding/decoding. //! -//! ## Error Handling +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_cwt_claims_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling //! //! All functions follow a consistent error handling pattern: //! - Return value: 0 = success, negative = error code //! - `out_error` parameter: Set to error handle on failure (caller must free) //! - Output parameters: Only valid if return is 0 //! -//! ## Memory Management +//! # Memory Ownership //! -//! Handles returned by this library must be freed using the corresponding `*_free` function: -//! - `cose_cwt_claims_free` for CWT claims handles -//! - `cose_cwt_error_free` for error handles -//! - `cose_cwt_string_free` for string pointers -//! - `cose_cwt_bytes_free` for byte buffer pointers +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_cwt_claims_free` for CWT claims handles +//! - `cose_cwt_error_free` for error handles +//! - `cose_cwt_string_free` for string pointers +//! - `cose_cwt_bytes_free` for byte buffer pointers //! -//! ## Thread Safety +//! # Thread Safety //! -//! All handles are thread-safe and can be used from multiple threads. However, handles -//! are not internally synchronized, so concurrent mutation requires external synchronization. +//! All functions are thread-safe. Handles are not internally synchronized, +//! so concurrent mutation requires external synchronization. pub mod error; pub mod provider; diff --git a/native/rust/validation/core/ffi/README.md b/native/rust/validation/core/ffi/README.md new file mode 100644 index 00000000..23b6cd29 --- /dev/null +++ b/native/rust/validation/core/ffi/README.md @@ -0,0 +1,54 @@ + + +# cose_sign1_validation_ffi + +C/C++ FFI projection for COSE_Sign1 message validation. + +## Overview + +This is the base validation FFI crate that exposes the core validator builder, validator runner, +and validation result types. Pack-specific functionality (X.509 certificates, MST, Azure Key Vault, +trust policy authoring) lives in separate FFI crates that extend the validator builder exposed here. + +This crate also exports shared infrastructure used by extension pack FFI crates: `cose_status_t`, +`with_catch_unwind`, thread-local error state, and the opaque validator builder/policy builder types. + +## Exported Functions + +| Function | Description | +|----------|-------------| +| `cose_sign1_validation_abi_version` | ABI version check | +| `cose_last_error_message_utf8` | Get thread-local error message | +| `cose_last_error_clear` | Clear thread-local error state | +| `cose_string_free` | Free a string returned by this library | +| `cose_sign1_validator_builder_new` | Create a new validator builder | +| `cose_sign1_validator_builder_free` | Free a validator builder | +| `cose_sign1_validator_builder_build` | Build a validator from the builder | +| `cose_sign1_validator_free` | Free a validator | +| `cose_sign1_validator_validate_bytes` | Validate a COSE_Sign1 message from bytes | +| `cose_sign1_validation_result_is_success` | Check if validation succeeded | +| `cose_sign1_validation_result_failure_message_utf8` | Get validation failure message | +| `cose_sign1_validation_result_free` | Free a validation result | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_sign1_validator_builder_t` | Opaque validator builder (extended by pack FFI crates) | +| `cose_sign1_validator_t` | Opaque compiled validator | +| `cose_sign1_validation_result_t` | Opaque validation result | +| `cose_trust_policy_builder_t` | Opaque trust policy builder (used by pack FFI crates) | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_validation`](../../core/) — COSE_Sign1 validation implementation. + +## Build + +```bash +cargo build --release -p cose_sign1_validation_ffi +``` diff --git a/native/rust/validation/core/ffi/src/lib.rs b/native/rust/validation/core/ffi/src/lib.rs index 1ca7341d..d656fb7a 100644 --- a/native/rust/validation/core/ffi/src/lib.rs +++ b/native/rust/validation/core/ffi/src/lib.rs @@ -1,10 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -//! Base FFI crate for COSE Sign1 validation. +//! C-ABI projection for `cose_sign1_validation`. +//! +//! This crate provides C-compatible FFI exports for COSE_Sign1 message validation. +//! It is the base validation FFI crate that exposes the core validator builder, +//! validator runner, and validation result types. Pack-specific functionality +//! (X.509 certificates, MST, Azure Key Vault, trust policy authoring) lives in +//! separate FFI crates that extend the validator builder exposed here. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! The ABI version is available via `cose_sign1_validation_abi_version()`. +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for a thread-local +//! error description. Call `cose_last_error_clear()` to reset error state. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_sign1_validator_builder_free` for builder handles +//! - `cose_sign1_validator_free` for validator handles +//! - `cose_sign1_validation_result_free` for result handles +//! - `cose_string_free` for error message strings +//! +//! # Thread Safety //! -//! This crate provides the core validator types and error-handling infrastructure. -//! Pack-specific functionality (X.509, MST, AKV, trust policy) lives in separate FFI crates. +//! All functions are thread-safe. Error state is thread-local. pub mod provider; diff --git a/native/rust/validation/primitives/Cargo.toml b/native/rust/validation/primitives/Cargo.toml index 7a24b938..1083184f 100644 --- a/native/rust/validation/primitives/Cargo.toml +++ b/native/rust/validation/primitives/Cargo.toml @@ -21,7 +21,6 @@ regex = { workspace = true, optional = true } anyhow.workspace = true cbor_primitives = { path = "../../primitives/cbor" } cbor_primitives_everparse = { path = "../../primitives/cbor/everparse" } -once_cell.workspace = true - + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } diff --git a/native/rust/validation/primitives/examples/trust_plan_minimal.rs b/native/rust/validation/primitives/examples/trust_plan_minimal.rs index f50a3f88..cd52f7b3 100644 --- a/native/rust/validation/primitives/examples/trust_plan_minimal.rs +++ b/native/rust/validation/primitives/examples/trust_plan_minimal.rs @@ -8,9 +8,9 @@ use cose_sign1_validation_primitives::policy::TrustPolicyBuilder; use cose_sign1_validation_primitives::rules::FnRule; use cose_sign1_validation_primitives::subject::TrustSubject; use cose_sign1_validation_primitives::TrustDecision; -use once_cell::sync::Lazy; use std::borrow::Cow; use std::sync::Arc; +use std::sync::LazyLock; #[derive(Debug)] struct ExampleFact { @@ -42,7 +42,7 @@ impl TrustFactProducer for ExampleProducer { } fn provides(&self) -> &'static [FactKey] { - static PROVIDED: Lazy<[FactKey; 1]> = Lazy::new(|| [FactKey::of::()]); + static PROVIDED: LazyLock<[FactKey; 1]> = LazyLock::new(|| [FactKey::of::()]); &*PROVIDED } } diff --git a/native/rust/validation/primitives/ffi/README.md b/native/rust/validation/primitives/ffi/README.md new file mode 100644 index 00000000..3edd9a6f --- /dev/null +++ b/native/rust/validation/primitives/ffi/README.md @@ -0,0 +1,78 @@ + + +# cose_sign1_validation_primitives_ffi + +C/C++ FFI projection for trust plan and trust policy authoring. + +## Overview + +This crate exposes a C ABI for composing compiled trust plans and trust policies, then +attaching them to a validator builder. It enables per-pack modularity: packs (certificates, +MST, AKV) remain separate crates, and trust-plan authoring is exposed as a reusable layer +that works across all packs. + +### Trust Plan Builder + +Compiles a bundled trust plan by composing the default plans provided by configured trust packs. +Supports OR/AND composition, allow-all, and deny-all strategies. + +### Trust Policy Builder + +Provides declarative rule authoring for CWT claims constraints, content type requirements, +detached payload presence, and counter-signature envelope integrity. + +## Exported Functions + +### Trust Plan Builder + +| Function | Description | +|----------|-------------| +| `cose_sign1_trust_plan_builder_new_from_validator_builder` | Create plan builder from validator builder | +| `cose_sign1_trust_plan_builder_free` | Free a trust plan builder | +| `cose_sign1_trust_plan_builder_add_all_pack_default_plans` | Add all pack default plans | +| `cose_sign1_trust_plan_builder_add_pack_default_plan_by_name` | Add a specific pack's default plan | +| `cose_sign1_trust_plan_builder_pack_count` | Get number of registered packs | +| `cose_sign1_trust_plan_builder_pack_name_utf8` | Get pack name by index | +| `cose_sign1_trust_plan_builder_pack_has_default_plan` | Check if pack has default plan | +| `cose_sign1_trust_plan_builder_clear_selected_plans` | Clear selected plans | +| `cose_sign1_trust_plan_builder_compile_or` | Compile with OR composition | +| `cose_sign1_trust_plan_builder_compile_and` | Compile with AND composition | +| `cose_sign1_trust_plan_builder_compile_allow_all` | Compile allow-all plan | +| `cose_sign1_trust_plan_builder_compile_deny_all` | Compile deny-all plan | +| `cose_sign1_compiled_trust_plan_free` | Free a compiled trust plan | +| `cose_sign1_validator_builder_with_compiled_trust_plan` | Attach compiled plan to validator builder | + +### Trust Policy Builder + +| Function | Description | +|----------|-------------| +| `cose_sign1_trust_policy_builder_new_from_validator_builder` | Create policy builder from validator builder | +| `cose_sign1_trust_policy_builder_free` | Free a trust policy builder | +| `cose_sign1_trust_policy_builder_and` | Combine policies with AND | +| `cose_sign1_trust_policy_builder_or` | Combine policies with OR | +| `cose_sign1_trust_policy_builder_compile` | Compile the policy | +| `cose_sign1_trust_policy_builder_require_content_type_*` | Content type constraints | +| `cose_sign1_trust_policy_builder_require_detached_payload_*` | Detached payload constraints | +| `cose_sign1_trust_policy_builder_require_counter_signature_*` | Counter-signature constraints | +| `cose_sign1_trust_policy_builder_require_cwt_*` | CWT claims constraints (~25 functions) | + +## Handle Types + +| Type | Description | +|------|-------------| +| `cose_sign1_trust_plan_builder_t` | Opaque trust plan builder | +| `cose_sign1_compiled_trust_plan_t` | Opaque compiled trust plan | + +## C Header + +`` + +## Parent Library + +[`cose_sign1_validation_primitives`](../../primitives/) — Trust policy and plan primitives. + +## Build + +```bash +cargo build --release -p cose_sign1_validation_primitives_ffi +``` diff --git a/native/rust/validation/primitives/ffi/src/lib.rs b/native/rust/validation/primitives/ffi/src/lib.rs index 533f5621..5093feed 100644 --- a/native/rust/validation/primitives/ffi/src/lib.rs +++ b/native/rust/validation/primitives/ffi/src/lib.rs @@ -1,18 +1,50 @@ -//! Trust policy authoring FFI bindings. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! C-ABI projection for `cose_sign1_validation_primitives`. +//! +//! This crate provides C-compatible FFI exports for trust policy and trust plan +//! authoring. It exposes a C ABI for composing compiled trust plans from the +//! default plans provided by configured trust packs and attaching them to a +//! validator builder. //! -//! This crate exposes a C ABI for authoring a bundled compiled trust plan and attaching it -//! to a validator builder. +//! # Architecture //! //! Design goal: per-pack modularity. //! - Packs (certificates/MST/AKV/...) remain separate crates and can be added to the base //! `cose_sign1_validator_builder_t` independently. //! - Trust-plan authoring is exposed as a separate pack (`cose_sign1_validation_primitives_ffi`). +//! - Future expansions can add declarative rule/predicate authoring in a stable way. +//! +//! # ABI Stability +//! +//! All exported functions use `extern "C"` calling convention. +//! Opaque handle types are passed as `*mut` (owned) or `*const` (borrowed). +//! +//! # Panic Safety +//! +//! All exported functions are wrapped in `catch_unwind` to prevent +//! Rust panics from crossing the FFI boundary. +//! +//! # Error Handling +//! +//! Functions return `cose_status_t` (0 = OK, non-zero = error). +//! On error, call `cose_last_error_message_utf8()` for details. +//! Error state is thread-local and safe for concurrent use. +//! +//! # Memory Ownership +//! +//! - `*mut T` parameters transfer ownership TO this function (consumed) +//! - `*const T` parameters are borrowed (caller retains ownership) +//! - `*mut *mut T` out-parameters transfer ownership FROM this function (caller must free) +//! - Every handle type has a corresponding `*_free()` function: +//! - `cose_sign1_trust_plan_builder_free` for trust plan builder handles +//! - `cose_sign1_compiled_trust_plan_free` for compiled trust plan handles +//! - `cose_sign1_trust_policy_builder_free` for trust policy builder handles //! -//! Current scope (M3 foundation): compile a bundled plan by composing the *default trust plans* -//! provided by configured trust packs. This is the minimal, deterministic authoring surface that -//! works well across C and C++. +//! # Thread Safety //! -//! Future expansions can add declarative rule/predicate authoring in a stable way. +//! All functions are thread-safe. Error state is thread-local. #![deny(unsafe_op_in_unsafe_fn)] #![allow(clippy::not_unsafe_ptr_arg_deref)] diff --git a/native/rust/validation/test_utils/README.md b/native/rust/validation/test_utils/README.md new file mode 100644 index 00000000..6f887e29 --- /dev/null +++ b/native/rust/validation/test_utils/README.md @@ -0,0 +1,134 @@ + + +# cose_sign1_validation_test_utils + +Test-only utilities for composing COSE_Sign1 validation scenarios. + +## Overview + +This crate provides lightweight helper types for assembling trust packs and +validation pipelines in tests **without** pulling in a full extension pack. +It exists to keep the production `cose_sign1_validation` API surface focused +while enabling concise, flexible test composition. + +Key capabilities: + +- **`SimpleTrustPack`** — Builder-pattern trust pack that implements + `CoseSign1TrustPack`, composable from any combination of fact producers, + key resolvers, post-signature validators, and default trust plans +- **`NoopTrustFactProducer`** — A no-op `TrustFactProducer` that produces + zero facts, useful as a placeholder when fact production is irrelevant to + the test + +## Architecture + +``` +┌────────────────────────────────────────────┐ +│ cose_sign1_validation_test_utils │ +│ │ +│ ┌──────────────────┐ ┌────────────────┐ │ +│ │ SimpleTrustPack │ │ NoopTrustFact │ │ +│ │ │ │ Producer │ │ +│ │ • fact_producer │ │ (produces ∅) │ │ +│ │ • key_resolvers │ └────────────────┘ │ +│ │ • post_sig_vals │ │ +│ │ • default_plan │ │ +│ └──────────────────┘ │ +└────────────────────────────────────────────┘ + │ │ + ▼ ▼ + cose_sign1_validation cose_sign1_validation_primitives + (CoseSign1TrustPack, (TrustFactProducer, FactKey, + CoseKeyResolver, CompiledTrustPlan) + PostSignatureValidator) +``` + +## Key Types + +### SimpleTrustPack + +A convenience `CoseSign1TrustPack` implementation for tests. Start with +`no_facts()` and layer on only the components the test requires: + +```rust +use cose_sign1_validation_test_utils::SimpleTrustPack; +use std::sync::Arc; + +// Minimal pack — no facts, no resolvers, no plan +let pack = SimpleTrustPack::no_facts("test-pack"); + +// Composed pack — custom producer + resolver + plan +let pack = SimpleTrustPack::no_facts("cert-test") + .with_fact_producer(Arc::new(my_producer)) + .with_cose_key_resolver(Arc::new(my_resolver)) + .with_default_trust_plan(my_compiled_plan); +``` + +### NoopTrustFactProducer + +A `TrustFactProducer` that does nothing — useful when a test needs a trust +pack but does not care about fact production: + +```rust +use cose_sign1_validation_test_utils::NoopTrustFactProducer; + +let producer = NoopTrustFactProducer::default(); +assert_eq!(producer.name(), "noop"); +assert!(producer.provides().is_empty()); +``` + +## Usage in Tests + +Typical pattern for building a validator with a custom trust plan: + +```rust +use cose_sign1_validation::fluent::*; +use cose_sign1_validation_test_utils::SimpleTrustPack; +use std::sync::Arc; + +// Build a trust pack with a custom key resolver +let pack = Arc::new( + SimpleTrustPack::no_facts("roundtrip") + .with_cose_key_resolver(Arc::new(my_key_resolver)) + .with_default_trust_plan(compiled_plan), +); + +// Use the pack in a validator +let validator = ValidatorBuilder::new() + .with_trust_pack(pack) + .build()?; + +let result = validator.validate(&cose_bytes, None)?; +``` + +## Memory Design + +- **`Arc`-based composition**: All components (producers, resolvers, validators) + are held as `Arc`, matching the ownership model of the production + `CoseSign1TrustPack` trait. +- **Clone-friendly**: `SimpleTrustPack` derives `Clone` so the same pack can be + shared across multiple validators in a test without rebuilding. +- **No heap overhead beyond `Arc` bumps**: Calling `.clone()` on a + `SimpleTrustPack` increments reference counts — it does not deep-copy + producers or resolvers. + +## Dependencies + +- `cose_sign1_validation` — `CoseSign1TrustPack`, `CoseKeyResolver`, `PostSignatureValidator` +- `cose_sign1_validation_primitives` — `TrustFactProducer`, `FactKey`, `CompiledTrustPlan` + +## Note + +This crate is **test-only**. It is compiled with `test = false` in its own +`Cargo.toml` (no self-tests) and is intended to be a `[dev-dependencies]` +entry in consumer crates. + +## See Also + +- [validation/core/](../core/) — Production validation framework +- [validation/primitives/](../primitives/) — Trust fact and plan types +- [extension_packs/certificates/](../../extension_packs/certificates/) — Real-world trust pack example + +## License + +Licensed under the [MIT License](../../../../LICENSE). \ No newline at end of file