diff --git a/.editorconfig b/.editorconfig index 722ed12..0075207 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,3 @@ indent_style = tab [*.{yaml,yml}] indent_style = space indent_size = 2 - -[*.sh] -indent_style = tab diff --git a/.gitattributes b/.gitattributes index e9c751a..5360ba2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,6 @@ *.go text eol=lf *.mod text eol=lf *.sum text eol=lf -*.sh text eol=lf *.yaml text eol=lf *.yml text eol=lf LICENSE text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9386ce..349d651 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,7 @@ * @alex-au-922 @DebugTsang @sammyfung /.github/ @alex-au-922 @DebugTsang @sammyfung -/.goreleaser.yaml @alex-au-922 @DebugTsang @sammyfung /cmd/ @alex-au-922 @DebugTsang @sammyfung /internal/ @alex-au-922 @DebugTsang @sammyfung -/scripts/release/ @alex-au-922 @DebugTsang @sammyfung /go.mod @alex-au-922 @DebugTsang @sammyfung /go.sum @alex-au-922 @DebugTsang @sammyfung diff --git a/.github/actions/setup-mise/action.yml b/.github/actions/setup-mise/action.yml new file mode 100644 index 0000000..b689cd9 --- /dev/null +++ b/.github/actions/setup-mise/action.yml @@ -0,0 +1,18 @@ +name: Set up pinned mise +description: Install the checksum-pinned mise binary allowed by this repository's Actions policy. + +runs: + using: composite + steps: + - name: Install mise + shell: bash + run: | + set -Eeuo pipefail + binary="$RUNNER_TEMP/mise" + curl --fail --location --retry 3 --output "$binary" \ + https://github.com/jdx/mise/releases/download/v2026.6.14/mise-v2026.6.14-linux-x64 + printf '%s %s\n' \ + 96ae1ef7b00a6ebbbec23ba1016d6e722f5e904966272f621d15326429e90d53 \ + "$binary" | sha256sum --check --status + chmod +x "$binary" + printf '%s\n' "$RUNNER_TEMP" >> "$GITHUB_PATH" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e02fb4e..284ebf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,175 +3,49 @@ name: CI on: pull_request: push: - branches: - - main - workflow_dispatch: + branches: [main] permissions: contents: read -env: - SYFT_CHECK_FOR_APP_UPDATE: "false" - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - quality: - name: Go, Bash, and workflow quality + test: + name: eventctl / e2e runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 20 steps: - - name: Check out the source + - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Load pinned tool versions - id: tools - run: scripts/release/load-tool-versions.sh - - - name: Install pinned ShellCheck - run: >- - scripts/release/install-shellcheck.sh - "$RUNNER_TEMP/eventctl-shellcheck-bin" - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v6.0.0 with: - go-version: ${{ steps.tools.outputs.go }} - cache: true + go-version-file: go.mod + + - name: Set up pinned mise + uses: ./.github/actions/setup-mise - - name: Verify module dependencies + - name: Verify dependencies and formatting run: | go mod download go mod tidy -diff go mod verify - git diff --exit-code -- go.mod go.sum - if [[ -n $(git ls-files --others --exclude-standard -- go.mod go.sum) ]]; then - printf 'go.mod or go.sum has an uncommitted change\n' >&2 - exit 1 - fi - - - name: Check Go formatting - run: | - unformatted=$(gofmt -l .) - if [[ -n $unformatted ]]; then - printf 'gofmt required:\n%s\n' "$unformatted" >&2 - exit 1 - fi - - - name: Vet - run: go vet -mod=readonly ./... - - - name: Unit and integration tests - run: go test -mod=readonly -count=1 ./... - - - name: Race detector - run: go test -mod=readonly -race -count=1 ./... + test -z "$(gofmt -l cmd internal tests)" - - name: Fuzz smoke tests - env: - FUZZ_TIME: 10s - run: scripts/release/fuzz-smoke.sh + - name: Run static checks + run: go vet ./... - - name: Vulnerability scan - run: go run golang.org/x/vuln/cmd/govulncheck@${{ steps.tools.outputs.govulncheck }} ./... + - name: Run canonical CLI E2E coverage + run: mise run test - - name: ShellCheck - run: >- - find scripts -type f -name '*.sh' -print0 | - xargs -0 "$RUNNER_TEMP/eventctl-shellcheck-bin/shellcheck" - - - name: Check Bash formatting - run: go run mvdan.cc/sh/v3/cmd/shfmt@${{ steps.tools.outputs.shfmt }} -d -ci scripts - - - name: Validate GitHub Actions workflows - run: go run github.com/rhysd/actionlint/cmd/actionlint@${{ steps.tools.outputs.actionlint }} - - - name: Enforce immutable action pins - run: scripts/release/check-action-pins.sh - - - name: Exercise release security boundaries - run: scripts/release/test-release-boundaries.sh - - - name: Enforce GitHub CLI security floor - run: scripts/release/require-gh-version.sh - - native-tests: - name: Native tests (${{ matrix.name }}) - strategy: - fail-fast: false - matrix: - include: - - name: Linux amd64 - runner: ubuntu-24.04 - - name: macOS arm64 - runner: macos-15 - - name: Windows amd64 - runner: windows-2025 - runs-on: ${{ matrix.runner }} - timeout-minutes: 20 - steps: - - name: Check out the source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Upload coverage artifact + if: ${{ env.ACT != 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - persist-credentials: false - - - name: Load pinned tool versions - id: tools - shell: bash - run: scripts/release/load-tool-versions.sh - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ steps.tools.outputs.go }} - cache: true - - - name: Run native tests - shell: bash - run: go test -mod=readonly -count=1 ./... - - reproducible-release: - name: Reproducible cross-platform release snapshot - needs: quality - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Check out the source and tags - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Load pinned tool versions - id: tools - run: scripts/release/load-tool-versions.sh - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ steps.tools.outputs.go }} - cache: true - - - name: Install pinned Syft - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - syft-version: ${{ steps.tools.outputs.syft }} - - - name: Install pinned GoReleaser - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 - with: - distribution: goreleaser - version: ${{ steps.tools.outputs.goreleaser }} - install-only: true - - - name: Validate GoReleaser configuration - run: goreleaser check - - - name: Prove archive reproducibility - run: | - snapshot_version="0.0.0-snapshot-$(git rev-parse --short HEAD)" - scripts/release/check-reproducible.sh "$snapshot_version" + name: eventctl-coverage + path: | + coverage/e2e.profile + coverage/e2e + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cb332b..dab4955 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,379 +2,74 @@ name: Release on: push: - tags: - - v* + tags: ["v*"] -permissions: {} - -env: - SYFT_CHECK_FOR_APP_UPDATE: "false" - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false +permissions: + contents: write jobs: - build: - name: Build release candidate - if: github.repository == 'pythonhk/eventctl' - runs-on: ubuntu-24.04 - timeout-minutes: 60 - permissions: - contents: read - outputs: - version: ${{ steps.metadata.outputs.version }} - source_digest: ${{ steps.metadata.outputs.source_digest }} - source_date: ${{ steps.metadata.outputs.source_date }} - steps: - - name: Check out the tag and full history - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Validate tag and repository - env: - GH_TOKEN: ${{ github.token }} - run: scripts/release/verify-tag.sh "${GITHUB_REF_NAME}" - - - name: Record release metadata - id: metadata - run: | - source_digest=$(git rev-parse --verify "${GITHUB_REF_NAME}^{commit}") - { - printf 'version=%s\n' "${GITHUB_REF_NAME#v}" - printf 'source_digest=%s\n' "$source_digest" - printf 'source_date=%s\n' \ - "$(TZ=UTC git show -s --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ "$source_digest")" - } >>"$GITHUB_OUTPUT" - - - name: Load pinned tool versions - id: tools - run: scripts/release/load-tool-versions.sh - - - name: Install pinned ShellCheck - run: >- - scripts/release/install-shellcheck.sh - "$RUNNER_TEMP/eventctl-shellcheck-bin" - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ steps.tools.outputs.go }} - cache: false - - - name: Install pinned Go security scanner - run: | - go install golang.org/x/vuln/cmd/govulncheck@${{ steps.tools.outputs.govulncheck }} - printf '%s\n' "$(go env GOPATH)/bin" >>"$GITHUB_PATH" - - - name: Run complete Go gates on exact release source - run: scripts/check.sh - - - name: Fuzz exact release source - env: - FUZZ_TIME: 10s - run: scripts/release/fuzz-smoke.sh - - - name: Run complete Bash and workflow gates - run: | - find scripts -type f -name '*.sh' -print0 | \ - xargs -0 "$RUNNER_TEMP/eventctl-shellcheck-bin/shellcheck" - go run mvdan.cc/sh/v3/cmd/shfmt@${{ steps.tools.outputs.shfmt }} -d -ci scripts - go run github.com/rhysd/actionlint/cmd/actionlint@${{ steps.tools.outputs.actionlint }} - scripts/release/check-action-pins.sh - scripts/release/test-release-boundaries.sh - - - name: Install pinned Syft - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - syft-version: ${{ steps.tools.outputs.syft }} - - - name: Install pinned GoReleaser - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 - with: - distribution: goreleaser - version: ${{ steps.tools.outputs.goreleaser }} - install-only: true - - - name: Build twice and prove exact release reproducibility - run: | - goreleaser check - scripts/release/check-reproducible.sh \ - "${{ steps.metadata.outputs.version }}" release - - - name: Store release candidate - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: eventctl-release-${{ steps.metadata.outputs.version }} - path: dist - if-no-files-found: error - compression-level: 0 - retention-days: 2 - - native-preflight: - name: Native preflight (${{ matrix.platform }}/${{ matrix.architecture }}) - needs: build - if: github.repository == 'pythonhk/eventctl' - strategy: - fail-fast: false - matrix: - include: - - platform: linux - architecture: amd64 - runner: ubuntu-24.04 - - platform: linux - architecture: arm64 - runner: ubuntu-24.04-arm - - platform: darwin - architecture: amd64 - runner: macos-15-intel - - platform: darwin - architecture: arm64 - runner: macos-15 - - platform: windows - architecture: amd64 - runner: windows-2025 - - platform: windows - architecture: arm64 - runner: windows-11-arm - runs-on: ${{ matrix.runner }} - timeout-minutes: 15 - permissions: - contents: read - steps: - - name: Check out verification scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Load pinned tool versions - id: tools - shell: bash - run: scripts/release/load-tool-versions.sh - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ steps.tools.outputs.go }} - cache: false - - - name: Run native source tests on exact release commit - shell: bash - run: go test -mod=readonly -count=1 ./... - - - name: Download release candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: eventctl-release-${{ needs.build.outputs.version }} - path: dist - - - name: Verify and execute native binary - shell: bash - run: >- - scripts/release/verify-asset.sh - dist - "${{ needs.build.outputs.version }}" - "${{ matrix.platform }}" - "${{ matrix.architecture }}" - "${{ needs.build.outputs.source_digest }}" - "${{ needs.build.outputs.source_date }}" - publish: - name: Attest and publish immutable release - needs: - - build - - native-preflight if: github.repository == 'pythonhk/eventctl' runs-on: ubuntu-24.04 timeout-minutes: 20 - environment: release - permissions: - attestations: write - contents: write - id-token: write steps: - - name: Check out exact release source + - name: Check out the tagged source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - name: Download verified release candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: eventctl-release-${{ needs.build.outputs.version }} - path: dist - - - name: Revalidate candidate and create asset-complete draft - env: - GH_TOKEN: ${{ github.token }} - run: | - scripts/release/verify-tag.sh "${GITHUB_REF_NAME}" - scripts/release/publish-draft.sh "${GITHUB_REF_NAME}" dist "${GITHUB_REPOSITORY}" - - - name: Attest build provenance - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-checksums: dist/SHA256SUMS - - - name: Attest darwin amd64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_darwin_amd64.tar.gz - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_darwin_amd64.tar.gz.spdx.json - - - name: Attest darwin arm64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_darwin_arm64.tar.gz - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_darwin_arm64.tar.gz.spdx.json - - - name: Attest linux amd64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_linux_amd64.tar.gz - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_linux_amd64.tar.gz.spdx.json - - - name: Attest linux arm64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_linux_arm64.tar.gz - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_linux_arm64.tar.gz.spdx.json - - - name: Attest windows amd64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_windows_amd64.zip - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_windows_amd64.zip.spdx.json - - - name: Attest windows arm64 SPDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 - with: - subject-path: dist/eventctl_${{ needs.build.outputs.version }}_windows_arm64.zip - sbom-path: dist/eventctl_${{ needs.build.outputs.version }}_windows_arm64.zip.spdx.json - - - name: Publish and prove immutable release - env: - GH_TOKEN: ${{ github.token }} - run: >- - scripts/release/finalize-release.sh - "${GITHUB_REF_NAME}" - "${GITHUB_REPOSITORY}" - dist - "${{ needs.build.outputs.source_digest }}" - - published-release-record: - name: Verify GitHub release record - needs: - - build - - publish - if: github.repository == 'pythonhk/eventctl' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - attestations: read - contents: read - steps: - - name: Check out verification scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v6.0.0 with: - persist-credentials: false + go-version-file: go.mod - - name: Verify immutable release attestation - env: - GH_TOKEN: ${{ github.token }} - run: >- - scripts/release/verify-release-record.sh - "${GITHUB_REF_NAME}" - "${GITHUB_REPOSITORY}" - "${{ needs.build.outputs.source_digest }}" + - name: Set up pinned mise + uses: ./.github/actions/setup-mise - published-native: - name: Re-download (${{ matrix.platform }}/${{ matrix.architecture }}) - needs: - - build - - publish - - published-release-record - if: github.repository == 'pythonhk/eventctl' - strategy: - fail-fast: false - matrix: - include: - - platform: linux - architecture: amd64 - extension: tar.gz - runner: ubuntu-24.04 - - platform: linux - architecture: arm64 - extension: tar.gz - runner: ubuntu-24.04-arm - - platform: darwin - architecture: amd64 - extension: tar.gz - runner: macos-15-intel - - platform: darwin - architecture: arm64 - extension: tar.gz - runner: macos-15 - - platform: windows - architecture: amd64 - extension: zip - runner: windows-2025 - - platform: windows - architecture: arm64 - extension: zip - runner: windows-11-arm - runs-on: ${{ matrix.runner }} - timeout-minutes: 15 - permissions: - attestations: read - contents: read - steps: - - name: Check out verification scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false + - name: Verify the exact tagged source + run: mise run test - - name: Download published asset and checksum contract - shell: bash + - name: Build checksum-pinned archives env: - GH_TOKEN: ${{ github.token }} + VERSION: ${{ github.ref_name }} + COMMIT: ${{ github.sha }} run: | - scripts/release/require-gh-version.sh - gh release verify "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" + version="${VERSION#v}" mkdir -p dist - asset="eventctl_${{ needs.build.outputs.version }}_${{ matrix.platform }}_${{ matrix.architecture }}.${{ matrix.extension }}" - gh release download "${GITHUB_REF_NAME}" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$asset" \ - --pattern SHA256SUMS \ - --dir dist - - - name: Verify immutable release asset and hosted attestations - shell: bash + lock=dist/eventctl.lock.json + jq -n \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg version "$version" \ + '{repository: $repository, version: $version, assets: {}}' > "$lock" + for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do + os="${target%/*}" + arch="${target#*/}" + directory="dist/eventctl_${version}_${os}_${arch}" + asset="eventctl_${version}_${os}_${arch}.tar.gz" + archive="dist/$asset" + mkdir -p "$directory" + GOOS="$os" GOARCH="$arch" go build -trimpath \ + -ldflags "-s -w -X github.com/pythonhk/eventctl/internal/buildinfo.Version=${version} -X github.com/pythonhk/eventctl/internal/buildinfo.Commit=${COMMIT}" \ + -o "$directory/eventctl" ./cmd/eventctl + binary_sha256=$(sha256sum "$directory/eventctl" | awk '{print $1}') + tar -C "$directory" -czf "$archive" eventctl + archive_sha256=$(sha256sum "$archive" | awk '{print $1}') + jq \ + --arg target "$os-$arch" \ + --arg name "$asset" \ + --arg archive_sha256 "$archive_sha256" \ + --arg binary_sha256 "$binary_sha256" \ + '.assets[$target] = {name: $name, archive_sha256: $archive_sha256, binary_sha256: $binary_sha256}' \ + "$lock" > "$lock.next" + mv "$lock.next" "$lock" + rm -rf "$directory" + done + jq -e -cS . "$lock" > "$lock.next" + mv "$lock.next" "$lock" + sha256sum dist/*.tar.gz > dist/SHA256SUMS + + - name: Publish immutable GitHub release env: GH_TOKEN: ${{ github.token }} - run: | - asset="dist/eventctl_${{ needs.build.outputs.version }}_${{ matrix.platform }}_${{ matrix.architecture }}.${{ matrix.extension }}" - gh release verify-asset "${GITHUB_REF_NAME}" "$asset" \ - --repo "${GITHUB_REPOSITORY}" - gh release verify-asset "${GITHUB_REF_NAME}" dist/SHA256SUMS \ - --repo "${GITHUB_REPOSITORY}" - scripts/release/verify-attestations.sh \ - "$asset" \ - "${GITHUB_REPOSITORY}" \ - "${GITHUB_REF_NAME}" \ - "${{ needs.build.outputs.source_digest }}" - - - name: Verify checksum, archive shape, metadata, and native execution - shell: bash - run: | - scripts/release/verify-asset.sh \ - dist \ - "${{ needs.build.outputs.version }}" \ - "${{ matrix.platform }}" \ - "${{ matrix.architecture }}" \ - "${{ needs.build.outputs.source_digest }}" \ - "${{ needs.build.outputs.source_date }}" + run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore index d8e1757..90bf837 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /bin/ /dist/ -/coverage.out +/coverage/ *.test .DS_Store diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 2283020..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,92 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/goreleaser/goreleaser/v2.17.1/www/docs/static/schema.json -version: 2 - -project_name: eventctl - -env: - - SYFT_CHECK_FOR_APP_UPDATE=false - -builds: - - id: eventctl - main: ./cmd/eventctl - binary: eventctl - env: - - CGO_ENABLED=0 - goos: - - darwin - - linux - - windows - goarch: - - amd64 - - arm64 - flags: - - -trimpath - - -buildvcs=true - - -mod=readonly - ldflags: - - >- - -s -w -buildid= - -X github.com/pythonhk/eventctl/internal/buildinfo.Version={{ .Version }} - -X github.com/pythonhk/eventctl/internal/buildinfo.Commit={{ .FullCommit }} - -X github.com/pythonhk/eventctl/internal/buildinfo.Date={{ .CommitDate }} - mod_timestamp: "{{ .CommitTimestamp }}" - -archives: - - id: eventctl-archives - ids: - - eventctl - name_template: "eventctl_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - builds_info: - owner: root - group: root - mode: 0755 - mtime: "{{ .CommitDate }}" - # Keep the archive surface exact and flat: one binary plus its license. - files: - - src: LICENSE - dst: LICENSE - info: - owner: root - group: root - mode: 0644 - mtime: "{{ .CommitDate }}" - -sboms: - - id: archive-spdx - artifacts: archive - ids: - - eventctl-archives - documents: - - "${artifact}.spdx.json" - cmd: syft - args: - - "$artifact" - - --output - - "spdx-json=$document" - -checksum: - name_template: SHA256SUMS - algorithm: sha256 - ids: - - eventctl-archives - -snapshot: - version_template: "0.0.0-snapshot-{{ .ShortCommit }}" - -changelog: - use: git - sort: asc - filters: - exclude: - - '^docs(?:\([^)]*\))?:' - - '^test(?:\([^)]*\))?:' - - '^chore(?:\([^)]*\))?:' - -release: - disable: true diff --git a/Makefile b/Makefile deleted file mode 100644 index 5d35502..0000000 --- a/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -.PHONY: build check test - -build: - go build -trimpath -o ./bin/eventctl ./cmd/eventctl - -test: - go test ./... - -check: - ./scripts/check.sh diff --git a/README.md b/README.md index afb0ba0..015a525 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,211 @@ # eventctl -`eventctl` is the offline-first command-line companion for reusable PythonHK -GitHub events. It creates participant signing keys, produces signed registration -and team-consent messages, and packages submissions as authenticated encrypted -bundles that are safe to commit to a public Git repository. - -Event repositories consume a prebuilt, pinned `eventctl` release. They must not -compile the CLI during an event workflow or download an unverified `latest` -binary. - -## Security properties - -- GitHub's numeric account ID is the identity; usernames are display data. -- Every signed action is bound to an event ID, numeric upstream repository ID, - actor ID, action kind, key epoch, configuration digest, and unique request ID. -- Every team member signs the same immutable team proposal. Partial consent does - not activate a team. -- A submission attempt is distinct from its content. Replaying the same attempt - is idempotent, while deliberately submitting the same content with a fresh - attempt ID is allowed by policy. -- Confidential submissions are signed before encryption. Encryption does not - replace authentication, actor binding, or durable replay tracking. -- V1 fixes the event and config epochs at `1`. Protected genesis pins the exact - config digest, delegated signing authority, and delegation validity window; - a derived event does not adopt an edited config after bootstrap. -- The CLI does not hold GitHub credentials, push commits, open pull requests, or - mutate event state. - -## Command families - -The implemented v1 interface is organized around: +`eventctl` is the small, offline CLI behind reusable PythonHK event +repositories. It creates participant keys, signs event-bound registrations, +team requests, and submissions, and encrypts result files for a team. + +It has no GitHub App, PEM, GitHub token, network client, Git mutation, or +organizer private key. The event repository supplies the trusted public event +binding and its protected `registry` branch supplies the authoritative state. + +## Commands + +```text +eventctl version +eventctl doctor [--event BINDING --registry REGISTRY] +eventctl key-gen --out DIR --passphrase-file PATH + +eventctl identity register|verify ... +eventctl team propose|consent|verify ... +eventctl submission prepare|verify ... + +eventctl sigcrypt ... +eventctl decverify ... +``` + +Every command writes exactly one JSON response to standard output. A successful +response has `ok: true`; a rejected request has `ok: false` and a non-empty +`error`. The process exits nonzero for the latter. + +## The two-branch model + +```text +main public event binding, workflows, and merged participant requests +registry protected authoritative identities, activated teams, and attempts +``` + +Participants work from forks and open pull requests to `main`. The normal +read-only workflow checks the GitHub actor and request shape. An organizer then +uses `eventctl` with the immutable GitHub creation time and writes the accepted +result through a reviewed PR to `registry`. No participant request changes +state by itself. + +`event/binding.json` is public, reviewed policy. It includes the event and +repository IDs, event epoch, validity window, terms digest, request TTLs, and +team/attempt limits. Every signed document embeds its derived event reference, +so it cannot be replayed into a different event, repository, or policy +revision. + +The single registry document is also event-bound: + +```json +{ + "v": 2, + "kind": "event-registry", + "event": { "event_id": "...", "event_epoch": 1, "repository_id": "...", "binding_sha256": "..." }, + "revision": 0, + "phase": "formation_open", + "enabled": true, + "disabled_reason": "", + "identities": [], + "teams": [], + "attempts": [] +} +``` + +The valid phases are `draft`, `registration_open`, `formation_open`, +`submissions_open`, and `closed`. `eventctl` requires `formation_open` for +team work and `submissions_open` for admission. It rejects an enabled registry +with a disabled reason, or a disabled registry without one. + +## One participant setup + +```bash +eventctl key-gen \ + --out participant-identity \ + --passphrase-file passphrase.txt +``` + +This creates four files: ```text -eventctl version --json -eventctl help [COMMAND [SUBCOMMAND]] -eventctl envelope classify --request PATH --out PATH -eventctl config validate|digest|sign|verify|delegation-sign|delegation-verify -eventctl doctor -eventctl key generate|show|backup -eventctl recipient generate|show -eventctl identity register|verify -eventctl team propose|consent|verify -eventctl submission pack|inspect|verify|prepare|authenticate-request|verify-request|decrypt-verify -eventctl replay classify -eventctl receipt sign|verify -eventctl scorer validate-request|sign-result|verify +signing.private.age encrypted Ed25519 signing key +signing.public.json Ed25519 verification document +recipient.private.age encrypted age hybrid decryption identity +recipient.public.json age hybrid recipient document ``` -Artifact-producing commands require an explicit output path. Machine-readable -output is written to standard output; diagnostics are written to standard -error. Protocol versions are independent of CLI semantic versions, and the CLI -fails closed on unsupported protocol majors. - -Runtime verification commands require the signed config, protected genesis, -and protected current-state metadata from the same immutable state ref. Intake -commands additionally require a trusted GitHub source timestamp. Bootstrap -config verification requires the signed organizer-root delegation and every -explicit root public key. Run a command with missing arguments to receive its -exact usage contract as structured JSON. - -`submission authenticate-request` verifies the signed request, actor, -registered key, repository, event/config, validity-window, digest, and replay -bindings without reading mutable pull-request metadata or the referenced -bundle. It is a replay-lookup primitive, not submission admission: a request -that is not already present in protected replay state must still pass -`submission verify-request` against fresh PR metadata and the exact bundle. - -`eventctl help`, `eventctl --help`, and `eventctl -h` return successful -machine-readable help in the same `pythonhk.eventctl/output/v1` response wrapper -as ordinary commands. Command-family and exact-command help are available as, -for example, `eventctl submission --help`, `eventctl help submission`, and -`eventctl submission pack --help`. To create the passphrase-protected hybrid -identity used for submission encryption, start with: +The two key types remain separate: Ed25519 signs documents, while age hybrid +ML-KEM768/X25519 decrypts team data. One passphrase protects both local private +files for convenience; the passphrase is only read from a file. + +## Registration and team formation + +Registration is self-signed, but it becomes active only when its verified +record is reviewed into `registry.identities`. The `--source-time` supplied to +verification must be a trusted immutable time, normally GitHub's pull-request +creation time—not a participant commit timestamp. ```bash -eventctl recipient generate \ - --identity-out judge-recipient.age \ - --recipient-out judge-recipient.txt +eventctl identity register \ + --event event/binding.json --actor-id 12345 \ + --sig-private-key participant-identity/signing.private.age \ + --recipient-public-key participant-identity/recipient.public.json \ + --passphrase-file passphrase.txt --output registration.json + +eventctl identity verify \ + --event event/binding.json --input registration.json \ + --expect-actor-id 12345 --source-time 2026-08-08T12:00:00Z \ + --output identity-record.json ``` -Private signing keys and hybrid recipient identities are passphrase-encrypted. -Passphrases are read from a terminal or `--passphrase-file`; they are never -accepted as command-line values. Submission decryption and extraction are -Linux-only and require every recipient identity named by the accepted archived -configuration. +An active member proposes a sorted team from the protected registry. Every +listed member, including the proposer, separately signs their consent. The +verifier resolves every signing and recipient key from the active registry; +keys claimed only in the request are never trusted. -Request timing is strict: the trusted GitHub source creation time must be -between the signed `issued_at` and `expires_at`, inclusive. Participant systems -should have synchronized clocks; after a `request is not yet valid` error, -correct the clock and generate a fresh request rather than editing timestamps. +```bash +eventctl team propose \ + --event event/binding.json --registry registry/state.json \ + --actor-id 12345 --member 12345 --member 67890 \ + --sig-private-key participant-identity/signing.private.age \ + --passphrase-file passphrase.txt --output proposal.json + +eventctl team consent \ + --event event/binding.json --registry registry/state.json \ + --proposal proposal.json --actor-id 67890 \ + --sig-private-key teammate-identity/signing.private.age \ + --passphrase-file teammate-passphrase.txt --output consent.json + +eventctl team verify \ + --event event/binding.json --registry registry/state.json \ + --proposal proposal.json --proposal-source-time 2026-08-08T12:00:00Z \ + --consent captain-consent.json --consent consent.json \ + --consent-source-time 12345=2026-08-08T12:01:00Z \ + --consent-source-time 67890=2026-08-08T12:02:00Z \ + --output verified-team.json +``` -`eventctl envelope classify` is a reconciler routing primitive, not a security -verifier. It accepts strict, bounded concrete v1 request JSON either directly -or inside the exact one-key `{ "envelope": ... }` transport wrapper, and writes -only `{status, trust, kind, request_id}` with `trust` fixed to `unverified`. -The original request must still pass its normal signature, actor, repository, -configuration, source-time, and lifecycle checks before any state effect. +The organizer records the verifier's team ID, proposal digest, and members as +one `registry.teams` entry. The registry prevents another active team from +reusing that team ID or any member. -## Build from source +## Submission admission -Building from source is intended for CLI contributors, not event participants: +Participants prepare an event-bound, signed request alongside the exact +submission file. `prepare` has no state effect; `verify` is the admission +operation used from the protected registry workflow. ```bash -go test ./... -go build -o ./bin/eventctl ./cmd/eventctl -./bin/eventctl version --json +eventctl submission prepare \ + --event event/binding.json --input exploit-package.zip \ + --metadata metadata.json --team-id TEAM_UUID --attempt-id ATTEMPT_UUID \ + --actor-id 12345 --sig-private-key participant-identity/signing.private.age \ + --passphrase-file passphrase.txt --output submission.json + +eventctl submission verify \ + --event event/binding.json --registry registry/state.json \ + --request submission.json --bundle exploit-package.zip --metadata metadata.json \ + --expect-actor-id 12345 --source-time 2026-08-08T12:10:00Z \ + --output verified-submission.json ``` -Official releases contain platform archives, SHA-256 checksums, SPDX SBOMs, and -build provenance. Event templates pin the expected version and per-platform -checksum in reviewed configuration. +Verification requires an active team, an active member with the same key +epoch, an unused attempt ID, and remaining per-team and total quotas. The +reviewed registry update records the attempt; it is the durable replay guard. + +## Encrypted result artifacts + +`sigcrypt` signs a bounded byte stream and encrypts it to one or more team +recipient public keys. A GitHub Actions judge can upload the ciphertext as an +artifact. Any intended team member can download it and run `decverify` using +their own recipient private key. -## Trust boundary +```bash +eventctl sigcrypt \ + --input judge.log --output judge.log.eventctl --context stream-binding.json \ + --sig-private-key organizer/signing.private.age \ + --passphrase-file organizer-passphrase.txt \ + --enc-public-key captain/recipient.public.json \ + --enc-public-key teammate/recipient.public.json + +eventctl decverify \ + --input judge.log.eventctl --output judge.log --context stream-binding.json \ + --ver-public-key organizer/signing.public.json \ + --dec-private-key teammate/recipient.private.age \ + --passphrase-file teammate-passphrase.txt +``` + +The stream header signs the event reference, stream purpose, signer, sorted +recipient key IDs, payload size, and SHA-256 digest. Input files are capped at +64 MiB and outputs are exclusive creates, so an existing file is never +overwritten silently. + +## Development + +The repository has one test entrypoint: + +```bash +mise run test +``` -`eventctl` handles canonicalization, cryptography, schema checks, and encrypted -bundle parsing. Bash in the event repository handles GitHub transport and calls -the CLI through a narrow adapter. Privileged GitHub workflows must independently -verify every participant envelope and must never execute participant-controlled -content. +It builds an instrumented `eventctl` binary and drives it as a real subprocess +through the complete registration, team, submission, stream, error, replay, +and quota lifecycle. It then merges `GOCOVERDIR` data and fails unless +statement coverage is exactly 100.0%. There are no unit-test tasks. -See the event template's protocol and security documentation for the complete -state machine, replay rules, and workflow trust boundaries. +`mise run format-code` formats Go sources. CI runs the same `mise run test` +entrypoint rather than a separate test command. ## License diff --git a/SECURITY.md b/SECURITY.md index a45bc8a..5f0f73b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,70 +1,31 @@ # Security policy -`eventctl` handles participant signing keys and encrypted event submissions. -Security reports should therefore avoid public issues until maintainers have -assessed the impact and users have a safe upgrade. - -## Supported versions - -Only the latest published release receives security fixes. Event repositories -must pin an exact archive digest, but organizers should migrate to a newer -release after reviewing protocol compatibility and release attestations. - -| Release | Security updates | -| --- | --- | -| Latest immutable release | Yes | -| Earlier releases and unreleased builds | No | - -## Report a vulnerability - -Use **Report a vulnerability** on the repository's Security tab to open a -private vulnerability report. Include: - -- the affected `eventctl` version and platform; -- the exact command or protocol operation involved; -- whether confidentiality, signature verification, identity binding, replay - safety, archive extraction, or release integrity is affected; -- minimal reproduction steps or a proof of concept with synthetic data; and -- any known workarounds or evidence of active exploitation. - -Do not include real participant private keys, plaintext submissions, recovery -material, GitHub tokens, or event secrets. Maintainers will acknowledge the -report privately, validate it, coordinate remediation, and publish an advisory -when users have a safe release. - -## Release integrity - -Official binaries exist only as immutable GitHub release assets in -`pythonhk/eventctl`. Each supported archive must: - -- appear in `SHA256SUMS`; -- have SLSA provenance signed by - `pythonhk/eventctl/.github/workflows/release.yml`; -- have an SPDX 2.3 SBOM attestation from the same workflow and source tag; -- report the exact full source commit digest pinned by the consumer lock; and -- belong to a verifiable immutable GitHub release. - -See `docs/release.md` for exact verification commands. Treat a missing or -invalid checksum, attestation, immutable-release record, or version/commit -binding as a security failure. Do not fall back to building from an event fork -or downloading another asset. Use GitHub CLI 2.93.0 or newer; older versions -must not perform release or artifact-attestation verification because they are -affected by GHSA-8xvp-7hj6-mcj9. - -## Key and data handling - -- Generate participant private keys outside event repository checkouts and - keep them with user-only filesystem permissions. -- Never commit plaintext submissions, private keys, decrypted temporary files, - access tokens, or organizer decryption identities. -- A successful encryption command does not prove the recipient identity is - correct. Verify the event configuration digest and recipient information - through the organizer's trusted channel first. -- Signatures establish the protocol identity and context encoded in the signed - envelope; they do not make untrusted files safe to execute. -- Decrypt and score participant-controlled data only in the event system's - isolated scoring boundary, never in a privileged repository-state writer. - -The CLI and its release pipeline are one part of the event trust boundary. The -event template's protected state, actor binding, replay handling, shutdown -controls, and isolated scorer remain independently required. +`eventctl` handles participant signing keys, event-bound attestations, +protected-registry verification, and encrypted event byte streams. Please +report vulnerabilities privately through the repository's GitHub Security tab. + +Include the affected version, command, platform, synthetic reproduction, and +whether confidentiality, signature verification, actor/team binding, replay +safety, or output integrity is affected. Never include real private keys, +plaintext submissions, GitHub tokens, or event secrets. + +## Operational boundary + +- Keep encrypted private key files outside event repository checkouts when + possible, with user-only filesystem permissions. +- Do not commit private keys, plaintext event logs, decrypted outputs, or + passphrase files. +- Verify the public event binding through trusted `main`, and consume registry + state only from its protected branch. +- Treat decrypted participant data as untrusted input and execute it only in + the event's isolated scoring boundary. +- Treat GitHub actor and immutable request-creation time as repository inputs; + they are not claims a participant can sign for themselves. +- The CLI does not authenticate GitHub requests, update protected state, or + replace the organizer's reviewed registry transition. It verifies that the + supplied state enforces active membership, replay, and quota rules. +- There is no GitHub App, PEM, GitHub token, or network client in normal + eventctl operation. + +Only the current protocol release is supported. Event repositories should pin +the exact binary version and review protocol compatibility before upgrading. diff --git a/cmd/eventctl/config.go b/cmd/eventctl/config.go deleted file mode 100644 index 0f218fb..0000000 --- a/cmd/eventctl/config.go +++ /dev/null @@ -1,303 +0,0 @@ -package main - -import ( - "io" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -type normalizedConfig struct { - Status string `json:"status"` - Kind string `json:"kind"` - Digest string `json:"digest"` - Event config.Event `json:"event"` -} - -type configSummary struct { - Path string `json:"path"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RepositoryID string `json:"repository_id"` - ConfigEpoch uint64 `json:"config_epoch"` - Digest string `json:"digest"` - SignatureCount int `json:"signature_count"` -} - -func runConfig(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl config validate|digest|sign|verify|delegation-sign|delegation-verify") - } - switch args[0] { - case "validate", "digest": - return configNormalize(args[0], args[1:]) - case "sign": - return configSign(args[1:], stderr) - case "verify": - return configVerify(args[1:]) - case "delegation-sign": - return configDelegationSign(args[1:], stderr) - case "delegation-verify": - return configDelegationVerify(args[1:]) - default: - return nil, usageError("usage: eventctl config validate|digest|sign|verify|delegation-sign|delegation-verify") - } -} - -func configNormalize(command string, args []string) (any, error) { - flags := newFlagSet("config " + command) - path := flags.String("config", "", "event YAML") - out := flags.String("out", "", "normalized JSON output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *path == "" || *out == "" { - return nil, usageError("usage: eventctl config " + command + " --config PATH --out PATH") - } - raw, err := readBounded(*path, config.MaxBytes) - if err != nil { - return nil, ioError("read config", err) - } - event, err := config.Parse(raw) - if err != nil { - return nil, invalidError("validate config", err) - } - digest, err := config.Digest(event) - if err != nil { - return nil, err - } - normalized := normalizedConfig{"valid", config.Kind, digest, event} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write normalized config", err) - } - return configSummary{*out, event.EventID, event.EventEpoch, event.BaseRepository.ID, event.ConfigEpoch, digest, len(event.Signatures)}, nil -} - -func configSign(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("config sign") - path := flags.String("config", "", "event YAML") - keyPath := flags.String("key", "", "encrypted signing key") - out := flags.String("out", "", "signed YAML output") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *path == "" || *keyPath == "" || *out == "" { - return nil, usageError("usage: eventctl config sign --config PATH --key PATH --out PATH [--passphrase-file PATH|-]") - } - raw, err := readBounded(*path, config.MaxBytes) - if err != nil { - return nil, ioError("read config", err) - } - event, err := config.ParseUnsigned(raw) - if err != nil { - return nil, invalidError("validate unsigned config", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt signing key", err) - } - event, err = config.Sign(event, pair.Private) - if err != nil { - return nil, verificationError("sign config", err) - } - encoded, err := config.MarshalYAML(event) - if err != nil { - return nil, err - } - if err := writeExclusive(*out, encoded, 0o644); err != nil { - return nil, ioError("write signed config", err) - } - digest, err := config.Digest(event) - if err != nil { - return nil, err - } - return struct { - Path string `json:"path"` - Digest string `json:"digest"` - KeyID string `json:"key_id"` - SignatureCount int `json:"signature_count"` - }{*out, digest, pair.Public.KeyID, len(event.Signatures)}, nil -} - -func configVerify(args []string) (any, error) { - flags := newFlagSet("config verify") - configPath := flags.String("config", "", "event YAML") - authorityPath := flags.String("authority", "", "protected genesis JSON") - statePath := flags.String("state-meta", "", "protected current state metadata JSON") - delegationPath := flags.String("delegation", "", "signed bootstrap config delegation") - var rootKeyPaths stringList - flags.Var(&rootKeyPaths, "root-key", "explicit organizer root public key (repeatable)") - expectEventID := flags.String("expect-event-id", "", "expected event ID for bootstrap") - expectRepositoryID := flags.String("expect-repository-id", "", "trusted numeric repository ID for bootstrap") - sourceTimeText := flags.String("source-time", "", "trusted source or bootstrap verification time") - out := flags.String("out", "", "normalized JSON output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl config verify --config PATH --source-time RFC3339 (--authority PATH --state-meta PATH | --delegation PATH --root-key PATH --expect-event-id ID --expect-repository-id ID) --out PATH") - } - runtimeMode := *authorityPath != "" || *statePath != "" - bootstrapMode := *delegationPath != "" || len(rootKeyPaths) != 0 || *expectEventID != "" || *expectRepositoryID != "" - if runtimeMode == bootstrapMode { - return nil, usageError("config verify requires exactly one runtime or bootstrap trust mode") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate trusted source time", err) - } - var event config.Event - var digest string - if runtimeMode { - if *authorityPath == "" || *statePath == "" { - return nil, usageError("runtime config verify requires --authority, --state-meta, and --source-time") - } - event, digest, _, err = loadTrustedContext(*configPath, *authorityPath, *statePath, sourceTime) - if err != nil { - return nil, verificationError("verify adopted config", err) - } - } else { - if *delegationPath == "" || len(rootKeyPaths) == 0 || *expectEventID == "" || *expectRepositoryID == "" { - return nil, usageError("bootstrap config verify requires --delegation, --root-key, --expect-event-id, --expect-repository-id, and --source-time") - } - configRaw, err := readBounded(*configPath, config.MaxBytes) - if err != nil { - return nil, ioError("read config", err) - } - event, err = config.Parse(configRaw) - if err != nil { - return nil, invalidError("parse config", err) - } - delegation, err := loadDelegation(*delegationPath) - if err != nil { - return nil, invalidError("load config delegation", err) - } - roots, err := loadPublicKeys(rootKeyPaths) - if err != nil { - return nil, invalidError("load organizer root keys", err) - } - verification, _, err := config.VerifyWithDelegation(event, delegation, roots, *expectEventID, *expectRepositoryID, sourceTime) - if err != nil { - return nil, verificationError("verify config through organizer root delegation", err) - } - digest = verification.Digest - } - normalized := normalizedConfig{"valid", config.Kind, digest, event} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified config", err) - } - return configSummary{*out, event.EventID, event.EventEpoch, event.BaseRepository.ID, event.ConfigEpoch, digest, len(event.Signatures)}, nil -} - -type normalizedDelegation struct { - Status string `json:"status"` - Kind string `json:"kind"` - Digest string `json:"digest"` - Authority config.Authority `json:"authority"` - RootKeyIDs []string `json:"root_key_ids"` - Delegation config.Delegation `json:"delegation"` -} - -func configDelegationSign(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("config delegation-sign") - delegationPath := flags.String("delegation", "", "unsigned delegation JSON") - keyPath := flags.String("key", "", "encrypted organizer root signing key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - out := flags.String("out", "", "signed delegation JSON output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *delegationPath == "" || *keyPath == "" || *out == "" { - return nil, usageError("usage: eventctl config delegation-sign --delegation PATH --key PATH --out PATH [--passphrase-file PATH|-]") - } - raw, err := readBounded(*delegationPath, config.MaxBytes) - if err != nil { - return nil, ioError("read unsigned delegation", err) - } - delegation, err := config.ParseUnsignedDelegation(raw) - if err != nil { - return nil, invalidError("validate unsigned delegation", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt organizer root key", err) - } - delegation, err = config.SignDelegation(delegation, pair.Private) - if err != nil { - return nil, verificationError("sign config delegation", err) - } - if err := config.VerifyDelegationRootSignature(delegation, pair.Public); err != nil { - return nil, verificationError("self-verify config delegation", err) - } - digest, err := envelope.DocumentDigest(delegation) - if err != nil { - return nil, err - } - encoded, err := canonical.Marshal(delegation) - if err != nil { - return nil, err - } - if err := writeExclusive(*out, append(encoded, '\n'), 0o644); err != nil { - return nil, ioError("write signed config delegation", err) - } - return struct { - Path string `json:"path"` - Digest string `json:"digest"` - RootKeyID string `json:"root_key_id"` - }{*out, digest, pair.Public.KeyID}, nil -} - -func configDelegationVerify(args []string) (any, error) { - flags := newFlagSet("config delegation-verify") - delegationPath := flags.String("delegation", "", "signed delegation JSON") - var rootKeyPaths stringList - flags.Var(&rootKeyPaths, "root-key", "explicit organizer root public key (repeatable)") - expectEventID := flags.String("expect-event-id", "", "expected event ID") - expectRepositoryID := flags.String("expect-repository-id", "", "trusted numeric repository ID") - sourceTimeText := flags.String("source-time", "", "trusted verification time") - out := flags.String("out", "", "normalized delegation output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *delegationPath == "" || len(rootKeyPaths) == 0 || *expectEventID == "" || *expectRepositoryID == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl config delegation-verify --delegation PATH --root-key PATH [--root-key PATH ...] --expect-event-id ID --expect-repository-id ID --source-time RFC3339 --out PATH") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate delegation verification time", err) - } - delegation, err := loadDelegation(*delegationPath) - if err != nil { - return nil, invalidError("load config delegation", err) - } - roots, err := loadPublicKeys(rootKeyPaths) - if err != nil { - return nil, invalidError("load organizer root keys", err) - } - verification, err := config.VerifyDelegation(delegation, roots, *expectEventID, *expectRepositoryID, sourceTime) - if err != nil { - return nil, verificationError("verify config delegation", err) - } - normalized := normalizedDelegation{"valid", config.DelegationKind, verification.Digest, verification.Authority, verification.RootKeyIDs, verification.Delegation} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified config delegation", err) - } - return struct { - Path string `json:"path"` - Digest string `json:"digest"` - Threshold int `json:"threshold"` - KeyCount int `json:"key_count"` - }{*out, verification.Digest, verification.Authority.Threshold, len(verification.Authority.Keys)}, nil -} - -func loadDelegation(path string) (config.Delegation, error) { - raw, err := readBounded(path, config.MaxBytes) - if err != nil { - return config.Delegation{}, err - } - return config.ParseDelegation(raw) -} - -func loadPublicKeys(paths []string) ([]identity.Public, error) { - keys := make([]identity.Public, 0, len(paths)) - for _, path := range paths { - raw, err := readBounded(path, 64*1024) - if err != nil { - return nil, err - } - key, err := identity.ParsePublic(raw) - if err != nil { - return nil, err - } - keys = append(keys, key) - } - return keys, nil -} diff --git a/cmd/eventctl/doctor.go b/cmd/eventctl/doctor.go deleted file mode 100644 index 133ca61..0000000 --- a/cmd/eventctl/doctor.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "bytes" - "crypto/ed25519" - "errors" - "io" - "runtime" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" -) - -type doctorResult struct { - Status string `json:"status"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - CanonicalProfile string `json:"canonical_profile"` - Runtime string `json:"runtime"` - OperatingSystem string `json:"operating_system"` - Architecture string `json:"architecture"` - ParticipantSupported bool `json:"participant_supported"` - JudgeExtractionSupported bool `json:"judge_extraction_supported"` - Cryptography []string `json:"cryptography"` -} - -func runDoctor(args []string) (any, error) { - flags := newFlagSet("doctor") - out := flags.String("out", "", "optional normalized doctor output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 { - return nil, usageError("usage: eventctl doctor [--out PATH]") - } - supportedOS := runtime.GOOS == "darwin" || runtime.GOOS == "linux" || runtime.GOOS == "windows" - supportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" - if !supportedOS || !supportedArch { - return nil, verificationError("unsupported eventctl platform", nil) - } - if err := doctorCryptoSelfTest(); err != nil { - return nil, verificationError("cryptographic self-test failed", err) - } - canonicalProbe, err := canonical.Marshal(struct { - A int `json:"a"` - Z int `json:"z"` - }{1, 2}) - if err != nil || !bytes.Equal(canonicalProbe, []byte(`{"a":1,"z":2}`)) { - return nil, verificationError("canonical JSON self-test failed", err) - } - result := doctorResult{ - Status: "healthy", Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - CanonicalProfile: "eventctl-canonical-json-v1", Runtime: runtime.Version(), - OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, - ParticipantSupported: true, JudgeExtractionSupported: runtime.GOOS == "linux", - Cryptography: []string{"Ed25519", "age-hybrid-mlkem768-x25519", "age-scrypt-private-key-storage", "SHA-256"}, - } - if *out != "" { - if err := writeCanonical(*out, result, 0o644); err != nil { - return nil, ioError("write doctor result", err) - } - } - return result, nil -} - -func doctorCryptoSelfTest() error { - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = byte(index + 1) - } - privateKey := ed25519.NewKeyFromSeed(seed) - message := []byte("eventctl doctor cryptographic self-test") - signature := ed25519.Sign(privateKey, message) - if !ed25519.Verify(privateKey.Public().(ed25519.PublicKey), message, signature) { - return errors.New("Ed25519 sign/verify mismatch") - } - identity, err := age.GenerateHybridIdentity() - if err != nil { - return err - } - var encrypted bytes.Buffer - writer, err := age.Encrypt(&encrypted, identity.Recipient()) - if err != nil { - return err - } - if _, err := writer.Write(message); err != nil { - return err - } - if err := writer.Close(); err != nil { - return err - } - reader, err := age.Decrypt(bytes.NewReader(encrypted.Bytes()), identity) - if err != nil { - return err - } - decrypted, err := io.ReadAll(io.LimitReader(reader, int64(len(message)+1))) - if err != nil { - return err - } - if !bytes.Equal(decrypted, message) { - return errors.New("hybrid age encrypt/decrypt mismatch") - } - return nil -} diff --git a/cmd/eventctl/doctor_test.go b/cmd/eventctl/doctor_test.go deleted file mode 100644 index a8cb6f2..0000000 --- a/cmd/eventctl/doctor_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import "testing" - -func TestDoctorRunsOfflineProtocolAndCryptoSelfTests(t *testing.T) { - value, err := runDoctor(nil) - if err != nil { - t.Fatal(err) - } - result, ok := value.(doctorResult) - if !ok { - t.Fatalf("runDoctor() result type = %T", value) - } - if result.Status != "healthy" || !result.ParticipantSupported || result.ProtocolVersion != 1 || len(result.Cryptography) != 4 { - t.Fatalf("runDoctor() = %#v", result) - } -} - -func TestDoctorRejectsPositionalInput(t *testing.T) { - if _, err := runDoctor([]string{"unexpected"}); err == nil { - t.Fatal("runDoctor() accepted positional input") - } -} diff --git a/cmd/eventctl/envelope.go b/cmd/eventctl/envelope.go deleted file mode 100644 index d6795f0..0000000 --- a/cmd/eventctl/envelope.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - - "github.com/pythonhk/eventctl/internal/canonical" - protocolenvelope "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/team" -) - -type envelopeClassification struct { - Status string `json:"status"` - Trust string `json:"trust"` - Kind string `json:"kind"` - RequestID string `json:"request_id"` -} - -func runEnvelope(args []string) (any, error) { - if len(args) == 0 || args[0] != "classify" { - return nil, usageError("usage: eventctl envelope classify --request PATH --out PATH") - } - flags := newFlagSet("envelope classify") - requestPath := flags.String("request", "", "raw durable request JSON") - out := flags.String("out", "", "unverified routing classification") - if err := flags.Parse(args[1:]); err != nil || flags.NArg() != 0 || *requestPath == "" || *out == "" { - return nil, usageError("usage: eventctl envelope classify --request PATH --out PATH") - } - raw, err := readBounded(*requestPath, protocolenvelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read durable request", err) - } - result, err := classifyEnvelope(raw) - if err != nil { - return nil, invalidError("classify untrusted durable request", err) - } - if err := writeCanonical(*out, result, 0o644); err != nil { - return nil, ioError("write envelope classification", err) - } - return result, nil -} - -// classifyEnvelope extracts only routing metadata. It deliberately does not -// verify a signature or compare trusted actor, repository, config, time, or -// GitHub source context; callers must pass the original request to its full -// intake verifier before any state effect. -func classifyEnvelope(raw []byte) (envelopeClassification, error) { - if len(raw) > protocolenvelope.MaxDocumentBytes { - return envelopeClassification{}, fmt.Errorf("durable request exceeds %d-byte limit", protocolenvelope.MaxDocumentBytes) - } - var transport map[string]json.RawMessage - if err := canonical.StrictUnmarshal(raw, &transport); err != nil { - return envelopeClassification{}, fmt.Errorf("decode durable request: %w", err) - } - if transport == nil { - return envelopeClassification{}, errors.New("durable request must be a JSON object") - } - documentRaw := raw - if wrapped, present := transport["envelope"]; present { - if len(transport) != 1 { - return envelopeClassification{}, errors.New("durable request wrapper must contain exactly one envelope field") - } - var embedded map[string]json.RawMessage - if err := canonical.StrictUnmarshal(wrapped, &embedded); err != nil || embedded == nil { - if err == nil { - err = errors.New("envelope is null") - } - return envelopeClassification{}, fmt.Errorf("decode wrapped durable request: %w", err) - } - documentRaw = wrapped - transport = embedded - } - kindRaw, ok := transport["kind"] - if !ok { - return envelopeClassification{}, errors.New("durable request is missing kind") - } - var kind string - if err := canonical.StrictUnmarshal(kindRaw, &kind); err != nil { - return envelopeClassification{}, fmt.Errorf("decode durable request kind: %w", err) - } - - requestID := "" - switch kind { - case protocolenvelope.RegistrationKind: - var document protocolenvelope.Registration - if err := canonical.StrictUnmarshal(documentRaw, &document); err != nil { - return envelopeClassification{}, fmt.Errorf("decode registration request: %w", err) - } - if err := document.ValidateUntrustedStructure(); err != nil { - return envelopeClassification{}, fmt.Errorf("validate untrusted registration structure: %w", err) - } - requestID = document.OperationID - case team.ProposalKind: - var document team.Proposal - if err := canonical.StrictUnmarshal(documentRaw, &document); err != nil { - return envelopeClassification{}, fmt.Errorf("decode team proposal: %w", err) - } - if err := document.ValidateUntrustedStructure(); err != nil { - return envelopeClassification{}, fmt.Errorf("validate untrusted team proposal structure: %w", err) - } - requestID = document.OperationID - case team.ConsentKind: - var document team.Consent - if err := canonical.StrictUnmarshal(documentRaw, &document); err != nil { - return envelopeClassification{}, fmt.Errorf("decode team consent: %w", err) - } - if err := document.ValidateUntrustedStructure(); err != nil { - return envelopeClassification{}, fmt.Errorf("validate untrusted team consent structure: %w", err) - } - requestID = document.OperationID - case protocolenvelope.SubmissionKind: - var document protocolenvelope.Submission - if err := canonical.StrictUnmarshal(documentRaw, &document); err != nil { - return envelopeClassification{}, fmt.Errorf("decode submission request: %w", err) - } - if err := document.ValidateUntrustedStructure(); err != nil { - return envelopeClassification{}, fmt.Errorf("validate untrusted submission structure: %w", err) - } - requestID = document.RequestID - default: - return envelopeClassification{}, fmt.Errorf("unsupported durable request kind %q", kind) - } - - return envelopeClassification{Status: "classified", Trust: "unverified", Kind: kind, RequestID: requestID}, nil -} diff --git a/cmd/eventctl/envelope_test.go b/cmd/eventctl/envelope_test.go deleted file mode 100644 index ed878ce..0000000 --- a/cmd/eventctl/envelope_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package main - -import ( - "bytes" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/pythonhk/eventctl/internal/canonical" - protocolenvelope "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/team" -) - -type classifierFixture struct { - name string - raw []byte - kind string - requestID string -} - -func TestClassifyEnvelopeDurableKindsAsUnverified(t *testing.T) { - t.Parallel() - for _, fixture := range classifierFixtures(t) { - fixture := fixture - t.Run(fixture.name, func(t *testing.T) { - t.Parallel() - got, err := classifyEnvelope(fixture.raw) - if err != nil { - t.Fatal(err) - } - want := envelopeClassification{ - Status: "classified", Trust: "unverified", - Kind: fixture.kind, RequestID: fixture.requestID, - } - if got != want { - t.Fatalf("classification = %#v, want %#v", got, want) - } - }) - } -} - -func TestClassifyEnvelopeRawAndExactWrapperAreIdentical(t *testing.T) { - t.Parallel() - for _, fixture := range classifierFixtures(t) { - fixture := fixture - t.Run(fixture.name, func(t *testing.T) { - t.Parallel() - rawClassification, err := classifyEnvelope(fixture.raw) - if err != nil { - t.Fatal(err) - } - wrapped := wrapClassifierRequest(fixture.raw) - wrappedClassification, err := classifyEnvelope(wrapped) - if err != nil { - t.Fatal(err) - } - if wrappedClassification != rawClassification { - t.Fatalf("wrapped classification = %#v, raw = %#v", wrappedClassification, rawClassification) - } - }) - } -} - -func TestClassifyEnvelopeDoesNotAuthenticateStructurallyEncodedSignature(t *testing.T) { - t.Parallel() - fixture := classifierFixtures(t)[0] - document := fixture.registration(t) - if err := identity.Verify(document.ParticipantKey, []byte("not the signed request"), document.Signature); err == nil { - t.Fatal("zero-filled test signature unexpectedly authenticated") - } - classification, err := classifyEnvelope(fixture.raw) - if err != nil { - t.Fatal(err) - } - if classification.Trust != "unverified" { - t.Fatalf("classification trust = %q, want unverified", classification.Trust) - } -} - -func TestEnvelopeClassifyCommandWritesExactCanonicalArtifact(t *testing.T) { - fixture := classifierFixtures(t)[0] - directory := t.TempDir() - requestPath := filepath.Join(directory, "request.json") - outPath := filepath.Join(directory, "classification.json") - if err := os.WriteFile(requestPath, fixture.raw, 0o600); err != nil { - t.Fatal(err) - } - var stdout bytes.Buffer - var stderr bytes.Buffer - exit := run([]string{"envelope", "classify", "--request", requestPath, "--out", outPath}, &stdout, &stderr) - if exit != 0 || stderr.String() != "" { - t.Fatalf("run exit=%d stderr=%q stdout=%q", exit, stderr.String(), stdout.String()) - } - var response struct { - OutputVersion string `json:"output_version"` - OK bool `json:"ok"` - Command string `json:"command"` - Result envelopeClassification `json:"result"` - Error *errorObject `json:"error"` - } - if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { - t.Fatal(err) - } - want := envelopeClassification{ - Status: "classified", Trust: "unverified", - Kind: fixture.kind, RequestID: fixture.requestID, - } - if response.OutputVersion != outputVersion || !response.OK || response.Command != "envelope.classify" || response.Error != nil || response.Result != want { - t.Fatalf("response = %#v, want result %#v", response, want) - } - wantRaw, err := canonical.Marshal(want) - if err != nil { - t.Fatal(err) - } - wantRaw = append(wantRaw, '\n') - gotRaw, err := os.ReadFile(outPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(gotRaw, wantRaw) { - t.Fatalf("classification artifact = %s, want %s", gotRaw, wantRaw) - } -} - -func TestClassifyEnvelopeRejectsNonConcreteOrMalformedInput(t *testing.T) { - t.Parallel() - fixtures := classifierFixtures(t) - valid := fixtures[0].raw - registration := fixtures[0].registration(t) - badID := registration - badID.OperationID = "not-a-uuid" - badIDRaw, err := canonical.Marshal(badID) - if err != nil { - t.Fatal(err) - } - badSignature := registration - badSignature.Signature.Value = "A" - badSignatureRaw, err := canonical.Marshal(badSignature) - if err != nil { - t.Fatal(err) - } - wrongSignatureKey := registration - wrongSignatureKey.Signature.KeyID = strings.Repeat("f", 64) - wrongSignatureKeyRaw, err := canonical.Marshal(wrongSignatureKey) - if err != nil { - t.Fatal(err) - } - - nestedDuplicate := append([]byte(`{"kind":"registration_request",`), valid[1:]...) - outerDuplicate := append([]byte(`{"envelope":`), valid...) - outerDuplicate = append(outerDuplicate, []byte(`,"envelope":`)...) - outerDuplicate = append(outerDuplicate, valid...) - outerDuplicate = append(outerDuplicate, '}') - outerExtra := append([]byte(`{"envelope":`), valid...) - outerExtra = append(outerExtra, []byte(`,"extra":true}`)...) - fractional := []byte(strings.Replace(string(valid), `"protocol_version":1`, `"protocol_version":1.5`, 1)) - exponent := []byte(strings.Replace(string(valid), `"protocol_version":1`, `"protocol_version":1e0`, 1)) - - cases := map[string][]byte{ - "unknown field": append([]byte(`{"unknown":true,`), valid[1:]...), - "duplicate kind": append([]byte(`{"kind":"registration_request",`), valid[1:]...), - "case variant field": append([]byte(`{"Kind":"registration_request",`), valid[1:]...), - "incomplete concrete": []byte(`{"kind":"registration_request"}`), - "unknown kind": []byte(`{"kind":"scorer_request"}`), - "outer duplicate": outerDuplicate, - "outer extra field": outerExtra, - "wrapper null": []byte(`{"envelope":null}`), - "wrapper string": []byte(`{"envelope":"request"}`), - "wrapper array": []byte(`{"envelope":[]}`), - "nested duplicate": wrapClassifierRequest(nestedDuplicate), - "trailing value": append(append([]byte(nil), valid...), []byte(` {}`)...), - "malformed": []byte(`{"kind":`), - "invalid UTF-8": []byte{0xff}, - "top-level array": []byte(`[]`), - "fractional number": fractional, - "exponent number": exponent, - "bad request ID": badIDRaw, - "bad signature": badSignatureRaw, - "wrong signature key": wrongSignatureKeyRaw, - "oversize": bytes.Repeat([]byte{' '}, protocolenvelope.MaxDocumentBytes+1), - } - for name, raw := range cases { - name := name - raw := raw - t.Run(name, func(t *testing.T) { - t.Parallel() - if got, err := classifyEnvelope(raw); err == nil { - t.Fatalf("classifyEnvelope accepted %s as %#v", name, got) - } - }) - } -} - -func classifierFixtures(t *testing.T) []classifierFixture { - t.Helper() - pair, err := identity.FromSeed(bytes.Repeat([]byte{17}, ed25519.SeedSize)) - if err != nil { - t.Fatal(err) - } - // Structurally canonical but cryptographically invalid. Classification must - // remain explicitly unverified and leave authentication to intake handlers. - signature := identity.Signature{ - Algorithm: identity.Algorithm, - KeyID: pair.Public.KeyID, - Value: base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), - } - repository := protocolenvelope.Repository{ID: "123", Owner: "pythonhk", Name: "example-event"} - const ( - registrationID = "11111111-1111-4111-8111-111111111111" - proposalID = "22222222-2222-4222-8222-222222222222" - consentID = "33333333-3333-4333-8333-333333333333" - submissionID = "44444444-4444-4444-8444-444444444444" - teamID = "55555555-5555-4555-8555-555555555555" - attemptID = "66666666-6666-4666-8666-666666666666" - ) - registration := protocolenvelope.Registration{ - Kind: protocolenvelope.RegistrationKind, Protocol: protocolenvelope.Protocol, - ProtocolVersion: protocolenvelope.ProtocolVersion, EventID: "example-event-2026", EventEpoch: "1", - OperationID: registrationID, ActorID: "42", KeyID: pair.Public.KeyID, KeyEpoch: "1", - BaseRepository: repository, ConfigDigest: strings.Repeat("1", 64), TermsDigest: strings.Repeat("2", 64), - IssuedAt: "2026-08-05T00:00:00Z", ExpiresAt: "2026-08-05T00:15:00Z", - ParticipantKey: pair.Public, Signature: signature, - } - proposal := team.Proposal{ - Kind: team.ProposalKind, Protocol: protocolenvelope.Protocol, - ProtocolVersion: protocolenvelope.ProtocolVersion, EventID: registration.EventID, EventEpoch: "1", - OperationID: proposalID, TeamID: teamID, ProposerActorID: "42", KeyID: pair.Public.KeyID, KeyEpoch: "1", - MemberActorIDs: []string{"42", "100"}, BaseRepository: repository, - ConfigDigest: strings.Repeat("1", 64), IssuedAt: registration.IssuedAt, ExpiresAt: registration.ExpiresAt, - Signature: signature, - } - consent := team.Consent{ - Kind: team.ConsentKind, Protocol: protocolenvelope.Protocol, - ProtocolVersion: protocolenvelope.ProtocolVersion, EventID: registration.EventID, EventEpoch: "1", - OperationID: consentID, TeamID: teamID, ProposalDigest: strings.Repeat("3", 64), - ActorID: "42", KeyID: pair.Public.KeyID, KeyEpoch: "1", Decision: "consent", - BaseRepository: repository, ConfigDigest: strings.Repeat("1", 64), - IssuedAt: registration.IssuedAt, ExpiresAt: registration.ExpiresAt, Signature: signature, - } - submission := protocolenvelope.Submission{ - Kind: protocolenvelope.SubmissionKind, Protocol: protocolenvelope.Protocol, - ProtocolVersion: protocolenvelope.ProtocolVersion, EventID: registration.EventID, EventEpoch: "1", - RequestID: submissionID, AttemptID: attemptID, ActorID: "42", KeyID: pair.Public.KeyID, KeyEpoch: "1", - TeamID: teamID, TeamProposalDigest: strings.Repeat("3", 64), BaseRepository: repository, - PullRequest: protocolenvelope.PullRequest{ - Number: 7, ID: "700", BaseRepositoryID: repository.ID, BaseRef: "main", - HeadRepositoryID: "456", HeadOwner: "participant", HeadRef: "submission", - HeadSHA: strings.Repeat("4", 40), - }, - ConfigDigest: strings.Repeat("1", 64), IssuedAt: registration.IssuedAt, ExpiresAt: registration.ExpiresAt, - DeliveryMode: protocolenvelope.SubmissionDeliveryMode, - Bundle: protocolenvelope.BundleReference{ - Path: "submission.eventctl", SizeBytes: 2048, SHA256: strings.Repeat("5", 64), - EnvelopeSHA256: strings.Repeat("6", 64), CiphertextSize: 1024, - CiphertextSHA256: strings.Repeat("7", 64), Format: protocolenvelope.SubmissionBundleFormat, - }, - Signature: signature, - } - return []classifierFixture{ - fixtureFromValue(t, "registration", registration, registration.Kind, registrationID), - fixtureFromValue(t, "team proposal", proposal, proposal.Kind, proposalID), - fixtureFromValue(t, "team consent", consent, consent.Kind, consentID), - fixtureFromValue(t, "submission", submission, submission.Kind, submissionID), - } -} - -func fixtureFromValue(t *testing.T, name string, value any, kind, requestID string) classifierFixture { - t.Helper() - raw, err := canonical.Marshal(value) - if err != nil { - t.Fatal(err) - } - return classifierFixture{name: name, raw: raw, kind: kind, requestID: requestID} -} - -func wrapClassifierRequest(raw []byte) []byte { - wrapped := append([]byte(`{"envelope":`), raw...) - return append(wrapped, '}') -} - -func (fixture classifierFixture) registration(t *testing.T) protocolenvelope.Registration { - t.Helper() - var document protocolenvelope.Registration - if err := canonical.StrictUnmarshal(fixture.raw, &document); err != nil { - t.Fatal(err) - } - return document -} diff --git a/cmd/eventctl/files.go b/cmd/eventctl/files.go deleted file mode 100644 index 8cfb118..0000000 --- a/cmd/eventctl/files.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" -) - -const maxSmallFile = 2 << 20 - -func readBounded(path string, limit int64) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return nil, err - } - if !info.Mode().IsRegular() { - return nil, errors.New("input is not a regular file") - } - if info.Size() > limit { - return nil, fmt.Errorf("input exceeds %d bytes", limit) - } - reader := io.LimitReader(file, limit+1) - raw, err := io.ReadAll(reader) - if err != nil { - return nil, err - } - if int64(len(raw)) > limit { - return nil, fmt.Errorf("input exceeds %d bytes", limit) - } - return raw, nil -} - -func writeExclusive(path string, data []byte, mode os.FileMode) error { - if path == "" { - return errors.New("output path is required") - } - parent := filepath.Dir(path) - if err := os.MkdirAll(parent, 0o700); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - ok := false - defer func() { - file.Close() - if !ok { - os.Remove(path) - } - }() - if _, err := file.Write(data); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - if err := file.Close(); err != nil { - return err - } - ok = true - return nil -} - -type stringList []string - -func (values *stringList) String() string { return fmt.Sprint([]string(*values)) } -func (values *stringList) Set(value string) error { *values = append(*values, value); return nil } diff --git a/cmd/eventctl/help.go b/cmd/eventctl/help.go deleted file mode 100644 index eef871b..0000000 --- a/cmd/eventctl/help.go +++ /dev/null @@ -1,242 +0,0 @@ -package main - -import ( - "fmt" - "io" - "strings" -) - -type helpCommand struct { - Name string `json:"name"` - Summary string `json:"summary"` - Usage string `json:"usage"` -} - -type helpResult struct { - Usage string `json:"usage"` - Summary string `json:"summary"` - Commands []helpCommand `json:"commands"` - Examples []string `json:"examples"` -} - -type helpGroup struct { - Command helpCommand - Commands []helpCommand - Examples []string -} - -var rootHelpCommands = []helpCommand{ - {"config", "Validate and authenticate immutable event configuration.", "eventctl config COMMAND [ARGS]"}, - {"doctor", "Run offline platform and cryptographic self-tests.", "eventctl doctor [--out PATH]"}, - {"envelope", "Strictly classify untrusted durable request routing metadata.", "eventctl envelope COMMAND [ARGS]"}, - {"help", "Show structured help for the CLI, a command family, or a subcommand.", "eventctl help [COMMAND [SUBCOMMAND]]"}, - {"identity", "Create and verify signed participant registration requests.", "eventctl identity COMMAND [ARGS]"}, - {"key", "Generate and manage passphrase-protected participant signing keys.", "eventctl key COMMAND [ARGS]"}, - {"receipt", "Sign and verify authoritative workflow receipts.", "eventctl receipt COMMAND [ARGS]"}, - {"recipient", "Generate passphrase-protected submission-encryption identities.", "eventctl recipient COMMAND [ARGS]"}, - {"replay", "Classify signed requests against durable replay state.", "eventctl replay COMMAND [ARGS]"}, - {"scorer", "Validate scoring requests and sign or verify scoring results.", "eventctl scorer COMMAND [ARGS]"}, - {"submission", "Create, inspect, verify, and decrypt authenticated encrypted submissions.", "eventctl submission COMMAND [ARGS]"}, - {"team", "Create and verify signed team proposals and consents.", "eventctl team COMMAND [ARGS]"}, - {"version", "Print machine-readable build information.", "eventctl version --json"}, -} - -var helpGroups = map[string]helpGroup{ - "config": { - Command: rootHelpCommands[0], - Commands: []helpCommand{ - {"validate", "Validate event YAML and write normalized JSON.", "eventctl config validate --config PATH --out PATH"}, - {"digest", "Validate event YAML and write normalized JSON with its digest.", "eventctl config digest --config PATH --out PATH"}, - {"sign", "Sign an unsigned event configuration.", "eventctl config sign --config PATH --key PATH --out PATH [--passphrase-file PATH|-]"}, - {"verify", "Verify config through adopted or bootstrap trust.", "eventctl config verify --config PATH --source-time RFC3339 (--authority PATH --state-meta PATH | --delegation PATH --root-key PATH --expect-event-id ID --expect-repository-id ID) --out PATH"}, - {"delegation-sign", "Add an organizer-root signature to a config delegation.", "eventctl config delegation-sign --delegation PATH --key PATH --out PATH [--passphrase-file PATH|-]"}, - {"delegation-verify", "Verify a config delegation against the exact organizer root set.", "eventctl config delegation-verify --delegation PATH --root-key PATH [--root-key PATH ...] --expect-event-id ID --expect-repository-id ID --source-time RFC3339 --out PATH"}, - }, - Examples: []string{ - "eventctl config validate --config event.yaml --out event.normalized.json", - "eventctl help config verify", - }, - }, - "envelope": { - Command: rootHelpCommands[2], - Commands: []helpCommand{ - {"classify", "Strictly extract unverified routing kind and request ID from one durable request.", "eventctl envelope classify --request PATH --out PATH"}, - }, - Examples: []string{"eventctl envelope classify --request request.json --out classification.json"}, - }, - "identity": { - Command: rootHelpCommands[4], - Commands: []helpCommand{ - {"register", "Create a signed participant registration request.", "eventctl identity register --config PATH --authority PATH --state-meta PATH --key PATH --actor-id ID --out PATH [--passphrase-file PATH|-] [--request-id UUID]"}, - {"verify", "Verify a registration at its trusted GitHub source time.", "eventctl identity verify --config PATH --authority PATH --state-meta PATH --request PATH --expect-actor-id ID --source-time RFC3339 --out PATH"}, - }, - Examples: []string{"eventctl help identity register"}, - }, - "key": { - Command: rootHelpCommands[5], - Commands: []helpCommand{ - {"generate", "Generate an Ed25519 signing key and public-key document.", "eventctl key generate --private-out PATH --public-out PATH [--passphrase-file PATH|-]"}, - {"show", "Decrypt a signing key and print its public identity.", "eventctl key show --key PATH [--passphrase-file PATH|-]"}, - {"public", "Alias for key show.", "eventctl key public --key PATH [--passphrase-file PATH|-]"}, - {"backup", "Re-encrypt a signing key into an exclusive backup file.", "eventctl key backup --key PATH --out PATH [--passphrase-file PATH|-] [--new-passphrase-file PATH|-]"}, - }, - Examples: []string{ - "eventctl key generate --private-out participant.key.age --public-out participant.pub.json", - "eventctl key backup --key participant.key.age --out participant.key.backup.age", - }, - }, - "receipt": { - Command: rootHelpCommands[6], - Commands: []helpCommand{ - {"sign", "Sign an authoritative receipt claim.", "eventctl receipt sign --config PATH --authority PATH --state-meta PATH --claim PATH --key PATH --out PATH [--passphrase-file PATH|-]"}, - {"verify", "Verify a receipt against its archived accepted configuration.", "eventctl receipt verify --config PATH --authority PATH --state-meta PATH --receipt PATH --out PATH"}, - }, - Examples: []string{"eventctl help receipt verify"}, - }, - "recipient": { - Command: rootHelpCommands[7], - Commands: []helpCommand{ - {"generate", "Generate a hybrid ML-KEM768/X25519 identity and public recipient.", "eventctl recipient generate --identity-out PATH --recipient-out PATH [--passphrase-file PATH|-]"}, - {"show", "Decrypt an identity and print its public recipient.", "eventctl recipient show --identity PATH [--passphrase-file PATH|-]"}, - }, - Examples: []string{ - "eventctl recipient generate --identity-out judge-recipient.age --recipient-out judge-recipient.txt", - "eventctl recipient show --identity judge-recipient.age", - }, - }, - "replay": { - Command: rootHelpCommands[8], - Commands: []helpCommand{ - {"classify", "Classify an incoming request as new, idempotent, or conflicting.", "eventctl replay classify --incoming PATH [--existing PATH] --out PATH"}, - }, - Examples: []string{"eventctl replay classify --incoming request.json --out replay.json"}, - }, - "scorer": { - Command: rootHelpCommands[9], - Commands: []helpCommand{ - {"validate-request", "Verify an accepted scoring request before judging.", "eventctl scorer validate-request --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --out PATH"}, - {"sign-result", "Validate and sign a scorer result.", "eventctl scorer sign-result --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --unsigned-result PATH --key PATH --out PATH [--passphrase-file PATH|-]"}, - {"verify", "Verify a signed scorer result.", "eventctl scorer verify --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --result PATH --out PATH"}, - }, - Examples: []string{"eventctl help scorer sign-result"}, - }, - "submission": { - Command: rootHelpCommands[10], - Commands: []helpCommand{ - {"pack", "Sign, archive, and encrypt a submission directory.", "eventctl submission pack --config PATH --authority PATH --state-meta PATH --registry PATH --teams PATH --team-id UUID --key PATH --actor-id ID --source DIR --bundle-out submission.eventctl --record-out PATH [--passphrase-file PATH|-] [--attempt-id UUID] [--request-id UUID]"}, - {"inspect", "Inspect the public framing metadata of an encrypted bundle.", "eventctl submission inspect --bundle PATH --out PATH"}, - {"verify", "Verify a bundle and its signed pack record without decrypting it.", "eventctl submission verify --config PATH --authority PATH --state-meta PATH --registry PATH --bundle submission.eventctl --record PATH --out PATH"}, - {"prepare", "Create a signed submission request for an existing bundle.", "eventctl submission prepare --config PATH --authority PATH --state-meta PATH --registry PATH --key PATH --actor-id ID --metadata PATH --bundle submission.eventctl --record PATH --out PATH [--passphrase-file PATH|-]"}, - {"authenticate-request", "Authenticate immutable request bindings without fresh PR or bundle access.", "eventctl submission authenticate-request --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --expect-actor-id ID --source-time RFC3339 --out PATH"}, - {"verify-request", "Verify a submission request at its trusted source time.", "eventctl submission verify-request --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --metadata PATH --bundle PATH --expect-actor-id ID --source-time RFC3339 --out PATH"}, - {"decrypt-verify", "Verify acceptance, decrypt, and safely extract a submission.", "eventctl submission decrypt-verify --config ARCHIVED_PATH --authority PATH --state-meta PATH --registry PATH --request PATH --acceptance RECEIPT --bundle PATH --identity PATH [--identity PATH ...] --out-dir PRIVATE_DIR --out PATH [--record PATH] [--passphrase-file PATH|-] [--expect-actor-id ID] [--expect-attempt-id UUID]"}, - }, - Examples: []string{ - "eventctl help submission pack", - "eventctl submission inspect --bundle submission.eventctl --out bundle-info.json", - }, - }, - "team": { - Command: rootHelpCommands[11], - Commands: []helpCommand{ - {"propose", "Create a signed immutable team proposal.", "eventctl team propose --config PATH --authority PATH --state-meta PATH --registry PATH --key PATH --actor-id ID --members PATH --out PATH [--passphrase-file PATH|-] [--team-id UUID] [--request-id UUID]"}, - {"consent", "Create a member signature over an existing team proposal.", "eventctl team consent --config PATH --authority PATH --state-meta PATH --registry PATH --proposal PATH --key PATH --actor-id ID --out PATH [--passphrase-file PATH|-] [--request-id UUID]"}, - {"verify", "Verify a team proposal or consent at its trusted source time.", "eventctl team verify --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --source-time RFC3339 [--proposal VERIFIED_PROPOSAL] --out PATH"}, - }, - Examples: []string{"eventctl help team propose"}, - }, -} - -var directHelp = map[string]helpResult{ - "doctor": { - Usage: "eventctl doctor [--out PATH]", Summary: rootHelpCommands[1].Summary, - Commands: []helpCommand{}, Examples: []string{"eventctl doctor", "eventctl doctor --out doctor.json"}, - }, - "help": { - Usage: "eventctl help [COMMAND [SUBCOMMAND]]", Summary: rootHelpCommands[3].Summary, - Commands: []helpCommand{}, Examples: []string{"eventctl --help", "eventctl help submission", "eventctl submission pack --help"}, - }, - "version": { - Usage: "eventctl version --json", Summary: rootHelpCommands[12].Summary, - Commands: []helpCommand{}, Examples: []string{"eventctl version --json"}, - }, -} - -func maybeRunHelp(args []string, output io.Writer) (int, bool) { - if len(args) == 0 { - return 0, false - } - if args[0] == "help" { - return runHelp(args[1:], output), true - } - if isHelpFlag(args[0]) { - if len(args) != 1 { - return emitFailure(output, "help", asCommandError(usageError("usage: eventctl help [COMMAND [SUBCOMMAND]]"))), true - } - return runHelp(nil, output), true - } - if len(args) == 2 && (args[1] == "help" || isHelpFlag(args[1])) { - return runHelp(args[:1], output), true - } - if len(args) == 3 && isHelpFlag(args[2]) { - return runHelp(args[:2], output), true - } - return 0, false -} - -func isHelpFlag(value string) bool { return value == "--help" || value == "-h" } - -func runHelp(topic []string, output io.Writer) int { - result, ok := helpFor(topic) - if !ok { - name := strings.Join(topic, " ") - return emitFailure(output, "help", asCommandError(usageError(fmt.Sprintf("unknown help topic %q", name)))) - } - command := "help" - if len(topic) != 0 { - command += "." + strings.Join(topic, ".") - } - return emitSuccess(output, command, result) -} - -func helpFor(topic []string) (helpResult, bool) { - if len(topic) == 0 { - return helpResult{ - Usage: "eventctl COMMAND [ARGS]", - Summary: "Offline signing, verification, and encrypted-submission tools for PythonHK GitHub events.", - Commands: append([]helpCommand(nil), rootHelpCommands...), - Examples: []string{ - "eventctl key generate --private-out participant.key.age --public-out participant.pub.json", - "eventctl recipient generate --identity-out judge-recipient.age --recipient-out judge-recipient.txt", - "eventctl help submission pack", - "eventctl doctor", - }, - }, true - } - if len(topic) == 1 { - if group, ok := helpGroups[topic[0]]; ok { - return helpResult{ - Usage: group.Command.Usage, Summary: group.Command.Summary, - Commands: append([]helpCommand(nil), group.Commands...), - Examples: append([]string(nil), group.Examples...), - }, true - } - result, ok := directHelp[topic[0]] - return result, ok - } - if len(topic) == 2 { - group, ok := helpGroups[topic[0]] - if !ok { - return helpResult{}, false - } - for _, command := range group.Commands { - if command.Name == topic[1] { - return helpResult{ - Usage: command.Usage, Summary: command.Summary, - Commands: []helpCommand{}, Examples: []string{command.Usage}, - }, true - } - } - } - return helpResult{}, false -} diff --git a/cmd/eventctl/help_test.go b/cmd/eventctl/help_test.go deleted file mode 100644 index a3ca62d..0000000 --- a/cmd/eventctl/help_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "slices" - "strings" - "testing" -) - -type decodedHelpResponse struct { - OutputVersion string `json:"output_version"` - OK bool `json:"ok"` - Command string `json:"command"` - Result helpResult `json:"result"` - Error *errorObject `json:"error"` -} - -func TestTopLevelHelpAliasesExitZeroAndListEveryCommand(t *testing.T) { - t.Parallel() - wantCommands := []string{ - "config", "doctor", "envelope", "help", "identity", "key", "receipt", - "recipient", "replay", "scorer", "submission", "team", "version", - } - for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}} { - args := args - t.Run(strings.Join(args, "_"), func(t *testing.T) { - t.Parallel() - got, stderr, exit := executeForHelpTest(args) - if exit != 0 { - t.Fatalf("run(%q) exit = %d, output = %#v", args, exit, got) - } - if stderr != "" { - t.Fatalf("run(%q) stderr = %q", args, stderr) - } - if !got.OK || got.Error != nil || got.OutputVersion != outputVersion || got.Command != "help" { - t.Fatalf("run(%q) response = %#v", args, got) - } - var names []string - for _, command := range got.Result.Commands { - names = append(names, command.Name) - if command.Summary == "" || command.Usage == "" { - t.Fatalf("command %q has incomplete help: %#v", command.Name, command) - } - } - if !slices.Equal(names, wantCommands) { - t.Fatalf("command names = %q, want %q", names, wantCommands) - } - if !containsSubstring(got.Result.Examples, "recipient generate") { - t.Fatalf("top-level examples do not show encryption-key setup: %q", got.Result.Examples) - } - }) - } -} - -func TestCommandFamilyHelpAliasesExitZero(t *testing.T) { - t.Parallel() - for _, args := range [][]string{ - {"help", "recipient"}, - {"recipient", "help"}, - {"recipient", "--help"}, - {"recipient", "-h"}, - } { - args := args - t.Run(strings.Join(args, "_"), func(t *testing.T) { - t.Parallel() - got, stderr, exit := executeForHelpTest(args) - if exit != 0 || stderr != "" || !got.OK || got.Command != "help.recipient" { - t.Fatalf("run(%q) exit=%d stderr=%q response=%#v", args, exit, stderr, got) - } - if got.Result.Usage != "eventctl recipient COMMAND [ARGS]" { - t.Fatalf("run(%q) usage = %q", args, got.Result.Usage) - } - if len(got.Result.Commands) != 2 || got.Result.Commands[0].Name != "generate" || got.Result.Commands[1].Name != "show" { - t.Fatalf("run(%q) commands = %#v", args, got.Result.Commands) - } - if !containsSubstring(got.Result.Examples, "recipient generate") { - t.Fatalf("recipient examples = %q", got.Result.Examples) - } - }) - } -} - -func TestExactCommandHelpAliasesExitZeroWithoutSideEffects(t *testing.T) { - t.Parallel() - for _, args := range [][]string{ - {"help", "recipient", "generate"}, - {"recipient", "generate", "--help"}, - {"recipient", "generate", "-h"}, - } { - args := args - t.Run(strings.Join(args, "_"), func(t *testing.T) { - t.Parallel() - got, stderr, exit := executeForHelpTest(args) - if exit != 0 || stderr != "" || !got.OK || got.Command != "help.recipient.generate" { - t.Fatalf("run(%q) exit=%d stderr=%q response=%#v", args, exit, stderr, got) - } - if !strings.Contains(got.Result.Usage, "--identity-out PATH --recipient-out PATH") { - t.Fatalf("run(%q) usage = %q", args, got.Result.Usage) - } - if len(got.Result.Commands) != 0 || len(got.Result.Examples) != 1 { - t.Fatalf("run(%q) result = %#v", args, got.Result) - } - }) - } -} - -func TestDirectCommandHelpExitsZero(t *testing.T) { - t.Parallel() - got, stderr, exit := executeForHelpTest([]string{"doctor", "--help"}) - if exit != 0 || stderr != "" || !got.OK || got.Command != "help.doctor" { - t.Fatalf("doctor --help exit=%d stderr=%q response=%#v", exit, stderr, got) - } - if got.Result.Usage != "eventctl doctor [--out PATH]" { - t.Fatalf("doctor usage = %q", got.Result.Usage) - } -} - -func TestEveryRegisteredCommandFamilyAndSubcommandHasExitZeroHelp(t *testing.T) { - t.Parallel() - for family, group := range helpGroups { - family := family - group := group - for _, args := range [][]string{ - {"help", family}, - {family, "help"}, - {family, "--help"}, - {family, "-h"}, - } { - args := args - t.Run(strings.Join(args, "_"), func(t *testing.T) { - t.Parallel() - assertHelpSuccess(t, args, "help."+family) - }) - } - for _, command := range group.Commands { - command := command - for _, args := range [][]string{ - {"help", family, command.Name}, - {family, command.Name, "--help"}, - {family, command.Name, "-h"}, - } { - args := args - t.Run(strings.Join(args, "_"), func(t *testing.T) { - t.Parallel() - assertHelpSuccess(t, args, "help."+family+"."+command.Name) - }) - } - } - } -} - -func TestEveryRegisteredDirectCommandHasExitZeroHelp(t *testing.T) { - t.Parallel() - for command := range directHelp { - command := command - t.Run("help_"+command, func(t *testing.T) { - t.Parallel() - assertHelpSuccess(t, []string{"help", command}, "help."+command) - }) - if command == "help" { - continue - } - for _, flag := range []string{"--help", "-h"} { - flag := flag - t.Run(command+"_"+flag, func(t *testing.T) { - t.Parallel() - assertHelpSuccess(t, []string{command, flag}, "help."+command) - }) - } - } -} - -func TestUnknownHelpTopicUsesStableUsageFailure(t *testing.T) { - t.Parallel() - got, stderr, exit := executeForHelpTest([]string{"help", "missing"}) - if exit != 2 || stderr != "" || got.OK || got.Command != "help" || got.Error == nil { - t.Fatalf("unknown help exit=%d stderr=%q response=%#v", exit, stderr, got) - } - if got.Error.Code != "usage" || !strings.Contains(got.Error.Message, `unknown help topic "missing"`) { - t.Fatalf("unknown help error = %#v", got.Error) - } -} - -func executeForHelpTest(args []string) (decodedHelpResponse, string, int) { - var stdout bytes.Buffer - var stderr bytes.Buffer - exit := run(args, &stdout, &stderr) - var decoded decodedHelpResponse - if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { - panic("decode help response: " + err.Error() + ": " + stdout.String()) - } - return decoded, stderr.String(), exit -} - -func assertHelpSuccess(t *testing.T, args []string, wantCommand string) { - t.Helper() - got, stderr, exit := executeForHelpTest(args) - if exit != 0 || stderr != "" || !got.OK || got.Error != nil || got.Command != wantCommand { - t.Fatalf("run(%q) exit=%d stderr=%q response=%#v", args, exit, stderr, got) - } - if got.Result.Usage == "" || got.Result.Summary == "" || len(got.Result.Examples) == 0 { - t.Fatalf("run(%q) returned incomplete help: %#v", args, got.Result) - } -} - -func containsSubstring(values []string, substring string) bool { - for _, value := range values { - if strings.Contains(value, substring) { - return true - } - } - return false -} diff --git a/cmd/eventctl/identity.go b/cmd/eventctl/identity.go deleted file mode 100644 index e643fa4..0000000 --- a/cmd/eventctl/identity.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "io" - "time" - - "github.com/pythonhk/eventctl/internal/envelope" -) - -type normalizedRequest struct { - Status string `json:"status"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - ReplayKey string `json:"replay_key"` - Document any `json:"document"` -} - -type requestSummary struct { - Path string `json:"path"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - ReplayKey string `json:"replay_key"` -} - -func runIdentity(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl identity register|verify") - } - switch args[0] { - case "register": - return identityRegister(args[1:], stderr) - case "verify": - return identityVerify(args[1:]) - default: - return nil, usageError("usage: eventctl identity register|verify") - } -} - -func identityRegister(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("identity register") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - keyPath := flags.String("key", "", "encrypted participant key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - actorID := flags.String("actor-id", "", "numeric GitHub actor ID") - keyEpoch := flags.String("key-epoch", "1", "registration key epoch") - requestID := flags.String("request-id", "", "UUIDv4 (generated if omitted)") - out := flags.String("out", "", "registration request output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *keyPath == "" || *actorID == "" || *out == "" { - return nil, usageError("usage: eventctl identity register --config PATH --authority PATH --state-meta PATH --key PATH --actor-id ID --out PATH [--passphrase-file PATH|-] [--request-id UUID]") - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requireOneOfPhases(meta, "registration_open", "formation_open"); err != nil { - return nil, verificationError("registration is not open", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt participant key", err) - } - issued := time.Now().UTC().Truncate(time.Second) - requestTTL := time.Duration(event.Registration.RequestTTLSeconds) * time.Second - raw, err := envelope.NewRegistration(envelope.RegistrationParams{EventID: event.EventID, EventEpoch: event.EventEpoch, OperationID: *requestID, ActorID: *actorID, KeyEpoch: *keyEpoch, BaseRepository: event.BaseRepository, ConfigDigest: digest, TermsDigest: event.Registration.TermsDigest, IssuedAt: issued, ExpiresAt: issued.Add(requestTTL)}, pair.Private) - if err != nil { - return nil, invalidError("create registration", err) - } - verified, err := envelope.VerifyRegistration(raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: issued}, requestTTL) - if err != nil { - return nil, verificationError("self-verify registration", err) - } - if err := writeExclusive(*out, append(raw, '\n'), 0o644); err != nil { - return nil, ioError("write registration request", err) - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - return nil, err - } - return requestSummary{*out, envelope.RegistrationKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -func identityVerify(args []string) (any, error) { - flags := newFlagSet("identity verify") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - requestPath := flags.String("request", "", "registration request") - actorID := flags.String("expect-actor-id", "", "trusted GitHub actor ID") - sourceTimeText := flags.String("source-time", "", "trusted immutable GitHub source creation time") - out := flags.String("out", "", "normalized verification output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *requestPath == "" || *actorID == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl identity verify --config PATH --authority PATH --state-meta PATH --request PATH --expect-actor-id ID --source-time RFC3339 --out PATH") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate trusted source time", err) - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, sourceTime) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requireOneOfPhases(meta, "registration_open", "formation_open"); err != nil { - return nil, verificationError("registration is not open", err) - } - raw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read registration request", err) - } - requestTTL := time.Duration(event.Registration.RequestTTLSeconds) * time.Second - verified, err := envelope.VerifyRegistration(raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: sourceTime}, requestTTL) - if err != nil { - return nil, verificationError("verify registration", err) - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - return nil, err - } - normalized := normalizedRequest{"verified", envelope.RegistrationKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey, verified.Document} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified registration", err) - } - return requestSummary{*out, envelope.RegistrationKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} diff --git a/cmd/eventctl/key.go b/cmd/eventctl/key.go deleted file mode 100644 index e61f672..0000000 --- a/cmd/eventctl/key.go +++ /dev/null @@ -1,252 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "flag" - "fmt" - "io" - "os" - "strings" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/identity" - "golang.org/x/term" -) - -type keyResult struct { - KeyID string `json:"key_id"` - PublicKey identity.Public `json:"public_key"` - PrivatePath string `json:"private_path"` - PublicPath string `json:"public_path"` -} - -func runKey(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl key generate|show|backup") - } - switch args[0] { - case "generate": - return keyGenerate(args[1:], stderr) - case "show", "public": - return keyShow(args[1:], stderr) - case "backup": - return keyBackup(args[1:], stderr) - default: - return nil, usageError("usage: eventctl key generate|show|backup") - } -} - -func keyGenerate(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("key generate") - privateOut := flags.String("private-out", "", "encrypted private key output") - publicOut := flags.String("public-out", "", "public key output") - passFile := flags.String("passphrase-file", "", "passphrase file or - for stdin") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *privateOut == "" || *publicOut == "" { - return nil, usageError("usage: eventctl key generate --private-out PATH --public-out PATH [--passphrase-file PATH|-]") - } - if *privateOut == *publicOut { - return nil, invalidError("private and public outputs must differ", nil) - } - passphrase, err := readPassphrase(*passFile, "New key passphrase: ", true, stderr) - if err != nil { - return nil, invalidError("read passphrase", err) - } - pair, err := identity.Generate() - if err != nil { - return nil, err - } - privateBytes, err := encryptPrivate(pair.Private, passphrase) - clear(passphrase) - if err != nil { - return nil, err - } - publicBytes, err := identity.MarshalPublic(pair.Public) - if err != nil { - return nil, err - } - if err := writeExclusive(*privateOut, privateBytes, 0o600); err != nil { - return nil, ioError("write encrypted private key", err) - } - if err := writeExclusive(*publicOut, publicBytes, 0o644); err != nil { - _ = os.Remove(*privateOut) - return nil, ioError("write public key", err) - } - return keyResult{KeyID: pair.Public.KeyID, PublicKey: pair.Public, PrivatePath: *privateOut, PublicPath: *publicOut}, nil -} - -func keyShow(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("key show") - keyPath := flags.String("key", "", "encrypted private key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *keyPath == "" { - return nil, usageError("usage: eventctl key show --key PATH [--passphrase-file PATH|-]") - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt private key", err) - } - return struct { - KeyID string `json:"key_id"` - PublicKey identity.Public `json:"public_key"` - }{pair.Public.KeyID, pair.Public}, nil -} - -func keyBackup(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("key backup") - keyPath := flags.String("key", "", "encrypted private key") - out := flags.String("out", "", "encrypted backup output") - passFile := flags.String("passphrase-file", "", "current passphrase file") - newPassFile := flags.String("new-passphrase-file", "", "new passphrase file") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *keyPath == "" || *out == "" { - return nil, usageError("usage: eventctl key backup --key PATH --out PATH [--passphrase-file PATH|-] [--new-passphrase-file PATH|-]") - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt source key", err) - } - newPass, err := readPassphrase(*newPassFile, "Backup passphrase: ", true, stderr) - if err != nil { - return nil, invalidError("read backup passphrase", err) - } - encrypted, err := encryptPrivate(pair.Private, newPass) - if err != nil { - clear(newPass) - return nil, err - } - verified, err := decryptPrivateBytes(encrypted, newPass) - clear(newPass) - if err != nil || verified.Public != pair.Public { - return nil, verificationError("verify encrypted backup", err) - } - if err := writeExclusive(*out, encrypted, 0o600); err != nil { - return nil, ioError("write encrypted backup", err) - } - return struct { - Path string `json:"path"` - KeyID string `json:"key_id"` - }{*out, pair.Public.KeyID}, nil -} - -func encryptPrivate(private identity.Private, passphrase []byte) ([]byte, error) { - raw, err := identity.MarshalPrivate(private) - if err != nil { - return nil, err - } - recipient, err := age.NewScryptRecipient(string(passphrase)) - if err != nil { - return nil, fmt.Errorf("create scrypt recipient: %w", err) - } - var output bytes.Buffer - writer, err := age.Encrypt(&output, recipient) - if err != nil { - return nil, err - } - if _, err := writer.Write(raw); err != nil { - return nil, err - } - if err := writer.Close(); err != nil { - return nil, err - } - return output.Bytes(), nil -} -func decryptPrivateBytes(encrypted, passphrase []byte) (identity.KeyPair, error) { - identityAge, err := age.NewScryptIdentity(string(passphrase)) - if err != nil { - return identity.KeyPair{}, err - } - reader, err := age.Decrypt(bytes.NewReader(encrypted), identityAge) - if err != nil { - return identity.KeyPair{}, err - } - raw, err := io.ReadAll(io.LimitReader(reader, maxSmallFile+1)) - if err != nil { - return identity.KeyPair{}, err - } - if len(raw) > maxSmallFile { - return identity.KeyPair{}, errors.New("decrypted key exceeds limit") - } - return identity.ParsePrivate(raw) -} -func loadPrivate(path, passFile string, stderr io.Writer) (identity.KeyPair, error) { - encrypted, err := readBounded(path, maxSmallFile) - if err != nil { - return identity.KeyPair{}, err - } - pass, err := readPassphrase(passFile, "Key passphrase: ", false, stderr) - if err != nil { - return identity.KeyPair{}, err - } - defer clear(pass) - return decryptPrivateBytes(encrypted, pass) -} - -func readPassphrase(path, prompt string, confirm bool, stderr io.Writer) ([]byte, error) { - if path != "" { - var raw []byte - var err error - if path == "-" { - raw, err = io.ReadAll(io.LimitReader(os.Stdin, 4097)) - } else { - raw, err = readBounded(path, 4096) - } - if err != nil { - return nil, err - } - if len(raw) > 4096 { - return nil, errors.New("passphrase exceeds 4096 bytes") - } - raw = bytes.TrimSuffix(raw, []byte("\n")) - raw = bytes.TrimSuffix(raw, []byte("\r")) - if err := validatePassphrase(raw); err != nil { - return nil, err - } - return append([]byte(nil), raw...), nil - } - fd := int(os.Stdin.Fd()) - if !term.IsTerminal(fd) { - return nil, errors.New("stdin is not a terminal; use --passphrase-file PATH or -") - } - fmt.Fprint(stderr, prompt) - first, err := term.ReadPassword(fd) - fmt.Fprintln(stderr) - if err != nil { - return nil, err - } - if err := validatePassphrase(first); err != nil { - clear(first) - return nil, err - } - if confirm { - fmt.Fprint(stderr, "Confirm "+strings.ToLower(prompt)) - second, err := term.ReadPassword(fd) - fmt.Fprintln(stderr) - if err != nil { - clear(first) - return nil, err - } - equal := bytes.Equal(first, second) - clear(second) - if !equal { - clear(first) - return nil, errors.New("passphrases do not match") - } - } - return first, nil -} -func validatePassphrase(value []byte) error { - if len(value) < 10 { - return errors.New("passphrase must be at least 10 bytes") - } - if bytes.IndexByte(value, 0) >= 0 { - return errors.New("passphrase contains NUL") - } - return nil -} -func clear(value []byte) { - for index := range value { - value[index] = 0 - } -} - -var _ = flag.ErrHelp diff --git a/cmd/eventctl/main.go b/cmd/eventctl/main.go index 1b35a8e..d0fd273 100644 --- a/cmd/eventctl/main.go +++ b/cmd/eventctl/main.go @@ -1,61 +1,543 @@ package main import ( + "encoding/json" + "errors" "fmt" "io" "os" + "runtime" "strings" + "time" + + "filippo.io/age" + "github.com/caarlos0/env/v11" + "github.com/spf13/cobra" + + "github.com/pythonhk/eventctl/internal/buildinfo" + "github.com/pythonhk/eventctl/internal/protocol" + "github.com/pythonhk/eventctl/internal/stream" ) +type response struct { + OK bool `json:"ok"` + Command string `json:"command"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type doctorEnvironment struct { + EventID string `env:"EVENTCTL_EVENT_ID" envDefault:"unset"` + EventEpoch int `env:"EVENTCTL_EVENT_EPOCH" envDefault:"1"` +} + func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - return emitFailure(stdout, "root", commandError{Code: "usage", Message: "a command is required", Exit: 2}) + root := newRoot(stdout, stderr) + root.SetArgs(args) + if err := root.Execute(); err != nil { + command := strings.TrimSpace(strings.TrimPrefix(root.CommandPath(), "eventctl")) + if command == "" { + command = "eventctl" + } + _ = writeResponse(stdout, response{OK: false, Command: command, Error: err.Error()}) + return 1 } - if exit, handled := maybeRunHelp(args, stdout); handled { - return exit + return 0 +} + +func newRoot(stdout, stderr io.Writer) *cobra.Command { + root := &cobra.Command{ + Use: "eventctl", + Short: "PythonHK event registration and byte-stream cryptography", + SilenceErrors: true, + SilenceUsage: true, } - var result any - var err error - command := args[0] - switch command { - case "version": - return runVersion(args[1:], stdout) - case "config": - result, err = runConfig(args[1:], stderr) - case "envelope": - result, err = runEnvelope(args[1:]) - case "key": - result, err = runKey(args[1:], stderr) - case "recipient": - result, err = runRecipient(args[1:], stderr) - case "identity": - result, err = runIdentity(args[1:], stderr) - case "team": - result, err = runTeam(args[1:], stderr) - case "submission": - result, err = runSubmission(args[1:], stderr) - case "replay": - result, err = runReplay(args[1:]) - case "receipt": - result, err = runReceipt(args[1:], stderr) - case "scorer": - result, err = runScorer(args[1:], stderr) - case "doctor": - result, err = runDoctor(args[1:]) - default: - err = usageError(fmt.Sprintf("unknown command %q", command)) + root.SetOut(stdout) + root.SetErr(stderr) + root.AddCommand(versionCommand(), doctorCommand(), keyGenCommand(), sigcryptCommand(), decverifyCommand(), identityCommand(), teamCommand(), submissionCommand()) + return root +} + +func versionCommand() *cobra.Command { + return &cobra.Command{Use: "version", Short: "print version information", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + return emit(command, buildinfo.Current()) + }} +} + +func doctorCommand() *cobra.Command { + var eventPath, registryPath string + command := &cobra.Command{Use: "doctor", Short: "check local eventctl configuration and protocol algorithms", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + if eventPath != "" { + binding, err := protocol.ReadEventBinding(eventPath) + if err != nil { + return err + } + result := map[string]any{"protocol": protocol.Protocol, "event": binding.Reference(), "go": runtime.Version(), "algorithms": map[string]string{"signing": "Ed25519", "recipient": "age-hybrid-mlkem768-x25519"}} + if registryPath != "" { + registry, readErr := protocol.ReadRegistry(registryPath, binding) + if readErr != nil { + return readErr + } + result["registry"] = map[string]any{"revision": registry.Revision, "phase": registry.Phase, "enabled": registry.Enabled, "identities": len(registry.Identities), "teams": len(registry.Teams), "attempts": len(registry.Attempts)} + } + return emit(command, result) + } + if registryPath != "" { + return errors.New("--registry requires --event") + } + var configuration doctorEnvironment + if err := env.Parse(&configuration); err != nil { + return fmt.Errorf("parse environment: %w", err) + } + if configuration.EventEpoch < 1 { + return errors.New("EVENTCTL_EVENT_EPOCH must be positive") + } + return emit(command, map[string]any{"protocol": protocol.Protocol, "event_id": configuration.EventID, "event_epoch": configuration.EventEpoch, "go": runtime.Version(), "algorithms": map[string]string{"signing": "Ed25519", "recipient": "age-hybrid-mlkem768-x25519"}}) + }} + command.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") + command.Flags().StringVar(®istryPath, "registry", "", "protected event registry JSON") + return command +} + +func keyGenCommand() *cobra.Command { + var directory, passphraseFile string + command := &cobra.Command{Use: "key-gen", Short: "create signing and recipient key pairs", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + value, err := protocol.GenerateKeyDirectory(directory, passphrase) + if err != nil { + return err + } + return emit(command, value) + }} + command.Flags().StringVar(&directory, "out", "", "key directory") + command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "key passphrase file") + require(command, "out") + return command +} + +func sigcryptCommand() *cobra.Command { + var input, output, context, signingPath, passphraseFile string + var recipientPaths []string + command := &cobra.Command{Use: "sigcrypt", Short: "sign and encrypt one byte stream", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadStreamBinding(context) + if err != nil { + return err + } + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + signingKey, err := protocol.LoadSigningPrivate(signingPath, passphrase) + if err != nil { + return err + } + recipients := make([]protocol.RecipientPublic, 0, len(recipientPaths)) + identities := make([]age.Recipient, 0, len(recipientPaths)) + seen := make(map[string]bool, len(recipientPaths)) + for _, path := range recipientPaths { + recipient, loadErr := protocol.LoadRecipientPublic(path) + if loadErr != nil { + return loadErr + } + if seen[recipient.KeyID] { + continue + } + seen[recipient.KeyID] = true + recipients = append(recipients, recipient) + identities = append(identities, recipient.Recipient) + } + value, err := stream.SealFile(input, output, binding, signingKey, recipients, identities) + if err != nil { + return err + } + return emit(command, value) + }} + command.Flags().StringVar(&input, "input", "", "input byte stream") + command.Flags().StringVar(&output, "output", "", "encrypted output") + command.Flags().StringVar(&context, "context", "", "stream binding JSON") + command.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") + command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") + command.Flags().StringArrayVar(&recipientPaths, "enc-public-key", nil, "recipient public key (repeatable)") + require(command, "input", "output", "context", "sig-private-key") + return command +} + +func decverifyCommand() *cobra.Command { + var input, output, context, signerPath, recipientPath, passphraseFile string + command := &cobra.Command{Use: "decverify", Short: "decrypt and verify one byte stream", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadStreamBinding(context) + if err != nil { + return err + } + signer, err := protocol.LoadSigningPublic(signerPath) + if err != nil { + return err + } + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + recipient, err := protocol.LoadRecipientPrivate(recipientPath, passphrase) + if err != nil { + return err + } + value, err := stream.OpenFile(input, output, binding, signer, recipient) + if err != nil { + return err + } + return emit(command, value) + }} + command.Flags().StringVar(&input, "input", "", "encrypted input") + command.Flags().StringVar(&output, "output", "", "verified plaintext output") + command.Flags().StringVar(&context, "context", "", "stream binding JSON") + command.Flags().StringVar(&signerPath, "ver-public-key", "", "signing public key") + command.Flags().StringVar(&recipientPath, "dec-private-key", "", "encrypted recipient key") + command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "recipient key passphrase file") + require(command, "input", "output", "context", "ver-public-key", "dec-private-key") + return command +} + +func identityCommand() *cobra.Command { + root := &cobra.Command{Use: "identity", Short: "register and verify participant identities"} + var eventPath, actorID, registrationID, signingPath, recipientPath, passphraseFile, output string + var keyEpoch int + register := &cobra.Command{Use: "register", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(eventPath) + if err != nil { + return err + } + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + key, err := protocol.LoadSigningPrivate(signingPath, passphrase) + if err != nil { + return err + } + recipient, err := protocol.LoadRecipientPublic(recipientPath) + if err != nil { + return err + } + value, err := protocol.RegisterIdentity(binding, actorID, keyEpoch, registrationID, key, recipient, now()) + if err != nil { + return err + } + if err := protocol.WriteJSON(output, value); err != nil { + return err + } + return emit(command, map[string]string{"output": output, "actor_id": value.ActorID, "registration_id": value.RegistrationID}) + }} + register.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") + register.Flags().StringVar(&actorID, "actor-id", "", "numeric GitHub actor ID") + register.Flags().IntVar(&keyEpoch, "key-epoch", 1, "identity key epoch") + register.Flags().StringVar(®istrationID, "registration-id", "", "UUIDv4 registration ID (generated if omitted)") + register.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") + register.Flags().StringVar(&recipientPath, "recipient-public-key", "", "age recipient public key") + register.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") + register.Flags().StringVar(&output, "output", "", "registration JSON") + require(register, "event", "actor-id", "sig-private-key", "recipient-public-key", "output") + + var verifyEvent, verifyInput, verifyActor, verifySourceTime, verifyOutput string + verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(verifyEvent) + if err != nil { + return err + } + var document protocol.IdentityRegistration + if err := protocol.ReadJSON(verifyInput, &document); err != nil { + return err + } + record, err := protocol.VerifyIdentity(document, binding, verifyActor, verifySourceTime) + if err != nil { + return err + } + if err := protocol.WriteJSON(verifyOutput, record); err != nil { + return err + } + return emit(command, map[string]string{"output": verifyOutput, "actor_id": record.ActorID, "registration_id": record.RegistrationID}) + }} + verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") + verify.Flags().StringVar(&verifyInput, "input", "", "registration JSON") + verify.Flags().StringVar(&verifyActor, "expect-actor-id", "", "trusted GitHub actor ID") + verify.Flags().StringVar(&verifySourceTime, "source-time", "", "trusted immutable source creation time") + verify.Flags().StringVar(&verifyOutput, "output", "", "verified identity record JSON") + require(verify, "event", "input", "expect-actor-id", "source-time", "output") + root.AddCommand(register, verify) + return root +} + +func teamCommand() *cobra.Command { + root := &cobra.Command{Use: "team", Short: "prepare, consent to, and verify team proposals"} + var eventPath, registryPath, teamID, actorID, signingPath, passphraseFile, output string + var members []string + propose := &cobra.Command{Use: "propose", Aliases: []string{"register"}, Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(eventPath) + if err != nil { + return err + } + registry, err := protocol.ReadRegistry(registryPath, binding) + if err != nil { + return err + } + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + key, err := protocol.LoadSigningPrivate(signingPath, passphrase) + if err != nil { + return err + } + proposal, err := protocol.ProposeTeam(binding, registry, teamID, actorID, members, key, now()) + if err != nil { + return err + } + if err := protocol.WriteJSON(output, proposal); err != nil { + return err + } + return emit(command, map[string]string{"output": output, "team_id": proposal.TeamID}) + }} + propose.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") + propose.Flags().StringVar(®istryPath, "registry", "", "protected active identity registry JSON") + propose.Flags().StringVar(&teamID, "team-id", "", "UUIDv4 team ID (generated if omitted)") + propose.Flags().StringVar(&actorID, "actor-id", "", "numeric proposer GitHub actor ID") + propose.Flags().StringArrayVar(&members, "member", nil, "member actor ID (repeatable, including proposer)") + propose.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") + propose.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") + propose.Flags().StringVar(&output, "output", "", "team proposal JSON") + require(propose, "event", "registry", "actor-id", "sig-private-key", "output") + + var consentEvent, consentRegistry, proposalPath, consentActor, consentSigning, consentPassphrase, consentOutput string + consent := &cobra.Command{Use: "consent", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(consentEvent) + if err != nil { + return err + } + registry, err := protocol.ReadRegistry(consentRegistry, binding) + if err != nil { + return err + } + var proposal protocol.TeamProposal + if err := protocol.ReadJSON(proposalPath, &proposal); err != nil { + return err + } + passphrase, err := readPassphrase(consentPassphrase) + if err != nil { + return err + } + key, err := protocol.LoadSigningPrivate(consentSigning, passphrase) + if err != nil { + return err + } + value, err := protocol.ConsentTeam(binding, registry, proposal, consentActor, key, now()) + if err != nil { + return err + } + if err := protocol.WriteJSON(consentOutput, value); err != nil { + return err + } + return emit(command, map[string]string{"output": consentOutput, "actor_id": value.ActorID}) + }} + consent.Flags().StringVar(&consentEvent, "event", "", "public event binding JSON") + consent.Flags().StringVar(&consentRegistry, "registry", "", "protected active identity registry JSON") + consent.Flags().StringVar(&proposalPath, "proposal", "", "team proposal JSON") + consent.Flags().StringVar(&consentActor, "actor-id", "", "numeric member GitHub actor ID") + consent.Flags().StringVar(&consentSigning, "sig-private-key", "", "encrypted signing key") + consent.Flags().StringVar(&consentPassphrase, "passphrase-file", "", "signing key passphrase file") + consent.Flags().StringVar(&consentOutput, "output", "", "team consent JSON") + require(consent, "event", "registry", "proposal", "actor-id", "sig-private-key", "output") + + var verifyEvent, verifyRegistry, verifyProposal, proposalSourceTime, verifyOutput string + var consentPaths, consentSourceTimes []string + verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(verifyEvent) + if err != nil { + return err + } + registry, err := protocol.ReadRegistry(verifyRegistry, binding) + if err != nil { + return err + } + var proposal protocol.TeamProposal + if err := protocol.ReadJSON(verifyProposal, &proposal); err != nil { + return err + } + if len(consentPaths) == 0 { + return errors.New("at least one --consent is required") + } + consents := make([]protocol.TeamConsent, 0, len(consentPaths)) + for _, path := range consentPaths { + var consent protocol.TeamConsent + if err := protocol.ReadJSON(path, &consent); err != nil { + return err + } + consents = append(consents, consent) + } + sourceTimes, err := parseActorTimes(consentSourceTimes) + if err != nil { + return err + } + value, err := protocol.VerifyTeam(binding, registry, proposal, proposalSourceTime, consents, sourceTimes) + if err != nil { + return err + } + if err := protocol.WriteJSON(verifyOutput, value); err != nil { + return err + } + return emit(command, map[string]string{"output": verifyOutput, "team_id": value.TeamID, "proposal_sha256": value.ProposalSHA256}) + }} + verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") + verify.Flags().StringVar(&verifyRegistry, "registry", "", "protected active identity registry JSON") + verify.Flags().StringVar(&verifyProposal, "proposal", "", "team proposal JSON") + verify.Flags().StringVar(&proposalSourceTime, "proposal-source-time", "", "trusted proposal pull-request creation time") + verify.Flags().StringArrayVar(&consentPaths, "consent", nil, "team consent JSON (repeatable)") + verify.Flags().StringArrayVar(&consentSourceTimes, "consent-source-time", nil, "actor_id=RFC3339 proof creation time (repeatable)") + verify.Flags().StringVar(&verifyOutput, "output", "", "verified team plan JSON") + require(verify, "event", "registry", "proposal", "proposal-source-time", "output") + root.AddCommand(propose, consent, verify) + return root +} + +func submissionCommand() *cobra.Command { + root := &cobra.Command{Use: "submission", Short: "prepare and verify event-bound submissions"} + var eventPath, input, metadata, teamID, attemptID, actorID, signingPath, passphraseFile, output string + var keyEpoch int + prepare := &cobra.Command{Use: "prepare", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(eventPath) + if err != nil { + return err + } + payload, err := protocol.ReadBytes(input) + if err != nil { + return err + } + var metadataBytes []byte + if metadata != "" { + metadataBytes, err = protocol.ReadBytes(metadata) + if err != nil { + return err + } + } + passphrase, err := readPassphrase(passphraseFile) + if err != nil { + return err + } + key, err := protocol.LoadSigningPrivate(signingPath, passphrase) + if err != nil { + return err + } + value, err := protocol.PrepareSubmission(binding, teamID, attemptID, actorID, keyEpoch, payload, metadataBytes, key, now()) + if err != nil { + return err + } + if err := protocol.WriteJSON(output, value); err != nil { + return err + } + return emit(command, map[string]string{"output": output, "attempt_id": value.AttemptID, "payload_sha256": value.PayloadSHA256}) + }} + prepare.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") + prepare.Flags().StringVar(&input, "input", "", "submission byte stream") + prepare.Flags().StringVar(&metadata, "metadata", "", "optional metadata JSON") + prepare.Flags().StringVar(&teamID, "team-id", "", "active UUIDv4 team ID") + prepare.Flags().StringVar(&attemptID, "attempt-id", "", "UUIDv4 attempt ID") + prepare.Flags().StringVar(&actorID, "actor-id", "", "numeric submitter GitHub actor ID") + prepare.Flags().IntVar(&keyEpoch, "key-epoch", 1, "identity key epoch") + prepare.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") + prepare.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") + prepare.Flags().StringVar(&output, "output", "", "submission request JSON") + require(prepare, "event", "input", "team-id", "attempt-id", "actor-id", "sig-private-key", "output") + + var verifyEvent, verifyRegistry, requestPath, bundlePath, verifyMetadata, verifyActor, verifySourceTime, verifyOutput string + verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + binding, err := protocol.ReadEventBinding(verifyEvent) + if err != nil { + return err + } + registry, err := protocol.ReadRegistry(verifyRegistry, binding) + if err != nil { + return err + } + var document protocol.Submission + if err := protocol.ReadJSON(requestPath, &document); err != nil { + return err + } + bundle, err := protocol.ReadBytes(bundlePath) + if err != nil { + return err + } + var metadataBytes []byte + if verifyMetadata != "" { + metadataBytes, err = protocol.ReadBytes(verifyMetadata) + if err != nil { + return err + } + } + if err := protocol.VerifySubmission(document, binding, registry, verifyActor, verifySourceTime, bundle, metadataBytes); err != nil { + return err + } + if err := protocol.WriteJSON(verifyOutput, document); err != nil { + return err + } + return emit(command, map[string]string{"output": verifyOutput, "attempt_id": document.AttemptID, "team_id": document.TeamID}) + }} + verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") + verify.Flags().StringVar(&verifyRegistry, "registry", "", "protected active identity registry JSON") + verify.Flags().StringVar(&requestPath, "request", "", "submission request JSON") + verify.Flags().StringVar(&bundlePath, "bundle", "", "exact submitted bundle") + verify.Flags().StringVar(&verifyMetadata, "metadata", "", "optional exact metadata") + verify.Flags().StringVar(&verifyActor, "expect-actor-id", "", "trusted GitHub actor ID") + verify.Flags().StringVar(&verifySourceTime, "source-time", "", "trusted immutable source creation time") + verify.Flags().StringVar(&verifyOutput, "output", "", "verified submission request JSON") + require(verify, "event", "registry", "request", "bundle", "expect-actor-id", "source-time", "output") + root.AddCommand(prepare, verify) + return root +} + +func parseActorTimes(values []string) (map[string]string, error) { + times := make(map[string]string, len(values)) + for _, value := range values { + actorID, timestamp, found := strings.Cut(value, "=") + if !found || actorID == "" || timestamp == "" || times[actorID] != "" { + return nil, errors.New("consent source time must be unique actor_id=RFC3339") + } + times[actorID] = timestamp } - if err != nil { - return emitFailure(stdout, command, asCommandError(err)) + return times, nil +} + +func require(command *cobra.Command, names ...string) { + for _, name := range names { + _ = command.MarkFlagRequired(name) } - return emitSuccess(stdout, commandName(args), result) } -func commandName(args []string) string { - if len(args) > 1 && !strings.HasPrefix(args[1], "-") { - return args[0] + "." + args[1] +func now() time.Time { return time.Now().UTC() } + +func readPassphrase(path string) (string, error) { + if path == "" { + return "", errors.New("--passphrase-file is required") + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read passphrase: %w", err) } - return args[0] + passphrase := strings.TrimSpace(string(data)) + if passphrase == "" { + return "", errors.New("passphrase file is empty") + } + return passphrase, nil +} + +func emit(command *cobra.Command, value any) error { + return writeResponse(command.OutOrStdout(), response{OK: true, Command: strings.TrimPrefix(command.CommandPath(), "eventctl "), Result: value}) +} + +func writeResponse(writer io.Writer, value response) error { + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + return encoder.Encode(value) } diff --git a/cmd/eventctl/main_test.go b/cmd/eventctl/main_test.go deleted file mode 100644 index 8950b10..0000000 --- a/cmd/eventctl/main_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import "testing" - -func TestCommandNameDoesNotTreatDirectCommandFlagAsSubcommand(t *testing.T) { - t.Parallel() - if got := commandName([]string{"doctor", "--out", "doctor.json"}); got != "doctor" { - t.Fatalf("commandName() = %q, want doctor", got) - } - if got := commandName([]string{"submission", "verify", "--out", "verified.json"}); got != "submission.verify" { - t.Fatalf("commandName() = %q, want submission.verify", got) - } -} diff --git a/cmd/eventctl/output.go b/cmd/eventctl/output.go deleted file mode 100644 index 473a58d..0000000 --- a/cmd/eventctl/output.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - - "github.com/pythonhk/eventctl/internal/canonical" -) - -const outputVersion = "pythonhk.eventctl/output/v1" - -type response struct { - OutputVersion string `json:"output_version"` - OK bool `json:"ok"` - Command string `json:"command"` - Result any `json:"result"` - Error *errorObject `json:"error"` -} - -type errorObject struct { - Code string `json:"code"` - Message string `json:"message"` -} -type commandError struct { - Code, Message string - Exit int - Cause error -} - -func (err commandError) Error() string { - if err.Cause != nil { - return err.Message + ": " + err.Cause.Error() - } - return err.Message -} -func (err commandError) Unwrap() error { return err.Cause } -func usageError(message string) error { return commandError{Code: "usage", Message: message, Exit: 2} } -func invalidError(message string, cause error) error { - return commandError{Code: "invalid_input", Message: message, Exit: 2, Cause: cause} -} -func verificationError(message string, cause error) error { - return commandError{Code: "verification_failed", Message: message, Exit: 3, Cause: cause} -} -func ioError(message string, cause error) error { - return commandError{Code: "io_error", Message: message, Exit: 4, Cause: cause} -} - -func asCommandError(err error) commandError { - var typed commandError - if errors.As(err, &typed) { - return typed - } - return commandError{Code: "internal_error", Message: "command failed", Exit: 1, Cause: err} -} - -func emitSuccess(output io.Writer, command string, result any) int { - return emit(output, response{OutputVersion: outputVersion, OK: true, Command: command, Result: result, Error: nil}, 0) -} -func emitFailure(output io.Writer, command string, err commandError) int { - message := err.Error() - if message == "" { - message = "command failed" - } - return emit(output, response{OutputVersion: outputVersion, OK: false, Command: command, Result: nil, Error: &errorObject{Code: err.Code, Message: message}}, err.Exit) -} -func emit(output io.Writer, value any, exit int) int { - raw, err := canonical.Marshal(value) - if err != nil { - fmt.Fprintf(output, "{\"ok\":false,\"error\":{\"code\":\"encoding_error\"}}\n") - return 1 - } - raw = append(raw, '\n') - if _, err := output.Write(raw); err != nil { - return 1 - } - return exit -} diff --git a/cmd/eventctl/receipt.go b/cmd/eventctl/receipt.go deleted file mode 100644 index 6fc1c6f..0000000 --- a/cmd/eventctl/receipt.go +++ /dev/null @@ -1,148 +0,0 @@ -package main - -import ( - "fmt" - "io" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/receipt" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -type normalizedReceipt struct { - Status string `json:"status"` - Kind string `json:"kind"` - DocumentDigest string `json:"document_digest"` - Document receipt.Receipt `json:"document"` -} - -type receiptSummary struct { - Path string `json:"path"` - Kind string `json:"kind"` - ReceiptID string `json:"receipt_id"` - OperationID string `json:"operation_id"` - DocumentDigest string `json:"document_digest"` - StateSequence uint64 `json:"state_sequence"` -} - -func runReceipt(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl receipt sign|verify") - } - switch args[0] { - case "sign": - return receiptSign(args[1:], stderr) - case "verify": - return receiptVerify(args[1:]) - default: - return nil, usageError("usage: eventctl receipt sign|verify") - } -} - -func receiptVerify(args []string) (any, error) { - flags := newFlagSet("receipt verify") - configPath := flags.String("config", "", "signed event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - receiptPath := flags.String("receipt", "", "signed committed-operation receipt") - out := flags.String("out", "", "normalized verified receipt output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *receiptPath == "" || *out == "" { - return nil, usageError("usage: eventctl receipt verify --config PATH --authority PATH --state-meta PATH --receipt PATH --out PATH") - } - raw, err := readBounded(*receiptPath, receipt.MaxSignedBytesWithLF) - if err != nil { - return nil, ioError("read receipt", err) - } - parsed, err := receipt.Parse(raw) - if err != nil { - return nil, invalidError("validate receipt", err) - } - sourceCreatedAt, _ := envelope.ParseTimestamp(parsed.SourceCreatedAt) - event, digest, meta, err := loadArchivedTrustedContext(*configPath, *authorityPath, *statePath, sourceCreatedAt) - if err != nil { - return nil, verificationError("verify historical event trust context", err) - } - verified, err := receipt.Verify(raw, receipt.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - SigningKey: event.Receipts.SigningKey, CurrentState: currentPointer(meta), - Now: time.Now().UTC(), - }) - if err != nil { - return nil, verificationError("verify committed operation receipt", err) - } - normalized := normalizedReceipt{"verified", receipt.Kind, verified.DocumentDigest, verified.Document} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified receipt", err) - } - return receiptSummary{*out, receipt.Kind, verified.Document.ReceiptID, verified.Document.OperationID, verified.DocumentDigest, verified.Document.StateAfter.Sequence}, nil -} - -func receiptSign(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("receipt sign") - configPath := flags.String("config", "", "signed event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - claimPath := flags.String("claim", "", "strict committed receipt-pending claim") - keyPath := flags.String("key", "", "encrypted dedicated receipt signing key") - passphraseFile := flags.String("passphrase-file", "", "passphrase file or -") - out := flags.String("out", "", "raw signed receipt output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *claimPath == "" || *keyPath == "" || *out == "" { - return nil, usageError("usage: eventctl receipt sign --config PATH --authority PATH --state-meta PATH --claim PATH --key PATH --out PATH [--passphrase-file PATH|-]") - } - claimRaw, err := readBounded(*claimPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read committed receipt claim", err) - } - claim, err := receipt.ParseCommittedClaim(claimRaw) - if err != nil { - return nil, invalidError("validate committed receipt claim", err) - } - sourceCreatedAt, _ := envelope.ParseTimestamp(claim.SourceCreatedAt) - event, digest, meta, err := loadArchivedTrustedContext(*configPath, *authorityPath, *statePath, sourceCreatedAt) - if err != nil { - return nil, verificationError("verify archived committed config", err) - } - pair, err := loadPrivate(*keyPath, *passphraseFile, stderr) - if err != nil { - return nil, verificationError("decrypt receipt signing key", err) - } - document, err := receipt.SignCommitted(claim, receipt.SignExpected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - SigningKey: event.Receipts.SigningKey, CurrentState: currentPointer(meta), - }, pair.Private) - if err != nil { - return nil, verificationError("sign committed operation receipt", err) - } - raw, err := canonical.Marshal(document) - if err != nil { - return nil, err - } - if len(raw) > receipt.MaxSignedBytes { - return nil, verificationError("enforce signed receipt size", fmt.Errorf("signed receipt is %d bytes, limit is %d", len(raw), receipt.MaxSignedBytes)) - } - if _, err := receipt.Verify(raw, receipt.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - SigningKey: event.Receipts.SigningKey, CurrentState: currentPointer(meta), - Now: time.Now().UTC(), - }); err != nil { - return nil, verificationError("self-verify signed receipt", err) - } - if err := writeExclusive(*out, append(raw, '\n'), 0o644); err != nil { - return nil, ioError("write signed receipt", err) - } - documentDigest, err := envelope.DocumentDigest(document) - if err != nil { - return nil, err - } - return receiptSummary{*out, receipt.Kind, document.ReceiptID, document.OperationID, documentDigest, document.StateAfter.Sequence}, nil -} - -func currentPointer(meta config.StateMeta) statepointer.Pointer { - return statepointer.Pointer{Sequence: meta.Sequence, JournalEventDigest: meta.JournalEventDigest} -} diff --git a/cmd/eventctl/recipient.go b/cmd/eventctl/recipient.go deleted file mode 100644 index b93e397..0000000 --- a/cmd/eventctl/recipient.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "context" - "io" - "os" - "path/filepath" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/keystore" - organizerrecipient "github.com/pythonhk/eventctl/internal/recipient" -) - -const recipientAlgorithm = "age-hybrid-mlkem768-x25519" - -type recipientPublicDocument struct { - Kind string `json:"kind"` - Algorithm string `json:"algorithm"` - PublicKey string `json:"public_key"` - Fingerprint string `json:"fingerprint"` -} - -func runRecipient(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl recipient generate|show") - } - switch args[0] { - case "generate": - return recipientGenerate(args[1:], stderr) - case "show": - return recipientShow(args[1:], stderr) - default: - return nil, usageError("usage: eventctl recipient generate|show") - } -} - -func recipientGenerate(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("recipient generate") - identityOut := flags.String("identity-out", "", "encrypted organizer identity output") - recipientOut := flags.String("recipient-out", "", "public hybrid recipient output") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *identityOut == "" || *recipientOut == "" { - return nil, usageError("usage: eventctl recipient generate --identity-out PATH --recipient-out PATH [--passphrase-file PATH|-]") - } - if filepath.Clean(*identityOut) == filepath.Clean(*recipientOut) { - return nil, invalidError("identity and recipient outputs must differ", nil) - } - passphrase, err := readPassphrase(*passFile, "New recipient identity passphrase: ", true, stderr) - if err != nil { - return nil, invalidError("read passphrase", err) - } - defer clear(passphrase) - if err := os.MkdirAll(filepath.Dir(*identityOut), 0o700); err != nil { - return nil, ioError("create recipient identity directory", err) - } - public, err := organizerrecipient.GenerateIdentityFile(context.Background(), *identityOut, passphrase, keystore.DefaultLimits()) - if err != nil { - return nil, ioError("generate encrypted recipient identity", err) - } - document := recipientPublicDocument{ - Kind: "submission_recipient", Algorithm: recipientAlgorithm, - PublicKey: public.Recipient, Fingerprint: public.Fingerprint, - } - raw, err := canonical.Marshal(document) - if err != nil { - _ = os.Remove(*identityOut) - return nil, err - } - if err := writeExclusive(*recipientOut, append(raw, '\n'), 0o644); err != nil { - _ = os.Remove(*identityOut) - return nil, ioError("write public recipient", err) - } - return struct { - IdentityPath string `json:"identity_path"` - RecipientPath string `json:"recipient_path"` - Algorithm string `json:"algorithm"` - Fingerprint string `json:"fingerprint"` - }{*identityOut, *recipientOut, recipientAlgorithm, public.Fingerprint}, nil -} - -func recipientShow(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("recipient show") - identityPath := flags.String("identity", "", "encrypted organizer identity") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *identityPath == "" { - return nil, usageError("usage: eventctl recipient show --identity PATH [--passphrase-file PATH|-]") - } - passphrase, err := readPassphrase(*passFile, "Recipient identity passphrase: ", false, stderr) - if err != nil { - return nil, invalidError("read passphrase", err) - } - defer clear(passphrase) - identity, err := organizerrecipient.LoadIdentity(context.Background(), *identityPath, passphrase, keystore.DefaultLimits()) - if err != nil { - return nil, verificationError("decrypt recipient identity", err) - } - public, err := organizerrecipient.Describe(identity) - if err != nil { - return nil, verificationError("validate recipient identity", err) - } - return recipientPublicDocument{ - Kind: "submission_recipient", Algorithm: recipientAlgorithm, - PublicKey: public.Recipient, Fingerprint: public.Fingerprint, - }, nil -} diff --git a/cmd/eventctl/registry_test.go b/cmd/eventctl/registry_test.go deleted file mode 100644 index 5db90ce..0000000 --- a/cmd/eventctl/registry_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "encoding/binary" - "os" - "path/filepath" - "strconv" - "testing" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/identity" -) - -func TestMaximumParticipantRegistryFitsActualLoader(t *testing.T) { - t.Parallel() - registry := identity.Registry{ - Schema: identity.RegistrySchema, - Identities: make([]identity.RegistryEntry, identity.MaxRegistryEntries), - } - const firstTwentyDigitActorID = uint64(10_000_000_000_000_000_000) - for index := range registry.Identities { - seed := make([]byte, 32) - binary.BigEndian.PutUint64(seed[24:], uint64(index+1)) - pair, err := identity.FromSeed(seed) - if err != nil { - t.Fatal(err) - } - registry.Identities[index] = identity.RegistryEntry{ - ActorID: strconv.FormatUint(firstTwentyDigitActorID+uint64(index), 10), - KeyEpoch: "99999999999999999999", - Identity: pair.Public, - } - } - raw, err := canonical.Marshal(registry) - if err != nil { - t.Fatal(err) - } - if len(raw) > config.MaxBytes { - t.Fatalf("worst-case %d-entry registry is %d bytes, loader cap is %d", identity.MaxRegistryEntries, len(raw), config.MaxBytes) - } - t.Logf("maximum %d-entry registry uses %d of %d loader bytes", identity.MaxRegistryEntries, len(raw), config.MaxBytes) - path := filepath.Join(t.TempDir(), "registry.json") - if err := os.WriteFile(path, raw, 0o600); err != nil { - t.Fatal(err) - } - loaded, err := loadRegistry(path) - if err != nil { - t.Fatalf("loadRegistry rejected maximum-participant fixture (%d bytes): %v", len(raw), err) - } - if len(loaded.Identities) != identity.MaxRegistryEntries { - t.Fatalf("loaded %d identities, want %d", len(loaded.Identities), identity.MaxRegistryEntries) - } - - registry.Identities = append(registry.Identities, identity.RegistryEntry{ - ActorID: "10000000000000001000", - KeyEpoch: "99999999999999999999", - Identity: registry.Identities[0].Identity, - }) - if err := registry.Validate(); err == nil { - t.Fatal("identity registry accepted 1,001 entries") - } -} diff --git a/cmd/eventctl/replay.go b/cmd/eventctl/replay.go deleted file mode 100644 index 1966cc6..0000000 --- a/cmd/eventctl/replay.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" -) - -func runReplay(args []string) (any, error) { - if len(args) == 0 || args[0] != "classify" { - return nil, usageError("usage: eventctl replay classify --incoming PATH [--existing PATH] --out PATH") - } - flags := newFlagSet("replay classify") - incomingPath := flags.String("incoming", "", "incoming fingerprint JSON") - existingPath := flags.String("existing", "", "stored fingerprint JSON") - out := flags.String("out", "", "classification JSON output") - if err := flags.Parse(args[1:]); err != nil || flags.NArg() != 0 || *incomingPath == "" || *out == "" { - return nil, usageError("usage: eventctl replay classify --incoming PATH [--existing PATH] --out PATH") - } - incoming, err := loadFingerprint(*incomingPath) - if err != nil { - return nil, invalidError("load incoming fingerprint", err) - } - var existing *envelope.Fingerprint - if *existingPath != "" { - value, loadErr := loadFingerprint(*existingPath) - if loadErr != nil { - return nil, invalidError("load existing fingerprint", loadErr) - } - existing = &value - } - disposition, err := envelope.ClassifyReplay(existing, incoming) - if err != nil { - return nil, invalidError("classify replay", err) - } - result := struct { - Status string `json:"status"` - Disposition envelope.ReplayDisposition `json:"disposition"` - Fingerprint envelope.Fingerprint `json:"fingerprint"` - }{"classified", disposition, incoming} - if err := writeCanonical(*out, result, 0o644); err != nil { - return nil, ioError("write replay classification", err) - } - return result, nil -} -func loadFingerprint(path string) (envelope.Fingerprint, error) { - raw, err := readBounded(path, 64*1024) - if err != nil { - return envelope.Fingerprint{}, err - } - var value envelope.Fingerprint - if err := canonical.StrictUnmarshal(raw, &value); err != nil { - return envelope.Fingerprint{}, err - } - if err := value.Validate(); err != nil { - return envelope.Fingerprint{}, err - } - return value, nil -} diff --git a/cmd/eventctl/scorer.go b/cmd/eventctl/scorer.go deleted file mode 100644 index 4daafa4..0000000 --- a/cmd/eventctl/scorer.go +++ /dev/null @@ -1,247 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/receipt" - "github.com/pythonhk/eventctl/internal/scorer" -) - -type normalizedScorerRequest struct { - Status string `json:"status"` - Kind string `json:"kind"` - DocumentDigest string `json:"document_digest"` - Document scorer.Request `json:"document"` -} - -type normalizedScorerResult struct { - Status string `json:"status"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - ReplayKey string `json:"replay_key"` - ScorerRequestDigest string `json:"scorer_request_digest"` - Document scorer.Result `json:"document"` -} - -func runScorer(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl scorer validate-request|sign-result|verify") - } - switch args[0] { - case "validate-request": - return scorerValidateRequest(args[1:]) - case "sign-result": - return scorerSignResult(args[1:], stderr) - case "verify": - return scorerVerify(args[1:]) - default: - return nil, usageError("usage: eventctl scorer validate-request|sign-result|verify") - } -} - -func scorerValidateRequest(args []string) (any, error) { - flags := newFlagSet("scorer validate-request") - configPath := flags.String("config", "", "signed event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - requestPath := flags.String("request", "", "unsigned scorer request") - acceptancePath := flags.String("acceptance", "", "signed accepted submission reservation receipt") - out := flags.String("out", "", "normalized scorer request output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *requestPath == "" || *acceptancePath == "" || *out == "" { - return nil, usageError("usage: eventctl scorer validate-request --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --out PATH") - } - raw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read scorer request", err) - } - request, err := scorer.ParseRequest(raw) - if err != nil { - return nil, invalidError("validate scorer request", err) - } - event, digest, meta, _, err := loadScorerTrust(*configPath, *authorityPath, *statePath, *acceptancePath, request) - if err != nil { - return nil, verificationError("verify historical event trust context", err) - } - expected := scorerExpected(event, digest, meta, time.Now().UTC()) - if err := scorer.VerifyRequestContext(request, expected); err != nil { - return nil, verificationError("verify scorer request context", err) - } - documentDigest, err := envelope.DocumentDigest(request) - if err != nil { - return nil, err - } - normalized := normalizedScorerRequest{"valid", scorer.RequestKind, documentDigest, request} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write normalized scorer request", err) - } - return struct { - Path string `json:"path"` - Kind string `json:"kind"` - DocumentDigest string `json:"document_digest"` - AttemptID string `json:"attempt_id"` - }{*out, scorer.RequestKind, documentDigest, request.AttemptID}, nil -} - -func scorerVerify(args []string) (any, error) { - flags := newFlagSet("scorer verify") - configPath := flags.String("config", "", "signed event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - requestPath := flags.String("request", "", "exact unsigned scorer request") - acceptancePath := flags.String("acceptance", "", "signed accepted submission reservation receipt") - resultPath := flags.String("result", "", "signed scorer result") - out := flags.String("out", "", "normalized verified scorer result") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *requestPath == "" || *acceptancePath == "" || *resultPath == "" || *out == "" { - return nil, usageError("usage: eventctl scorer verify --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --result PATH --out PATH") - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read scorer request", err) - } - parsedRequest, err := scorer.ParseRequest(requestRaw) - if err != nil { - return nil, invalidError("validate scorer request", err) - } - event, digest, meta, _, err := loadScorerTrust(*configPath, *authorityPath, *statePath, *acceptancePath, parsedRequest) - if err != nil { - return nil, verificationError("verify historical event trust context", err) - } - resultRaw, err := readBounded(*resultPath, int64(event.Scoring.MaximumResultBytes)) - if err != nil { - return nil, ioError("read scorer result", err) - } - verified, err := scorer.Verify(requestRaw, resultRaw, scorerExpected(event, digest, meta, time.Now().UTC())) - if err != nil { - return nil, verificationError("verify scorer result", err) - } - normalized := normalizedScorerResult{"verified", scorer.ResultKind, verified.Fingerprint.RequestDigest, verified.DocumentDigest, verified.Fingerprint.ReplayKey, verified.ScorerRequestDigest, verified.Document} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified scorer result", err) - } - return requestSummary{*out, scorer.ResultKind, verified.Fingerprint.RequestDigest, verified.DocumentDigest, verified.Fingerprint.ReplayKey}, nil -} - -func scorerSignResult(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("scorer sign-result") - configPath := flags.String("config", "", "signed archived event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - requestPath := flags.String("request", "", "exact unsigned scorer request") - acceptancePath := flags.String("acceptance", "", "signed accepted submission reservation receipt") - unsignedPath := flags.String("unsigned-result", "", "strict unsigned scorer result") - keyPath := flags.String("key", "", "encrypted dedicated scorer result key") - passphraseFile := flags.String("passphrase-file", "", "passphrase file or -") - out := flags.String("out", "", "raw signed scorer result output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *requestPath == "" || *acceptancePath == "" || *unsignedPath == "" || *keyPath == "" || *out == "" { - return nil, usageError("usage: eventctl scorer sign-result --config PATH --authority PATH --state-meta PATH --request PATH --acceptance PATH --unsigned-result PATH --key PATH --out PATH [--passphrase-file PATH|-]") - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read scorer request", err) - } - request, err := scorer.ParseRequest(requestRaw) - if err != nil { - return nil, invalidError("validate scorer request", err) - } - event, digest, meta, _, err := loadScorerTrust(*configPath, *authorityPath, *statePath, *acceptancePath, request) - if err != nil { - return nil, verificationError("verify accepted scorer request", err) - } - unsignedRaw, err := readBounded(*unsignedPath, int64(event.Scoring.MaximumResultBytes)) - if err != nil { - return nil, ioError("read unsigned scorer result", err) - } - payload, err := scorer.ParseUnsignedResult(unsignedRaw, event.Scoring.MaximumResultBytes) - if err != nil { - return nil, invalidError("validate unsigned scorer result", err) - } - pair, err := loadPrivate(*keyPath, *passphraseFile, stderr) - if err != nil { - return nil, verificationError("decrypt scorer result key", err) - } - expected := scorerExpected(event, digest, meta, time.Now().UTC()) - document, err := scorer.SignResult(payload, request, expected, pair.Private) - if err != nil { - return nil, verificationError("sign scorer result", err) - } - resultRaw, err := canonical.Marshal(document) - if err != nil { - return nil, err - } - verified, err := scorer.Verify(requestRaw, resultRaw, expected) - if err != nil { - return nil, verificationError("self-verify scorer result", err) - } - if err := writeExclusive(*out, append(resultRaw, '\n'), 0o644); err != nil { - return nil, ioError("write signed scorer result", err) - } - return requestSummary{*out, scorer.ResultKind, verified.Fingerprint.RequestDigest, verified.DocumentDigest, verified.Fingerprint.ReplayKey}, nil -} - -func loadScorerTrust(configPath, authorityPath, statePath, acceptancePath string, request scorer.Request) (config.Event, string, config.StateMeta, receipt.Verified, error) { - acceptanceRaw, err := readBounded(acceptancePath, envelope.MaxDocumentBytes) - if err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - parsedAcceptance, err := receipt.Parse(acceptanceRaw) - if err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - sourceCreatedAt, err := envelope.ParseTimestamp(parsedAcceptance.SourceCreatedAt) - if err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - event, digest, meta, err := loadArchivedTrustedContext(configPath, authorityPath, statePath, sourceCreatedAt) - if err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - if err := requireOneOfPhases(meta, "submissions_open", "frozen"); err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, fmt.Errorf("scoring is not allowed by current protected control state: %w", err) - } - if digest != parsedAcceptance.ConfigDigest || event.BaseRepository.ID != parsedAcceptance.BaseRepositoryID || request.ConfigDigest != digest { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, errors.New("accepted reservation does not bind archived config/current authorities") - } - verified, err := receipt.Verify(acceptanceRaw, receipt.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - SigningKey: event.Receipts.SigningKey, CurrentState: currentPointer(meta), - Now: time.Now().UTC(), - }) - if err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - if err := verifyScorerAcceptance(verified, request); err != nil { - return config.Event{}, "", config.StateMeta{}, receipt.Verified{}, err - } - return event, digest, meta, verified, nil -} - -func verifyScorerAcceptance(verified receipt.Verified, request scorer.Request) error { - document := verified.Document - if verified.DocumentDigest != request.ReservationReceiptDigest || document.RequestKind != envelope.SubmissionKind || document.Outcome != "accepted" || !document.QuotaCharged || document.ActorID != request.ActorID || document.TeamID == nil || *document.TeamID != request.TeamID || document.AttemptID == nil || *document.AttemptID != request.AttemptID || document.RequestDocumentDigest == nil || *document.RequestDocumentDigest != request.SubmissionEnvelopeDigest || !document.StateAfter.Equal(request.Reservation) || document.SourceCreatedAt != request.SourceCreatedAt || document.IssuedAt != request.AcceptedAt || document.ScorerResultDigest != nil || document.ReservationReceiptDigest != nil { - return errors.New("reservation receipt does not exactly bind scorer request acceptance") - } - if err := document.ValidateSourceWindow(request.IssuedAt, request.ExpiresAt); err != nil { - return err - } - return nil -} - -func scorerExpected(event config.Event, digest string, meta config.StateMeta, now time.Time) scorer.Expected { - return scorer.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, BaseRef: event.Submissions.BaseRef, - ConfigDigest: digest, - Scorer: scorer.Identity{ID: event.Scoring.ScorerID, Version: event.Scoring.ScorerVersion, PolicyDigest: event.Scoring.PolicyDigest}, - ResultKey: event.Scoring.ResultKey, MaximumResultBytes: event.Scoring.MaximumResultBytes, - MaximumCiphertextBytes: event.Submissions.MaximumCiphertextBytes, - CurrentState: currentPointer(meta), Now: now, - } -} diff --git a/cmd/eventctl/submission.go b/cmd/eventctl/submission.go deleted file mode 100644 index 37a06ad..0000000 --- a/cmd/eventctl/submission.go +++ /dev/null @@ -1,700 +0,0 @@ -package main - -import ( - "context" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "runtime" - "sort" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/bundle" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/keystore" - "github.com/pythonhk/eventctl/internal/receipt" - organizerrecipient "github.com/pythonhk/eventctl/internal/recipient" - "github.com/pythonhk/eventctl/internal/team" -) - -func runSubmission(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl submission pack|inspect|verify|prepare|authenticate-request|verify-request|decrypt-verify") - } - switch args[0] { - case "pack": - return submissionPack(args[1:], stderr) - case "inspect": - return submissionInspect(args[1:]) - case "verify": - return submissionVerifyPublic(args[1:]) - case "prepare": - return submissionPrepare(args[1:], stderr) - case "authenticate-request": - return submissionAuthenticateRequest(args[1:]) - case "verify-request": - return submissionVerifyRequest(args[1:]) - case "decrypt-verify": - return submissionDecryptVerify(args[1:], stderr) - default: - return nil, usageError("usage: eventctl submission pack|inspect|verify|prepare|authenticate-request|verify-request|decrypt-verify") - } -} - -func submissionPack(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("submission pack") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - teamsPath := flags.String("teams", "", "protected current teams view") - teamID := flags.String("team-id", "", "active team UUIDv4") - keyPath := flags.String("key", "", "encrypted participant key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - actorID := flags.String("actor-id", "", "numeric GitHub actor ID") - attemptID := flags.String("attempt-id", "", "UUIDv4 (generated if omitted)") - requestID := flags.String("request-id", "", "UUIDv4 (generated if omitted)") - source := flags.String("source", "", "submission source directory") - bundleOut := flags.String("bundle-out", "", "must be submission.eventctl") - recordOut := flags.String("record-out", "", "local pack record output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *teamsPath == "" || *teamID == "" || *keyPath == "" || *actorID == "" || *source == "" || *bundleOut == "" || *recordOut == "" { - return nil, usageError("usage: eventctl submission pack --config PATH --authority PATH --state-meta PATH --registry PATH --teams PATH --team-id UUID --key PATH --actor-id ID --source DIR --bundle-out submission.eventctl --record-out PATH [--passphrase-file PATH|-] [--attempt-id UUID] [--request-id UUID]") - } - if filepath.Base(filepath.Clean(*bundleOut)) != "submission.eventctl" { - return nil, invalidError("--bundle-out file name must be submission.eventctl", nil) - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "submissions_open"); err != nil { - return nil, verificationError("submissions are not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt participant key", err) - } - keyEpoch, err := resolveKeyEpoch(registry, *actorID, pair.Public) - if err != nil { - return nil, verificationError("resolve active registration", err) - } - teamsRaw, err := readBounded(*teamsPath, 16<<20) - if err != nil { - return nil, ioError("read protected teams view", err) - } - teamsView, err := team.ParseTeamsView(teamsRaw) - if err != nil { - return nil, verificationError("validate protected teams view", err) - } - if teamsView.EventID != event.EventID || teamsView.EventEpoch != event.EventEpoch || teamsView.ConfigDigest != digest || teamsView.Sequence != meta.Sequence || teamsView.JournalEventDigest != meta.JournalEventDigest { - return nil, verificationError("protected teams view does not match trusted current state", nil) - } - activeTeam, ok := teamsView.FindActiveTeam(*teamID) - if !ok { - return nil, verificationError("team is not active in protected current state", nil) - } - if !containsActor(activeTeam.MemberActorIDs, *actorID) { - return nil, verificationError("submission actor is not a team member", nil) - } - if *attemptID == "" { - *attemptID, err = envelope.NewRequestID() - if err != nil { - return nil, err - } - } - if *requestID == "" { - *requestID, err = envelope.NewRequestID() - if err != nil { - return nil, err - } - } - if err := validateSourceExtensions(*source, event.Submissions.AllowedExtensions); err != nil { - return nil, invalidError("validate source extensions", err) - } - recipients := make([]*age.HybridRecipient, 0, len(event.Submissions.Encryption.Recipients)) - for _, configured := range event.Submissions.Encryption.Recipients { - recipient, parseErr := age.ParseHybridRecipient(configured.PublicKey) - if parseErr != nil { - return nil, verificationError("parse trusted hybrid recipient", parseErr) - } - recipients = append(recipients, recipient) - } - privateKey, _, err := identity.SigningKey(pair.Private) - if err != nil { - return nil, err - } - issued := time.Now().UTC().Truncate(time.Second) - limits := bundleLimits(event) - packed, err := bundle.PackDirectory(context.Background(), bundle.PackOptions{SourceDir: *source, OutputPath: *bundleOut, Binding: bundle.Binding{EventID: event.EventID, EventEpoch: event.EventEpoch, RequestID: *requestID, AttemptID: *attemptID, ActorID: *actorID, KeyID: pair.Public.KeyID, KeyEpoch: keyEpoch, TeamID: activeTeam.TeamID, TeamProposalDigest: activeTeam.ProposalDigest, BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, IssuedAt: formatTimestamp(issued), ExpiresAt: formatTimestamp(issued.Add(time.Duration(event.Submissions.EnvelopeTTLSeconds) * time.Second)), RecipientEpoch: event.Submissions.Encryption.RecipientEpoch}, Recipients: recipients, SigningKey: privateKey, Limits: limits}) - if err != nil { - return nil, invalidError("pack encrypted submission", err) - } - record := packRecordFrom(packed, issued) - if err := record.Validate(); err != nil { - return nil, verificationError("validate generated pack record", err) - } - if err := writeCanonical(*recordOut, record, 0o600); err != nil { - return nil, ioError("write pack record", err) - } - return struct { - Bundle string `json:"bundle"` - Record string `json:"record"` - BundleSHA256 string `json:"bundle_sha256"` - EnvelopeSHA256 string `json:"envelope_sha256"` - AttemptID string `json:"attempt_id"` - RequestID string `json:"request_id"` - }{*bundleOut, *recordOut, packed.BundleSHA256, packed.EnvelopeSHA256, *attemptID, *requestID}, nil -} - -func submissionInspect(args []string) (any, error) { - flags := newFlagSet("submission inspect") - path := flags.String("bundle", "", "encrypted bundle") - out := flags.String("out", "", "normalized inspection output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *path == "" || *out == "" { - return nil, usageError("usage: eventctl submission inspect --bundle PATH --out PATH") - } - inspection, err := bundle.Inspect(context.Background(), *path, bundle.DefaultLimits()) - if err != nil { - return nil, verificationError("inspect bundle", err) - } - result := struct { - Status string `json:"status"` - Kind string `json:"kind"` - EnvelopeDigest string `json:"envelope_digest"` - BundleDigest string `json:"bundle_digest"` - BundleSize uint64 `json:"bundle_size"` - Envelope bundle.Envelope `json:"envelope"` - }{"inspected", bundle.EnvelopeKind, inspection.EnvelopeSHA256, inspection.BundleSHA256, inspection.BundleSize, inspection.Envelope} - if err := writeCanonical(*out, result, 0o644); err != nil { - return nil, ioError("write bundle inspection", err) - } - return result, nil -} - -func submissionPrepare(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("submission prepare") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - keyPath := flags.String("key", "", "encrypted participant key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - actorID := flags.String("actor-id", "", "numeric GitHub actor ID") - metadataPath := flags.String("metadata", "", "bounded GitHub PR metadata JSON") - bundlePath := flags.String("bundle", "", "encrypted bundle (submission.eventctl)") - recordPath := flags.String("record", "", "local pack record") - out := flags.String("out", "", "signed submission request output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *keyPath == "" || *actorID == "" || *metadataPath == "" || *bundlePath == "" || *recordPath == "" || *out == "" { - return nil, usageError("usage: eventctl submission prepare --config PATH --authority PATH --state-meta PATH --registry PATH --key PATH --actor-id ID --metadata PATH --bundle submission.eventctl --record PATH --out PATH [--passphrase-file PATH|-]") - } - if filepath.Base(filepath.Clean(*bundlePath)) != "submission.eventctl" { - return nil, invalidError("--bundle file name must be submission.eventctl", nil) - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "submissions_open"); err != nil { - return nil, verificationError("submissions are not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt participant key", err) - } - keyEpoch, err := resolveKeyEpoch(registry, *actorID, pair.Public) - if err != nil { - return nil, verificationError("resolve active registration", err) - } - metadataRaw, err := readBounded(*metadataPath, 64*1024) - if err != nil { - return nil, ioError("read PR metadata", err) - } - metadata, err := envelope.ParsePRMetadata(metadataRaw) - if err != nil { - return nil, invalidError("validate PR metadata", err) - } - if metadata.ActorID != *actorID || metadata.PullRequest.BaseRef != event.Submissions.BaseRef { - return nil, verificationError("PR metadata does not match actor/configured base ref", nil) - } - recordRaw, err := readBounded(*recordPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read pack record", err) - } - record, err := envelope.ParsePackRecord(recordRaw) - if err != nil { - return nil, verificationError("validate pack record", err) - } - inspection, err := bundle.AuthenticatePublic(context.Background(), *bundlePath, pair.Public, bundleLimits(event)) - if err != nil { - return nil, verificationError("inspect committed bundle", err) - } - if err := compareRecordInspection(record, inspection); err != nil { - return nil, verificationError("pack record/bundle mismatch", err) - } - if err := compareConfiguredRecipients(inspection.Envelope, event); err != nil { - return nil, verificationError("bundle recipient policy mismatch", err) - } - if record.EventID != event.EventID || record.EventEpoch != event.EventEpoch || record.BaseRepositoryID != event.BaseRepository.ID || record.ConfigDigest != digest || record.ActorID != *actorID || record.KeyID != pair.Public.KeyID || record.KeyEpoch != keyEpoch { - return nil, verificationError("pack record does not match trusted actor/config", nil) - } - issued := time.Now().UTC().Truncate(time.Second) - envelopeTTL := time.Duration(event.Submissions.EnvelopeTTLSeconds) * time.Second - reference := envelope.BundleReference{Path: "submission.eventctl", SizeBytes: record.Bundle.SizeBytes, SHA256: record.Bundle.SHA256, EnvelopeSHA256: record.Bundle.EnvelopeSHA256, CiphertextSize: record.Bundle.CiphertextSize, CiphertextSHA256: record.Bundle.CiphertextSHA256, Format: envelope.SubmissionBundleFormat} - raw, err := envelope.NewSubmission(envelope.SubmissionParams{EventID: event.EventID, EventEpoch: event.EventEpoch, RequestID: record.RequestID, AttemptID: record.AttemptID, ActorID: *actorID, KeyEpoch: keyEpoch, TeamID: record.TeamID, TeamProposalDigest: record.TeamProposalDigest, Metadata: metadata, ConfigDigest: digest, IssuedAt: issued, ExpiresAt: issued.Add(envelopeTTL), Bundle: reference}, pair.Private) - if err != nil { - return nil, invalidError("create submission request", err) - } - verified, err := envelope.VerifySubmission(raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: issued}, envelopeTTL, registry) - if err != nil { - return nil, verificationError("self-verify submission request", err) - } - if err := writeExclusive(*out, append(raw, '\n'), 0o644); err != nil { - return nil, ioError("write submission request", err) - } - docDigest, _ := envelope.DocumentDigest(verified.Document) - return requestSummary{*out, envelope.SubmissionKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -// submissionAuthenticateRequest authenticates only the immutable signed -// submission request and its trusted event, actor, source-time, and registry -// bindings. It intentionally does not gate on the current lifecycle phase or -// event enablement and does not fetch fresh PR metadata or the referenced -// bundle. Intake adapters use this result solely to perform a protected replay -// lookup before deciding whether the full mutable admission checks in -// verify-request are required. -func submissionAuthenticateRequest(args []string) (any, error) { - flags := newFlagSet("submission authenticate-request") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - requestPath := flags.String("request", "", "signed submission request") - actorID := flags.String("expect-actor-id", "", "trusted workflow actor ID") - sourceTimeText := flags.String("source-time", "", "trusted immutable GitHub source creation time") - out := flags.String("out", "", "normalized authenticated request output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *requestPath == "" || *actorID == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl submission authenticate-request --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --expect-actor-id ID --source-time RFC3339 --out PATH") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate trusted source time", err) - } - event, digest, _, err := loadTrustedContext(*configPath, *authority, *stateMeta, sourceTime) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read submission request", err) - } - verified, docDigest, err := verifySubmissionAuthenticity(event, digest, *actorID, sourceTime, registry, requestRaw) - if err != nil { - return nil, verificationError("authenticate submission request", err) - } - normalized := normalizedRequest{"verified", envelope.SubmissionKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey, verified.Document} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write authenticated submission", err) - } - return requestSummary{*out, envelope.SubmissionKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -func submissionVerifyRequest(args []string) (any, error) { - flags := newFlagSet("submission verify-request") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - requestPath := flags.String("request", "", "signed submission request") - metadataPath := flags.String("metadata", "", "fresh GitHub PR metadata") - bundlePath := flags.String("bundle", "", "bundle fetched at exact head SHA") - actorID := flags.String("expect-actor-id", "", "trusted workflow actor ID") - sourceTimeText := flags.String("source-time", "", "trusted immutable GitHub source creation time") - out := flags.String("out", "", "normalized verification output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *requestPath == "" || *metadataPath == "" || *bundlePath == "" || *actorID == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl submission verify-request --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --metadata PATH --bundle PATH --expect-actor-id ID --source-time RFC3339 --out PATH") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate trusted source time", err) - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, sourceTime) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "submissions_open"); err != nil { - return nil, verificationError("submissions are not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read submission request", err) - } - verified, docDigest, err := verifySubmissionAuthenticity(event, digest, *actorID, sourceTime, registry, requestRaw) - if err != nil { - return nil, verificationError("verify submission request", err) - } - metadataRaw, err := readBounded(*metadataPath, 64*1024) - if err != nil { - return nil, ioError("read PR metadata", err) - } - metadata, err := envelope.ParsePRMetadata(metadataRaw) - if err != nil { - return nil, verificationError("validate PR metadata", err) - } - if metadata != verified.Document.MetadataEquivalent() || metadata.PullRequest.BaseRef != event.Submissions.BaseRef { - return nil, verificationError("fresh PR metadata does not match signed request", nil) - } - trustedBundleSigner, ok := registry.Resolve(verified.Document.ActorID, verified.Document.KeyEpoch) - if !ok || trustedBundleSigner.KeyID != verified.Document.KeyID { - return nil, verificationError("bundle signer is not the verified submission signer", nil) - } - inspection, err := bundle.AuthenticatePublic(context.Background(), *bundlePath, trustedBundleSigner, bundleLimits(event)) - if err != nil { - return nil, verificationError("inspect fetched bundle", err) - } - if err := compareSubmissionInspection(verified.Document, inspection); err != nil { - return nil, verificationError("submission request/bundle mismatch", err) - } - if err := compareConfiguredRecipients(inspection.Envelope, event); err != nil { - return nil, verificationError("bundle recipient policy mismatch", err) - } - normalized := normalizedRequest{"verified", envelope.SubmissionKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey, verified.Document} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified submission", err) - } - return requestSummary{*out, envelope.SubmissionKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -func verifySubmissionAuthenticity(event config.Event, configDigest, actorID string, sourceTime time.Time, registry identity.Registry, requestRaw []byte) (envelope.VerifiedSubmission, string, error) { - envelopeTTL := time.Duration(event.Submissions.EnvelopeTTLSeconds) * time.Second - verified, err := envelope.VerifySubmission(requestRaw, envelope.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - RepositoryID: event.BaseRepository.ID, ActorID: actorID, - ConfigDigest: configDigest, Now: sourceTime, - }, envelopeTTL, registry) - if err != nil { - return envelope.VerifiedSubmission{}, "", err - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - return envelope.VerifiedSubmission{}, "", err - } - return verified, docDigest, nil -} - -func submissionDecryptVerify(args []string, stderr io.Writer) (any, error) { - if runtime.GOOS != "linux" { - return nil, verificationError("submission decryption is supported only on Linux", bundle.ErrExtractionUnsupported) - } - flags := newFlagSet("submission decrypt-verify") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - requestPath := flags.String("request", "", "signed post-push submission request") - acceptancePath := flags.String("acceptance", "", "organizer-signed accepted submission receipt") - bundlePath := flags.String("bundle", "", "encrypted bundle") - recordPath := flags.String("record", "", "optional local pack record") - var identityPaths stringList - flags.Var(&identityPaths, "identity", "encrypted organizer hybrid identity (repeat for every configured recipient)") - passFile := flags.String("passphrase-file", "", "identity passphrase file or -") - expectActorID := flags.String("expect-actor-id", "", "optional trusted actor ID") - expectAttemptID := flags.String("expect-attempt-id", "", "optional trusted attempt UUID") - outDir := flags.String("out-dir", "", "new private plaintext directory") - out := flags.String("out", "", "private normalized verification output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *requestPath == "" || *acceptancePath == "" || *bundlePath == "" || len(identityPaths) == 0 || *outDir == "" || *out == "" { - return nil, usageError("usage: eventctl submission decrypt-verify --config ARCHIVED_PATH --authority PATH --state-meta PATH --registry PATH --request PATH --acceptance RECEIPT --bundle PATH --identity PATH [--identity PATH ...] --out-dir PRIVATE_DIR --out PATH [--record PATH] [--passphrase-file PATH|-] [--expect-actor-id ID] [--expect-attempt-id UUID]") - } - if filepath.Base(filepath.Clean(*bundlePath)) != "submission.eventctl" { - return nil, invalidError("--bundle file name must be submission.eventctl", nil) - } - parent := filepath.Dir(filepath.Clean(*outDir)) - parentInfo, err := os.Lstat(parent) - if err != nil || !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 || parentInfo.Mode().Perm()&0o077 != 0 { - return nil, invalidError("--out-dir parent must be an existing private directory with no group/other permissions", err) - } - genesis, currentMeta, err := loadAuthorityState(*authority, *stateMeta) - if err != nil { - return nil, verificationError("verify protected genesis/current-state authorities", err) - } - if err := requireOneOfPhases(currentMeta, "submissions_open", "frozen"); err != nil { - return nil, verificationError("scoring is not allowed in the current phase", err) - } - acceptanceRaw, err := readBounded(*acceptancePath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read accepted submission receipt", err) - } - parsedAcceptance, err := receipt.Parse(acceptanceRaw) - if err != nil { - return nil, verificationError("validate accepted submission receipt", err) - } - sourceCreatedAt, err := envelope.ParseTimestamp(parsedAcceptance.SourceCreatedAt) - if err != nil { - return nil, verificationError("validate accepted receipt source time", err) - } - event, digest, err := loadArchivedConfig(*configPath, genesis, currentMeta, sourceCreatedAt) - if err != nil { - return nil, verificationError("verify archived accepted config", err) - } - if parsedAcceptance.ConfigDigest != digest || parsedAcceptance.BaseRepositoryID != event.BaseRepository.ID { - return nil, verificationError("accepted receipt does not bind archived config/repository", nil) - } - verifiedAcceptance, err := receipt.Verify(acceptanceRaw, receipt.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - SigningKey: event.Receipts.SigningKey, CurrentState: currentPointer(currentMeta), Now: time.Now().UTC(), - }) - if err != nil { - return nil, verificationError("verify accepted submission receipt", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read submission request", err) - } - verifiedRequest, err := envelope.VerifySubmission(requestRaw, envelope.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, - ActorID: *expectActorID, ConfigDigest: digest, Now: time.Time{}, - }, time.Duration(event.Submissions.EnvelopeTTLSeconds)*time.Second, registry) - if err != nil { - return nil, verificationError("verify signed submission request", err) - } - if *expectAttemptID != "" && verifiedRequest.Document.AttemptID != *expectAttemptID { - return nil, verificationError("submission attempt does not match trusted reservation", nil) - } - requestDocumentDigest, err := envelope.DocumentDigest(verifiedRequest.Document) - if err != nil { - return nil, err - } - acceptance := verifiedAcceptance.Document - if acceptance.RequestKind != envelope.SubmissionKind || acceptance.Outcome != "accepted" || !acceptance.QuotaCharged || acceptance.ReasonCode != nil || acceptance.OperationID != verifiedRequest.Document.RequestID || acceptance.ReplayKey != verifiedRequest.Fingerprint.ReplayKey || acceptance.RequestDigest != verifiedRequest.Fingerprint.RequestDigest || acceptance.RequestDocumentDigest == nil || *acceptance.RequestDocumentDigest != requestDocumentDigest || acceptance.ActorID != verifiedRequest.Document.ActorID || acceptance.TeamID == nil || *acceptance.TeamID != verifiedRequest.Document.TeamID || acceptance.AttemptID == nil || *acceptance.AttemptID != verifiedRequest.Document.AttemptID { - return nil, verificationError("accepted receipt does not bind the exact submission reservation", nil) - } - if err := acceptance.ValidateSourceWindow(verifiedRequest.Document.IssuedAt, verifiedRequest.Document.ExpiresAt); err != nil { - return nil, verificationError("accepted source time is outside signed submission window", err) - } - trustedPublic, ok := registry.Resolve(verifiedRequest.Document.ActorID, verifiedRequest.Document.KeyEpoch) - if !ok || trustedPublic.KeyID != verifiedRequest.Document.KeyID { - return nil, verificationError("submission signer is not active in trusted registry", nil) - } - verificationKey, err := identity.VerificationKey(trustedPublic) - if err != nil { - return nil, verificationError("decode trusted verification key", err) - } - passphrase, err := readPassphrase(*passFile, "Recipient identity passphrase: ", false, stderr) - if err != nil { - return nil, invalidError("read recipient passphrase", err) - } - expectedRecipientIDs, err := configuredRecipientFingerprints(event) - if err != nil { - clear(passphrase) - return nil, verificationError("derive trusted recipient policy", err) - } - decryptionIdentities := make([]*age.HybridIdentity, 0, len(identityPaths)) - identityFingerprints := make([]string, 0, len(identityPaths)) - for _, identityPath := range identityPaths { - decryptionIdentity, loadErr := organizerrecipient.LoadIdentity(context.Background(), identityPath, passphrase, keystore.DefaultLimits()) - if loadErr != nil { - clear(passphrase) - return nil, verificationError("decrypt organizer identity", loadErr) - } - publicRecipient, describeErr := organizerrecipient.Describe(decryptionIdentity) - if describeErr != nil { - clear(passphrase) - return nil, verificationError("validate organizer identity", describeErr) - } - decryptionIdentities = append(decryptionIdentities, decryptionIdentity) - identityFingerprints = append(identityFingerprints, publicRecipient.Fingerprint) - } - clear(passphrase) - if !matchesConfiguredIdentitySet(identityFingerprints, expectedRecipientIDs) { - return nil, verificationError("organizer identity set does not exactly match every configured recipient", nil) - } - verifiedBundle, err := bundle.DecryptToDirectory(context.Background(), *bundlePath, *outDir, decryptionIdentities, verificationKey, bundleLimits(event), event.Submissions.AllowedExtensions) - if err != nil { - return nil, verificationError("authenticate and decrypt bundle", err) - } - inspection := bundle.Inspection{Envelope: verifiedBundle.Envelope, EnvelopeSHA256: verifiedBundle.EnvelopeSHA256, BundleSize: verifiedBundle.BundleSize, BundleSHA256: verifiedBundle.BundleSHA256} - if err := compareSubmissionInspection(verifiedRequest.Document, inspection); err != nil { - _ = os.RemoveAll(*outDir) - return nil, verificationError("signed request/decrypted bundle mismatch", err) - } - if err := compareConfiguredRecipients(verifiedBundle.Envelope, event); err != nil { - _ = os.RemoveAll(*outDir) - return nil, verificationError("decrypted bundle recipient policy mismatch", err) - } - if *recordPath != "" { - recordRaw, readErr := readBounded(*recordPath, envelope.MaxDocumentBytes) - if readErr != nil { - _ = os.RemoveAll(*outDir) - return nil, ioError("read pack record", readErr) - } - record, parseErr := envelope.ParsePackRecord(recordRaw) - if parseErr != nil { - _ = os.RemoveAll(*outDir) - return nil, verificationError("pack record/decrypted bundle mismatch", parseErr) - } - if compareErr := compareRecordInspection(record, inspection); compareErr != nil { - _ = os.RemoveAll(*outDir) - return nil, verificationError("pack record/decrypted bundle mismatch", compareErr) - } - } - result := struct { - Status string `json:"status"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - BundleDigest string `json:"bundle_digest"` - EnvelopeDigest string `json:"envelope_digest"` - ManifestDigest string `json:"manifest_digest"` - AcceptanceDigest string `json:"acceptance_digest"` - OutputDirectory string `json:"output_directory"` - Envelope bundle.Envelope `json:"envelope"` - Manifest bundle.Manifest `json:"manifest"` - }{"verified", "decrypted_submission_bundle", verifiedRequest.Fingerprint.RequestDigest, requestDocumentDigest, verifiedBundle.BundleSHA256, verifiedBundle.EnvelopeSHA256, verifiedBundle.Envelope.InnerManifestSHA256, verifiedAcceptance.DocumentDigest, *outDir, verifiedBundle.Envelope, verifiedBundle.Manifest} - if err := writeCanonical(*out, result, 0o600); err != nil { - _ = os.RemoveAll(*outDir) - return nil, ioError("write private decryption result", err) - } - return struct { - Path string `json:"path"` - OutputDirectory string `json:"output_directory"` - AttemptID string `json:"attempt_id"` - BundleDigest string `json:"bundle_digest"` - }{*out, *outDir, verifiedRequest.Document.AttemptID, verifiedBundle.BundleSHA256}, nil -} - -func bundleLimits(event config.Event) bundle.Limits { - return bundle.Limits{MaxCiphertextBytes: event.Submissions.MaximumCiphertextBytes, MaxEnvelopeBytes: 256 * 1024, MaxPlaintextBytes: event.Submissions.MaximumPlaintextBytes, MaxFileBytes: event.Submissions.MaximumFileBytes, MaxFiles: uint32(event.Submissions.MaximumPlaintextFiles), MaxManifestBytes: 4 * 1024 * 1024, MaxRecipients: uint32(len(event.Submissions.Encryption.Recipients)), MaxTotalFileBytes: event.Submissions.MaximumPlaintextBytes, MaxValidity: time.Duration(event.Submissions.EnvelopeTTLSeconds) * time.Second} -} -func packRecordFrom(packed bundle.Packed, created time.Time) envelope.PackRecord { - e := packed.Envelope - return envelope.PackRecord{Kind: envelope.PackRecordKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, EventID: e.EventID, EventEpoch: e.EventEpoch, RequestID: e.RequestID, AttemptID: e.AttemptID, ActorID: e.ActorID, KeyID: e.KeyID, KeyEpoch: e.KeyEpoch, TeamID: e.TeamID, TeamProposalDigest: e.TeamProposalDigest, BaseRepositoryID: e.BaseRepositoryID, ConfigDigest: e.ConfigDigest, RecipientEpoch: e.RecipientEpoch, RecipientKeyIDs: append([]string(nil), e.RecipientKeyIDs...), InnerManifestSHA256: e.InnerManifestSHA256, FileCount: e.FileCount, PlaintextSize: e.PlaintextSize, Bundle: envelope.PackRecordBundle{Path: "submission.eventctl", Format: envelope.SubmissionBundleFormat, SizeBytes: packed.BundleSize, SHA256: packed.BundleSHA256, EnvelopeSHA256: packed.EnvelopeSHA256, CiphertextSize: e.CiphertextSize, CiphertextSHA256: e.CiphertextSHA256}, CreatedAt: formatTimestamp(created)} -} -func compareRecordInspection(r envelope.PackRecord, i bundle.Inspection) error { - e := i.Envelope - if r.Bundle.SizeBytes != i.BundleSize || r.Bundle.SHA256 != i.BundleSHA256 || r.Bundle.EnvelopeSHA256 != i.EnvelopeSHA256 || r.Bundle.CiphertextSize != e.CiphertextSize || r.Bundle.CiphertextSHA256 != e.CiphertextSHA256 || r.EventID != e.EventID || r.EventEpoch != e.EventEpoch || r.RequestID != e.RequestID || r.AttemptID != e.AttemptID || r.ActorID != e.ActorID || r.KeyID != e.KeyID || r.KeyEpoch != e.KeyEpoch || r.TeamID != e.TeamID || r.TeamProposalDigest != e.TeamProposalDigest || r.BaseRepositoryID != e.BaseRepositoryID || r.ConfigDigest != e.ConfigDigest || r.RecipientEpoch != e.RecipientEpoch || !equalStrings(r.RecipientKeyIDs, e.RecipientKeyIDs) || r.InnerManifestSHA256 != e.InnerManifestSHA256 || r.FileCount != e.FileCount || r.PlaintextSize != e.PlaintextSize { - return errors.New("record differs from inspected bundle") - } - return nil -} - -func compareConfiguredRecipients(envelopeValue bundle.Envelope, event config.Event) error { - expected, err := configuredRecipientFingerprints(event) - if err != nil { - return err - } - if envelopeValue.RecipientEpoch != event.Submissions.Encryption.RecipientEpoch || !equalStrings(envelopeValue.RecipientKeyIDs, expected) { - return errors.New("bundle recipient epoch or exact recipient set differs from signed config") - } - return nil -} - -func configuredRecipientFingerprints(event config.Event) ([]string, error) { - fingerprints := make([]string, 0, len(event.Submissions.Encryption.Recipients)) - for _, configured := range event.Submissions.Encryption.Recipients { - recipient, err := age.ParseHybridRecipient(configured.PublicKey) - if err != nil { - return nil, err - } - fingerprint, err := bundle.HybridRecipientFingerprint(recipient) - if err != nil { - return nil, err - } - fingerprints = append(fingerprints, fingerprint) - } - sort.Strings(fingerprints) - for index := 1; index < len(fingerprints); index++ { - if fingerprints[index-1] == fingerprints[index] { - return nil, errors.New("signed config contains duplicate recipient public keys") - } - } - return fingerprints, nil -} - -func equalStrings(left, right []string) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] != right[index] { - return false - } - } - return true -} - -func matchesConfiguredIdentitySet(supplied, expected []string) bool { - ordered := append([]string(nil), supplied...) - sort.Strings(ordered) - return equalStrings(ordered, expected) -} -func compareSubmissionInspection(s envelope.Submission, i bundle.Inspection) error { - e := i.Envelope - if s.Bundle.SizeBytes != i.BundleSize || s.Bundle.SHA256 != i.BundleSHA256 || s.Bundle.EnvelopeSHA256 != i.EnvelopeSHA256 || s.Bundle.CiphertextSize != e.CiphertextSize || s.Bundle.CiphertextSHA256 != e.CiphertextSHA256 || s.EventID != e.EventID || s.EventEpoch != e.EventEpoch || s.RequestID != e.RequestID || s.AttemptID != e.AttemptID || s.ActorID != e.ActorID || s.KeyID != e.KeyID || s.KeyEpoch != e.KeyEpoch || s.TeamID != e.TeamID || s.TeamProposalDigest != e.TeamProposalDigest || s.BaseRepository.ID != e.BaseRepositoryID || s.ConfigDigest != e.ConfigDigest { - return errors.New("signed request differs from inspected bundle") - } - return nil -} -func validateSourceExtensions(root string, allowed []string) error { - set := map[string]struct{}{} - for _, ext := range allowed { - set[ext] = struct{}{} - } - return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == root { - return nil - } - if entry.Type()&fs.ModeSymlink != 0 { - return errors.New("source contains a symlink") - } - if entry.IsDir() { - return nil - } - if !entry.Type().IsRegular() { - return errors.New("source contains a special file") - } - extension := filepath.Ext(entry.Name()) - if _, ok := set[extension]; !ok { - return errors.New("source contains a disallowed extension: " + extension) - } - return nil - }) -} -func formatTimestamp(value time.Time) string { - return value.UTC().Truncate(time.Second).Format("2006-01-02T15:04:05Z") -} -func containsActor(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} diff --git a/cmd/eventctl/submission_authenticate_test.go b/cmd/eventctl/submission_authenticate_test.go deleted file mode 100644 index 8385cc8..0000000 --- a/cmd/eventctl/submission_authenticate_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -type submissionAuthenticationFixture struct { - configPath string - authorityPath string - stateMetaPath string - registryPath string - requestPath string - sourceTime time.Time - request envelope.Submission - verified envelope.VerifiedSubmission - docDigest string -} - -func TestSubmissionAuthenticateRequestWorksAfterLifecycleClosureWithoutMutableInputs(t *testing.T) { - fixture := newSubmissionAuthenticationFixture(t) - outPath := filepath.Join(t.TempDir(), "authenticated.json") - args := fixture.commandArgs(outPath, fixture.request.ActorID, fixture.requestPath) - - var stdout bytes.Buffer - var stderr bytes.Buffer - exit := run(args, &stdout, &stderr) - if exit != 0 || stderr.String() != "" { - t.Fatalf("run exit=%d stderr=%q stdout=%q", exit, stderr.String(), stdout.String()) - } - var response struct { - OutputVersion string `json:"output_version"` - OK bool `json:"ok"` - Command string `json:"command"` - Result requestSummary `json:"result"` - Error *errorObject `json:"error"` - } - if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { - t.Fatal(err) - } - wantSummary := requestSummary{ - Path: outPath, Kind: envelope.SubmissionKind, - RequestDigest: fixture.verified.Fingerprint.RequestDigest, - DocumentDigest: fixture.docDigest, - ReplayKey: fixture.verified.Fingerprint.ReplayKey, - } - if response.OutputVersion != outputVersion || !response.OK || response.Command != "submission.authenticate-request" || response.Error != nil || response.Result != wantSummary { - t.Fatalf("response = %#v, want result %#v", response, wantSummary) - } - - artifactRaw, err := os.ReadFile(outPath) - if err != nil { - t.Fatal(err) - } - var artifact struct { - Status string `json:"status"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - ReplayKey string `json:"replay_key"` - Document envelope.Submission `json:"document"` - } - if err := canonical.StrictUnmarshal(artifactRaw, &artifact); err != nil { - t.Fatal(err) - } - if artifact.Status != "verified" || artifact.Kind != envelope.SubmissionKind || artifact.RequestDigest != fixture.verified.Fingerprint.RequestDigest || artifact.DocumentDigest != fixture.docDigest || artifact.ReplayKey != fixture.verified.Fingerprint.ReplayKey || artifact.Document != fixture.request { - t.Fatalf("authenticated artifact = %#v", artifact) - } -} - -func TestSubmissionAuthenticateRequestRejectsWrongActorAndForgedDocument(t *testing.T) { - fixture := newSubmissionAuthenticationFixture(t) - directory := t.TempDir() - - forged := fixture.request - forged.PullRequest.HeadSHA = strings.Repeat("b", 40) - forgedPath := filepath.Join(directory, "forged.json") - writeCanonicalFixture(t, forgedPath, forged) - - for name, test := range map[string]struct { - actorID string - requestPath string - }{ - "wrong trusted actor": {actorID: "43", requestPath: fixture.requestPath}, - "forged signed field": {actorID: fixture.request.ActorID, requestPath: forgedPath}, - } { - t.Run(name, func(t *testing.T) { - outPath := filepath.Join(directory, strings.ReplaceAll(name, " ", "-")+".out.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - exit := run(fixture.commandArgs(outPath, test.actorID, test.requestPath), &stdout, &stderr) - if exit != 3 || stderr.String() != "" { - t.Fatalf("run exit=%d stderr=%q stdout=%q", exit, stderr.String(), stdout.String()) - } - var response struct { - OK bool `json:"ok"` - Command string `json:"command"` - Error *errorObject `json:"error"` - } - if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { - t.Fatal(err) - } - if response.OK || response.Command != "submission" || response.Error == nil || response.Error.Code != "verification_failed" || !strings.Contains(response.Error.Message, "authenticate submission request") { - t.Fatalf("failure response = %#v", response) - } - if _, err := os.Stat(outPath); !os.IsNotExist(err) { - t.Fatalf("rejected request created output: %v", err) - } - }) - } -} - -func (fixture submissionAuthenticationFixture) commandArgs(outPath, actorID, requestPath string) []string { - return []string{ - "submission", "authenticate-request", - "--config", fixture.configPath, - "--authority", fixture.authorityPath, - "--state-meta", fixture.stateMetaPath, - "--registry", fixture.registryPath, - "--request", requestPath, - "--expect-actor-id", actorID, - "--source-time", fixture.sourceTime.Format(time.RFC3339), - "--out", outPath, - } -} - -func newSubmissionAuthenticationFixture(t *testing.T) submissionAuthenticationFixture { - t.Helper() - configSigner := deterministicCLIKeyPair(t, 31) - receiptSigner := deterministicCLIKeyPair(t, 32) - scorerSigner := deterministicCLIKeyPair(t, 33) - participant := deterministicCLIKeyPair(t, 34) - hybridIdentity, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - externalJudgeURL := "https://judge.example.invalid/v1/score" - event := config.Event{ - Kind: config.Kind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: "replay-event-2026", EventEpoch: "1", - BaseRepository: envelope.Repository{ID: "123", Owner: "pythonhk", Name: "replay-event-2026"}, - ConfigEpoch: 1, DelegationEpoch: 1, DelegationDigest: strings.Repeat("a", 64), - IssuedAt: "2026-08-01T00:00:00Z", ExpiresAt: "2027-08-01T00:00:00Z", - InitialState: config.InitialState{Phase: "draft", Enabled: false, DisabledReason: "template_not_bootstrapped"}, - Registration: config.Registration{MaximumParticipants: 20, RequestTTLSeconds: 1800, TermsDigest: strings.Repeat("1", 64), KeyAlgorithm: identity.Algorithm, KeyRotationPolicy: "unsupported"}, - Teams: config.Teams{MinimumSize: 2, MaximumSize: 5, MaximumProposalsPerParticipant: 1, ProposalTTLSeconds: 604800, MembershipLockPhase: "submissions_open"}, - Submissions: config.Submissions{ - BaseRef: "main", MaximumAttemptsPerTeam: 10, MaximumTotalAttempts: 100, - MaximumCiphertextBytes: 47_000_000, MaximumPlaintextBytes: 42_000_000, - MaximumFileBytes: 20_000_000, MaximumPlaintextFiles: 4096, - EnvelopeTTLSeconds: 1800, DeliveryMode: envelope.SubmissionDeliveryMode, - FailedConsumeQuota: true, AllowedExtensions: []string{".csv"}, - Encryption: config.Encryption{ - Algorithm: "age-hybrid-mlkem768-x25519", RecipientEpoch: "1", - Recipients: []config.Recipient{{RecipientID: "primary_judge", PublicKey: hybridIdentity.Recipient().String()}}, - }, - }, - Scoring: config.Scoring{ - Mode: "external_judge", ScorerID: "example_scorer", ScorerVersion: "v1.0.0", - PolicyDigest: strings.Repeat("2", 64), MaximumResultBytes: 65536, - ResultKey: scorerSigner.Public, ExternalJudgeURL: &externalJudgeURL, - }, - State: config.State{ - Branch: "event-state", Public: true, WriterAppSlug: "pythonhk-event-state-writer", - WriterConcurrencyGroup: "event-state-writer", JournalFormat: "hash-linked-json-v1", - }, - Receipts: config.Receipts{SigningKey: receiptSigner.Public}, - } - event, err = config.Sign(event, configSigner.Private) - if err != nil { - t.Fatal(err) - } - digest, err := config.Digest(event) - if err != nil { - t.Fatal(err) - } - - authority := config.Genesis{ - SchemaVersion: 1, EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - GenesisDelegationDigest: event.DelegationDigest, - ConfigDelegationValidFrom: "2026-08-01T00:00:00Z", - ConfigDelegationExpiresAt: "2027-08-01T00:00:00Z", - ConfigAuthority: config.Authority{Threshold: 1, Keys: []identity.Public{configSigner.Public}}, - ReceiptAuthority: event.Receipts.SigningKey, - CreatedAt: "2026-08-01T00:00:00Z", - OperationID: "10000000-0000-4000-8000-000000000001", - OrganizerActorID: "42", - TeamMinimumSize: event.Teams.MinimumSize, - TeamMaximumSize: event.Teams.MaximumSize, - TeamMaximumProposalsPerParticipant: event.Teams.MaximumProposalsPerParticipant, - SubmissionQuota: event.Submissions.MaximumAttemptsPerTeam, - SubmissionMaximumTotalAttempts: event.Submissions.MaximumTotalAttempts, - Writer: config.Writer{ - AppSlug: "pythonhk-event-state-writer", InstallationID: "1", Provenance: "local_bootstrap", - }, - } - disabledReason := "event_closed" - stateMeta := config.StateMeta{ - Kind: "state_meta_view", Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: event.EventID, EventEpoch: event.EventEpoch, BaseRepositoryID: event.BaseRepository.ID, - ConfigDigest: digest, ConfigAuthorityDigest: event.DelegationDigest, - ReceiptAuthority: event.Receipts.SigningKey, Sequence: 10, - JournalEventDigest: strings.Repeat("c", 64), LifecyclePhase: "closed", - Enabled: false, DisabledReason: &disabledReason, - } - registry := identity.Registry{ - Schema: identity.RegistrySchema, - Identities: []identity.RegistryEntry{{ActorID: "84", KeyEpoch: "1", Identity: participant.Public}}, - } - sourceTime := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) - requestRaw, err := envelope.NewSubmission(envelope.SubmissionParams{ - EventID: event.EventID, EventEpoch: event.EventEpoch, - RequestID: "20000000-0000-4000-8000-000000000002", - AttemptID: "30000000-0000-4000-8000-000000000003", - ActorID: "84", KeyEpoch: "1", - TeamID: "40000000-0000-4000-8000-000000000004", - TeamProposalDigest: strings.Repeat("d", 64), - Metadata: envelope.PRMetadata{ - Kind: "github_pr_metadata", ActorID: "84", PullRequestAuthorID: "84", - BaseRepository: event.BaseRepository, - PullRequest: envelope.PullRequest{ - Number: 7, ID: "700", BaseRepositoryID: event.BaseRepository.ID, BaseRef: "main", - HeadRepositoryID: "456", HeadOwner: "participant", HeadRef: "attempt-one", - HeadSHA: strings.Repeat("e", 40), - }, - }, - ConfigDigest: digest, IssuedAt: sourceTime.Add(-time.Minute), - ExpiresAt: sourceTime.Add(14 * time.Minute), - Bundle: envelope.BundleReference{ - Path: "submission.eventctl", SizeBytes: 2048, SHA256: strings.Repeat("3", 64), - EnvelopeSHA256: strings.Repeat("4", 64), CiphertextSize: 1024, - CiphertextSHA256: strings.Repeat("5", 64), Format: envelope.SubmissionBundleFormat, - }, - }, participant.Private) - if err != nil { - t.Fatal(err) - } - verified, err := envelope.VerifySubmission(requestRaw, envelope.Expected{ - EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, - ActorID: "84", ConfigDigest: digest, Now: sourceTime, - }, 1800*time.Second, registry) - if err != nil { - t.Fatal(err) - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - configPath := filepath.Join(directory, "event.yaml") - configRaw, err := config.MarshalYAML(event) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { - t.Fatal(err) - } - authorityPath := filepath.Join(directory, "genesis.json") - stateMetaPath := filepath.Join(directory, "state-meta.json") - registryPath := filepath.Join(directory, "registry.json") - requestPath := filepath.Join(directory, "request.json") - writeCanonicalFixture(t, authorityPath, authority) - writeCanonicalFixture(t, stateMetaPath, stateMeta) - writeCanonicalFixture(t, registryPath, registry) - if err := os.WriteFile(requestPath, append(requestRaw, '\n'), 0o600); err != nil { - t.Fatal(err) - } - - return submissionAuthenticationFixture{ - configPath: configPath, authorityPath: authorityPath, stateMetaPath: stateMetaPath, - registryPath: registryPath, requestPath: requestPath, sourceTime: sourceTime, - request: verified.Document, verified: verified, docDigest: docDigest, - } -} - -func deterministicCLIKeyPair(t *testing.T, fill byte) identity.KeyPair { - t.Helper() - pair, err := identity.FromSeed(bytes.Repeat([]byte{fill}, 32)) - if err != nil { - t.Fatal(err) - } - return pair -} - -func writeCanonicalFixture(t *testing.T, path string, value any) { - t.Helper() - raw, err := canonical.Marshal(value) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/cmd/eventctl/submission_public_verify.go b/cmd/eventctl/submission_public_verify.go deleted file mode 100644 index 0fb7199..0000000 --- a/cmd/eventctl/submission_public_verify.go +++ /dev/null @@ -1,118 +0,0 @@ -package main - -import ( - "context" - "errors" - "path/filepath" - "slices" - "sort" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/bundle" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" -) - -type normalizedPublicBundle struct { - Status string `json:"status"` - Kind string `json:"kind"` - EnvelopeDigest string `json:"envelope_digest"` - BundleDigest string `json:"bundle_digest"` - BundleSize uint64 `json:"bundle_size"` - Envelope bundle.Envelope `json:"envelope"` - Record envelope.PackRecord `json:"record"` -} - -func submissionVerifyPublic(args []string) (any, error) { - flags := newFlagSet("submission verify") - configPath := flags.String("config", "", "signed event config") - authorityPath := flags.String("authority", "", "protected genesis authority") - statePath := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "protected public identity registry") - bundlePath := flags.String("bundle", "", "encrypted submission bundle") - recordPath := flags.String("record", "", "local submission pack record") - out := flags.String("out", "", "normalized public verification output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authorityPath == "" || *statePath == "" || *registryPath == "" || *bundlePath == "" || *recordPath == "" || *out == "" { - return nil, usageError("usage: eventctl submission verify --config PATH --authority PATH --state-meta PATH --registry PATH --bundle submission.eventctl --record PATH --out PATH") - } - if filepath.Base(filepath.Clean(*bundlePath)) != "submission.eventctl" { - return nil, invalidError("--bundle file name must be submission.eventctl", nil) - } - event, digest, _, err := loadTrustedContext(*configPath, *authorityPath, *statePath, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - recordRaw, err := readBounded(*recordPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read pack record", err) - } - record, err := envelope.ParsePackRecord(recordRaw) - if err != nil { - return nil, verificationError("validate pack record", err) - } - registered, ok := registry.Resolve(record.ActorID, record.KeyEpoch) - if !ok || registered.KeyID != record.KeyID { - return nil, verificationError("pack record signer is not registered", nil) - } - inspection, err := bundle.AuthenticatePublic(context.Background(), *bundlePath, registered, bundleLimits(event)) - if err != nil { - return nil, verificationError("authenticate public bundle envelope", err) - } - if err := compareRecordInspection(record, inspection); err != nil { - return nil, verificationError("pack record/bundle mismatch", err) - } - if !slices.Equal(record.RecipientKeyIDs, inspection.Envelope.RecipientKeyIDs) || record.CreatedAt != inspection.Envelope.IssuedAt { - return nil, verificationError("pack record recipient/timestamp differs from authenticated envelope", nil) - } - configuredRecipientIDs, err := configuredHybridRecipientIDs(event.Submissions.Encryption.Recipients) - if err != nil { - return nil, verificationError("derive configured recipient fingerprints", err) - } - e := inspection.Envelope - if e.EventID != event.EventID || e.EventEpoch != event.EventEpoch || e.BaseRepositoryID != event.BaseRepository.ID || e.ConfigDigest != digest || e.RecipientEpoch != event.Submissions.Encryption.RecipientEpoch || !slices.Equal(e.RecipientKeyIDs, configuredRecipientIDs) { - return nil, verificationError("authenticated bundle does not match trusted event/config recipient set", nil) - } - if err := envelope.ValidateWindowWithin(e.IssuedAt, e.ExpiresAt, time.Now().UTC(), time.Duration(event.Submissions.EnvelopeTTLSeconds)*time.Second); err != nil { - return nil, verificationError("bundle validity window", err) - } - normalized := normalizedPublicBundle{"verified", bundle.EnvelopeKind, inspection.EnvelopeSHA256, inspection.BundleSHA256, inspection.BundleSize, inspection.Envelope, record} - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified public bundle", err) - } - return struct { - Path string `json:"path"` - Kind string `json:"kind"` - BundleDigest string `json:"bundle_digest"` - EnvelopeDigest string `json:"envelope_digest"` - ActorID string `json:"actor_id"` - AttemptID string `json:"attempt_id"` - }{*out, bundle.EnvelopeKind, inspection.BundleSHA256, inspection.EnvelopeSHA256, e.ActorID, e.AttemptID}, nil -} - -func configuredHybridRecipientIDs(configured []config.Recipient) ([]string, error) { - ids := make([]string, 0, len(configured)) - for _, configuredRecipient := range configured { - encoded := configuredRecipient.PublicKey - recipient, err := age.ParseHybridRecipient(encoded) - if err != nil || recipient.String() != encoded { - return nil, errors.New("configured hybrid recipient is invalid") - } - fingerprint, err := bundle.HybridRecipientFingerprint(recipient) - if err != nil { - return nil, err - } - ids = append(ids, fingerprint) - } - sort.Strings(ids) - for index := 1; index < len(ids); index++ { - if ids[index-1] == ids[index] { - return nil, errors.New("configured recipient fingerprints are duplicated") - } - } - return ids, nil -} diff --git a/cmd/eventctl/submission_test.go b/cmd/eventctl/submission_test.go deleted file mode 100644 index 7005e5f..0000000 --- a/cmd/eventctl/submission_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import "testing" - -func TestConfiguredIdentitySetRequiresEveryRecipient(t *testing.T) { - t.Parallel() - expected := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} - for name, test := range map[string]struct { - supplied []string - want bool - }{ - "same": {[]string{expected[0], expected[1]}, true}, - "swapped": {[]string{expected[1], expected[0]}, true}, - "missing": {[]string{expected[0]}, false}, - "extra": {[]string{expected[0], expected[1], "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}, false}, - "duplicate": {[]string{expected[0], expected[0]}, false}, - } { - t.Run(name, func(t *testing.T) { - if got := matchesConfiguredIdentitySet(test.supplied, expected); got != test.want { - t.Fatalf("matchesConfiguredIdentitySet() = %v, want %v", got, test.want) - } - }) - } -} diff --git a/cmd/eventctl/team.go b/cmd/eventctl/team.go deleted file mode 100644 index 33bb14a..0000000 --- a/cmd/eventctl/team.go +++ /dev/null @@ -1,305 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "io" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/team" -) - -func runTeam(args []string, stderr io.Writer) (any, error) { - if len(args) == 0 { - return nil, usageError("usage: eventctl team propose|consent|verify") - } - switch args[0] { - case "propose": - return teamPropose(args[1:], stderr) - case "consent": - return teamConsent(args[1:], stderr) - case "verify": - return teamVerify(args[1:]) - default: - return nil, usageError("usage: eventctl team propose|consent|verify") - } -} - -type teamTrustFlags struct{ configPath, authority, stateMeta, registry string } - -func addTeamTrustFlags(flags interface { - String(string, string, string) *string -}) teamTrustFlags { - return teamTrustFlags{*flags.String("config", "", "signed event config"), *flags.String("authority", "", "protected genesis"), *flags.String("state-meta", "", "protected current state metadata"), *flags.String("registry", "", "trusted identity registry")} -} - -func teamPropose(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("team propose") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - keyPath := flags.String("key", "", "encrypted participant key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - actorID := flags.String("actor-id", "", "numeric GitHub actor ID") - membersPath := flags.String("members", "", "JSON array of numeric actor IDs") - teamID := flags.String("team-id", "", "UUIDv4 (generated if omitted)") - requestID := flags.String("request-id", "", "UUIDv4 (generated if omitted)") - out := flags.String("out", "", "team proposal output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *keyPath == "" || *actorID == "" || *membersPath == "" || *out == "" { - return nil, usageError("usage: eventctl team propose --config PATH --authority PATH --state-meta PATH --registry PATH --key PATH --actor-id ID --members PATH --out PATH [--passphrase-file PATH|-] [--team-id UUID] [--request-id UUID]") - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "formation_open"); err != nil { - return nil, verificationError("team formation is not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt participant key", err) - } - keyEpoch, err := resolveKeyEpoch(registry, *actorID, pair.Public) - if err != nil { - return nil, verificationError("resolve active registration", err) - } - members, err := loadMembers(*membersPath) - if err != nil { - return nil, invalidError("load team members", err) - } - if uint64(len(members)) < event.Teams.MinimumSize || uint64(len(members)) > event.Teams.MaximumSize { - return nil, invalidError("team size is outside configured bounds", nil) - } - issued := time.Now().UTC().Truncate(time.Second) - proposalTTL := time.Duration(event.Teams.ProposalTTLSeconds) * time.Second - raw, err := team.NewProposal(team.ProposalParams{EventID: event.EventID, EventEpoch: event.EventEpoch, OperationID: *requestID, TeamID: *teamID, ProposerActorID: *actorID, KeyEpoch: keyEpoch, MemberActorIDs: members, BaseRepository: event.BaseRepository, ConfigDigest: digest, IssuedAt: issued, ExpiresAt: issued.Add(proposalTTL)}, pair.Private) - if err != nil { - return nil, invalidError("create team proposal", err) - } - verified, err := team.VerifyProposal(raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: issued}, proposalTTL, registry) - if err != nil { - return nil, verificationError("self-verify team proposal", err) - } - if err := writeExclusive(*out, append(raw, '\n'), 0o644); err != nil { - return nil, ioError("write team proposal", err) - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - return nil, err - } - return requestSummary{*out, team.ProposalKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -func teamConsent(args []string, stderr io.Writer) (any, error) { - flags := newFlagSet("team consent") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - proposalPath := flags.String("proposal", "", "raw team proposal") - keyPath := flags.String("key", "", "encrypted participant key") - passFile := flags.String("passphrase-file", "", "passphrase file or -") - actorID := flags.String("actor-id", "", "numeric GitHub actor ID") - requestID := flags.String("request-id", "", "UUIDv4 (generated if omitted)") - out := flags.String("out", "", "team consent output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *proposalPath == "" || *keyPath == "" || *actorID == "" || *out == "" { - return nil, usageError("usage: eventctl team consent --config PATH --authority PATH --state-meta PATH --registry PATH --proposal PATH --key PATH --actor-id ID --out PATH [--passphrase-file PATH|-] [--request-id UUID]") - } - event, _, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, time.Now().UTC()) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "formation_open"); err != nil { - return nil, verificationError("team formation is not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - pair, err := loadPrivate(*keyPath, *passFile, stderr) - if err != nil { - return nil, verificationError("decrypt participant key", err) - } - keyEpoch, err := resolveKeyEpoch(registry, *actorID, pair.Public) - if err != nil { - return nil, verificationError("resolve active registration", err) - } - proposalRaw, err := readBounded(*proposalPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read team proposal", err) - } - issued := time.Now().UTC().Truncate(time.Second) - proposalTTL := time.Duration(event.Teams.ProposalTTLSeconds) * time.Second - raw, err := team.NewConsent(proposalRaw, team.ConsentParams{OperationID: *requestID, ActorID: *actorID, KeyEpoch: keyEpoch, IssuedAt: issued, ExpiresAt: issued.Add(proposalTTL)}, pair.Private, registry) - if err != nil { - return nil, invalidError("create team consent", err) - } - verified, err := team.VerifyConsent(proposalRaw, raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ConfigDigest: meta.ConfigDigest, Now: issued}, proposalTTL, registry) - if err != nil { - return nil, verificationError("self-verify team consent", err) - } - if err := writeExclusive(*out, append(raw, '\n'), 0o644); err != nil { - return nil, ioError("write team consent", err) - } - docDigest, err := envelope.DocumentDigest(verified.Document) - if err != nil { - return nil, err - } - return requestSummary{*out, team.ConsentKind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey}, nil -} - -func teamVerify(args []string) (any, error) { - flags := newFlagSet("team verify") - configPath := flags.String("config", "", "signed event config") - authority := flags.String("authority", "", "protected genesis") - stateMeta := flags.String("state-meta", "", "protected current state metadata") - registryPath := flags.String("registry", "", "trusted identity registry") - requestPath := flags.String("request", "", "proposal or consent request") - proposalPath := flags.String("proposal", "", "verified proposal output, required for consent") - sourceTimeText := flags.String("source-time", "", "trusted immutable GitHub source creation time") - out := flags.String("out", "", "normalized verification output") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *configPath == "" || *authority == "" || *stateMeta == "" || *registryPath == "" || *requestPath == "" || *sourceTimeText == "" || *out == "" { - return nil, usageError("usage: eventctl team verify --config PATH --authority PATH --state-meta PATH --registry PATH --request PATH --source-time RFC3339 [--proposal VERIFIED_PROPOSAL] --out PATH") - } - sourceTime, err := parseTrustedSourceTime(*sourceTimeText) - if err != nil { - return nil, invalidError("validate trusted source time", err) - } - event, digest, meta, err := loadTrustedContext(*configPath, *authority, *stateMeta, sourceTime) - if err != nil { - return nil, verificationError("verify event trust context", err) - } - if err := requirePhase(meta, "formation_open"); err != nil { - return nil, verificationError("team formation is not open", err) - } - registry, err := loadRegistry(*registryPath) - if err != nil { - return nil, verificationError("load identity registry", err) - } - requestRaw, err := readBounded(*requestPath, envelope.MaxDocumentBytes) - if err != nil { - return nil, ioError("read team request", err) - } - kind, err := documentKind(requestRaw) - if err != nil { - return nil, invalidError("read team request kind", err) - } - expected := envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ConfigDigest: digest, Now: sourceTime} - proposalTTL := time.Duration(event.Teams.ProposalTTLSeconds) * time.Second - var normalized normalizedRequest - switch kind { - case team.ProposalKind: - verified, verifyErr := team.VerifyProposal(requestRaw, expected, proposalTTL, registry) - if verifyErr != nil { - return nil, verificationError("verify team proposal", verifyErr) - } - docDigest, _ := envelope.DocumentDigest(verified.Document) - normalized = normalizedRequest{"verified", kind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey, verified.Document} - case team.ConsentKind: - if *proposalPath == "" { - return nil, usageError("team consent verification requires --proposal VERIFIED_PROPOSAL") - } - proposalRaw, loadErr := loadVerifiedProposal(*proposalPath) - if loadErr != nil { - return nil, verificationError("load verified team proposal", loadErr) - } - verified, verifyErr := team.VerifyConsent(proposalRaw, requestRaw, expected, proposalTTL, registry) - if verifyErr != nil { - return nil, verificationError("verify team consent", verifyErr) - } - docDigest, _ := envelope.DocumentDigest(verified.Document) - normalized = normalizedRequest{"verified", kind, verified.Fingerprint.RequestDigest, docDigest, verified.Fingerprint.ReplayKey, verified.Document} - default: - return nil, invalidError("unsupported team request kind", errors.New(kind)) - } - if err := writeCanonical(*out, normalized, 0o644); err != nil { - return nil, ioError("write verified team request", err) - } - return requestSummary{*out, normalized.Kind, normalized.RequestDigest, normalized.DocumentDigest, normalized.ReplayKey}, nil -} - -func loadRegistry(path string) (identity.Registry, error) { - raw, err := readBounded(path, config.MaxBytes) - if err != nil { - return identity.Registry{}, err - } - return identity.ParseRegistry(raw) -} -func resolveKeyEpoch(registry identity.Registry, actorID string, public identity.Public) (string, error) { - epoch := "" - for _, entry := range registry.Identities { - if entry.ActorID == actorID && entry.Identity == public { - if epoch != "" { - return "", errors.New("multiple registry epochs match key") - } - epoch = entry.KeyEpoch - } - } - if epoch == "" { - return "", errors.New("signing key is not the actor's trusted registration") - } - return epoch, nil -} -func loadMembers(path string) ([]string, error) { - raw, err := readBounded(path, 64*1024) - if err != nil { - return nil, err - } - var members []string - if err := canonical.StrictUnmarshal(raw, &members); err != nil { - return nil, err - } - if len(members) == 0 { - return nil, errors.New("members array is empty") - } - return members, nil -} -func documentKind(raw []byte) (string, error) { - var object map[string]json.RawMessage - if err := canonical.StrictUnmarshal(raw, &object); err != nil { - return "", err - } - value, ok := object["kind"] - if !ok { - return "", errors.New("missing kind") - } - var kind string - if err := canonical.StrictUnmarshal(value, &kind); err != nil { - return "", err - } - return kind, nil -} -func loadVerifiedProposal(path string) ([]byte, error) { - raw, err := readBounded(path, envelope.MaxDocumentBytes*2) - if err != nil { - return nil, err - } - var normalized struct { - Status string `json:"status"` - Kind string `json:"kind"` - RequestDigest string `json:"request_digest"` - DocumentDigest string `json:"document_digest"` - ReplayKey string `json:"replay_key"` - Document team.Proposal `json:"document"` - } - if err := canonical.StrictUnmarshal(raw, &normalized); err != nil { - return nil, err - } - if normalized.Status != "verified" || normalized.Kind != team.ProposalKind { - return nil, errors.New("file is not a verified team proposal") - } - docDigest, err := envelope.DocumentDigest(normalized.Document) - if err != nil || docDigest != normalized.DocumentDigest { - return nil, errors.New("verified proposal document digest mismatch") - } - return canonical.Marshal(normalized.Document) -} diff --git a/cmd/eventctl/trust.go b/cmd/eventctl/trust.go deleted file mode 100644 index 6c64db6..0000000 --- a/cmd/eventctl/trust.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/config" - "github.com/pythonhk/eventctl/internal/envelope" -) - -func loadTrustedContext(configPath, authorityPath, statePath string, now time.Time) (config.Event, string, config.StateMeta, error) { - configRaw, err := readBounded(configPath, config.MaxBytes) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - event, err := config.Parse(configRaw) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - authorityRaw, err := readBounded(authorityPath, config.MaxBytes) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - authority, err := config.ParseAuthority(authorityRaw) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - stateRaw, err := readBounded(statePath, config.MaxBytes) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - meta, err := config.ParseStateMeta(stateRaw) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - verification, err := config.VerifyAdoptedConfig(event, authority, meta, now) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - return event, verification.Digest, meta, nil -} - -func loadAuthorityState(authorityPath, statePath string) (config.Genesis, config.StateMeta, error) { - authorityRaw, err := readBounded(authorityPath, config.MaxBytes) - if err != nil { - return config.Genesis{}, config.StateMeta{}, err - } - authority, err := config.ParseAuthority(authorityRaw) - if err != nil { - return config.Genesis{}, config.StateMeta{}, err - } - stateRaw, err := readBounded(statePath, config.MaxBytes) - if err != nil { - return config.Genesis{}, config.StateMeta{}, err - } - meta, err := config.ParseStateMeta(stateRaw) - if err != nil { - return config.Genesis{}, config.StateMeta{}, err - } - if err := config.VerifyAuthorityState(authority, meta); err != nil { - return config.Genesis{}, config.StateMeta{}, err - } - return authority, meta, nil -} - -func loadArchivedTrustedContext(configPath, authorityPath, statePath string, sourceCreatedAt time.Time) (config.Event, string, config.StateMeta, error) { - authority, meta, err := loadAuthorityState(authorityPath, statePath) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - event, digest, err := loadArchivedConfig(configPath, authority, meta, sourceCreatedAt) - if err != nil { - return config.Event{}, "", config.StateMeta{}, err - } - return event, digest, meta, nil -} - -// loadArchivedConfig authenticates one complete historical config against an -// already loaded genesis/current-state pair. Callers that also consume a -// receipt use the immutable receipt source_created_at as sourceCreatedAt, then -// verify the receipt with the key from this authenticated config. The receipt's -// issued_at is commit time and may legitimately follow config expiry. -func loadArchivedConfig(configPath string, authority config.Genesis, meta config.StateMeta, sourceCreatedAt time.Time) (config.Event, string, error) { - configRaw, err := readBounded(configPath, config.MaxBytes) - if err != nil { - return config.Event{}, "", err - } - event, err := config.Parse(configRaw) - if err != nil { - return config.Event{}, "", err - } - verification, err := config.VerifyArchivedConfig(event, authority, meta, sourceCreatedAt) - if err != nil { - return config.Event{}, "", err - } - return event, verification.Digest, nil -} - -func requirePhase(meta config.StateMeta, phase string) error { - return requireOneOfPhases(meta, phase) -} - -func requireOneOfPhases(meta config.StateMeta, phases ...string) error { - if !meta.Enabled { - return fmt.Errorf("event is disabled: %v", meta.DisabledReason) - } - for _, phase := range phases { - if meta.LifecyclePhase == phase { - return nil - } - } - return fmt.Errorf("event phase is %q, require one of %v", meta.LifecyclePhase, phases) -} - -func parseTrustedSourceTime(value string) (time.Time, error) { - if value == "" { - return time.Time{}, fmt.Errorf("--source-time is required") - } - parsed, err := envelope.ParseTimestamp(value) - if err != nil { - return time.Time{}, fmt.Errorf("invalid --source-time: %w", err) - } - return parsed, nil -} - -func writeCanonical(path string, value any, mode os.FileMode) error { - raw, err := canonical.Marshal(value) - if err != nil { - return err - } - raw = append(raw, '\n') - return writeExclusive(path, raw, mode) -} diff --git a/cmd/eventctl/trust_test.go b/cmd/eventctl/trust_test.go deleted file mode 100644 index f288fc7..0000000 --- a/cmd/eventctl/trust_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "testing" - - "github.com/pythonhk/eventctl/internal/config" -) - -func TestScoringControlPhasesAndEmergencyGate(t *testing.T) { - t.Parallel() - meta := config.StateMeta{Enabled: true, LifecyclePhase: "submissions_open"} - if err := requireOneOfPhases(meta, "submissions_open", "frozen"); err != nil { - t.Fatalf("submissions_open rejected: %v", err) - } - meta.LifecyclePhase = "frozen" - if err := requireOneOfPhases(meta, "submissions_open", "frozen"); err != nil { - t.Fatalf("frozen drain rejected: %v", err) - } - meta.LifecyclePhase = "closed" - if err := requireOneOfPhases(meta, "submissions_open", "frozen"); err == nil { - t.Fatal("closed phase allowed scoring") - } - meta.LifecyclePhase = "frozen" - meta.Enabled = false - if err := requireOneOfPhases(meta, "submissions_open", "frozen"); err == nil { - t.Fatal("emergency-disabled event allowed scoring") - } -} diff --git a/cmd/eventctl/version.go b/cmd/eventctl/version.go deleted file mode 100644 index df6681f..0000000 --- a/cmd/eventctl/version.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "flag" - "io" - - "github.com/pythonhk/eventctl/internal/buildinfo" -) - -func runVersion(args []string, output io.Writer) int { - flags := newFlagSet("version") - jsonOutput := flags.Bool("json", false, "emit JSON") - if err := flags.Parse(args); err != nil || flags.NArg() != 0 || !*jsonOutput { - return emitFailure(output, "version", asCommandError(usageError("usage: eventctl version --json"))) - } - return emitRaw(output, buildinfo.Current()) -} - -func emitRaw(output io.Writer, value any) int { return emit(output, value, 0) } - -func newFlagSet(name string) *flag.FlagSet { - flags := flag.NewFlagSet(name, flag.ContinueOnError) - flags.SetOutput(io.Discard) - return flags -} diff --git a/docs/architecture.md b/docs/architecture.md index fd91e85..63e3792 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,95 +1,104 @@ -# Architecture and trust model - -## Responsibilities - -`eventctl` is an offline cryptographic and serialization tool. It is responsible -for: - -- generating and loading participant Ed25519 keys; -- producing and verifying versioned, domain-separated signed envelopes; -- canonicalizing protocol JSON deterministically; -- assembling bounded file manifests; -- signing plaintext submission manifests before encryption; -- encrypting and decrypting submission bundles with the vetted Go `age` - implementation; and -- rejecting malformed, ambiguous, truncated, oversized, or unsafe inputs. - -It is deliberately not responsible for GitHub authentication, repository -mutation, workflow dispatch, quota decisions, team activation, scoring, or event -lifecycle decisions. Those decisions require current server-side state. - -## Independent trust inputs - -Three checks are required and must not substitute for one another: - -1. **CLI release trust** verifies that the executable came from the expected - `pythonhk/eventctl` release workflow and matches the event's pinned version - and platform checksum. -2. **Event configuration trust** verifies the event ID, numeric upstream - repository ID, protocol version, limits, recipient key and epoch, and the - one v1 configuration digest pinned in protected genesis. Genesis also pins - the root-verified delegation digest, authority, and validity window. -3. **GitHub actor trust** compares the numeric actor ID in a verified envelope - with numeric IDs from the GitHub webhook and freshly fetched API metadata. - -A valid signature proves possession of a participant key. It does not prove -that GitHub authenticated the expected actor, that an attempt is fresh, or that -quota remains. Durable protected event state supplies those properties. - -## Protocol rules - -- Protocol versions are independent of CLI semantic versions. -- Envelopes use fixed schemas and reject unknown fields. -- IDs and digests are serialized unambiguously; GitHub database IDs are decimal - strings to avoid consumer integer-precision differences. -- Signatures are domain separated by protocol and action kind. -- Signing keys and encryption identities are never derived from one another. -- Participant signing material is never accepted through command-line arguments - or environment variables. -- Encryption is sign-then-encrypt. Important public routing hints are duplicated - inside the encrypted signed manifest and must match after decryption. -- A parser must consume the authenticated encrypted stream through EOF before - reporting success. - -## Replay model - -The CLI creates random request and attempt identifiers, but replay protection is -stateful and enforced by the event controller: +# eventctl architecture + +`eventctl` is a local protocol tool, not an event platform. The repository +controls GitHub transport, review, and state promotion; `eventctl` controls +strict documents, signatures, age encryption, and verification. + +```text +participant fork / organizer workflow + │ files, trusted actor ID, immutable source time + ā–¼ + Cobra command layer + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + ā–¼ ā–¼ + protocol v2 stream container + event + registry signed header + age payload +``` + +## Trust model + +The repository has two relevant branches: ```text -(event_id, request_kind, request_id) -> request_digest and terminal result +main public event binding, workflows, request history +registry protected authoritative event state ``` -Actor, repository, epochs, key identity, and configuration remain covered by -the request digest and must be verified against trusted context before this -replay-key lookup. +`event/binding.json` from trusted `main` defines policy and derives an event +reference. A participant-signed document carries that reference. The reviewed +`registry/state.json` from `registry` contains the active identities, teams, +and attempts. Both are command inputs; neither is fetched over the network by +the CLI. + +There is deliberately no GitHub App, app private key, organizer PEM, GitHub +token, or GitHub API client in the runtime. GitHub verifies who opened a pull +request; the trusted workflow passes that numeric actor ID and GitHub-created +timestamp to `eventctl`. The organizer promotes the resulting verified record +through a normal reviewed registry PR. + +## Protocol documents + +All JSON documents are bounded, reject duplicate keys and unknown fields, and +use concrete structures for stable signed bytes. Signatures are Ed25519 over: + +```text +eventctl:eventctl/v2:\0 +``` -- The same replay key with the same request digest returns the original result. -- The same replay key with a different request digest is an idempotency - conflict. -- The same content under a new attempt ID is an intentional new attempt. -- A copied envelope fails when its signed actor, repository, event, action, team - generation, configuration digest, PR address, or sealed head SHA differs from - trusted GitHub and event state. +The protocol has four main public document types: -Timestamps and expiry windows limit stale requests but are not replay protection. +- `identity-registration`: binds a GitHub actor ID to an Ed25519 public key, + age recipient public key, key epoch, event reference, and short request + window. +- `team-proposal` and `team-consent`: bind the exact sorted member set and + their registry-pinned key epochs. All members must consent separately. +- `submission`: binds a team, actor, attempt, payload/metadata digests, and + request window. +- `stream-binding`: binds an encrypted result artifact to the event, team, + attempt, purpose, and trusted artifact ID. -## Submission transport +The protected registry is intentionally one normalized document, rather than +separate identity, membership, and replay indexes. It validates: -The CLI prepares an encrypted artifact but does not call the result ā€œsubmitted.ā€ -Submission occurs only when the GitHub controller authenticates the actor, -resolves the upstream PR by base plus fork owner and branch, verifies the exact -numeric fork repository and head SHA, and atomically reserves an attempt in -protected state. +- event reference and lifecycle phase; +- sorted active identities and their public keys; +- sorted active teams, with no member active in two teams; +- sorted attempts whose submitter belongs to the recorded team. + +`team verify` rejects a team ID or member already active in that registry. +`submission verify` requires `submissions_open`, active membership with the +same key epoch, and an unused attempt within the binding's quotas. The command +does not write state—the reviewed registry PR is the durable state transition. + +## Key custody and stream crypto + +`key-gen` creates separate participant key material: + +```text +Ed25519 private key signs participant documents +age hybrid private key decrypts artifacts addressed to that participant +``` + +Both private files are age-scrypt encrypted with one local passphrase. The +separate primitives are intentional: an Ed25519 key cannot be safely reused +as an age encryption key. + +The stream container is: + +```text +8-byte magic | 4-byte big-endian header length | JSON header | age ciphertext +``` -The PR and source branch are the address. The exact head SHA and payload digest -are the tamper seal. +The signed header covers the public stream binding, signer, sorted recipient +key IDs, payload size, and SHA-256 digest. The payload is age-encrypted to all +recipient public keys. `decverify` verifies the header before accepting a +decrypted payload and creates its output exclusively. -## Private-key handling +## Testing boundary -Key files are created outside the repository with restrictive permissions and -exclusive writes. Passphrases are read from a terminal or -`--passphrase-file` (`-` means standard input), never from a passphrase-valued -argument or environment variable. Backups remain encrypted. Logs and JSON -output must never contain private keys, passphrases, or decrypted submission -bytes. +The only test suite is `tests/e2e`. It builds the real CLI with +`-cover -coverpkg=./...`, invokes it as a subprocess, and drives state through +event binding, identity, team, submission, stream, malformed-file, replay, and +quota scenarios. Every subprocess inherits `GOCOVERDIR`; `mise run test` +merges the counters and requires 100.0% statement coverage. diff --git a/docs/release.md b/docs/release.md deleted file mode 100644 index 9a60d8f..0000000 --- a/docs/release.md +++ /dev/null @@ -1,309 +0,0 @@ -# Release and verification runbook - -`eventctl` releases are prebuilt once and reused by every event repository. An -event must pin an exact version, full source and signer-workflow commit digests, -platform archive, and archive SHA-256 digest; it must never download `latest` -or compile the CLI during an event workflow. For this same-repository workflow, -the source and signer digests must be equal. - -## Release contract - -The repository-owned tool versions are in -`scripts/release/tool-versions.env`. Release archives follow this stable naming -contract: - -```text -eventctl_VERSION_darwin_amd64.tar.gz -eventctl_VERSION_darwin_arm64.tar.gz -eventctl_VERSION_linux_amd64.tar.gz -eventctl_VERSION_linux_arm64.tar.gz -eventctl_VERSION_windows_amd64.zip -eventctl_VERSION_windows_arm64.zip -``` - -Every archive contains exactly one executable plus the tagged source -`LICENSE`, has a matching `.spdx.json` SPDX 2.3 SBOM, and is listed in -`SHA256SUMS`. The SBOM document name and its archive package name, SHA-256 -version, and checksum must identify that exact paired archive. No other archive -entries are allowed. The binary reports the release version, full source -commit digest, and exact UTC source commit date through: - -```bash -eventctl version --json -``` - -`eventctl doctor` must also report the operating system and architecture named -by the archive. Native verification checks both fields so emulation cannot make -a mislabeled archive appear valid. - -The build uses `CGO_ENABLED=0`, `-trimpath`, a fixed commit timestamp, no Go -build ID, the pinned Go toolchain, and no restored Go dependency/build cache. -CI builds the archives twice and requires byte-for-byte identical archive -hashes. SBOM generation scans the built archive locally; online package-data -enrichment and Syft's update check are intentionally disabled. -Release tests and builds also use Go's read-only module mode, so they cannot -silently repair dependency metadata after the tag is created. - -## One-time repository controls - -Configure these controls before the first tag is pushed. They are repository -state and are not supplied by the source tree. - -1. Keep `pythonhk/eventctl` public and set the default workflow token to read - repository contents only. -2. Create a protected environment named `release`. Require at least one - maintainer reviewer, prevent self-review, and restrict deployment branches - and tags to protected `v*` tags. -3. Enable **release immutability** in repository settings. This applies only to - releases published after it is enabled. -4. Add two active tag rulesets for `v*`: - - a creation-only ruleset whose bypass list contains only the named release - maintainer; and - - a separate immutable-tag ruleset with no bypass that restricts updates - and deletions and blocks force pushes. - Keeping these rules separate prevents the creation bypass from authorizing a - later tag rewrite or deletion. Published immutable releases also lock their - tag and assets. -5. Protect `main`: require pull requests, CODEOWNERS review for - `.github/workflows/**`, `.goreleaser.yaml`, `scripts/release/**`, and - `internal/buildinfo/**`, and require the complete CI check set. Before - activating this rule, merge the reviewed CODEOWNERS change and confirm that - the default-branch CODEOWNERS names an independent maintainer; GitHub - evaluates CODEOWNERS from the pull request's base branch. -6. Allow only the actions used by the workflows. They are pinned to full - 40-character commit SHAs; the version comments are review hints, not the - security boundary. -7. Enable private vulnerability reporting so `SECURITY.md`'s reporting path is - available before the first public release. - -Before approving the first `release` environment deployment, an administrator -must read back the immutable-release setting and both tag rulesets from GitHub. -The release job also requires `isImmutable: true` after publication and fails -otherwise, but that postcondition is not a substitute for the pre-release -readback. - -## Prepare a release - -1. Merge the intended release commit into `main`. -2. Confirm all required CI jobs passed for that exact commit, including the - reproducible snapshot build and native tests. -3. Review dependency, cryptography, workflow, action-pin, Go toolchain, - GoReleaser, and Syft changes explicitly. -4. Create an annotated SemVer tag. Prereleases may use a SemVer suffix. - Build metadata (`+metadata`) is intentionally not accepted in release tags, - so each tag maps to one unambiguous archive-name version. - -```bash -git switch main -git pull --ff-only -git tag -a v1.2.3 -m 'eventctl v1.2.3' -git push origin v1.2.3 -``` - -The tag push starts `.github/workflows/release.yml`. Before approving its -protected environment, compare the workflow commit SHA with the reviewed -`main` commit and confirm no release already exists for the tag. - -## What the workflow proves - -The workflow performs these gates in order: - -1. Verify the annotated SemVer tag resolves to the checked-out commit, is - reachable from `origin/main`, and has no existing release. -2. Rerun the complete Go, race, vulnerability, fuzz, Bash, workflow, action-pin, - and release-boundary gates on the exact tagged source. Cross-compile six - CGO-free binaries, produce paired SPDX 2.3 SBOMs and `SHA256SUMS`, and build - twice from independent empty Go build caches to prove reproducibility. -3. On fresh native Linux, macOS, and Windows runners for both amd64 and arm64, - rerun native source tests, download the candidate, and verify the exact - version, source digest, UTC source date, operating system, and architecture. -4. Enter the protected `release` environment and create an asset-complete draft - release. Compare GitHub's server-computed SHA-256 digest and size for every - one of the thirteen draft assets with the local files at initial upload and - again immediately before publication; also peel the remote tag and require - it to equal the source digest at that boundary. -5. Create GitHub SLSA provenance for every archive and an SPDX SBOM attestation - for each corresponding archive. -6. Publish the draft and require `isImmutable: true`, the same release ID, the - exact thirteen server-computed asset digests and sizes, and the same peeled - tag target. A mismatch after publication makes the version unusable. -7. Verify GitHub's immutable release attestation. On a second set of fresh - native runners, verify that record before download, then verify each archive - and `SHA256SUMS` against the immutable release plus both hosted attestations - before extracting or executing any binary. -8. Verify the public binary's checksum, archive allowlist, exact version, - source digest and date, and native operating system and architecture. - -The post-publication jobs are evidence about the actual public release, not a -local build artifact. If one fails, do not replace an asset or move the tag. - -## Consumer verification - -Download only the platform archive and `SHA256SUMS`. The following example is -for macOS arm64; replace the archive name for another target. Verification -requires GitHub CLI 2.93.0 or newer. Versions through 2.92.0 are affected by -[GHSA-8xvp-7hj6-mcj9](https://github.com/cli/cli/security/advisories/GHSA-8xvp-7hj6-mcj9) -and must not be used for release or attestation verification. - -```bash -lock=/absolute/path/to/tools/eventctl.lock.json -platform=darwin-arm64 -repository=$(jq -er '.repository' "$lock") -version=$(jq -er '.version' "$lock") -source_ref=$(jq -er '.attestation.source_ref' "$lock") -tag=${source_ref#refs/tags/} -asset=$(jq -er --arg platform "$platform" '.assets[$platform].name' "$lock") -source_digest=$(jq -er '.attestation.source_digest' "$lock") -signer_digest=$(jq -er '.attestation.signer_digest' "$lock") -signer_workflow=$(jq -er '.attestation.signer_workflow' "$lock") -source_date=$(jq -er '.attestation.source_date' "$lock") -provenance_predicate=$(jq -er '.attestation.predicate_type' "$lock") -sbom_predicate=$(jq -er '.attestation.sbom_predicate_type' "$lock") -expected_manifest_digest=$(jq -er '.checksums.sha256' "$lock") -expected_archive_digest=$(jq -er --arg platform "$platform" \ - '.assets[$platform].sha256' "$lock") -expected_binary_digest=$(jq -er --arg platform "$platform" \ - '.assets[$platform].binary_sha256' "$lock") -expected_os=darwin -expected_arch=arm64 - -test "$repository" = pythonhk/eventctl -test "$(jq -er '.attestation.repository' "$lock")" = "$repository" -test "$tag" = "v$version" -test "$source_ref" = "refs/tags/$tag" -test "$signer_workflow" = pythonhk/eventctl/.github/workflows/release.yml -[[ $source_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] -[[ $signer_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] -[[ $signer_digest == "$source_digest" ]] -[[ $source_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] -[[ $expected_manifest_digest =~ ^[0-9a-f]{64}$ ]] -[[ $expected_archive_digest =~ ^[0-9a-f]{64}$ ]] -[[ $expected_binary_digest =~ ^[0-9a-f]{64}$ ]] - -# Fail before any network access if GitHub CLI is absent, unsupported, or old. -scripts/release/require-gh-version.sh - -# Verify the immutable release record before downloading any asset. -gh release verify "$tag" --repo "$repository" - -gh release download "$tag" \ - --repo "$repository" \ - --pattern "$asset" \ - --pattern SHA256SUMS - -# Bind both downloaded files to the already-verified immutable release before -# trusting their contents or executing the binary. -gh release verify-asset "$tag" "$asset" --repo "$repository" -gh release verify-asset "$tag" SHA256SUMS --repo "$repository" - -manifest_actual=$(shasum -a 256 SHA256SUMS | awk '{ print $1 }') -test "$manifest_actual" = "$expected_manifest_digest" -expected=$(awk -v name="$asset" '$2 == name || $2 == "*" name { print $1 }' SHA256SUMS) -actual=$(shasum -a 256 "$asset" | awk '{ print $1 }') -test "$actual" = "$expected" -test "$actual" = "$expected_archive_digest" - -gh attestation verify "$asset" \ - --repo "$repository" \ - --deny-self-hosted-runners \ - --signer-digest "$signer_digest" \ - --signer-workflow "$signer_workflow" \ - --source-ref "$source_ref" \ - --source-digest "$source_digest" \ - --predicate-type "$provenance_predicate" - -gh attestation verify "$asset" \ - --repo "$repository" \ - --deny-self-hosted-runners \ - --signer-digest "$signer_digest" \ - --signer-workflow "$signer_workflow" \ - --source-ref "$source_ref" \ - --source-digest "$source_digest" \ - --predicate-type "$sbom_predicate" - -# Reject duplicate, traversal, link, and extra-entry archive shapes before use. -expected_entries=$(printf '%s\n' LICENSE eventctl | LC_ALL=C sort) -actual_entries=$(tar -tzf "$asset" | LC_ALL=C sort) -test "$actual_entries" = "$expected_entries" -verify_dir=$(mktemp -d) -trap 'rm -rf -- "$verify_dir"' EXIT HUP INT TERM -tar -xzf "$asset" -C "$verify_dir" -test -f "$verify_dir/eventctl" && test ! -L "$verify_dir/eventctl" -test -f "$verify_dir/LICENSE" && test ! -L "$verify_dir/LICENSE" -binary_actual=$(shasum -a 256 "$verify_dir/eventctl" | awk '{ print $1 }') -test "$binary_actual" = "$expected_binary_digest" -version_json=$("$verify_dir/eventctl" version --json) -printf '%s' "$version_json" | jq -e \ - --arg version "$version" \ - --arg commit "$source_digest" \ - --arg date "$source_date" \ - '.version == $version and .commit == $commit and .date == $date' - -doctor_json=$("$verify_dir/eventctl" doctor) -printf '%s' "$doctor_json" | jq -e \ - --arg operating_system "$expected_os" \ - --arg architecture "$expected_arch" \ - '.output_version == "pythonhk.eventctl/output/v1" and - .ok == true and .command == "doctor" and .error == null and - .result.status == "healthy" and - .result.operating_system == $operating_system and - .result.architecture == $architecture' - -``` - -The event starter's installer must perform the equivalent checksum and -provenance validation before extracting or executing the CLI, and it must -verify the immutable release record before downloading. A checksum fetched from -the same release protects transfer integrity; the GitHub attestation binds that -digest to this repository and workflow. - -## Failed and superseded releases - -- Failure before draft creation leaves GitHub release state unchanged. -- A failed draft is unpublished and mutable. Inspect the workflow and draft - assets, record the reason, then an authorized maintainer may remove only that - draft before rerunning the same tag. -- Once published, the release and its tag are immutable. Any build, native - execution, checksum, SBOM, attestation, or readback failure makes the version - unusable. Fix the cause and publish a new patch version; never reuse or move - the old tag. -- If a release was accidentally published without immutability, treat it as - compromised, enable immutability, and publish a new version. Enabling the - setting is not retroactive. - -## Updating release dependencies - -Dependabot may propose action or Go module updates, but it does not replace -review. For every action update, resolve the advertised immutable version tag -in the action's official repository, record the full commit SHA in `uses:`, and -retain the human-readable version comment. Then run: - -```bash -scripts/release/check-action-pins.sh -scripts/release/load-tool-versions.sh -``` - -For GoReleaser or Syft updates, update the single version contract, review their -official release notes and checksums, run `goreleaser check`, and require CI to -re-prove deterministic archives. Never use a version range, floating major tag, -or `latest` in the release workflow. - -The initial reviewed action set is: - -| Action | Version | Pinned commit | -| --- | --- | --- | -| `actions/checkout` | `v7.0.1` | [`3d3c42e5aac5ba805825da76410c181273ba90b1`](https://github.com/actions/checkout/commit/3d3c42e5aac5ba805825da76410c181273ba90b1) | -| `actions/setup-go` | `v7.0.0` | [`b7ad1dad31e06c5925ef5d2fc7ad053ef454303e`](https://github.com/actions/setup-go/commit/b7ad1dad31e06c5925ef5d2fc7ad053ef454303e) | -| `actions/upload-artifact` | `v7.0.1` | [`043fb46d1a93c77aae656e7c1c64a875d1fc6a0a`](https://github.com/actions/upload-artifact/commit/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) | -| `actions/download-artifact` | `v8.0.1` | [`3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c`](https://github.com/actions/download-artifact/commit/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) | -| `actions/attest` | `v4.2.1` | [`508db95dd578ae2727ebd6217d5ba78e4fbda05d`](https://github.com/actions/attest/commit/508db95dd578ae2727ebd6217d5ba78e4fbda05d) | -| `anchore/sbom-action` | `v0.24.0` | [`e22c389904149dbc22b58101806040fa8d37a610`](https://github.com/anchore/sbom-action/commit/e22c389904149dbc22b58101806040fa8d37a610) | -| `goreleaser/goreleaser-action` | `v7.2.3` | [`f06c13b6b1a9625abc9e6e439d9c05a8f2190e94`](https://github.com/goreleaser/goreleaser-action/commit/f06c13b6b1a9625abc9e6e439d9c05a8f2190e94) | - -## Release evidence - -Retain the tag commit, protected-environment approval, workflow URL, CI and -native job results, release URL, exact asset names, draft digest/size readback, -`SHA256SUMS`, SPDX SBOMs, artifact-attestation verification output, -immutable-release verification, and the version JSON from every native target. -These are the minimum facts needed to reproduce the release decision. diff --git a/go.mod b/go.mod index 2404a60..fb0edea 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,20 @@ toolchain go1.26.5 require ( filippo.io/age v1.3.1 - go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 + github.com/caarlos0/env/v11 v11.4.1 + github.com/samber/lo v1.53.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 ) require ( filippo.io/hpke v0.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0340119..b14d402 100644 --- a/go.sum +++ b/go.sum @@ -4,13 +4,32 @@ filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 4dea6a6..83e7ee6 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -1,16 +1,18 @@ -// Package buildinfo exposes release metadata injected with -ldflags. package buildinfo +const ( + Protocol = "eventctl/v2" +) + var ( - Version = "dev" - Commit = "unknown" - Date = "unknown" + Version = "0.2.0-dev" + Commit = "dev" ) type Info struct { - Version string `json:"version"` - Commit string `json:"commit"` - Date string `json:"date"` + Version string `json:"version"` + Commit string `json:"commit"` + Protocol string `json:"protocol"` } -func Current() Info { return Info{Version: Version, Commit: Commit, Date: Date} } +func Current() Info { return Info{Version, Commit, Protocol} } diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go deleted file mode 100644 index 369efe2..0000000 --- a/internal/bundle/bundle_test.go +++ /dev/null @@ -1,1052 +0,0 @@ -package bundle - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "io" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "testing" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/identity" -) - -func TestCustomLimitsRequireExplicitMaxValidity(t *testing.T) { - t.Parallel() - custom := DefaultLimits() - custom.MaxValidity = 0 - if _, err := normalizeLimits(custom); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("normalizeLimits() error = %v, want ErrLimitExceeded", err) - } -} - -func TestPackInspectVerifyAndDecryptRoundTrip(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, filepath.Join(source, "nested")) - mustWriteFile(t, filepath.Join(source, "z.txt"), []byte("last\n"), 0o644) - mustWriteFile(t, filepath.Join(source, "nested", "a.bin"), []byte{0, 1, 2, 3}, 0o600) - output := filepath.Join(root, "submission.evt") - - packed, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, - OutputPath: output, - Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{fixture.recipient}, - SigningKey: fixture.privateKey, - }) - if err != nil { - t.Fatal(err) - } - if packed.BundleSHA256 == "" || packed.BundleSize == 0 { - t.Fatalf("PackDirectory() returned incomplete bundle metadata: %#v", packed) - } - if got := packed.Manifest.Files; len(got) != 2 || got[0].Path != "nested/a.bin" || got[1].Path != "z.txt" { - t.Fatalf("manifest files are not sorted: %#v", got) - } - if runtime.GOOS != "windows" { - assertFileMode(t, output, 0o600) - } - - inspection, err := Inspect(context.Background(), output, Limits{}) - if err != nil { - t.Fatal(err) - } - if inspection.BundleSHA256 != packed.BundleSHA256 || inspection.BundleSize != packed.BundleSize { - t.Fatalf("Inspect() = %#v; packed digest/size = %s/%d", inspection, packed.BundleSHA256, packed.BundleSize) - } - if got := bindingFromEnvelope(inspection.Envelope); got != fixture.binding() { - t.Fatalf("Inspect() binding = %#v", got) - } - if inspection.EnvelopeSHA256 != packed.EnvelopeSHA256 { - t.Fatalf("Inspect() envelope digest = %s, want %s", inspection.EnvelopeSHA256, packed.EnvelopeSHA256) - } - - verified, err := Verify( - context.Background(), - output, - []*age.HybridIdentity{fixture.identity}, - fixture.publicKey, - Limits{}, - ) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(verified, packed.Verified) { - t.Fatalf("Verify() = %#v, want %#v", verified, packed.Verified) - } - if !extractionSupported { - return - } - - destination := filepath.Join(root, "decrypted") - decrypted, err := DecryptToDirectory( - context.Background(), - output, - destination, - []*age.HybridIdentity{fixture.identity}, - fixture.publicKey, - Limits{}, - []string{".bin", ".txt"}, - ) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(decrypted, packed.Verified) { - t.Fatalf("DecryptToDirectory() = %#v, want %#v", decrypted, packed.Verified) - } - assertFileContents(t, filepath.Join(destination, "nested", "a.bin"), []byte{0, 1, 2, 3}) - assertFileContents(t, filepath.Join(destination, "z.txt"), []byte("last\n")) - assertFileMode(t, filepath.Join(destination, "nested", "a.bin"), 0o600) - assertFileMode(t, filepath.Join(destination, "z.txt"), 0o600) -} - -func TestPackRejectsUnsafeSourcesLimitsAndExistingOutput(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - baseOptions := func(root, source, output string) PackOptions { - return PackOptions{ - SourceDir: source, - OutputPath: output, - Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{fixture.recipient}, - SigningKey: fixture.privateKey, - } - } - t.Run("symlink", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(root, "target"), []byte("secret"), 0o600) - if err := os.Symlink(filepath.Join(root, "target"), filepath.Join(source, "link")); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - _, err := PackDirectory(context.Background(), baseOptions(root, source, filepath.Join(root, "out.evt"))) - if !errors.Is(err, ErrUnsafePath) { - t.Fatalf("PackDirectory() error = %v, want ErrUnsafePath", err) - } - }) - t.Run("output inside source", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data"), []byte("x"), 0o600) - _, err := PackDirectory(context.Background(), baseOptions(root, source, filepath.Join(source, "out.evt"))) - if !errors.Is(err, ErrUnsafePath) { - t.Fatalf("PackDirectory() error = %v, want ErrUnsafePath", err) - } - }) - t.Run("output parent symlink aliases source", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data"), []byte("x"), 0o600) - alias := filepath.Join(root, "source-alias") - if err := os.Symlink(source, alias); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - _, err := PackDirectory(context.Background(), baseOptions(root, source, filepath.Join(alias, "out.evt"))) - if !errors.Is(err, ErrUnsafePath) { - t.Fatalf("PackDirectory() error = %v, want ErrUnsafePath", err) - } - }) - t.Run("existing output", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data"), []byte("x"), 0o600) - output := filepath.Join(root, "out.evt") - mustWriteFile(t, output, []byte("preserve"), 0o600) - _, err := PackDirectory(context.Background(), baseOptions(root, source, output)) - if !errors.Is(err, ErrDestinationExists) { - t.Fatalf("PackDirectory() error = %v, want ErrDestinationExists", err) - } - assertFileContents(t, output, []byte("preserve")) - }) - t.Run("oversize", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data"), []byte("12345"), 0o600) - limits := DefaultLimits() - limits.MaxFileBytes = 4 - options := baseOptions(root, source, filepath.Join(root, "out.evt")) - options.Limits = limits - _, err := PackDirectory(context.Background(), options) - if !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("PackDirectory() error = %v, want ErrLimitExceeded", err) - } - }) - t.Run("too many files", func(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "a"), []byte("a"), 0o600) - mustWriteFile(t, filepath.Join(source, "b"), []byte("b"), 0o600) - limits := DefaultLimits() - limits.MaxFiles = 1 - options := baseOptions(root, source, filepath.Join(root, "out.evt")) - options.Limits = limits - _, err := PackDirectory(context.Background(), options) - if !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("PackDirectory() error = %v, want ErrLimitExceeded", err) - } - }) -} - -func TestInspectAndVerifyRejectTamperingTruncationAndWrongKeys(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - bundlePath := makeValidBundle(t, fixture) - original := mustReadFile(t, bundlePath) - - t.Run("ciphertext tamper", func(t *testing.T) { - mutated := append([]byte(nil), original...) - mutated[len(mutated)-1] ^= 0x80 - path := filepath.Join(t.TempDir(), "tampered.evt") - mustWriteFile(t, path, mutated, 0o600) - _, err := Inspect(context.Background(), path, Limits{}) - if !errors.Is(err, ErrCiphertextDigest) { - t.Fatalf("Inspect() error = %v, want ErrCiphertextDigest", err) - } - }) - t.Run("truncation", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "truncated.evt") - mustWriteFile(t, path, original[:len(original)-1], 0o600) - if _, err := Inspect(context.Background(), path, Limits{}); err == nil { - t.Fatal("Inspect() unexpectedly accepted a truncated bundle") - } - }) - t.Run("symlink input", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "linked.evt") - if err := os.Symlink(bundlePath, path); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - if _, err := Inspect(context.Background(), path, Limits{}); !errors.Is(err, ErrInvalidFormat) { - t.Fatalf("Inspect() error = %v, want ErrInvalidFormat", err) - } - }) - t.Run("wrong recipient", func(t *testing.T) { - wrongIdentity, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - destination := filepath.Join(t.TempDir(), "must-not-exist") - _, err = Verify( - context.Background(), bundlePath, - []*age.HybridIdentity{wrongIdentity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrNoRecipient) { - t.Fatalf("Verify() error = %v, want ErrNoRecipient", err) - } - if _, statErr := os.Lstat(destination); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("failed decryption exposed destination: %v", statErr) - } - }) - t.Run("wrong signing key", func(t *testing.T) { - wrongPublic, _, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - _, err = Verify( - context.Background(), bundlePath, - []*age.HybridIdentity{fixture.identity}, wrongPublic, Limits{}, - ) - if !errors.Is(err, ErrSignature) { - t.Fatalf("Verify() error = %v, want ErrSignature", err) - } - }) - t.Run("outer signature", func(t *testing.T) { - mutated := append([]byte(nil), original...) - envelopeLength := binary.BigEndian.Uint32(mutated[outerMagicSize : outerMagicSize+4]) - signatureOffset := outerMagicSize + 4 + int(envelopeLength) - mutated[signatureOffset] ^= 1 - path := filepath.Join(t.TempDir(), "bad-signature.evt") - mustWriteFile(t, path, mutated, 0o600) - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrSignature) { - t.Fatalf("Verify() error = %v, want ErrSignature", err) - } - }) -} - -func TestAuthenticatePublicVerifiesOuterSignatureBeforeCiphertext(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - bundlePath := makeValidBundle(t, fixture) - pair, err := identity.FromSeed(fixture.privateKey.Seed()) - if err != nil { - t.Fatal(err) - } - authenticated, err := AuthenticatePublic(context.Background(), bundlePath, pair.Public, Limits{}) - if err != nil { - t.Fatal(err) - } - inspected, err := Inspect(context.Background(), bundlePath, Limits{}) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(authenticated, inspected) { - t.Fatalf("AuthenticatePublic() = %#v, want %#v", authenticated, inspected) - } - - mutated := mustReadFile(t, bundlePath) - envelopeLength := binary.BigEndian.Uint32(mutated[outerMagicSize : outerMagicSize+4]) - signatureOffset := outerMagicSize + 4 + int(envelopeLength) - mutated[signatureOffset] ^= 1 - mutated[len(mutated)-1] ^= 1 - badPath := filepath.Join(t.TempDir(), "bad-signature-and-ciphertext.evt") - mustWriteFile(t, badPath, mutated, 0o600) - if _, err := AuthenticatePublic(context.Background(), badPath, pair.Public, Limits{}); !errors.Is(err, ErrSignature) { - t.Fatalf("AuthenticatePublic() error = %v, want ErrSignature before ciphertext digest", err) - } -} - -func TestPublicAndDecryptVerificationEnforceConfiguredValidity(t *testing.T) { - t.Parallel() - fixture := newCryptoFixture(t) - pair, err := identity.FromSeed(fixture.privateKey.Seed()) - if err != nil { - t.Fatal(err) - } - configured := DefaultLimits() - configured.MaxValidity = 15 * time.Minute - if _, err := AuthenticatePublic(context.Background(), makeValidBundle(t, fixture), pair.Public, configured); err != nil { - t.Fatalf("exact configured bundle TTL rejected: %v", err) - } - - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data.txt"), []byte("submission\n"), 0o600) - binding := fixture.binding() - binding.ExpiresAt = "2030-06-01T02:15:01Z" - over := filepath.Join(root, "over-ttl.evt") - if _, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, OutputPath: over, Binding: binding, - Recipients: []*age.HybridRecipient{fixture.recipient}, SigningKey: fixture.privateKey, - }); err != nil { - t.Fatal(err) - } - if _, err := AuthenticatePublic(context.Background(), over, pair.Public, configured); err == nil { - t.Fatal("public verification accepted a bundle above the signed config TTL") - } - if _, err := Verify(context.Background(), over, []*age.HybridIdentity{fixture.identity}, fixture.publicKey, configured); err == nil { - t.Fatal("decrypt verification accepted a bundle above the signed config TTL") - } -} - -func TestVerifyAuthenticatesCiphertextEOFAndInnerLayers(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - tests := []struct { - name string - mutate func(*forgedBundle) - want error - }{ - { - name: "authenticated EOF", - mutate: func(bundle *forgedBundle) { - bundle.truncateCiphertext = true - }, - want: ErrInvalidFormat, - }, - { - name: "inner signature", - mutate: func(bundle *forgedBundle) { - bundle.corruptInnerSignature = true - }, - want: ErrSignature, - }, - { - name: "inner outer binding", - mutate: func(bundle *forgedBundle) { - changed := bindingFromManifest(bundle.manifest) - changed.TeamID = "22222222-2222-4222-8222-222222222222" - bundle.outerBinding = &changed - }, - want: ErrManifestMismatch, - }, - { - name: "file digest", - mutate: func(bundle *forgedBundle) { - bundle.manifest.Files[0].SHA256 = strings.Repeat("0", 64) - }, - want: ErrFileDigest, - }, - } - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - t.Parallel() - forged := validForgedBundle(fixture) - test.mutate(&forged) - path := writeForgedBundle(t, fixture, forged) - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, test.want) { - t.Fatalf("Verify() error = %v, want %v", err, test.want) - } - }) - } -} - -func TestVerifyRequiresExactInnerOuterBindingEquality(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - tests := []struct { - name string - mutate func(*Binding) - }{ - {name: "event epoch", mutate: func(binding *Binding) { binding.EventEpoch = "2" }}, - {name: "key epoch", mutate: func(binding *Binding) { binding.KeyEpoch = "2" }}, - {name: "recipient epoch", mutate: func(binding *Binding) { binding.RecipientEpoch = "4" }}, - {name: "team proposal digest", mutate: func(binding *Binding) { binding.TeamProposalDigest = strings.Repeat("c", 64) }}, - {name: "config digest", mutate: func(binding *Binding) { binding.ConfigDigest = strings.Repeat("d", 64) }}, - {name: "actor ID", mutate: func(binding *Binding) { binding.ActorID = "9007199254740994" }}, - {name: "base repository ID", mutate: func(binding *Binding) { binding.BaseRepositoryID = "12345678901234567891" }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - forged := validForgedBundle(fixture) - outer := bindingFromManifest(forged.manifest) - test.mutate(&outer) - forged.outerBinding = &outer - path := writeForgedBundle(t, fixture, forged) - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrManifestMismatch) { - t.Fatalf("Verify() error = %v, want ErrManifestMismatch", err) - } - }) - } -} - -func TestVerifyRejectsMaliciousArchivePaths(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - paths := []string{ - "/absolute.txt", - ".hidden", - "../traversal.txt", - "dir/../../traversal.txt", - "back\\slash.txt", - "nul\x00byte.txt", - "C:/windows.txt", - "dir//duplicate-separator.txt", - "NUL.txt", - "COM1.log", - "trailing.", - "trailing ", - "question?.txt", - "cafĆ©.txt", - } - for _, maliciousPath := range paths { - maliciousPath := maliciousPath - t.Run(hex.EncodeToString([]byte(maliciousPath)), func(t *testing.T) { - t.Parallel() - forged := validForgedBundle(fixture) - forged.manifest.Files[0].Path = maliciousPath - path := writeForgedBundle(t, fixture, forged) - destination := filepath.Join(t.TempDir(), "must-not-exist") - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrUnsafePath) { - t.Fatalf("Verify() error = %v, want ErrUnsafePath", err) - } - if _, statErr := os.Lstat(destination); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("malicious extraction exposed destination: %v", statErr) - } - }) - } -} - -func TestVerifyRejectsDuplicateCaseAndFileDirectoryCollisions(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - tests := []struct { - name string - paths []string - }{ - {name: "duplicate", paths: []string{"a.txt", "a.txt"}}, - {name: "case collision", paths: []string{"A.txt", "a.txt"}}, - {name: "file directory collision", paths: []string{"a", "a/b.txt"}}, - } - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - forged := validForgedBundle(fixture) - forged.manifest.Files = nil - for _, filePath := range test.paths { - forged.manifest.Files = append(forged.manifest.Files, File{ - Path: filePath, SizeBytes: 1, SHA256: digestHex([]byte("x")), - }) - } - forged.fileContents = bytes.Repeat([]byte("x"), len(test.paths)) - path := writeForgedBundle(t, fixture, forged) - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrUnsafePath) && !errors.Is(err, ErrInvalidFormat) { - t.Fatalf("Verify() error = %v, want path rejection", err) - } - }) - } -} - -func TestHybridRecipientFingerprintGoldenDomain(t *testing.T) { - t.Parallel() - - const encoded = "age1pq1test" - const expected = "2651fefe8cd806e925f2ac51a665f0712d782def1693ff694219c6b44a8112c1" - if got := ageRecipientKeyID(encoded); got != expected { - t.Fatalf("ageRecipientKeyID(%q) = %s, want %s", encoded, got, expected) - } -} - -func TestPackAndVerifyWithTwoHybridRecipients(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - second, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data.txt"), []byte("two recipients\n"), 0o600) - path := filepath.Join(root, "submission.evt") - packed, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, OutputPath: path, Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{second.Recipient(), fixture.recipient}, - SigningKey: fixture.privateKey, - }) - if err != nil { - t.Fatal(err) - } - wantIDs := []string{ - ageRecipientKeyID(second.Recipient().String()), - ageRecipientKeyID(fixture.recipient.String()), - } - if wantIDs[0] > wantIDs[1] { - wantIDs[0], wantIDs[1] = wantIDs[1], wantIDs[0] - } - if !reflect.DeepEqual(packed.Envelope.RecipientKeyIDs, wantIDs) { - t.Fatalf("recipient IDs = %#v, want %#v", packed.Envelope.RecipientKeyIDs, wantIDs) - } - verified, err := Verify( - context.Background(), path, - []*age.HybridIdentity{second, fixture.identity}, fixture.publicKey, Limits{}, - ) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(verified, packed.Verified) { - t.Fatalf("Verify() = %#v, want %#v", verified, packed.Verified) - } - - extra, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - setTests := []struct { - name string - identities []*age.HybridIdentity - }{ - {name: "missing", identities: []*age.HybridIdentity{fixture.identity}}, - {name: "duplicate", identities: []*age.HybridIdentity{fixture.identity, fixture.identity}}, - {name: "extra", identities: []*age.HybridIdentity{fixture.identity, second, extra}}, - } - for _, test := range setTests { - t.Run(test.name, func(t *testing.T) { - _, err := Verify(context.Background(), path, test.identities, fixture.publicKey, Limits{}) - if !errors.Is(err, ErrRecipientSet) { - t.Fatalf("Verify() error = %v, want ErrRecipientSet", err) - } - }) - } -} - -func TestVerifyRejectsPartialRecipientStanzaReplacement(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - declaredSecond, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - undeclared, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - declaredIDs := []string{ - ageRecipientKeyID(fixture.recipient.String()), - ageRecipientKeyID(declaredSecond.Recipient().String()), - } - if declaredIDs[0] > declaredIDs[1] { - declaredIDs[0], declaredIDs[1] = declaredIDs[1], declaredIDs[0] - } - forged := validForgedBundle(fixture) - forged.encryptionRecipients = []*age.HybridRecipient{fixture.recipient, undeclared.Recipient()} - forged.declaredRecipientIDs = declaredIDs - path := writeForgedBundle(t, fixture, forged) - _, err = Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity, declaredSecond}, fixture.publicKey, Limits{}, - ) - if !errors.Is(err, ErrRecipientSet) { - t.Fatalf("Verify() error = %v, want ErrRecipientSet", err) - } -} - -func TestVerifyRequiresExactHybridStanzaSet(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - declaredSecond, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - undeclared, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - declaredIDs := []string{ - ageRecipientKeyID(fixture.recipient.String()), - ageRecipientKeyID(declaredSecond.Recipient().String()), - } - if declaredIDs[0] > declaredIDs[1] { - declaredIDs[0], declaredIDs[1] = declaredIDs[1], declaredIDs[0] - } - tests := []struct { - name string - recipients []*age.HybridRecipient - wantError bool - }{ - { - name: "swapped order", - recipients: []*age.HybridRecipient{declaredSecond.Recipient(), fixture.recipient}, - }, - { - name: "extra undeclared stanza", - recipients: []*age.HybridRecipient{fixture.recipient, declaredSecond.Recipient(), undeclared.Recipient()}, - wantError: true, - }, - { - name: "duplicate replaces declared stanza", - recipients: []*age.HybridRecipient{fixture.recipient, fixture.recipient}, - wantError: true, - }, - { - name: "missing declared stanza", - recipients: []*age.HybridRecipient{fixture.recipient}, - wantError: true, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - forged := validForgedBundle(fixture) - forged.encryptionRecipients = test.recipients - forged.declaredRecipientIDs = append([]string(nil), declaredIDs...) - path := writeForgedBundle(t, fixture, forged) - _, err := Verify( - context.Background(), path, - []*age.HybridIdentity{fixture.identity, declaredSecond}, fixture.publicKey, Limits{}, - ) - if test.wantError && err == nil { - t.Fatal("Verify() unexpectedly accepted a non-exact stanza set") - } - if !test.wantError && err != nil { - t.Fatalf("Verify() error = %v, want success", err) - } - }) - } -} - -func TestVerifyRejectsDeclaredRecipientDifferentFromCiphertextRecipient(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - actualIdentity, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - forged := validForgedBundle(fixture) - forged.encryptionRecipients = []*age.HybridRecipient{actualIdentity.Recipient()} - forged.declaredRecipientIDs = []string{ageRecipientKeyID(fixture.recipient.String())} - path := writeForgedBundle(t, fixture, forged) - for name, identities := range map[string][]*age.HybridIdentity{ - "declared identity": {fixture.identity}, - "actual identity": {actualIdentity}, - } { - t.Run(name, func(t *testing.T) { - _, err := Verify(context.Background(), path, identities, fixture.publicKey, Limits{}) - if !errors.Is(err, ErrNoRecipient) { - t.Fatalf("Verify() error = %v, want ErrNoRecipient", err) - } - }) - } -} - -func TestSignatureDomainGoldenVectors(t *testing.T) { - t.Parallel() - - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = byte(index) - } - privateKey := ed25519.NewKeyFromSeed(seed) - payload := []byte(`{"kind":"golden"}`) - tests := []struct { - name string - domain string - expected string - }{ - { - name: "manifest", domain: manifestSignatureDomain, - expected: "989b144feeea7a9e5efba5c61f6383df56eb7e11e78d2c1298e1f1e72eeb04309c6aff3e0b5f5b418c98d7eb66d63919cabc664c524898c6414b70843d220805", - }, - { - name: "envelope", domain: envelopeSignatureDomain, - expected: "01018a86a569075f80da8b5f462732b42b61ce885f8ff5bafd3c89de86b9f4a2f80803e172b1b9e5f351a33015550523a53338d6831a0e59e040dfb435717802", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - signature := ed25519.Sign(privateKey, signingMessage(test.domain, payload)) - if got := hex.EncodeToString(signature); got != test.expected { - t.Fatalf("signature = %s, want %s", got, test.expected) - } - }) - } -} - -func TestPackRejectsInconsistentEd25519PrivateKey(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - invalid := append(ed25519.PrivateKey(nil), fixture.privateKey...) - invalid[len(invalid)-1] ^= 1 - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data.txt"), []byte("x"), 0o600) - _, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, OutputPath: filepath.Join(root, "out.evt"), Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{fixture.recipient}, SigningKey: invalid, - }) - if err == nil || !strings.Contains(err.Error(), "inconsistent public-key suffix") { - t.Fatalf("PackDirectory() error = %v, want inconsistent public-key suffix", err) - } -} - -func TestPublishBundleDoesNotOverwriteDestinationCreatedAfterPreflight(t *testing.T) { - t.Parallel() - - root := t.TempDir() - work := filepath.Join(root, "work") - mustMkdirAll(t, work) - ciphertextPath := filepath.Join(work, "ciphertext") - ciphertext, err := os.OpenFile(ciphertextPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - t.Fatal(err) - } - defer ciphertext.Close() - if _, err := ciphertext.Write([]byte("ciphertext")); err != nil { - t.Fatal(err) - } - output := filepath.Join(root, "submission.evt") - mustWriteFile(t, output, []byte("concurrent winner"), 0o600) - _, _, err = publishBundle( - context.Background(), output, []byte(`{}`), make([]byte, ed25519.SignatureSize), - ciphertext, work, 1024, - ) - if !errors.Is(err, ErrDestinationExists) { - t.Fatalf("publishBundle() error = %v, want ErrDestinationExists", err) - } - assertFileContents(t, output, []byte("concurrent winner")) -} - -type cryptoFixture struct { - identity *age.HybridIdentity - recipient *age.HybridRecipient - privateKey ed25519.PrivateKey - publicKey ed25519.PublicKey -} - -func newCryptoFixture(t *testing.T) cryptoFixture { - t.Helper() - identity, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return cryptoFixture{ - identity: identity, recipient: identity.Recipient(), - privateKey: privateKey, publicKey: publicKey, - } -} - -func (fixture cryptoFixture) binding() Binding { - return Binding{ - EventID: "pyhk-2026", - EventEpoch: "1", - BaseRepositoryID: "12345678901234567890", - ActorID: "9007199254740993", - KeyID: identity.KeyID(fixture.publicKey), - KeyEpoch: "1", - TeamID: "11111111-1111-4111-8111-111111111111", - TeamProposalDigest: strings.Repeat("b", 64), - AttemptID: "018f0f92-9f14-4ed0-aeca-123456789abc", - RequestID: "018f0f92-9f14-4ed0-aeca-abcdef123456", - ConfigDigest: strings.Repeat("a", 64), - RecipientEpoch: "3", - IssuedAt: "2030-06-01T02:00:00Z", - ExpiresAt: "2030-06-01T02:15:00Z", - } -} - -func makeValidBundle(t *testing.T, fixture cryptoFixture) string { - t.Helper() - root := t.TempDir() - source := filepath.Join(root, "source") - mustMkdirAll(t, source) - mustWriteFile(t, filepath.Join(source, "data.txt"), []byte("submission\n"), 0o600) - output := filepath.Join(root, "submission.evt") - _, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, OutputPath: output, Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{fixture.recipient}, SigningKey: fixture.privateKey, - }) - if err != nil { - t.Fatal(err) - } - return output -} - -type forgedBundle struct { - manifest Manifest - fileContents []byte - outerBinding *Binding - corruptInnerSignature bool - truncateCiphertext bool - encryptionRecipients []*age.HybridRecipient - declaredRecipientIDs []string -} - -func validForgedBundle(fixture cryptoFixture) forgedBundle { - contents := []byte("x") - return forgedBundle{ - manifest: manifestFromBinding(fixture.binding(), []File{{ - Path: "safe.txt", SizeBytes: uint64(len(contents)), SHA256: digestHex(contents), - }}), - fileContents: contents, - } -} - -func writeForgedBundle(t *testing.T, fixture cryptoFixture, forged forgedBundle) string { - t.Helper() - manifestBytes, err := marshalCanonical(forged.manifest) - if err != nil { - t.Fatal(err) - } - manifestSignature := ed25519.Sign( - fixture.privateKey, - signingMessage(manifestSignatureDomain, manifestBytes), - ) - if forged.corruptInnerSignature { - manifestSignature[0] ^= 1 - } - var ciphertext bytes.Buffer - recipients := forged.encryptionRecipients - if len(recipients) == 0 { - recipients = []*age.HybridRecipient{fixture.recipient} - } - ageRecipients := make([]age.Recipient, len(recipients)) - for index := range recipients { - ageRecipients[index] = recipients[index] - } - encrypted, err := age.Encrypt(&ciphertext, ageRecipients...) - if err != nil { - t.Fatal(err) - } - mustWrite(t, encrypted, innerMagic[:]) - mustBinaryWrite(t, encrypted, uint32(len(manifestBytes))) - mustWrite(t, encrypted, manifestBytes) - mustWrite(t, encrypted, manifestSignature) - mustWrite(t, encrypted, forged.fileContents) - if err := encrypted.Close(); err != nil { - t.Fatal(err) - } - ciphertextBytes := ciphertext.Bytes() - if forged.truncateCiphertext { - ciphertextBytes = ciphertextBytes[:len(ciphertextBytes)-1] - } - binding := bindingFromManifest(forged.manifest) - if forged.outerBinding != nil { - binding = *forged.outerBinding - } - plaintextSize, err := plaintextSizeForManifest(manifestBytes, forged.manifest.Files) - if err != nil { - t.Fatal(err) - } - envelope := envelopeFromBinding(binding) - envelope.Encryption = EncryptionAlgorithm - envelope.SignatureAlgorithm = identity.Algorithm - envelope.RecipientKeyIDs = forged.declaredRecipientIDs - if len(envelope.RecipientKeyIDs) == 0 { - envelope.RecipientKeyIDs = []string{ageRecipientKeyID(fixture.recipient.String())} - } - envelope.InnerManifestSHA256 = digestHex(manifestBytes) - envelope.FileCount = uint32(len(forged.manifest.Files)) - envelope.PlaintextSize = plaintextSize - envelope.CiphertextSize = uint64(len(ciphertextBytes)) - envelope.CiphertextSHA256 = digestHex(ciphertextBytes) - envelopeBytes, err := marshalCanonical(envelope) - if err != nil { - t.Fatal(err) - } - envelopeSignature := ed25519.Sign( - fixture.privateKey, - signingMessage(envelopeSignatureDomain, envelopeBytes), - ) - var bundle bytes.Buffer - mustWrite(t, &bundle, outerMagic[:]) - mustBinaryWrite(t, &bundle, uint32(len(envelopeBytes))) - mustWrite(t, &bundle, envelopeBytes) - mustWrite(t, &bundle, envelopeSignature) - mustWrite(t, &bundle, ciphertextBytes) - path := filepath.Join(t.TempDir(), "forged.evt") - mustWriteFile(t, path, bundle.Bytes(), 0o600) - return path -} - -func mustWrite(t *testing.T, writer io.Writer, value []byte) { - t.Helper() - if _, err := writer.Write(value); err != nil { - t.Fatal(err) - } -} - -func mustBinaryWrite(t *testing.T, writer io.Writer, value any) { - t.Helper() - if err := binary.Write(writer, binary.BigEndian, value); err != nil { - t.Fatal(err) - } -} - -func mustMkdirAll(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(path, 0o700); err != nil { - t.Fatal(err) - } -} - -func mustWriteFile(t *testing.T, path string, contents []byte, mode os.FileMode) { - t.Helper() - if err := os.WriteFile(path, contents, mode); err != nil { - t.Fatal(err) - } -} - -func mustReadFile(t *testing.T, path string) []byte { - t.Helper() - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return contents -} - -func assertFileContents(t *testing.T, path string, want []byte) { - t.Helper() - got := mustReadFile(t, path) - if !bytes.Equal(got, want) { - t.Fatalf("%s contents = %q, want %q", path, got, want) - } -} - -func assertFileMode(t *testing.T, path string, want os.FileMode) { - t.Helper() - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != want { - t.Fatalf("%s mode = %04o, want %04o", path, got, want) - } -} - -func TestDigestHexUsesSHA256(t *testing.T) { - t.Parallel() - digest := sha256.Sum256([]byte("eventctl")) - if got, want := digestHex([]byte("eventctl")), hex.EncodeToString(digest[:]); got != want { - t.Fatalf("digestHex() = %s, want %s", got, want) - } -} - -func TestV1HardSizeLimits(t *testing.T) { - t.Parallel() - - limits := DefaultLimits() - if limits.MaxCiphertextBytes != 47_000_000 || limits.MaxPlaintextBytes != 42_000_000 || - limits.MaxFileBytes != 42_000_000 || limits.MaxTotalFileBytes != 42_000_000 || - MaxBundleBytesV1 != 48_000_000 { - t.Fatalf("unexpected v1 hard limits: %#v; bundle=%d", limits, MaxBundleBytesV1) - } - tooLarge := limits - tooLarge.MaxPlaintextBytes++ - if _, err := normalizeLimits(tooLarge); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("normalizeLimits() error = %v, want ErrLimitExceeded", err) - } - - path := filepath.Join(t.TempDir(), "oversized.evt") - file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - t.Fatal(err) - } - if err := file.Truncate(int64(MaxBundleBytesV1 + 1)); err != nil { - file.Close() - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - if _, err := Inspect(context.Background(), path, Limits{}); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("Inspect() error = %v, want ErrLimitExceeded", err) - } -} diff --git a/internal/bundle/extraction_linux_test.go b/internal/bundle/extraction_linux_test.go deleted file mode 100644 index 3332035..0000000 --- a/internal/bundle/extraction_linux_test.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build linux - -package bundle - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "filippo.io/age" -) - -func TestDecryptLateFailuresLeaveNoDestinationOrStagingResidue(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - tests := []struct { - name string - mutate func(*forgedBundle) - }{ - { - name: "second file digest", - mutate: func(forged *forgedBundle) { - forged.manifest.Files = []File{ - {Path: "a.txt", SizeBytes: 1, SHA256: digestHex([]byte("a"))}, - {Path: "b.txt", SizeBytes: 1, SHA256: strings.Repeat("0", 64)}, - } - forged.fileContents = []byte("ab") - }, - }, - { - name: "final age authentication", - mutate: func(forged *forgedBundle) { - forged.truncateCiphertext = true - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - forged := validForgedBundle(fixture) - test.mutate(&forged) - bundlePath := writeForgedBundle(t, fixture, forged) - parent := filepath.Join(t.TempDir(), "extract-parent") - if err := os.Mkdir(parent, 0o700); err != nil { - t.Fatal(err) - } - destination := filepath.Join(parent, "submission") - _, err := DecryptToDirectory( - context.Background(), bundlePath, destination, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, []string{".txt"}, - ) - if err == nil { - t.Fatal("DecryptToDirectory() unexpectedly succeeded") - } - if _, statErr := os.Lstat(destination); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("failed extraction exposed destination: %v", statErr) - } - entries, readErr := os.ReadDir(parent) - if readErr != nil { - t.Fatal(readErr) - } - if len(entries) != 0 { - t.Fatalf("failed extraction left staging residue: %#v", entries) - } - }) - } -} - -func TestDecryptRejectsAuthenticatedCustomClientExtensionsBeforePublication(t *testing.T) { - t.Parallel() - - fixture := newCryptoFixture(t) - forged := validForgedBundle(fixture) - forged.manifest.Files = []File{ - {Path: "run.sh", SizeBytes: 1, SHA256: digestHex([]byte("a"))}, - {Path: "tool.exe", SizeBytes: 1, SHA256: digestHex([]byte("b"))}, - } - forged.fileContents = []byte("ab") - bundlePath := writeForgedBundle(t, fixture, forged) - parent := filepath.Join(t.TempDir(), "extract-parent") - if err := os.Mkdir(parent, 0o700); err != nil { - t.Fatal(err) - } - destination := filepath.Join(parent, "submission") - _, err := DecryptToDirectory( - context.Background(), bundlePath, destination, - []*age.HybridIdentity{fixture.identity}, fixture.publicKey, Limits{}, []string{".csv"}, - ) - if !errors.Is(err, ErrDisallowedExtension) { - t.Fatalf("DecryptToDirectory() error = %v, want ErrDisallowedExtension", err) - } - if _, statErr := os.Lstat(destination); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("forbidden custom-client manifest exposed destination: %v", statErr) - } - entries, readErr := os.ReadDir(parent) - if readErr != nil { - t.Fatal(readErr) - } - if len(entries) != 0 { - t.Fatalf("forbidden custom-client manifest left staging residue: %#v", entries) - } -} - -func TestRenameNoReplacePreservesConcurrentDestination(t *testing.T) { - t.Parallel() - - root := t.TempDir() - staging := filepath.Join(root, "staging") - destination := filepath.Join(root, "destination") - if err := os.Mkdir(staging, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(destination, 0o700); err != nil { - t.Fatal(err) - } - mustWriteFile(t, filepath.Join(staging, "new"), []byte("new"), 0o600) - mustWriteFile(t, filepath.Join(destination, "winner"), []byte("winner"), 0o600) - if err := renameNoReplace(staging, destination); err == nil { - t.Fatal("renameNoReplace() overwrote concurrent destination") - } - assertFileContents(t, filepath.Join(destination, "winner"), []byte("winner")) - assertFileContents(t, filepath.Join(staging, "new"), []byte("new")) -} diff --git a/internal/bundle/extraction_supported_linux.go b/internal/bundle/extraction_supported_linux.go deleted file mode 100644 index 949e664..0000000 --- a/internal/bundle/extraction_supported_linux.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build linux - -package bundle - -const extractionSupported = true diff --git a/internal/bundle/extraction_supported_other.go b/internal/bundle/extraction_supported_other.go deleted file mode 100644 index b78c24c..0000000 --- a/internal/bundle/extraction_supported_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !linux - -package bundle - -const extractionSupported = false diff --git a/internal/bundle/fuzz_test.go b/internal/bundle/fuzz_test.go deleted file mode 100644 index 5f78ded..0000000 --- a/internal/bundle/fuzz_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package bundle - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/binary" - "os" - "path/filepath" - "testing" - "time" - - "filippo.io/age" -) - -// FuzzBundleParsing exercises both unauthenticated public inspection and the -// full authenticated/decrypted parser. Inputs and all parser allocations are -// held under deliberately small caps so corpus growth cannot become a resource -// exhaustion vector in CI. -func FuzzBundleParsing(f *testing.F) { - identity, err := age.GenerateHybridIdentity() - if err != nil { - f.Fatal(err) - } - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - f.Fatal(err) - } - fixture := cryptoFixture{ - identity: identity, recipient: identity.Recipient(), - privateKey: privateKey, publicKey: publicKey, - } - root := f.TempDir() - source := filepath.Join(root, "source") - if err := os.Mkdir(source, 0o700); err != nil { - f.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "seed.txt"), []byte("seed\n"), 0o600); err != nil { - f.Fatal(err) - } - validPath := filepath.Join(root, "seed.evt") - if _, err := PackDirectory(context.Background(), PackOptions{ - SourceDir: source, OutputPath: validPath, Binding: fixture.binding(), - Recipients: []*age.HybridRecipient{identity.Recipient()}, SigningKey: privateKey, - Limits: fuzzLimits(), - }); err != nil { - f.Fatal(err) - } - valid, err := os.ReadFile(validPath) - if err != nil { - f.Fatal(err) - } - - f.Add([]byte{}) - f.Add(append([]byte(nil), outerMagic[:]...)) - oversizedLength := append([]byte(nil), outerMagic[:]...) - oversizedLength = binary.BigEndian.AppendUint32(oversizedLength, fuzzLimits().MaxEnvelopeBytes+1) - f.Add(oversizedLength) - f.Add(valid) - for _, length := range []int{1, outerMagicSize, outerMagicSize + 4, len(valid) - 1} { - f.Add(append([]byte(nil), valid[:length]...)) - } - - f.Fuzz(func(t *testing.T, input []byte) { - if len(input) > 128*1024 { - t.Skip() - } - path := filepath.Join(t.TempDir(), "input.evt") - if err := os.WriteFile(path, input, 0o600); err != nil { - t.Fatal(err) - } - _, _ = Inspect(context.Background(), path, fuzzLimits()) - _, _ = Verify( - context.Background(), path, []*age.HybridIdentity{identity}, publicKey, fuzzLimits(), - ) - }) -} - -// FuzzInnerContainer targets the decrypted framing, canonical manifest parser, -// detached inner signature, path trie, and bounded file consumers directly. -// This complements FuzzBundleParsing, whose arbitrary mutations are usually -// rejected earlier by the outer signature or age authentication. -func FuzzInnerContainer(f *testing.F) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - f.Fatal(err) - } - fixture := cryptoFixture{privateKey: privateKey, publicKey: publicKey} - contents := []byte("seed\n") - manifest := manifestFromBinding(fixture.binding(), []File{{ - Path: "seed.txt", SizeBytes: uint64(len(contents)), SHA256: digestHex(contents), - }}) - manifestBytes, err := marshalCanonical(manifest) - if err != nil { - f.Fatal(err) - } - signature := ed25519.Sign(privateKey, signingMessage(manifestSignatureDomain, manifestBytes)) - var valid bytes.Buffer - valid.Write(innerMagic[:]) - if err := binary.Write(&valid, binary.BigEndian, uint32(len(manifestBytes))); err != nil { - f.Fatal(err) - } - valid.Write(manifestBytes) - valid.Write(signature) - valid.Write(contents) - validBytes := valid.Bytes() - - envelope := envelopeFromBinding(fixture.binding()) - envelope.InnerManifestSHA256 = digestHex(manifestBytes) - envelope.FileCount = uint32(len(manifest.Files)) - envelope.PlaintextSize, err = plaintextSizeForManifest(manifestBytes, manifest.Files) - if err != nil { - f.Fatal(err) - } - - f.Add([]byte{}) - f.Add(append([]byte(nil), innerMagic[:]...)) - oversizedLength := append([]byte(nil), innerMagic[:]...) - oversizedLength = binary.BigEndian.AppendUint32(oversizedLength, fuzzLimits().MaxManifestBytes+1) - f.Add(oversizedLength) - f.Add(append([]byte(nil), validBytes...)) - for _, length := range []int{1, innerMagicSize, innerMagicSize + 4, len(validBytes) - 1} { - f.Add(append([]byte(nil), validBytes[:length]...)) - } - - f.Fuzz(func(t *testing.T, input []byte) { - if len(input) > 128*1024 { - t.Skip() - } - _, _ = readAndVerifyInner( - context.Background(), bytes.NewReader(input), envelope, publicKey, fuzzLimits(), "", nil, - ) - }) -} - -func fuzzLimits() Limits { - return Limits{ - MaxCiphertextBytes: 64 * 1024, - MaxEnvelopeBytes: 8 * 1024, - MaxFileBytes: 32 * 1024, - MaxFiles: 64, - MaxManifestBytes: 32 * 1024, - MaxPlaintextBytes: 48 * 1024, - MaxRecipients: 4, - MaxTotalFileBytes: 48 * 1024, - MaxValidity: 24 * time.Hour, - } -} diff --git a/internal/bundle/pack.go b/internal/bundle/pack.go deleted file mode 100644 index eb7389b..0000000 --- a/internal/bundle/pack.go +++ /dev/null @@ -1,585 +0,0 @@ -package bundle - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "io" - "math" - "os" - "path/filepath" - "sort" - "strings" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/identity" -) - -// PackOptions configures PackDirectory. OutputPath must be outside SourceDir -// and must not already exist. -type PackOptions struct { - SourceDir string - OutputPath string - Binding Binding - Recipients []*age.HybridRecipient - SigningKey ed25519.PrivateKey - Limits Limits -} - -type sourceFile struct { - manifest File -} - -type recipientAndID struct { - recipient *age.HybridRecipient - keyID string -} - -// PackDirectory signs a canonical file manifest, encrypts it and the regular -// file contents to age hybrid ML-KEM-768+X25519 recipients, signs the -// ciphertext envelope, and -// atomically publishes a mode-0600 bundle on Unix without overwriting an -// existing path. On Windows, age encryption is the confidentiality boundary; -// os.Chmod does not configure a private DACL. -func PackDirectory(ctx context.Context, options PackOptions) (Packed, error) { - limits, err := normalizeLimits(options.Limits) - if err != nil { - return Packed{}, err - } - if err := validateSigningKey(options.SigningKey); err != nil { - return Packed{}, err - } - publicKey := options.SigningKey.Public().(ed25519.PublicKey) - signerKeyID := identity.KeyID(publicKey) - if options.Binding.KeyID == "" { - options.Binding.KeyID = signerKeyID - } - if options.Binding.KeyID != signerKeyID { - return Packed{}, fmt.Errorf("signing key ID does not match binding key_id") - } - if err := validateBinding(options.Binding, limits.MaxValidity); err != nil { - return Packed{}, err - } - if options.SourceDir == "" || options.OutputPath == "" { - return Packed{}, fmt.Errorf("source directory and output path are required") - } - if err := ensureOutputOutsideSource(options.SourceDir, options.OutputPath); err != nil { - return Packed{}, err - } - sourceInfo, err := os.Lstat(options.SourceDir) - if err != nil { - return Packed{}, fmt.Errorf("inspect source directory: %w", err) - } - if sourceInfo.Mode()&os.ModeSymlink != 0 || !sourceInfo.IsDir() { - return Packed{}, fmt.Errorf("%w: source must be a real directory", ErrUnsafePath) - } - sourceRoot, err := os.OpenRoot(options.SourceDir) - if err != nil { - return Packed{}, fmt.Errorf("open confined source root: %w", err) - } - defer sourceRoot.Close() - openedSourceInfo, err := sourceRoot.Stat(".") - if err != nil || !openedSourceInfo.IsDir() || !os.SameFile(sourceInfo, openedSourceInfo) { - return Packed{}, fmt.Errorf("%w: source directory changed while opening", ErrSourceChanged) - } - if _, err := os.Lstat(options.OutputPath); err == nil { - return Packed{}, fmt.Errorf("%w: %s", ErrDestinationExists, options.OutputPath) - } else if !errors.Is(err, os.ErrNotExist) { - return Packed{}, fmt.Errorf("inspect output path: %w", err) - } - - recipients, err := normalizeRecipients(options.Recipients, limits) - if err != nil { - return Packed{}, err - } - files, totalBytes, err := collectSourceFiles(ctx, sourceRoot, limits) - if err != nil { - return Packed{}, err - } - manifest := manifestFromBinding(options.Binding, make([]File, len(files))) - for index := range files { - manifest.Files[index] = files[index].manifest - } - if err := validateManifest(manifest, limits); err != nil { - return Packed{}, err - } - manifestBytes, err := marshalCanonical(manifest) - if err != nil { - return Packed{}, err - } - if uint64(len(manifestBytes)) > uint64(limits.MaxManifestBytes) { - return Packed{}, fmt.Errorf("%w: manifest exceeds %d bytes", ErrLimitExceeded, limits.MaxManifestBytes) - } - manifestSignature := ed25519.Sign(options.SigningKey, signingMessage(manifestSignatureDomain, manifestBytes)) - plaintextSize, err := checkedAdd( - innerMagicSize, - 4, - uint64(len(manifestBytes)), - ed25519.SignatureSize, - totalBytes, - ) - if err != nil { - return Packed{}, err - } - if plaintextSize > limits.MaxPlaintextBytes { - return Packed{}, fmt.Errorf("%w: plaintext exceeds %d bytes", ErrLimitExceeded, limits.MaxPlaintextBytes) - } - - outputDirectory := filepath.Dir(options.OutputPath) - workDirectory, err := os.MkdirTemp(outputDirectory, ".eventctl-work-*") - if err != nil { - return Packed{}, fmt.Errorf("create private bundle work directory: %w", err) - } - defer os.RemoveAll(workDirectory) - if err := os.Chmod(workDirectory, 0o700); err != nil { - return Packed{}, fmt.Errorf("secure private bundle work directory: %w", err) - } - ciphertextPath := filepath.Join(workDirectory, "ciphertext.tmp") - ciphertextFile, err := os.OpenFile(ciphertextPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return Packed{}, fmt.Errorf("create ciphertext temporary file: %w", err) - } - defer ciphertextFile.Close() - - ageRecipients := make([]age.Recipient, len(recipients)) - for index := range recipients { - ageRecipients[index] = recipients[index].recipient - } - encryptedWriter, err := age.Encrypt(ciphertextFile, ageRecipients...) - if err != nil { - return Packed{}, fmt.Errorf("initialize age encryption: %w", err) - } - writeErr := writeInnerPayload(ctx, encryptedWriter, sourceRoot, files, manifestBytes, manifestSignature) - closeEncryptionErr := encryptedWriter.Close() - if writeErr != nil { - return Packed{}, writeErr - } - if closeEncryptionErr != nil { - return Packed{}, fmt.Errorf("finalize age encryption: %w", closeEncryptionErr) - } - if err := ciphertextFile.Sync(); err != nil { - return Packed{}, fmt.Errorf("sync ciphertext: %w", err) - } - - ciphertextSize, ciphertextDigest, err := digestOpenRegularFile(ciphertextFile, limits.MaxCiphertextBytes) - if err != nil { - return Packed{}, fmt.Errorf("inspect ciphertext: %w", err) - } - recipientKeyIDs := make([]string, len(recipients)) - for index := range recipients { - recipientKeyIDs[index] = recipients[index].keyID - } - envelope := envelopeFromBinding(options.Binding) - envelope.RecipientKeyIDs = recipientKeyIDs - envelope.InnerManifestSHA256 = digestHex(manifestBytes) - envelope.FileCount = uint32(len(files)) - envelope.PlaintextSize = plaintextSize - envelope.CiphertextSize = ciphertextSize - envelope.CiphertextSHA256 = ciphertextDigest - envelope.Encryption = EncryptionAlgorithm - envelope.SignatureAlgorithm = identity.Algorithm - if err := validateEnvelope(envelope, limits); err != nil { - return Packed{}, err - } - envelopeBytes, err := marshalCanonical(envelope) - if err != nil { - return Packed{}, err - } - if uint64(len(envelopeBytes)) > uint64(limits.MaxEnvelopeBytes) { - return Packed{}, fmt.Errorf("%w: envelope exceeds %d bytes", ErrLimitExceeded, limits.MaxEnvelopeBytes) - } - envelopeSignature := ed25519.Sign(options.SigningKey, signingMessage(envelopeSignatureDomain, envelopeBytes)) - bundleSize, bundleDigest, err := publishBundle( - ctx, - options.OutputPath, - envelopeBytes, - envelopeSignature, - ciphertextFile, - workDirectory, - MaxBundleBytesV1, - ) - if err != nil { - return Packed{}, err - } - return Packed{ - Verified: Verified{ - Envelope: envelope, - Manifest: manifest, - EnvelopeSHA256: digestHex(envelopeBytes), - BundleSize: bundleSize, - BundleSHA256: bundleDigest, - }, - }, nil -} - -func normalizeRecipients(input []*age.HybridRecipient, limits Limits) ([]recipientAndID, error) { - if len(input) == 0 { - return nil, fmt.Errorf("%w: at least one hybrid ML-KEM-768+X25519 recipient is required", ErrNoRecipient) - } - if uint64(len(input)) > uint64(limits.MaxRecipients) { - return nil, fmt.Errorf("%w: recipient count exceeds %d", ErrLimitExceeded, limits.MaxRecipients) - } - recipients := make([]recipientAndID, len(input)) - for index, recipient := range input { - if recipient == nil { - return nil, fmt.Errorf("recipient %d is nil", index) - } - encoded := recipient.String() - if !strings.HasPrefix(encoded, "age1pq1") { - return nil, fmt.Errorf("recipient %d is not an age hybrid ML-KEM-768+X25519 recipient", index) - } - recipients[index] = recipientAndID{recipient: recipient, keyID: ageRecipientKeyID(encoded)} - } - sort.Slice(recipients, func(left, right int) bool { - return recipients[left].keyID < recipients[right].keyID - }) - for index := 1; index < len(recipients); index++ { - if recipients[index-1].keyID == recipients[index].keyID { - return nil, fmt.Errorf("duplicate recipient %s", recipients[index].keyID) - } - } - return recipients, nil -} - -func ensureOutputOutsideSource(sourceDirectory, outputPath string) error { - sourceAbsolute, err := filepath.Abs(sourceDirectory) - if err != nil { - return fmt.Errorf("resolve source directory: %w", err) - } - outputAbsolute, err := filepath.Abs(outputPath) - if err != nil { - return fmt.Errorf("resolve output path: %w", err) - } - resolvedSource, err := filepath.EvalSymlinks(sourceAbsolute) - if err != nil { - return fmt.Errorf("resolve source directory symlinks: %w", err) - } - resolvedOutputParent, err := filepath.EvalSymlinks(filepath.Dir(outputAbsolute)) - if err != nil { - return fmt.Errorf("resolve output directory symlinks: %w", err) - } - resolvedOutput := filepath.Join(resolvedOutputParent, filepath.Base(outputAbsolute)) - relative, err := filepath.Rel(resolvedSource, resolvedOutput) - if err != nil { - return fmt.Errorf("compare source and output paths: %w", err) - } - if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fmt.Errorf("%w: output must be outside the source directory", ErrUnsafePath) - } - return nil -} - -func collectSourceFiles(ctx context.Context, sourceRoot *os.Root, limits Limits) ([]sourceFile, uint64, error) { - files := make([]sourceFile, 0, 256) - paths := newPortablePathSet() - directories := []string{"."} - var directoryCount uint64 = 1 - var total uint64 - for len(directories) != 0 { - if err := ctx.Err(); err != nil { - return nil, 0, err - } - directoryPath := directories[len(directories)-1] - directories = directories[:len(directories)-1] - directory, err := sourceRoot.Open(filepath.FromSlash(directoryPath)) - if err != nil { - return nil, 0, fmt.Errorf("open source directory %q: %w", directoryPath, err) - } - info, err := directory.Stat() - if err != nil || !info.IsDir() { - directory.Close() - return nil, 0, fmt.Errorf("%w: source directory changed at %q", ErrSourceChanged, directoryPath) - } - for { - entries, readErr := directory.ReadDir(128) - for _, entry := range entries { - if err := ctx.Err(); err != nil { - directory.Close() - return nil, 0, err - } - filePath := pathJoin(directoryPath, entry.Name()) - rootPath := filepath.FromSlash(filePath) - entryInfo, err := sourceRoot.Lstat(rootPath) - if err != nil { - directory.Close() - return nil, 0, fmt.Errorf("inspect source entry %q: %w", filePath, err) - } - if entryInfo.Mode()&os.ModeSymlink != 0 { - directory.Close() - return nil, 0, fmt.Errorf("%w: source contains symlink %q", ErrUnsafePath, filePath) - } - if entryInfo.IsDir() { - directoryCount++ - if directoryCount > uint64(limits.MaxFiles)+1 { - directory.Close() - return nil, 0, fmt.Errorf("%w: source contains too many directories", ErrLimitExceeded) - } - directories = append(directories, filePath) - continue - } - if !entryInfo.Mode().IsRegular() { - directory.Close() - return nil, 0, fmt.Errorf("%w: source contains non-regular file %q", ErrUnsafePath, filePath) - } - if uint64(len(files)) >= uint64(limits.MaxFiles) { - directory.Close() - return nil, 0, fmt.Errorf("%w: source contains more than %d files", ErrLimitExceeded, limits.MaxFiles) - } - if entryInfo.Size() < 0 || uint64(entryInfo.Size()) > limits.MaxFileBytes { - directory.Close() - return nil, 0, fmt.Errorf("%w: %q exceeds maximum file size", ErrLimitExceeded, filePath) - } - archivePath := filepath.ToSlash(filePath) - if err := validateArchivePath(archivePath); err != nil { - directory.Close() - return nil, 0, err - } - if err := paths.add(archivePath); err != nil { - directory.Close() - return nil, 0, err - } - total, err = checkedAdd(total, uint64(entryInfo.Size())) - if err != nil || total > limits.MaxTotalFileBytes { - directory.Close() - return nil, 0, fmt.Errorf("%w: source exceeds maximum total size", ErrLimitExceeded) - } - files = append(files, sourceFile{manifest: File{Path: archivePath, SizeBytes: uint64(entryInfo.Size())}}) - } - if errors.Is(readErr, io.EOF) { - break - } - if readErr != nil { - directory.Close() - return nil, 0, fmt.Errorf("read source directory %q: %w", directoryPath, readErr) - } - } - if err := directory.Close(); err != nil { - return nil, 0, fmt.Errorf("close source directory %q: %w", directoryPath, err) - } - } - if len(files) == 0 { - return nil, 0, fmt.Errorf("source directory contains no regular files") - } - sort.Slice(files, func(left, right int) bool { - return files[left].manifest.Path < files[right].manifest.Path - }) - for index := range files { - rootPath := filepath.FromSlash(files[index].manifest.Path) - expectedInfo, err := sourceRoot.Lstat(rootPath) - if err != nil || !expectedInfo.Mode().IsRegular() || expectedInfo.Size() < 0 || uint64(expectedInfo.Size()) != files[index].manifest.SizeBytes { - return nil, 0, fmt.Errorf("%w: %q", ErrSourceChanged, files[index].manifest.Path) - } - digest, err := hashSourceFile(ctx, sourceRoot, rootPath, expectedInfo) - if err != nil { - return nil, 0, err - } - files[index].manifest.SHA256 = digest - } - return files, total, nil -} - -func pathJoin(parent, name string) string { - if parent == "." { - return name - } - return parent + "/" + name -} - -func hashSourceFile(ctx context.Context, sourceRoot *os.Root, filePath string, expectedInfo os.FileInfo) (string, error) { - file, err := sourceRoot.Open(filePath) - if err != nil { - return "", fmt.Errorf("open source file %q: %w", filePath, err) - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return "", fmt.Errorf("inspect source file %q: %w", filePath, err) - } - if !info.Mode().IsRegular() || !os.SameFile(expectedInfo, info) || info.Size() != expectedInfo.Size() { - return "", fmt.Errorf("%w: %q", ErrSourceChanged, filePath) - } - hash := sha256.New() - read := &contextReader{ctx: ctx, reader: file} - written, err := io.Copy(hash, io.LimitReader(read, expectedInfo.Size()+1)) - if err != nil { - return "", fmt.Errorf("hash source file %q: %w", filePath, err) - } - if written != expectedInfo.Size() { - return "", fmt.Errorf("%w: %q", ErrSourceChanged, filePath) - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func writeInnerPayload( - ctx context.Context, - destination io.Writer, - sourceRoot *os.Root, - files []sourceFile, - manifestBytes []byte, - manifestSignature []byte, -) error { - if _, err := destination.Write(innerMagic[:]); err != nil { - return fmt.Errorf("write encrypted inner magic: %w", err) - } - if err := binary.Write(destination, binary.BigEndian, uint32(len(manifestBytes))); err != nil { - return fmt.Errorf("write encrypted manifest length: %w", err) - } - if _, err := destination.Write(manifestBytes); err != nil { - return fmt.Errorf("write encrypted manifest: %w", err) - } - if _, err := destination.Write(manifestSignature); err != nil { - return fmt.Errorf("write encrypted manifest signature: %w", err) - } - for _, source := range files { - if err := copySourceFile(ctx, destination, sourceRoot, source); err != nil { - return err - } - } - return nil -} - -func copySourceFile(ctx context.Context, destination io.Writer, sourceRoot *os.Root, source sourceFile) error { - expectedPath := filepath.FromSlash(source.manifest.Path) - fileInfo, err := sourceRoot.Lstat(expectedPath) - if err != nil { - return fmt.Errorf("inspect source file %q: %w", source.manifest.Path, err) - } - if fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() || uint64(fileInfo.Size()) != source.manifest.SizeBytes { - return fmt.Errorf("%w: %q", ErrSourceChanged, source.manifest.Path) - } - file, err := sourceRoot.Open(expectedPath) - if err != nil { - return fmt.Errorf("open source file %q: %w", source.manifest.Path, err) - } - defer file.Close() - openedInfo, err := file.Stat() - if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(fileInfo, openedInfo) { - return fmt.Errorf("%w: %q", ErrSourceChanged, source.manifest.Path) - } - hash := sha256.New() - limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: file}, N: int64(source.manifest.SizeBytes) + 1} - written, err := io.Copy(io.MultiWriter(destination, hash), limited) - if err != nil { - return fmt.Errorf("encrypt source file %q: %w", source.manifest.Path, err) - } - if uint64(written) != source.manifest.SizeBytes || hex.EncodeToString(hash.Sum(nil)) != source.manifest.SHA256 { - return fmt.Errorf("%w: %q", ErrSourceChanged, source.manifest.Path) - } - return nil -} - -func digestOpenRegularFile(file *os.File, maximum uint64) (uint64, string, error) { - if maximum >= math.MaxInt64 { - return 0, "", fmt.Errorf("%w: hash limit does not fit a signed 64-bit reader", ErrLimitExceeded) - } - info, err := file.Stat() - if err != nil { - return 0, "", err - } - if !info.Mode().IsRegular() || info.Size() < 0 || uint64(info.Size()) > maximum { - return 0, "", fmt.Errorf("%w: invalid file size", ErrLimitExceeded) - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - return 0, "", err - } - hash := sha256.New() - written, err := io.Copy(hash, io.LimitReader(file, int64(maximum)+1)) - if err != nil { - return 0, "", err - } - finalInfo, err := file.Stat() - if err != nil { - return 0, "", err - } - if uint64(written) > maximum || written != info.Size() || finalInfo.Size() != info.Size() { - return 0, "", fmt.Errorf("%w: file changed or grew while hashing", ErrLimitExceeded) - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - return 0, "", err - } - return uint64(written), hex.EncodeToString(hash.Sum(nil)), nil -} - -func publishBundle( - ctx context.Context, - outputPath string, - envelopeBytes, envelopeSignature []byte, - ciphertext *os.File, - workDirectory string, - maximumBundleSize uint64, -) (uint64, string, error) { - temporaryPath := filepath.Join(workDirectory, "bundle.tmp") - temporary, err := os.OpenFile(temporaryPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return 0, "", fmt.Errorf("create bundle temporary file: %w", err) - } - closed := false - defer func() { - if !closed { - temporary.Close() - } - }() - if _, err := temporary.Write(outerMagic[:]); err != nil { - temporary.Close() - return 0, "", fmt.Errorf("write bundle magic: %w", err) - } - if err := binary.Write(temporary, binary.BigEndian, uint32(len(envelopeBytes))); err != nil { - temporary.Close() - return 0, "", fmt.Errorf("write envelope length: %w", err) - } - if _, err := temporary.Write(envelopeBytes); err != nil { - temporary.Close() - return 0, "", fmt.Errorf("write envelope: %w", err) - } - if _, err := temporary.Write(envelopeSignature); err != nil { - temporary.Close() - return 0, "", fmt.Errorf("write envelope signature: %w", err) - } - if _, err := ciphertext.Seek(0, io.SeekStart); err != nil { - return 0, "", fmt.Errorf("seek ciphertext: %w", err) - } - _, copyErr := io.Copy(temporary, &contextReader{ctx: ctx, reader: ciphertext}) - if copyErr != nil { - return 0, "", fmt.Errorf("write ciphertext: %w", copyErr) - } - if err := temporary.Sync(); err != nil { - return 0, "", fmt.Errorf("sync bundle: %w", err) - } - bundleSize, bundleDigest, err := digestOpenRegularFile(temporary, maximumBundleSize) - if err != nil { - return 0, "", fmt.Errorf("hash completed bundle: %w", err) - } - if err := temporary.Close(); err != nil { - return 0, "", fmt.Errorf("close bundle: %w", err) - } - closed = true - // The source lives in a private mode-0700 directory under the output - // directory. A hard link atomically publishes the exact inode and refuses - // replacement on Unix and Windows alike. - if err := os.Link(temporaryPath, outputPath); err != nil { - if _, statErr := os.Lstat(outputPath); statErr == nil { - return 0, "", fmt.Errorf("%w: %s", ErrDestinationExists, outputPath) - } - return 0, "", fmt.Errorf("publish bundle atomically: %w", err) - } - // Publication is complete after Link succeeds. Cleanup failure must not turn - // success into a retry that collides with the already-published output. - _ = os.Remove(temporaryPath) - return bundleSize, bundleDigest, nil -} - -type contextReader struct { - ctx context.Context - reader io.Reader -} - -func (reader *contextReader) Read(buffer []byte) (int, error) { - if err := reader.ctx.Err(); err != nil { - return 0, err - } - return reader.reader.Read(buffer) -} diff --git a/internal/bundle/public.go b/internal/bundle/public.go deleted file mode 100644 index a5b51a6..0000000 --- a/internal/bundle/public.go +++ /dev/null @@ -1,41 +0,0 @@ -package bundle - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "errors" - "fmt" - - "github.com/pythonhk/eventctl/internal/identity" -) - -// AuthenticatePublic verifies the detached signature on the small public -// envelope before hashing the bounded ciphertext. It does not decrypt the -// inner manifest and therefore makes no claim about confidential file content. -func AuthenticatePublic( - ctx context.Context, - bundlePath string, - signingIdentity identity.Public, - requestedLimits Limits, -) (Inspection, error) { - if err := signingIdentity.Validate(); err != nil { - return Inspection{}, fmt.Errorf("validate registered signing identity: %w", err) - } - publicKey, err := base64.RawURLEncoding.Strict().DecodeString(signingIdentity.PublicKey) - if err != nil || len(publicKey) != ed25519.PublicKeySize { - return Inspection{}, errors.New("registered signing identity has an invalid public key") - } - parsed, limits, err := openBundle(bundlePath, requestedLimits) - if err != nil { - return Inspection{}, err - } - defer parsed.file.Close() - if signingIdentity.KeyID != parsed.envelope.KeyID { - return Inspection{}, fmt.Errorf("%w: signer key ID does not match registered key", ErrSignature) - } - if !ed25519.Verify(ed25519.PublicKey(publicKey), signingMessage(envelopeSignatureDomain, parsed.envelopeBytes), parsed.envelopeSignature) { - return Inspection{}, fmt.Errorf("%w: outer envelope", ErrSignature) - } - return inspectDigests(ctx, parsed, limits) -} diff --git a/internal/bundle/public_test.go b/internal/bundle/public_test.go deleted file mode 100644 index a2c1926..0000000 --- a/internal/bundle/public_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package bundle - -import ( - "context" - "encoding/base64" - "errors" - "testing" - - "github.com/pythonhk/eventctl/internal/identity" -) - -func TestAuthenticatePublicVerifiesOuterSignature(t *testing.T) { - fixture := newCryptoFixture(t) - path := makeValidBundle(t, fixture) - public := identity.Public{ - Algorithm: identity.Algorithm, KeyID: identity.KeyID(fixture.publicKey), - PublicKey: base64.RawURLEncoding.EncodeToString(fixture.publicKey), - } - inspection, err := AuthenticatePublic(context.Background(), path, public, Limits{}) - if err != nil { - t.Fatal(err) - } - if inspection.Envelope.KeyID != public.KeyID || inspection.BundleSHA256 == "" { - t.Fatalf("AuthenticatePublic() returned incomplete result: %#v", inspection) - } - - wrong := newCryptoFixture(t) - wrongPublic := identity.Public{ - Algorithm: identity.Algorithm, KeyID: identity.KeyID(wrong.publicKey), - PublicKey: base64.RawURLEncoding.EncodeToString(wrong.publicKey), - } - if _, err := AuthenticatePublic(context.Background(), path, wrongPublic, Limits{}); !errors.Is(err, ErrSignature) { - t.Fatalf("wrong-key error = %v, want ErrSignature", err) - } -} diff --git a/internal/bundle/read.go b/internal/bundle/read.go deleted file mode 100644 index 7a56779..0000000 --- a/internal/bundle/read.go +++ /dev/null @@ -1,626 +0,0 @@ -package bundle - -import ( - "bufio" - "bytes" - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "hash" - "io" - "os" - "path" - "path/filepath" - "strings" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/identity" -) - -const ( - maxAgeHeaderBytes = 64 * 1024 - // A valid ML-KEM-768+X25519 stanza carries a roughly 1.5 KiB encoded - // encapsulation. Keep the per-line bound comfortably above that while the - // independent total header cap remains 64 KiB. - maxAgeHeaderLineBytes = 4096 -) - -type parsedBundle struct { - file *os.File - envelope Envelope - envelopeBytes []byte - envelopeSignature []byte - ciphertextOffset int64 - bundleSize uint64 -} - -// Inspect validates the public envelope, all size bounds, exact container EOF, -// and the ciphertext digest. It cannot authenticate the signer without the -// registered Ed25519 public key; use Verify for that. -func Inspect(ctx context.Context, bundlePath string, limits Limits) (Inspection, error) { - parsed, normalizedLimits, err := openBundle(bundlePath, limits) - if err != nil { - return Inspection{}, err - } - defer parsed.file.Close() - return inspectDigests(ctx, parsed, normalizedLimits) -} - -// Verify authenticates both signed layers, requires the exact signed set of -// HybridIdentity values, proves each identity independently unwraps the -// authenticated header, decrypts the complete age stream, verifies every file -// digest, and consumes the authenticated stream through EOF without writing -// plaintext to disk. -func Verify( - ctx context.Context, - bundlePath string, - identities []*age.HybridIdentity, - signingKey ed25519.PublicKey, - limits Limits, -) (Verified, error) { - return processBundle(ctx, bundlePath, identities, signingKey, limits, "", nil) -} - -// DecryptToDirectory performs all Verify checks in a private staging directory -// and atomically renames it to destination only after authenticated EOF. Every -// extracted regular file has mode 0600. Destination must not already exist. -func DecryptToDirectory( - ctx context.Context, - bundlePath string, - destination string, - identities []*age.HybridIdentity, - signingKey ed25519.PublicKey, - limits Limits, - allowedExtensions []string, -) (Verified, error) { - if !extractionSupported { - return Verified{}, ErrExtractionUnsupported - } - if destination == "" { - return Verified{}, fmt.Errorf("destination directory is required") - } - if _, err := os.Lstat(destination); err == nil { - return Verified{}, fmt.Errorf("%w: %s", ErrDestinationExists, destination) - } else if !errors.Is(err, os.ErrNotExist) { - return Verified{}, fmt.Errorf("inspect destination: %w", err) - } - parent := filepath.Dir(destination) - base := filepath.Base(filepath.Clean(destination)) - if base == "." || base == string(filepath.Separator) { - return Verified{}, fmt.Errorf("%w: unsafe destination", ErrUnsafePath) - } - staging, err := os.MkdirTemp(parent, "."+base+".eventctl-*") - if err != nil { - return Verified{}, fmt.Errorf("create decryption staging directory: %w", err) - } - defer os.RemoveAll(staging) - if err := os.Chmod(staging, 0o700); err != nil { - return Verified{}, fmt.Errorf("secure decryption staging directory: %w", err) - } - if len(allowedExtensions) == 0 { - return Verified{}, fmt.Errorf("%w: extraction policy has no allowed extensions", ErrDisallowedExtension) - } - verified, err := processBundle(ctx, bundlePath, identities, signingKey, limits, staging, allowedExtensions) - if err != nil { - return Verified{}, err - } - if _, err := os.Lstat(destination); err == nil { - return Verified{}, fmt.Errorf("%w: %s", ErrDestinationExists, destination) - } else if !errors.Is(err, os.ErrNotExist) { - return Verified{}, fmt.Errorf("reinspect destination: %w", err) - } - if err := renameNoReplace(staging, destination); err != nil { - if _, statErr := os.Lstat(destination); statErr == nil { - return Verified{}, fmt.Errorf("%w: %s", ErrDestinationExists, destination) - } - return Verified{}, fmt.Errorf("publish decrypted directory atomically: %w", err) - } - return verified, nil -} - -func processBundle( - ctx context.Context, - bundlePath string, - identities []*age.HybridIdentity, - signingKey ed25519.PublicKey, - requestedLimits Limits, - destinationRoot string, - allowedExtensions []string, -) (Verified, error) { - limits, err := normalizeLimits(requestedLimits) - if err != nil { - return Verified{}, err - } - if err := validateVerificationKey(signingKey); err != nil { - return Verified{}, err - } - if len(identities) == 0 { - return Verified{}, fmt.Errorf("%w: at least one hybrid ML-KEM-768+X25519 identity is required", ErrNoRecipient) - } - parsed, _, err := openBundle(bundlePath, limits) - if err != nil { - return Verified{}, err - } - defer parsed.file.Close() - if identity.KeyID(signingKey) != parsed.envelope.KeyID { - return Verified{}, fmt.Errorf("%w: signer key ID does not match registered key", ErrSignature) - } - if !ed25519.Verify(signingKey, signingMessage(envelopeSignatureDomain, parsed.envelopeBytes), parsed.envelopeSignature) { - return Verified{}, fmt.Errorf("%w: outer envelope", ErrSignature) - } - ageIdentities, err := matchingIdentities(identities, parsed.envelope.RecipientKeyIDs) - if err != nil { - return Verified{}, err - } - if _, err := parsed.file.Seek(parsed.ciphertextOffset, io.SeekStart); err != nil { - return Verified{}, fmt.Errorf("seek ciphertext: %w", err) - } - if err := validateAgeHeader( - io.NewSectionReader(parsed.file, parsed.ciphertextOffset, int64(parsed.envelope.CiphertextSize)), - len(parsed.envelope.RecipientKeyIDs), - ); err != nil { - return Verified{}, err - } - if err := verifyEveryRecipientHeader(ctx, parsed, ageIdentities); err != nil { - return Verified{}, err - } - // Hash the exact ciphertext bytes as age decrypts them. This keeps the - // authenticated plaintext, signed ciphertext digest, and returned complete - // bundle digest bound to one descriptor and one streaming pass. - bundleHash, err := hashBundlePrefix(ctx, parsed) - if err != nil { - return Verified{}, err - } - ciphertextHash := sha256.New() - hashedCiphertext := io.TeeReader( - &contextReader{ctx: ctx, reader: parsed.file}, - io.MultiWriter(bundleHash, ciphertextHash), - ) - limitedCiphertext := &io.LimitedReader{R: hashedCiphertext, N: int64(parsed.envelope.CiphertextSize)} - plaintext, err := age.Decrypt(limitedCiphertext, ageIdentities...) - if err != nil { - var noMatch *age.NoIdentityMatchError - if errors.As(err, &noMatch) { - return Verified{}, fmt.Errorf("%w: %v", ErrNoRecipient, err) - } - return Verified{}, fmt.Errorf("%w: initialize age decryption: %v", ErrInvalidFormat, err) - } - countedPlaintext := &countingReader{reader: plaintext} - manifest, err := readAndVerifyInner(ctx, countedPlaintext, parsed.envelope, signingKey, limits, destinationRoot, allowedExtensions) - if err != nil { - return Verified{}, err - } - var trailing [1]byte - count, eofErr := countedPlaintext.Read(trailing[:]) - if count != 0 || !errors.Is(eofErr, io.EOF) { - if eofErr == nil { - eofErr = errors.New("trailing plaintext") - } - return Verified{}, fmt.Errorf("%w: encrypted stream did not end authentically: %v", ErrInvalidFormat, eofErr) - } - if countedPlaintext.count != parsed.envelope.PlaintextSize { - return Verified{}, fmt.Errorf("%w: plaintext size differs from signed envelope", ErrManifestMismatch) - } - if limitedCiphertext.N != 0 { - return Verified{}, fmt.Errorf("%w: age reader did not consume ciphertext EOF", ErrInvalidFormat) - } - inspection, err := finishInspection(parsed, bundleHash, ciphertextHash) - if err != nil { - return Verified{}, err - } - return Verified{ - Envelope: parsed.envelope, - Manifest: manifest, - EnvelopeSHA256: inspection.EnvelopeSHA256, - BundleSize: inspection.BundleSize, - BundleSHA256: inspection.BundleSHA256, - }, nil -} - -func matchingIdentities(identities []*age.HybridIdentity, signedRecipientKeyIDs []string) ([]age.Identity, error) { - signed := make(map[string]struct{}, len(signedRecipientKeyIDs)) - for _, keyID := range signedRecipientKeyIDs { - signed[keyID] = struct{}{} - } - if len(identities) != len(signedRecipientKeyIDs) { - return nil, fmt.Errorf("%w: %w: supplied %d identities for %d signed recipients", ErrNoRecipient, ErrRecipientSet, len(identities), len(signedRecipientKeyIDs)) - } - byID := make(map[string]*age.HybridIdentity, len(identities)) - for index, identity := range identities { - if identity == nil { - return nil, fmt.Errorf("%w: %w: identity %d is nil", ErrNoRecipient, ErrRecipientSet, index) - } - keyID := ageRecipientKeyID(identity.Recipient().String()) - if _, declared := signed[keyID]; !declared { - return nil, fmt.Errorf("%w: %w: supplied identity %s is not signed", ErrNoRecipient, ErrRecipientSet, keyID) - } - if _, duplicate := byID[keyID]; duplicate { - return nil, fmt.Errorf("%w: %w: supplied identity %s is duplicated", ErrNoRecipient, ErrRecipientSet, keyID) - } - byID[keyID] = identity - } - matched := make([]age.Identity, len(signedRecipientKeyIDs)) - for index, keyID := range signedRecipientKeyIDs { - identity := byID[keyID] - if identity == nil { - return nil, fmt.Errorf("%w: %w: signed recipient %s has no supplied identity", ErrNoRecipient, ErrRecipientSet, keyID) - } - matched[index] = identity - } - return matched, nil -} - -// verifyEveryRecipientHeader proves that each signed recipient identity can -// independently unwrap a stanza from the authenticated age header. Combined -// with the exact stanza count and exact supplied/signed identity-set check, -// this detects anonymous-stanza replacement such as signed [B,C] encrypted to -// [B,A]. It intentionally does not stream plaintext once per identity. -func verifyEveryRecipientHeader(ctx context.Context, parsed *parsedBundle, identities []age.Identity) error { - for index, identity := range identities { - section := io.NewSectionReader(parsed.file, parsed.ciphertextOffset, int64(parsed.envelope.CiphertextSize)) - if _, err := age.Decrypt(&contextReader{ctx: ctx, reader: section}, identity); err != nil { - var noMatch *age.NoIdentityMatchError - if errors.As(err, &noMatch) { - return fmt.Errorf("%w: %w: signed identity %d cannot unwrap the ciphertext header", ErrNoRecipient, ErrRecipientSet, index) - } - return fmt.Errorf("%w: verify recipient %d header: %v", ErrInvalidFormat, index, err) - } - } - return nil -} - -func openBundle(bundlePath string, requestedLimits Limits) (*parsedBundle, Limits, error) { - limits, err := normalizeLimits(requestedLimits) - if err != nil { - return nil, Limits{}, err - } - pathInfo, err := os.Lstat(bundlePath) - if err != nil { - return nil, Limits{}, fmt.Errorf("inspect bundle path: %w", err) - } - if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() { - return nil, Limits{}, fmt.Errorf("%w: bundle path must be a regular file, not a symlink", ErrInvalidFormat) - } - file, err := os.Open(bundlePath) - if err != nil { - return nil, Limits{}, fmt.Errorf("open bundle: %w", err) - } - failed := true - defer func() { - if failed { - file.Close() - } - }() - info, err := file.Stat() - if err != nil { - return nil, Limits{}, fmt.Errorf("inspect bundle: %w", err) - } - if !info.Mode().IsRegular() || info.Size() < 0 || !os.SameFile(pathInfo, info) { - return nil, Limits{}, fmt.Errorf("%w: bundle is not a regular file", ErrInvalidFormat) - } - if uint64(info.Size()) > MaxBundleBytesV1 { - return nil, Limits{}, fmt.Errorf("%w: bundle exceeds maximum size", ErrLimitExceeded) - } - - var magic [outerMagicSize]byte - if _, err := io.ReadFull(file, magic[:]); err != nil { - return nil, Limits{}, fmt.Errorf("%w: read bundle magic: %v", ErrInvalidFormat, err) - } - if magic != outerMagic { - return nil, Limits{}, fmt.Errorf("%w: unsupported bundle magic", ErrInvalidFormat) - } - var envelopeLength uint32 - if err := binary.Read(file, binary.BigEndian, &envelopeLength); err != nil { - return nil, Limits{}, fmt.Errorf("%w: read envelope length: %v", ErrInvalidFormat, err) - } - if envelopeLength == 0 || envelopeLength > limits.MaxEnvelopeBytes { - return nil, Limits{}, fmt.Errorf("%w: envelope length exceeds limit", ErrLimitExceeded) - } - envelopeBytes := make([]byte, envelopeLength) - if _, err := io.ReadFull(file, envelopeBytes); err != nil { - return nil, Limits{}, fmt.Errorf("%w: read envelope: %v", ErrInvalidFormat, err) - } - var envelope Envelope - if err := unmarshalCanonical(envelopeBytes, &envelope); err != nil { - return nil, Limits{}, err - } - if err := validateEnvelope(envelope, limits); err != nil { - return nil, Limits{}, err - } - envelopeSignature := make([]byte, ed25519.SignatureSize) - if _, err := io.ReadFull(file, envelopeSignature); err != nil { - return nil, Limits{}, fmt.Errorf("%w: read envelope signature: %v", ErrInvalidFormat, err) - } - ciphertextOffset, err := file.Seek(0, io.SeekCurrent) - if err != nil { - return nil, Limits{}, fmt.Errorf("locate ciphertext: %w", err) - } - expectedSize, err := checkedAdd(uint64(ciphertextOffset), envelope.CiphertextSize) - if err != nil { - return nil, Limits{}, err - } - if expectedSize != uint64(info.Size()) { - return nil, Limits{}, fmt.Errorf("%w: bundle is truncated or has trailing bytes", ErrInvalidFormat) - } - failed = false - return &parsedBundle{ - file: file, - envelope: envelope, - envelopeBytes: envelopeBytes, - envelopeSignature: envelopeSignature, - ciphertextOffset: ciphertextOffset, - bundleSize: uint64(info.Size()), - }, limits, nil -} - -// inspectDigests performs one bounded pass over the file, hashing the full -// bundle and its ciphertext section simultaneously. Inspect calls it without -// authenticating the outer signature; AuthenticatePublic calls it only after -// authenticating that signature. Verify instead hashes the same bytes while -// decrypting them. -func inspectDigests(ctx context.Context, parsed *parsedBundle, _ Limits) (Inspection, error) { - bundleHash, err := hashBundlePrefix(ctx, parsed) - if err != nil { - return Inspection{}, err - } - ciphertextHash := sha256.New() - ciphertext := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: parsed.file}, N: int64(parsed.envelope.CiphertextSize)} - written, err := io.Copy(io.MultiWriter(bundleHash, ciphertextHash), ciphertext) - if err != nil { - return Inspection{}, fmt.Errorf("hash ciphertext: %w", err) - } - if ciphertext.N != 0 || uint64(written) != parsed.envelope.CiphertextSize { - return Inspection{}, ErrCiphertextDigest - } - return finishInspection(parsed, bundleHash, ciphertextHash) -} - -func hashBundlePrefix(ctx context.Context, parsed *parsedBundle) (hash.Hash, error) { - if _, err := parsed.file.Seek(0, io.SeekStart); err != nil { - return nil, fmt.Errorf("seek bundle: %w", err) - } - bundleHash := sha256.New() - prefixHash := sha256.New() - prefix := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: parsed.file}, N: parsed.ciphertextOffset} - if _, err := io.Copy(io.MultiWriter(bundleHash, prefixHash), prefix); err != nil { - return nil, fmt.Errorf("hash bundle prefix: %w", err) - } - if prefix.N != 0 { - return nil, fmt.Errorf("%w: bundle prefix is truncated", ErrInvalidFormat) - } - expectedPrefixHash := sha256.New() - expectedPrefixHash.Write(outerMagic[:]) - var envelopeLength [4]byte - binary.BigEndian.PutUint32(envelopeLength[:], uint32(len(parsed.envelopeBytes))) - expectedPrefixHash.Write(envelopeLength[:]) - expectedPrefixHash.Write(parsed.envelopeBytes) - expectedPrefixHash.Write(parsed.envelopeSignature) - if !bytes.Equal(prefixHash.Sum(nil), expectedPrefixHash.Sum(nil)) { - return nil, fmt.Errorf("%w: bundle prefix changed while inspecting", ErrInvalidFormat) - } - return bundleHash, nil -} - -func finishInspection(parsed *parsedBundle, bundleHash, ciphertextHash hash.Hash) (Inspection, error) { - if hex.EncodeToString(ciphertextHash.Sum(nil)) != parsed.envelope.CiphertextSHA256 { - return Inspection{}, ErrCiphertextDigest - } - if parsed.bundleSize > MaxBundleBytesV1 { - return Inspection{}, fmt.Errorf("%w: bundle exceeds maximum size", ErrLimitExceeded) - } - finalInfo, statErr := parsed.file.Stat() - if statErr != nil { - return Inspection{}, fmt.Errorf("reinspect bundle: %w", statErr) - } - currentOffset, seekErr := parsed.file.Seek(0, io.SeekCurrent) - if seekErr != nil || currentOffset < 0 || uint64(currentOffset) != parsed.bundleSize || - finalInfo.Size() < 0 || uint64(finalInfo.Size()) != parsed.bundleSize { - return Inspection{}, fmt.Errorf("%w: bundle changed while inspecting", ErrInvalidFormat) - } - return Inspection{ - Envelope: parsed.envelope, - EnvelopeSHA256: digestHex(parsed.envelopeBytes), - BundleSize: parsed.bundleSize, - BundleSHA256: hex.EncodeToString(bundleHash.Sum(nil)), - }, nil -} - -func readAndVerifyInner( - ctx context.Context, - plaintext io.Reader, - envelope Envelope, - signingKey ed25519.PublicKey, - limits Limits, - destinationRoot string, - allowedExtensions []string, -) (Manifest, error) { - var magic [innerMagicSize]byte - if _, err := io.ReadFull(plaintext, magic[:]); err != nil { - return Manifest{}, fmt.Errorf("%w: read encrypted inner magic: %v", ErrInvalidFormat, err) - } - if magic != innerMagic { - return Manifest{}, fmt.Errorf("%w: unsupported encrypted inner magic", ErrInvalidFormat) - } - var manifestLength uint32 - if err := binary.Read(plaintext, binary.BigEndian, &manifestLength); err != nil { - return Manifest{}, fmt.Errorf("%w: read manifest length: %v", ErrInvalidFormat, err) - } - if manifestLength == 0 || manifestLength > limits.MaxManifestBytes { - return Manifest{}, fmt.Errorf("%w: manifest length exceeds limit", ErrLimitExceeded) - } - manifestBytes := make([]byte, manifestLength) - if _, err := io.ReadFull(plaintext, manifestBytes); err != nil { - return Manifest{}, fmt.Errorf("%w: read manifest: %v", ErrInvalidFormat, err) - } - var manifest Manifest - if err := unmarshalCanonical(manifestBytes, &manifest); err != nil { - return Manifest{}, err - } - if err := validateManifest(manifest, limits); err != nil { - return Manifest{}, err - } - manifestSignature := make([]byte, ed25519.SignatureSize) - if _, err := io.ReadFull(plaintext, manifestSignature); err != nil { - return Manifest{}, fmt.Errorf("%w: read manifest signature: %v", ErrInvalidFormat, err) - } - if !ed25519.Verify(signingKey, signingMessage(manifestSignatureDomain, manifestBytes), manifestSignature) { - return Manifest{}, fmt.Errorf("%w: inner manifest", ErrSignature) - } - if bindingFromManifest(manifest) != bindingFromEnvelope(envelope) || - digestHex(manifestBytes) != envelope.InnerManifestSHA256 || - uint32(len(manifest.Files)) != envelope.FileCount { - return Manifest{}, ErrManifestMismatch - } - expectedPlaintextSize, err := plaintextSizeForManifest(manifestBytes, manifest.Files) - if err != nil { - return Manifest{}, err - } - if expectedPlaintextSize != envelope.PlaintextSize { - return Manifest{}, fmt.Errorf("%w: declared plaintext size is inconsistent", ErrManifestMismatch) - } - if destinationRoot != "" { - if err := validateManifestExtensions(manifest, allowedExtensions); err != nil { - return Manifest{}, err - } - } - for _, file := range manifest.Files { - if err := consumeFile(ctx, plaintext, file, destinationRoot); err != nil { - return Manifest{}, err - } - } - return manifest, nil -} - -func validateManifestExtensions(manifest Manifest, allowedExtensions []string) error { - allowed := make(map[string]struct{}, len(allowedExtensions)) - for _, extension := range allowedExtensions { - allowed[extension] = struct{}{} - } - for _, file := range manifest.Files { - extension := path.Ext(file.Path) - if _, ok := allowed[extension]; !ok { - return fmt.Errorf("%w: %q has extension %q", ErrDisallowedExtension, file.Path, extension) - } - } - return nil -} - -// validateAgeHeader places a small independent bound around age's streaming -// header parser. filippo.io/age correctly authenticates its header but accepts -// an arbitrary number and size of stanzas; a public bundle must not be able to -// allocate up to the full ciphertext limit before recipient selection. -func validateAgeHeader(reader io.Reader, expectedRecipients int) error { - if expectedRecipients < 1 { - return fmt.Errorf("%w: no signed recipient IDs", ErrInvalidFormat) - } - limited := &io.LimitedReader{R: reader, N: maxAgeHeaderBytes + 1} - buffered := bufio.NewReaderSize(limited, maxAgeHeaderLineBytes) - readLine := func() ([]byte, error) { - line, err := buffered.ReadSlice('\n') - if errors.Is(err, bufio.ErrBufferFull) { - return nil, fmt.Errorf("%w: age header line exceeds %d bytes", ErrLimitExceeded, maxAgeHeaderLineBytes) - } - if err != nil { - return nil, fmt.Errorf("%w: read age header: %v", ErrInvalidFormat, err) - } - return line, nil - } - intro, err := readLine() - if err != nil { - return err - } - if string(intro) != "age-encryption.org/v1\n" { - return fmt.Errorf("%w: unsupported age header", ErrInvalidFormat) - } - recipientCount := 0 - for { - line, err := readLine() - if err != nil { - return err - } - switch { - case bytes.HasPrefix(line, []byte("-> ")): - if !bytes.HasPrefix(line, []byte("-> mlkem768x25519 ")) { - return fmt.Errorf("%w: bundle contains a non-hybrid age stanza", ErrInvalidFormat) - } - recipientCount++ - if recipientCount > expectedRecipients { - return fmt.Errorf("%w: age stanza count exceeds signed recipient count", ErrLimitExceeded) - } - case bytes.HasPrefix(line, []byte("--- ")): - if recipientCount != expectedRecipients { - return fmt.Errorf("%w: age stanza count differs from signed recipient count", ErrInvalidFormat) - } - return nil - default: - if recipientCount == 0 { - return fmt.Errorf("%w: malformed age header", ErrInvalidFormat) - } - } - } -} - -func plaintextSizeForManifest(manifestBytes []byte, files []File) (uint64, error) { - values := []uint64{innerMagicSize, 4, uint64(len(manifestBytes)), ed25519.SignatureSize} - for _, file := range files { - values = append(values, file.SizeBytes) - } - return checkedAdd(values...) -} - -func consumeFile(ctx context.Context, plaintext io.Reader, entry File, destinationRoot string) error { - var destination *os.File - writer := io.Writer(io.Discard) - if destinationRoot != "" { - filePath := filepath.Join(destinationRoot, filepath.FromSlash(entry.Path)) - relative, err := filepath.Rel(destinationRoot, filePath) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { - return fmt.Errorf("%w: extracted path escaped staging directory", ErrUnsafePath) - } - if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil { - return fmt.Errorf("create directory for %q: %w", entry.Path, err) - } - destination, err = os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - return fmt.Errorf("create decrypted file %q: %w", entry.Path, err) - } - writer = destination - } - hash := sha256.New() - limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: plaintext}, N: int64(entry.SizeBytes)} - written, copyErr := io.Copy(io.MultiWriter(writer, hash), limited) - if destination != nil { - if copyErr == nil { - copyErr = destination.Sync() - } - closeErr := destination.Close() - if copyErr == nil { - copyErr = closeErr - } - } - if copyErr != nil { - return fmt.Errorf("read encrypted file %q: %w", entry.Path, copyErr) - } - if limited.N != 0 || uint64(written) != entry.SizeBytes { - return fmt.Errorf("%w: file %q is truncated", ErrInvalidFormat, entry.Path) - } - if hex.EncodeToString(hash.Sum(nil)) != entry.SHA256 { - return fmt.Errorf("%w: %s", ErrFileDigest, entry.Path) - } - return nil -} - -type countingReader struct { - reader io.Reader - count uint64 -} - -func (reader *countingReader) Read(buffer []byte) (int, error) { - count, err := reader.reader.Read(buffer) - reader.count += uint64(count) - return count, err -} diff --git a/internal/bundle/rename_noreplace_darwin.go b/internal/bundle/rename_noreplace_darwin.go deleted file mode 100644 index 76f2270..0000000 --- a/internal/bundle/rename_noreplace_darwin.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build darwin - -package bundle - -import "golang.org/x/sys/unix" - -func renameNoReplace(source, destination string) error { - return unix.RenameatxNp( - unix.AT_FDCWD, - source, - unix.AT_FDCWD, - destination, - unix.RENAME_EXCL, - ) -} diff --git a/internal/bundle/rename_noreplace_linux.go b/internal/bundle/rename_noreplace_linux.go deleted file mode 100644 index c7622dc..0000000 --- a/internal/bundle/rename_noreplace_linux.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build linux - -package bundle - -import "golang.org/x/sys/unix" - -func renameNoReplace(source, destination string) error { - return unix.Renameat2( - unix.AT_FDCWD, - source, - unix.AT_FDCWD, - destination, - unix.RENAME_NOREPLACE, - ) -} diff --git a/internal/bundle/rename_noreplace_other.go b/internal/bundle/rename_noreplace_other.go deleted file mode 100644 index 8f1ee33..0000000 --- a/internal/bundle/rename_noreplace_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !darwin && !linux && !windows - -package bundle - -import "fmt" - -func renameNoReplace(_, _ string) error { - return fmt.Errorf("atomic no-replace directory publication is unsupported on this platform") -} diff --git a/internal/bundle/rename_noreplace_windows.go b/internal/bundle/rename_noreplace_windows.go deleted file mode 100644 index 2661874..0000000 --- a/internal/bundle/rename_noreplace_windows.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build windows - -package bundle - -import "os" - -// os.Rename uses MoveFile on Windows and fails when destination already exists. -func renameNoReplace(source, destination string) error { - return os.Rename(source, destination) -} diff --git a/internal/bundle/types.go b/internal/bundle/types.go deleted file mode 100644 index 0869e08..0000000 --- a/internal/bundle/types.go +++ /dev/null @@ -1,320 +0,0 @@ -// Package bundle implements the authenticated encrypted submission bundle -// format used by eventctl. -// -// A bundle has two independently signed layers. The outer envelope signs the -// ciphertext digest and routing metadata, while the encrypted inner manifest -// signs the same metadata and the ordered file hashes. Callers must still bind -// the signing public key to the authenticated GitHub actor in event state. -package bundle - -import ( - "bytes" - "crypto/ed25519" - "errors" - "fmt" - "math" - "time" - - protocolenvelope "github.com/pythonhk/eventctl/internal/envelope" -) - -const ( - Protocol = protocolenvelope.Protocol - ProtocolVersion = protocolenvelope.ProtocolVersion - ManifestKind = "submission_inner_manifest" - EnvelopeKind = "encrypted_bundle_envelope" - EncryptionAlgorithm = "age-hybrid-mlkem768-x25519" - HybridRecipientFingerprintDomain = "age-hybrid-mlkem768-x25519\x00" - - MaxBundleBytesV1 uint64 = 48_000_000 - MaxCiphertextBytesV1 uint64 = 47_000_000 - MaxPlaintextBytesV1 uint64 = 42_000_000 - - manifestSignatureDomain = "eventctl submission manifest v1\x00" - envelopeSignatureDomain = "eventctl encrypted bundle envelope v1\x00" - - innerMagicSize = 8 - outerMagicSize = 8 -) - -var ( - innerMagic = [innerMagicSize]byte{'E', 'V', 'T', 'I', 'N', 'N', 'R', 1} - outerMagic = [outerMagicSize]byte{'E', 'V', 'T', 'B', 'N', 'D', 'L', 1} - - ErrCiphertextDigest = errors.New("bundle ciphertext digest mismatch") - ErrDestinationExists = errors.New("bundle destination already exists") - ErrDisallowedExtension = errors.New("bundle file extension is not allowed") - ErrExtractionUnsupported = errors.New("bundle extraction is unsupported on this platform") - ErrFileDigest = errors.New("bundle file digest mismatch") - ErrInvalidFormat = errors.New("invalid bundle format") - ErrLimitExceeded = errors.New("bundle limit exceeded") - ErrManifestMismatch = errors.New("bundle inner and outer manifests differ") - ErrNoRecipient = errors.New("no age identity matched the bundle") - ErrRecipientSet = errors.New("bundle recipient set mismatch") - ErrSignature = errors.New("bundle signature verification failed") - ErrSourceChanged = errors.New("source file changed while packing") - ErrUnsafePath = errors.New("unsafe bundle path") -) - -// Limits bounds every attacker-controlled allocation and stream processed by -// this package. A zero-value Limits selects DefaultLimits. -type Limits struct { - MaxCiphertextBytes uint64 - MaxEnvelopeBytes uint32 - MaxFileBytes uint64 - MaxFiles uint32 - MaxManifestBytes uint32 - MaxPlaintextBytes uint64 - MaxRecipients uint32 - MaxTotalFileBytes uint64 - MaxValidity time.Duration -} - -// DefaultLimits returns the v1 hard processing limits. -func DefaultLimits() Limits { - return Limits{ - MaxCiphertextBytes: MaxCiphertextBytesV1, - MaxEnvelopeBytes: 256 * 1024, - MaxFileBytes: MaxPlaintextBytesV1, - MaxFiles: protocolenvelope.MaxSubmissionFilesV1, - MaxManifestBytes: 4 * 1024 * 1024, - MaxPlaintextBytes: MaxPlaintextBytesV1, - MaxRecipients: 32, - MaxTotalFileBytes: MaxPlaintextBytesV1, - MaxValidity: protocolenvelope.MaxGenericValidity, - } -} - -func normalizeLimits(limits Limits) (Limits, error) { - if limits == (Limits{}) { - return DefaultLimits(), nil - } - hard := DefaultLimits() - if limits.MaxCiphertextBytes == 0 || limits.MaxEnvelopeBytes == 0 || - limits.MaxFileBytes == 0 || limits.MaxFiles == 0 || - limits.MaxManifestBytes == 0 || limits.MaxPlaintextBytes == 0 || limits.MaxRecipients == 0 || - limits.MaxTotalFileBytes == 0 || limits.MaxValidity <= 0 { - return Limits{}, fmt.Errorf("%w: every custom limit must be positive", ErrLimitExceeded) - } - if limits.MaxValidity < time.Second || limits.MaxValidity%time.Second != 0 { - return Limits{}, fmt.Errorf("%w: maximum validity must be positive whole seconds", ErrLimitExceeded) - } - if limits.MaxFileBytes > limits.MaxTotalFileBytes { - return Limits{}, fmt.Errorf("%w: maximum file size exceeds maximum total size", ErrLimitExceeded) - } - if limits.MaxTotalFileBytes > limits.MaxPlaintextBytes { - return Limits{}, fmt.Errorf("%w: maximum total file size exceeds maximum plaintext size", ErrLimitExceeded) - } - if limits.MaxCiphertextBytes > hard.MaxCiphertextBytes || - limits.MaxEnvelopeBytes > hard.MaxEnvelopeBytes || - limits.MaxFileBytes > hard.MaxFileBytes || limits.MaxFiles > hard.MaxFiles || - limits.MaxManifestBytes > hard.MaxManifestBytes || - limits.MaxPlaintextBytes > hard.MaxPlaintextBytes || - limits.MaxRecipients > hard.MaxRecipients || - limits.MaxTotalFileBytes > hard.MaxTotalFileBytes || - limits.MaxValidity > hard.MaxValidity { - return Limits{}, fmt.Errorf("%w: custom limits exceed the v1 hard processing profile", ErrLimitExceeded) - } - if limits.MaxFileBytes >= math.MaxInt64 || limits.MaxTotalFileBytes >= math.MaxInt64 || - limits.MaxCiphertextBytes >= math.MaxInt64 || limits.MaxPlaintextBytes >= math.MaxInt64 { - return Limits{}, fmt.Errorf("%w: stream limits must fit in signed 64-bit readers", ErrLimitExceeded) - } - return limits, nil -} - -// Binding is the in-memory replay and actor-binding context copied into the -// flat signed JSON objects. Repository/ref/head and PR metadata are -// intentionally excluded because a separate post-push signed request binds -// those values and the complete bundle digest. -type Binding struct { - EventID string - EventEpoch string - RequestID string - AttemptID string - ActorID string - KeyID string - KeyEpoch string - TeamID string - TeamProposalDigest string - BaseRepositoryID string - ConfigDigest string - IssuedAt string - ExpiresAt string - RecipientEpoch string -} - -// File records one regular file in normalized slash-separated form. -type File struct { - Path string `json:"path"` - SizeBytes uint64 `json:"size_bytes"` - SHA256 string `json:"sha256"` -} - -// Manifest is the signed plaintext manifest encrypted inside the age payload. -type Manifest struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RequestID string `json:"request_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - RecipientEpoch string `json:"recipient_epoch"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Files []File `json:"files"` -} - -// Envelope is the public, signed metadata placed before the age ciphertext. -// It intentionally omits filenames and file hashes. -type Envelope struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RequestID string `json:"request_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - RecipientEpoch string `json:"recipient_epoch"` - RecipientKeyIDs []string `json:"recipient_key_ids"` - InnerManifestSHA256 string `json:"inner_manifest_sha256"` - FileCount uint32 `json:"file_count"` - PlaintextSize uint64 `json:"plaintext_size"` - CiphertextSize uint64 `json:"ciphertext_size"` - CiphertextSHA256 string `json:"ciphertext_sha256"` - Encryption string `json:"encryption"` - SignatureAlgorithm string `json:"signature_algorithm"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -// Inspection contains structurally validated public metadata and same-file -// digests. Inspect returns it without signer authentication; -// AuthenticatePublic authenticates the outer signer before returning it. -// Verify is still required for claims about the encrypted inner manifest and -// file contents. -type Inspection struct { - Envelope Envelope - EnvelopeSHA256 string - BundleSize uint64 - BundleSHA256 string -} - -// Packed contains the authenticated data used to build a new bundle plus the -// digest of the complete file that should be bound by the post-push submission -// request. -type Packed struct { - Verified -} - -// Verified contains the authenticated public envelope and decrypted manifest. -// Its digests and size were computed from the same open file descriptor that -// was authenticated and decrypted, so callers can safely compare them with a -// signed post-push bundle reference without a second path-based inspection. -type Verified struct { - Envelope Envelope - Manifest Manifest - EnvelopeSHA256 string - BundleSize uint64 - BundleSHA256 string -} - -func checkedAdd(values ...uint64) (uint64, error) { - var total uint64 - for _, value := range values { - if value > math.MaxUint64-total { - return 0, fmt.Errorf("%w: byte count overflow", ErrLimitExceeded) - } - total += value - } - return total, nil -} - -func signingMessage(domain string, payload []byte) []byte { - message := make([]byte, 0, len(domain)+len(payload)) - message = append(message, domain...) - message = append(message, payload...) - return message -} - -func bindingFromManifest(manifest Manifest) Binding { - return Binding{ - EventID: manifest.EventID, EventEpoch: manifest.EventEpoch, - RequestID: manifest.RequestID, AttemptID: manifest.AttemptID, - ActorID: manifest.ActorID, KeyID: manifest.KeyID, KeyEpoch: manifest.KeyEpoch, - TeamID: manifest.TeamID, TeamProposalDigest: manifest.TeamProposalDigest, - BaseRepositoryID: manifest.BaseRepositoryID, - ConfigDigest: manifest.ConfigDigest, IssuedAt: manifest.IssuedAt, - ExpiresAt: manifest.ExpiresAt, RecipientEpoch: manifest.RecipientEpoch, - } -} - -func bindingFromEnvelope(envelope Envelope) Binding { - return Binding{ - EventID: envelope.EventID, EventEpoch: envelope.EventEpoch, - RequestID: envelope.RequestID, AttemptID: envelope.AttemptID, - ActorID: envelope.ActorID, KeyID: envelope.KeyID, KeyEpoch: envelope.KeyEpoch, - TeamID: envelope.TeamID, TeamProposalDigest: envelope.TeamProposalDigest, - BaseRepositoryID: envelope.BaseRepositoryID, - ConfigDigest: envelope.ConfigDigest, IssuedAt: envelope.IssuedAt, - ExpiresAt: envelope.ExpiresAt, RecipientEpoch: envelope.RecipientEpoch, - } -} - -func manifestFromBinding(binding Binding, files []File) Manifest { - return Manifest{ - Kind: ManifestKind, Protocol: Protocol, ProtocolVersion: ProtocolVersion, - EventID: binding.EventID, EventEpoch: binding.EventEpoch, - RequestID: binding.RequestID, AttemptID: binding.AttemptID, - ActorID: binding.ActorID, KeyID: binding.KeyID, KeyEpoch: binding.KeyEpoch, - TeamID: binding.TeamID, TeamProposalDigest: binding.TeamProposalDigest, - BaseRepositoryID: binding.BaseRepositoryID, - ConfigDigest: binding.ConfigDigest, RecipientEpoch: binding.RecipientEpoch, - IssuedAt: binding.IssuedAt, ExpiresAt: binding.ExpiresAt, Files: files, - } -} - -func envelopeFromBinding(binding Binding) Envelope { - return Envelope{ - Kind: EnvelopeKind, Protocol: Protocol, ProtocolVersion: ProtocolVersion, - EventID: binding.EventID, EventEpoch: binding.EventEpoch, - RequestID: binding.RequestID, AttemptID: binding.AttemptID, - ActorID: binding.ActorID, KeyID: binding.KeyID, KeyEpoch: binding.KeyEpoch, - TeamID: binding.TeamID, TeamProposalDigest: binding.TeamProposalDigest, - BaseRepositoryID: binding.BaseRepositoryID, - ConfigDigest: binding.ConfigDigest, RecipientEpoch: binding.RecipientEpoch, - IssuedAt: binding.IssuedAt, ExpiresAt: binding.ExpiresAt, - } -} - -func validateSigningKey(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("invalid Ed25519 private key length: got %d", len(privateKey)) - } - derived := ed25519.NewKeyFromSeed(privateKey.Seed()) - if !bytes.Equal(privateKey, derived) { - return errors.New("Ed25519 private key has an inconsistent public-key suffix") - } - return nil -} - -func validateVerificationKey(publicKey ed25519.PublicKey) error { - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("invalid Ed25519 public key length: got %d", len(publicKey)) - } - return nil -} diff --git a/internal/bundle/validate.go b/internal/bundle/validate.go deleted file mode 100644 index 7715e20..0000000 --- a/internal/bundle/validate.go +++ /dev/null @@ -1,282 +0,0 @@ -package bundle - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "fmt" - "path" - "strings" - "time" - "unicode/utf8" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/canonical" - protocolenvelope "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -const maxArchivePathBytes = 255 - -func marshalCanonical(value any) ([]byte, error) { - encoded, err := canonical.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode canonical JSON: %w", err) - } - return encoded, nil -} - -func unmarshalCanonical(data []byte, value any) error { - canonicalData, err := canonical.Canonicalize(data) - if err != nil { - return fmt.Errorf("%w: decode canonical JSON: %v", ErrInvalidFormat, err) - } - if !bytes.Equal(data, canonicalData) { - return fmt.Errorf("%w: JSON is not in canonical eventctl encoding", ErrInvalidFormat) - } - if err := canonical.StrictUnmarshal(data, value); err != nil { - return fmt.Errorf("%w: decode canonical JSON: %v", ErrInvalidFormat, err) - } - return nil -} - -func validateBinding(binding Binding, maximumValidity time.Duration) error { - if !protocolenvelope.IsEventID(binding.EventID) { - return fmt.Errorf("%w: event_id is invalid", ErrInvalidFormat) - } - if err := identity.ValidateDecimal(binding.EventEpoch, "event_epoch"); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidFormat, err) - } - if !protocolenvelope.IsUUID(binding.RequestID) || !protocolenvelope.IsUUID(binding.AttemptID) { - return fmt.Errorf("%w: request_id and attempt_id must be lower-case UUIDv4 values", ErrInvalidFormat) - } - if !isCanonicalDecimalID(binding.BaseRepositoryID) { - return fmt.Errorf("%w: base_repository_id must be a positive canonical decimal string of at most 20 digits", ErrInvalidFormat) - } - if !isCanonicalDecimalID(binding.ActorID) { - return fmt.Errorf("%w: actor_id must be a positive canonical decimal string of at most 20 digits", ErrInvalidFormat) - } - if !identity.IsDigest(binding.KeyID) || !identity.IsDigest(binding.ConfigDigest) { - return fmt.Errorf("%w: key_id and config_digest must be lowercase SHA-256 digests", ErrInvalidFormat) - } - if err := identity.ValidateDecimal(binding.KeyEpoch, "key_epoch"); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidFormat, err) - } - if !protocolenvelope.IsUUID(binding.TeamID) { - return fmt.Errorf("%w: team_id must be a lower-case UUIDv4", ErrInvalidFormat) - } - if !identity.IsDigest(binding.TeamProposalDigest) { - return fmt.Errorf("%w: team_proposal_digest must be a lowercase SHA-256 digest", ErrInvalidFormat) - } - if err := identity.ValidateDecimal(binding.RecipientEpoch, "recipient_epoch"); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidFormat, err) - } - if err := protocolenvelope.ValidateWindowWithin(binding.IssuedAt, binding.ExpiresAt, time.Time{}, maximumValidity); err != nil { - return fmt.Errorf("%w: invalid validity window: %v", ErrInvalidFormat, err) - } - return nil -} - -func isCanonicalDecimalID(value string) bool { - if len(value) == 0 || len(value) > 20 || value[0] == '0' { - return false - } - for _, character := range value { - if character < '0' || character > '9' { - return false - } - } - return true -} - -func validateManifest(manifest Manifest, limits Limits) error { - if manifest.Kind != ManifestKind || manifest.Protocol != Protocol || manifest.ProtocolVersion != ProtocolVersion { - return fmt.Errorf("%w: unsupported manifest discriminator", ErrInvalidFormat) - } - if err := validateBinding(bindingFromManifest(manifest), limits.MaxValidity); err != nil { - return err - } - if len(manifest.Files) == 0 { - return fmt.Errorf("%w: manifest has no files", ErrInvalidFormat) - } - if uint64(len(manifest.Files)) > uint64(limits.MaxFiles) { - return fmt.Errorf("%w: manifest file count exceeds %d", ErrLimitExceeded, limits.MaxFiles) - } - var total uint64 - paths := newPortablePathSet() - for index, file := range manifest.Files { - if err := validateArchivePath(file.Path); err != nil { - return fmt.Errorf("file %d: %w", index, err) - } - if file.SizeBytes > limits.MaxFileBytes { - return fmt.Errorf("%w: %q exceeds maximum file size", ErrLimitExceeded, file.Path) - } - if !isLowerHex(file.SHA256, sha256.Size*2) { - return fmt.Errorf("%w: %q has an invalid SHA-256 digest", ErrInvalidFormat, file.Path) - } - var err error - total, err = checkedAdd(total, file.SizeBytes) - if err != nil || total > limits.MaxTotalFileBytes { - return fmt.Errorf("%w: total file size exceeds %d", ErrLimitExceeded, limits.MaxTotalFileBytes) - } - if index > 0 { - previous := manifest.Files[index-1].Path - if previous >= file.Path { - return fmt.Errorf("%w: file paths are duplicated or not strictly sorted", ErrInvalidFormat) - } - } - if err := paths.add(file.Path); err != nil { - return err - } - } - return nil -} - -func validateEnvelope(envelope Envelope, limits Limits) error { - if envelope.Kind != EnvelopeKind || envelope.Protocol != Protocol || envelope.ProtocolVersion != ProtocolVersion { - return fmt.Errorf("%w: unsupported envelope discriminator", ErrInvalidFormat) - } - if err := validateBinding(bindingFromEnvelope(envelope), limits.MaxValidity); err != nil { - return err - } - if envelope.Encryption != EncryptionAlgorithm || envelope.SignatureAlgorithm != identity.Algorithm { - return fmt.Errorf("%w: unsupported cryptographic algorithms", ErrInvalidFormat) - } - if !isLowerHex(envelope.InnerManifestSHA256, sha256.Size*2) || - !isLowerHex(envelope.CiphertextSHA256, sha256.Size*2) { - return fmt.Errorf("%w: envelope contains an invalid digest", ErrInvalidFormat) - } - if len(envelope.RecipientKeyIDs) == 0 || uint64(len(envelope.RecipientKeyIDs)) > uint64(limits.MaxRecipients) { - return fmt.Errorf("%w: invalid recipient count", ErrLimitExceeded) - } - for index, keyID := range envelope.RecipientKeyIDs { - if !isLowerHex(keyID, sha256.Size*2) { - return fmt.Errorf("%w: invalid recipient key ID", ErrInvalidFormat) - } - if index > 0 && envelope.RecipientKeyIDs[index-1] >= keyID { - return fmt.Errorf("%w: recipient key IDs are duplicated or not sorted", ErrInvalidFormat) - } - } - if envelope.FileCount == 0 || envelope.FileCount > limits.MaxFiles { - return fmt.Errorf("%w: invalid file count", ErrLimitExceeded) - } - if envelope.PlaintextSize == 0 || envelope.PlaintextSize > limits.MaxPlaintextBytes { - return fmt.Errorf("%w: invalid plaintext size", ErrLimitExceeded) - } - if envelope.CiphertextSize == 0 || envelope.CiphertextSize > limits.MaxCiphertextBytes { - return fmt.Errorf("%w: invalid ciphertext size", ErrLimitExceeded) - } - return nil -} - -type portablePathNode struct { - children map[string]*portablePathNode - filePath string -} - -type portablePathSet struct { - root portablePathNode -} - -func newPortablePathSet() *portablePathSet { - return &portablePathSet{root: portablePathNode{children: make(map[string]*portablePathNode)}} -} - -// add rejects exact, ASCII-case-folded, and file/directory prefix collisions -// in O(number of path segments). validateArchivePath has already restricted -// paths to ASCII, so strings.ToLower is the complete portable case fold. -func (paths *portablePathSet) add(filePath string) error { - node := &paths.root - for _, segment := range strings.Split(filePath, "/") { - if node.filePath != "" { - return fmt.Errorf("%w: file and directory paths collide at %q and %q", ErrUnsafePath, node.filePath, filePath) - } - folded := strings.ToLower(segment) - next := node.children[folded] - if next == nil { - next = &portablePathNode{children: make(map[string]*portablePathNode)} - node.children[folded] = next - } - node = next - } - if node.filePath != "" { - return fmt.Errorf("%w: case-colliding paths %q and %q", ErrUnsafePath, node.filePath, filePath) - } - if len(node.children) != 0 { - return fmt.Errorf("%w: file and directory paths collide at %q", ErrUnsafePath, filePath) - } - node.filePath = filePath - return nil -} - -func validateArchivePath(value string) error { - if value == "" || len(value) > maxArchivePathBytes || !utf8.ValidString(value) { - return fmt.Errorf("%w: path is empty, too long, or invalid UTF-8", ErrUnsafePath) - } - if strings.ContainsRune(value, 0) || strings.Contains(value, "\\") || strings.Contains(value, ":") || - strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || path.IsAbs(value) || path.Clean(value) != value { - return fmt.Errorf("%w: %q is absolute, traversing, or non-canonical", ErrUnsafePath, value) - } - segments := strings.Split(value, "/") - for _, segment := range segments { - if segment == "" || segment == "." || segment == ".." || - strings.HasSuffix(segment, ".") || strings.HasSuffix(segment, " ") || isWindowsDeviceName(segment) { - return fmt.Errorf("%w: path segment %q is not portable", ErrUnsafePath, segment) - } - for index, r := range segment { - isAlphaNumeric := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' - if !isAlphaNumeric && r != '.' && r != '_' && r != '-' || index == 0 && !isAlphaNumeric { - return fmt.Errorf("%w: path contains a non-portable character", ErrUnsafePath) - } - } - } - return nil -} - -func isWindowsDeviceName(segment string) bool { - base := segment - if dot := strings.IndexByte(base, '.'); dot >= 0 { - base = base[:dot] - } - base = strings.ToUpper(base) - if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || - base == "CONIN$" || base == "CONOUT$" { - return true - } - if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) { - return base[3] >= '1' && base[3] <= '9' - } - return false -} - -func isLowerHex(value string, length int) bool { - if len(value) != length || strings.ToLower(value) != value { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -func digestHex(value []byte) string { - digest := sha256.Sum256(value) - return hex.EncodeToString(digest[:]) -} - -func ageRecipientKeyID(encodedRecipient string) string { - return digestHex([]byte(HybridRecipientFingerprintDomain + encodedRecipient)) -} - -// HybridRecipientFingerprint returns the v1 organizer-recipient fingerprint. -// Only the native age HybridRecipient profile is accepted by its static type; -// callers cannot pass legacy X25519, SSH, plugin, or scrypt recipients. -func HybridRecipientFingerprint(recipient *age.HybridRecipient) (string, error) { - if recipient == nil { - return "", fmt.Errorf("hybrid recipient is nil") - } - encoded := recipient.String() - parsed, err := age.ParseHybridRecipient(encoded) - if err != nil || parsed.String() != encoded || !strings.HasPrefix(encoded, "age1pq1") { - return "", fmt.Errorf("hybrid recipient is not in canonical age encoding") - } - return ageRecipientKeyID(encoded), nil -} diff --git a/internal/canonical/canonical.go b/internal/canonical/canonical.go deleted file mode 100644 index 3470ec5..0000000 --- a/internal/canonical/canonical.go +++ /dev/null @@ -1,484 +0,0 @@ -// Package canonical implements the deliberately small canonical JSON profile -// used by eventctl's signed protocol objects. -// -// The profile accepts JSON nulls, booleans, strings, arrays, objects, and -// arbitrary-size base-10 integers. Object keys are ordered by their UTF-8 byte -// representation. Duplicate keys, non-integer numbers, invalid UTF-8, and lone -// UTF-16 surrogate escapes are rejected. Protocol structures use strings for -// identifiers, so signed values do not depend on another implementation's -// floating-point or integer width. -package canonical - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "math/big" - "reflect" - "sort" - "strconv" - "strings" - "unicode/utf16" - "unicode/utf8" -) - -const maxNestingDepth = 64 - -var jsonUnmarshalerType = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() - -// Marshal encodes v using eventctl's canonical JSON profile. -func Marshal(v any) ([]byte, error) { - raw, err := json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("marshal JSON: %w", err) - } - return Canonicalize(raw) -} - -// Canonicalize validates raw as one JSON value and returns its canonical form. -func Canonicalize(raw []byte) ([]byte, error) { - value, err := decodeValue(raw) - if err != nil { - return nil, err - } - - var output bytes.Buffer - if err := writeValue(&output, value); err != nil { - return nil, err - } - return output.Bytes(), nil -} - -func decodeValue(raw []byte) (any, error) { - if !utf8.Valid(raw) { - return nil, errors.New("JSON is not valid UTF-8") - } - if err := validateSurrogateEscapes(raw); err != nil { - return nil, err - } - - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - value, err := readValue(decoder, 0) - if err != nil { - return nil, err - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - if err == nil { - return nil, errors.New("JSON contains a trailing value") - } - return nil, fmt.Errorf("read trailing JSON: %w", err) - } - - return value, nil -} - -// StrictUnmarshal rejects duplicate keys, non-canonical value types, trailing -// data, and missing, unknown, or case-folded struct fields before decoding raw -// into dst. Protocol structs deliberately do not use omitempty: nullable fields -// must still be present as JSON null. -func StrictUnmarshal(raw []byte, dst any) error { - if dst == nil { - return errors.New("decode destination is nil") - } - value, err := decodeValue(raw) - if err != nil { - return err - } - if err := validateExactFields(value, reflect.TypeOf(dst), "$"); err != nil { - return err - } - - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - decoder.UseNumber() - if err := decoder.Decode(dst); err != nil { - return fmt.Errorf("decode JSON: %w", err) - } - if err := ensureEOF(decoder); err != nil { - return err - } - return nil -} - -func ensureEOF(decoder *json.Decoder) error { - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - if err == nil { - return errors.New("JSON contains a trailing value") - } - return fmt.Errorf("read trailing JSON: %w", err) - } - return nil -} - -func readValue(decoder *json.Decoder, depth int) (any, error) { - token, err := decoder.Token() - if err != nil { - return nil, fmt.Errorf("decode JSON token: %w", err) - } - - delimiter, isDelimiter := token.(json.Delim) - if !isDelimiter { - if number, ok := token.(json.Number); ok { - return parseInteger(number) - } - return token, nil - } - if depth >= maxNestingDepth { - return nil, fmt.Errorf("JSON nesting depth exceeds %d", maxNestingDepth) - } - - switch delimiter { - case '{': - object := make(map[string]any) - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return nil, fmt.Errorf("decode object key: %w", err) - } - key, ok := keyToken.(string) - if !ok { - return nil, errors.New("object key is not a string") - } - if _, exists := object[key]; exists { - return nil, fmt.Errorf("duplicate object key %q", key) - } - value, err := readValue(decoder, depth+1) - if err != nil { - return nil, err - } - object[key] = value - } - end, err := decoder.Token() - if err != nil { - return nil, fmt.Errorf("close object: %w", err) - } - if end != json.Delim('}') { - return nil, errors.New("object is not closed") - } - return object, nil - case '[': - var array []any - for decoder.More() { - value, err := readValue(decoder, depth+1) - if err != nil { - return nil, err - } - array = append(array, value) - } - end, err := decoder.Token() - if err != nil { - return nil, fmt.Errorf("close array: %w", err) - } - if end != json.Delim(']') { - return nil, errors.New("array is not closed") - } - return array, nil - default: - return nil, fmt.Errorf("unexpected JSON delimiter %q", delimiter) - } -} - -type jsonFieldCandidate struct { - typeOf reflect.Type - depth int - tagged bool -} - -// validateExactFields prevents encoding/json's case-insensitive field matching -// from widening signed protocol schemas. Maps intentionally accept arbitrary -// keys, and types with custom JSON decoding (including json.RawMessage) remain -// opaque so callers can explicitly opt into carrying unstructured JSON. -func validateExactFields(value any, destination reflect.Type, path string) error { - for destination.Kind() == reflect.Pointer { - if destination.Implements(jsonUnmarshalerType) { - return nil - } - destination = destination.Elem() - } - if implementsJSONUnmarshaler(destination) || value == nil { - return nil - } - - switch destination.Kind() { - case reflect.Interface: - return nil - case reflect.Struct: - object, ok := value.(map[string]any) - if !ok { - return nil - } - fields := exactJSONFields(destination) - for name, item := range object { - fieldType, exists := fields[name] - if !exists { - return fmt.Errorf("unknown JSON field %q at %s", name, path) - } - if err := validateExactFields(item, fieldType, path+"."+name); err != nil { - return err - } - } - for name := range fields { - if _, exists := object[name]; !exists { - return fmt.Errorf("missing required JSON field %q at %s", name, path) - } - } - case reflect.Map: - object, ok := value.(map[string]any) - if !ok { - return nil - } - for name, item := range object { - if err := validateExactFields(item, destination.Elem(), path+"."+name); err != nil { - return err - } - } - case reflect.Slice, reflect.Array: - array, ok := value.([]any) - if !ok { - return nil - } - for index, item := range array { - itemPath := fmt.Sprintf("%s[%d]", path, index) - if err := validateExactFields(item, destination.Elem(), itemPath); err != nil { - return err - } - } - } - return nil -} - -func implementsJSONUnmarshaler(typeOf reflect.Type) bool { - if typeOf.Implements(jsonUnmarshalerType) { - return true - } - return typeOf.Kind() != reflect.Pointer && reflect.PointerTo(typeOf).Implements(jsonUnmarshalerType) -} - -func exactJSONFields(typeOf reflect.Type) map[string]reflect.Type { - candidates := make(map[string][]jsonFieldCandidate) - collectJSONFields(typeOf, 0, make(map[reflect.Type]bool), candidates) - - fields := make(map[string]reflect.Type, len(candidates)) - for name, possible := range candidates { - minimumDepth := possible[0].depth - for _, candidate := range possible[1:] { - if candidate.depth < minimumDepth { - minimumDepth = candidate.depth - } - } - - var dominant []jsonFieldCandidate - for _, candidate := range possible { - if candidate.depth == minimumDepth { - dominant = append(dominant, candidate) - } - } - if len(dominant) == 1 { - fields[name] = dominant[0].typeOf - continue - } - - var tagged []jsonFieldCandidate - for _, candidate := range dominant { - if candidate.tagged { - tagged = append(tagged, candidate) - } - } - if len(tagged) == 1 { - fields[name] = tagged[0].typeOf - } - } - return fields -} - -func collectJSONFields( - typeOf reflect.Type, - depth int, - ancestors map[reflect.Type]bool, - candidates map[string][]jsonFieldCandidate, -) { - for typeOf.Kind() == reflect.Pointer { - typeOf = typeOf.Elem() - } - if typeOf.Kind() != reflect.Struct || ancestors[typeOf] { - return - } - ancestors[typeOf] = true - defer delete(ancestors, typeOf) - - for index := 0; index < typeOf.NumField(); index++ { - field := typeOf.Field(index) - tag := field.Tag.Get("json") - if tag == "-" { - continue - } - name, _, _ := strings.Cut(tag, ",") - tagged := name != "" - - promotedType := field.Type - for promotedType.Kind() == reflect.Pointer { - promotedType = promotedType.Elem() - } - if field.Anonymous && !tagged && promotedType.Kind() == reflect.Struct { - collectJSONFields(promotedType, depth+1, ancestors, candidates) - continue - } - if !field.IsExported() { - continue - } - if !tagged { - name = field.Name - } - candidates[name] = append(candidates[name], jsonFieldCandidate{ - typeOf: field.Type, - depth: depth, - tagged: tagged, - }) - } -} - -func parseInteger(number json.Number) (*big.Int, error) { - text := number.String() - if text == "" { - return nil, errors.New("empty JSON number") - } - start := 0 - if text[0] == '-' { - start = 1 - } - if start == len(text) { - return nil, fmt.Errorf("invalid integer %q", text) - } - if text[start] == '0' && len(text)-start != 1 { - return nil, fmt.Errorf("integer %q has a leading zero", text) - } - for _, character := range text[start:] { - if character < '0' || character > '9' { - return nil, fmt.Errorf("non-integer JSON number %q is not allowed", text) - } - } - integer, ok := new(big.Int).SetString(text, 10) - if !ok { - return nil, fmt.Errorf("invalid integer %q", text) - } - return integer, nil -} - -func writeValue(output *bytes.Buffer, value any) error { - switch typed := value.(type) { - case nil: - output.WriteString("null") - case bool: - output.WriteString(strconv.FormatBool(typed)) - case string: - encoded, err := json.Marshal(typed) - if err != nil { - return fmt.Errorf("encode string: %w", err) - } - output.Write(encoded) - case *big.Int: - output.WriteString(typed.String()) - case []any: - output.WriteByte('[') - for index, item := range typed { - if index > 0 { - output.WriteByte(',') - } - if err := writeValue(output, item); err != nil { - return err - } - } - output.WriteByte(']') - case map[string]any: - keys := make([]string, 0, len(typed)) - for key := range typed { - keys = append(keys, key) - } - sort.Strings(keys) - output.WriteByte('{') - for index, key := range keys { - if index > 0 { - output.WriteByte(',') - } - encodedKey, err := json.Marshal(key) - if err != nil { - return fmt.Errorf("encode object key: %w", err) - } - output.Write(encodedKey) - output.WriteByte(':') - if err := writeValue(output, typed[key]); err != nil { - return err - } - } - output.WriteByte('}') - default: - return fmt.Errorf("unsupported canonical JSON type %T", value) - } - return nil -} - -func validateSurrogateEscapes(raw []byte) error { - insideString := false - for index := 0; index < len(raw); index++ { - character := raw[index] - if !insideString { - if character == '"' { - insideString = true - } - continue - } - if character == '"' { - insideString = false - continue - } - if character != '\\' { - continue - } - index++ - if index >= len(raw) { - return errors.New("unterminated string escape") - } - if raw[index] != 'u' { - continue - } - codeUnit, next, err := parseCodeUnit(raw, index) - if err != nil { - return err - } - index = next - 1 - if codeUnit >= 0xD800 && codeUnit <= 0xDBFF { - if next+1 >= len(raw) || raw[next] != '\\' || raw[next+1] != 'u' { - return errors.New("high surrogate is not followed by a low surrogate") - } - low, afterLow, err := parseCodeUnit(raw, next+1) - if err != nil { - return err - } - if low < 0xDC00 || low > 0xDFFF || !utf16.IsSurrogate(rune(low)) { - return errors.New("high surrogate is not followed by a low surrogate") - } - index = afterLow - 1 - continue - } - if codeUnit >= 0xDC00 && codeUnit <= 0xDFFF { - return errors.New("low surrogate is not preceded by a high surrogate") - } - } - return nil -} - -func parseCodeUnit(raw []byte, uIndex int) (uint16, int, error) { - if uIndex+5 > len(raw) { - return 0, 0, errors.New("truncated Unicode escape") - } - hexDigits := string(raw[uIndex+1 : uIndex+5]) - parsed, err := strconv.ParseUint(hexDigits, 16, 16) - if err != nil { - return 0, 0, fmt.Errorf("invalid Unicode escape \\u%s", hexDigits) - } - return uint16(parsed), uIndex + 5, nil -} diff --git a/internal/canonical/canonical_test.go b/internal/canonical/canonical_test.go deleted file mode 100644 index 3190df9..0000000 --- a/internal/canonical/canonical_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package canonical - -import ( - "encoding/json" - "reflect" - "strings" - "testing" -) - -func TestCanonicalizeSortsAndNormalizesIntegers(t *testing.T) { - t.Parallel() - - got, err := Canonicalize([]byte(` { "z": -0, "a": [2, 1], "unicode": "šŸ˜€" } `)) - if err != nil { - t.Fatal(err) - } - want := `{"a":[2,1],"unicode":"šŸ˜€","z":0}` - if string(got) != want { - t.Fatalf("Canonicalize() = %s, want %s", got, want) - } -} - -func TestCanonicalizeRejectsAmbiguousJSON(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "duplicate literal": `{"a":1,"a":2}`, - "duplicate escaped": `{"a":1,"\u0061":2}`, - "fraction": `{"a":1.0}`, - "exponent": `{"a":1e2}`, - "lone high surrogate": `{"a":"\ud800"}`, - "lone low surrogate": `{"a":"\udc00"}`, - "trailing value": `{} {}`, - } - for name, input := range tests { - name, input := name, input - t.Run(name, func(t *testing.T) { - t.Parallel() - if _, err := Canonicalize([]byte(input)); err == nil { - t.Fatalf("Canonicalize(%q) unexpectedly succeeded", input) - } - }) - } -} - -func TestStrictUnmarshalRejectsUnknownNestedField(t *testing.T) { - t.Parallel() - - type inner struct { - Value string `json:"value"` - } - type outer struct { - Inner inner `json:"inner"` - } - var result outer - if err := StrictUnmarshal([]byte(`{"inner":{"value":"ok","extra":true}}`), &result); err == nil { - t.Fatal("StrictUnmarshal unexpectedly accepted an unknown field") - } - if err := StrictUnmarshal([]byte(`{"inner":{"value":"ok"}}`), &result); err != nil { - t.Fatal(err) - } - want := outer{Inner: inner{Value: "ok"}} - if !reflect.DeepEqual(result, want) { - t.Fatalf("result = %#v, want %#v", result, want) - } -} - -func TestStrictUnmarshalRejectsCaseFoldedKnownField(t *testing.T) { - t.Parallel() - - type document struct { - EventID string `json:"event_id"` - } - var result document - if err := StrictUnmarshal([]byte(`{"EVENT_ID":"event-1"}`), &result); err == nil { - t.Fatal("StrictUnmarshal unexpectedly accepted a case-folded field name") - } - if err := StrictUnmarshal([]byte(`{"event_id":"event-1"}`), &result); err != nil { - t.Fatal(err) - } -} - -func TestStrictUnmarshalRejectsMissingZeroAndNullableFields(t *testing.T) { - t.Parallel() - - type document struct { - Enabled bool `json:"enabled"` - Reason *string `json:"reason"` - } - for name, input := range map[string]string{ - "missing bool": `{"reason":null}`, - "missing nullable": `{"enabled":false}`, - } { - t.Run(name, func(t *testing.T) { - var result document - if err := StrictUnmarshal([]byte(input), &result); err == nil { - t.Fatalf("StrictUnmarshal unexpectedly accepted %s", input) - } - }) - } - var result document - if err := StrictUnmarshal([]byte(`{"enabled":false,"reason":null}`), &result); err != nil { - t.Fatal(err) - } -} - -func TestCanonicalizeEnforcesNestingLimit(t *testing.T) { - t.Parallel() - - atLimit := strings.Repeat("[", maxNestingDepth) + "null" + strings.Repeat("]", maxNestingDepth) - if _, err := Canonicalize([]byte(atLimit)); err != nil { - t.Fatalf("Canonicalize rejected JSON at the nesting limit: %v", err) - } - - tooDeep := "[" + atLimit + "]" - if _, err := Canonicalize([]byte(tooDeep)); err == nil { - t.Fatal("Canonicalize unexpectedly accepted JSON beyond the nesting limit") - } -} - -func TestStrictUnmarshalKeepsMapsAndRawMessagesOpaque(t *testing.T) { - t.Parallel() - - type document struct { - Metadata map[string]json.RawMessage `json:"metadata"` - Payload json.RawMessage `json:"payload"` - } - input := []byte(`{"metadata":{"Arbitrary-Key":{"CASE_FOLDED":true}},"payload":{"Unstructured":1}}`) - var result document - if err := StrictUnmarshal(input, &result); err != nil { - t.Fatal(err) - } - if string(result.Metadata["Arbitrary-Key"]) != `{"CASE_FOLDED":true}` { - t.Fatalf("metadata raw message = %s", result.Metadata["Arbitrary-Key"]) - } - if string(result.Payload) != `{"Unstructured":1}` { - t.Fatalf("payload raw message = %s", result.Payload) - } -} diff --git a/internal/config/authority.go b/internal/config/authority.go deleted file mode 100644 index 3f6598e..0000000 --- a/internal/config/authority.go +++ /dev/null @@ -1,227 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "regexp" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -var stateReasonPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) - -type Authority struct { - Threshold int `json:"threshold"` - Keys []identity.Public `json:"keys"` -} - -type Writer struct { - AppSlug string `json:"app_slug"` - InstallationID string `json:"installation_id"` - Provenance string `json:"provenance"` -} - -// Genesis is the complete protected state genesis document accepted by -// --authority. Accepting the whole object prevents helpers from accidentally -// dropping event/repository/delegation bindings while extracting keys. -type Genesis struct { - SchemaVersion int `json:"schema_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - GenesisDelegationDigest string `json:"genesis_delegation_digest"` - ConfigDelegationValidFrom string `json:"config_delegation_valid_from"` - ConfigDelegationExpiresAt string `json:"config_delegation_expires_at"` - ConfigAuthority Authority `json:"config_authority"` - ReceiptAuthority identity.Public `json:"receipt_authority"` - CreatedAt string `json:"created_at"` - OperationID string `json:"operation_id"` - OrganizerActorID string `json:"organizer_actor_id"` - TeamMinimumSize uint64 `json:"team_minimum_size"` - TeamMaximumSize uint64 `json:"team_maximum_size"` - TeamMaximumProposalsPerParticipant uint64 `json:"team_maximum_proposals_per_participant"` - SubmissionQuota uint64 `json:"submission_quota"` - SubmissionMaximumTotalAttempts uint64 `json:"submission_maximum_total_attempts"` - Writer Writer `json:"writer"` -} - -type StateMeta struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - ConfigAuthorityDigest string `json:"config_authority_digest"` - ReceiptAuthority identity.Public `json:"receipt_authority"` - Sequence uint64 `json:"sequence"` - JournalEventDigest string `json:"journal_event_digest"` - LifecyclePhase string `json:"lifecycle_phase"` - Enabled bool `json:"enabled"` - DisabledReason *string `json:"disabled_reason"` -} - -func ParseAuthority(raw []byte) (Genesis, error) { - if len(raw) > MaxBytes { - return Genesis{}, errors.New("authority document exceeds 1 MiB") - } - var genesis Genesis - if err := canonical.StrictUnmarshal(raw, &genesis); err != nil { - return Genesis{}, fmt.Errorf("decode protected genesis authority: %w", err) - } - if err := genesis.Validate(); err != nil { - return Genesis{}, err - } - return genesis, nil -} - -func ParseStateMeta(raw []byte) (StateMeta, error) { - if len(raw) > MaxBytes { - return StateMeta{}, errors.New("state metadata exceeds 1 MiB") - } - var meta StateMeta - if err := canonical.StrictUnmarshal(raw, &meta); err != nil { - return StateMeta{}, fmt.Errorf("decode protected state metadata: %w", err) - } - if err := meta.Validate(); err != nil { - return StateMeta{}, err - } - return meta, nil -} - -func (meta StateMeta) Validate() error { - if meta.Kind != "state_meta_view" || meta.Protocol != envelope.Protocol || meta.ProtocolVersion != envelope.ProtocolVersion || !envelope.IsEventID(meta.EventID) || meta.EventEpoch != "1" || envelope.ValidateRepositoryID(meta.BaseRepositoryID) != nil || !envelope.IsDigest(meta.ConfigDigest) || !envelope.IsDigest(meta.ConfigAuthorityDigest) || meta.ReceiptAuthority.Validate() != nil || meta.Sequence < 1 || meta.Sequence > statepointer.MaxSequenceV1 || !envelope.IsDigest(meta.JournalEventDigest) { - return errors.New("protected state metadata binding is invalid") - } - phases := map[string]bool{"draft": true, "registration_open": true, "formation_open": true, "submissions_open": true, "frozen": true, "closed": true, "archived": true} - if !phases[meta.LifecyclePhase] { - return errors.New("state lifecycle phase is invalid") - } - if meta.Enabled && meta.DisabledReason != nil { - return errors.New("enabled state must have null disabled_reason") - } - if !meta.Enabled && (meta.DisabledReason == nil || !stateReasonPattern.MatchString(*meta.DisabledReason)) { - return errors.New("disabled state requires a reason code") - } - return nil -} - -func (g Genesis) Validate() error { - if g.SchemaVersion != 1 || !envelope.IsEventID(g.EventID) || g.EventEpoch != "1" || envelope.ValidateRepositoryID(g.BaseRepositoryID) != nil { - return errors.New("genesis protocol/event/repository binding is invalid") - } - if !envelope.IsDigest(g.ConfigDigest) || !envelope.IsDigest(g.GenesisDelegationDigest) { - return errors.New("genesis config/delegation digest is invalid") - } - delegationValidFrom, err := envelope.ParseTimestamp(g.ConfigDelegationValidFrom) - if err != nil { - return fmt.Errorf("config delegation valid_from: %w", err) - } - delegationExpiresAt, err := envelope.ParseTimestamp(g.ConfigDelegationExpiresAt) - if err != nil || !delegationExpiresAt.After(delegationValidFrom) { - return errors.New("config delegation validity window is invalid") - } - if _, err := envelope.ParseTimestamp(g.CreatedAt); err != nil { - return err - } - if !envelope.IsUUID(g.OperationID) || identity.ValidateDecimal(g.OrganizerActorID, "organizer_actor_id") != nil { - return errors.New("genesis operation metadata is invalid") - } - if g.TeamMinimumSize < 1 || g.TeamMaximumSize < g.TeamMinimumSize || g.TeamMaximumSize > 64 || g.TeamMaximumProposalsPerParticipant < 1 || g.TeamMaximumProposalsPerParticipant > MaxTeamProposalsPerParticipantV1 || g.SubmissionQuota < 1 || g.SubmissionQuota > MaxTotalSubmissionAttemptsV1 || g.SubmissionMaximumTotalAttempts < 1 || g.SubmissionMaximumTotalAttempts > MaxTotalSubmissionAttemptsV1 || g.SubmissionQuota > g.SubmissionMaximumTotalAttempts { - return errors.New("genesis team/quota policy is invalid") - } - if !appSlugPattern.MatchString(g.Writer.AppSlug) || identity.ValidateDecimal(g.Writer.InstallationID, "installation_id") != nil || g.Writer.Provenance != "local_bootstrap" { - return errors.New("genesis writer identity is invalid") - } - if g.ConfigAuthority.Threshold < 1 || g.ConfigAuthority.Threshold > 16 || len(g.ConfigAuthority.Keys) < g.ConfigAuthority.Threshold || len(g.ConfigAuthority.Keys) > 16 { - return errors.New("genesis config authority threshold/key count is invalid") - } - for index, key := range g.ConfigAuthority.Keys { - if err := key.Validate(); err != nil { - return fmt.Errorf("authority key %d: %w", index, err) - } - if index > 0 && g.ConfigAuthority.Keys[index-1].KeyID >= key.KeyID { - return errors.New("authority keys must be strictly sorted by key_id") - } - } - if err := g.ReceiptAuthority.Validate(); err != nil { - return fmt.Errorf("receipt authority: %w", err) - } - return nil -} - -// VerifyWithAuthority binds config verification to the complete protected -// genesis authority. Genesis config epoch 1 is additionally digest-pinned. -func VerifyWithAuthority(event Event, genesis Genesis, now time.Time) (Verification, error) { - if err := genesis.Validate(); err != nil { - return Verification{}, err - } - if event.EventID != genesis.EventID || event.EventEpoch != genesis.EventEpoch || event.BaseRepository.ID != genesis.BaseRepositoryID || event.DelegationEpoch != 1 || event.DelegationDigest != genesis.GenesisDelegationDigest || event.Receipts.SigningKey != genesis.ReceiptAuthority || event.Teams.MinimumSize != genesis.TeamMinimumSize || event.Teams.MaximumSize != genesis.TeamMaximumSize || event.Teams.MaximumProposalsPerParticipant != genesis.TeamMaximumProposalsPerParticipant || event.Submissions.MaximumAttemptsPerTeam != genesis.SubmissionQuota || event.Submissions.MaximumTotalAttempts != genesis.SubmissionMaximumTotalAttempts { - return Verification{}, errors.New("event config does not match protected genesis authority") - } - delegationValidFrom, _ := envelope.ParseTimestamp(genesis.ConfigDelegationValidFrom) - delegationExpiresAt, _ := envelope.ParseTimestamp(genesis.ConfigDelegationExpiresAt) - if !now.IsZero() && (now.UTC().Before(delegationValidFrom) || now.UTC().After(delegationExpiresAt)) { - return Verification{}, errors.New("config delegation is outside its protected validity window") - } - verification, err := Verify(event, genesis.ConfigAuthority.Keys, genesis.ConfigAuthority.Threshold, now) - if err != nil { - return Verification{}, err - } - if verification.Digest != genesis.ConfigDigest { - return Verification{}, errors.New("genesis config digest does not match protected state") - } - return verification, nil -} - -// VerifyAdoptedConfig additionally proves the config digest and trust bindings -// are adopted by the protected current-state view fetched at one immutable ref. -func VerifyAdoptedConfig(event Event, genesis Genesis, meta StateMeta, now time.Time) (Verification, error) { - verification, err := VerifyArchivedConfig(event, genesis, meta, now) - if err != nil { - return Verification{}, err - } - if verification.Digest != meta.ConfigDigest { - return Verification{}, errors.New("event config is not the config adopted by protected current state") - } - return verification, nil -} - -// VerifyArchivedConfig authenticates a historical signed config at the trusted -// immutable GitHub source creation time while using current state only to confirm the same immutable -// event/repository authorities. It intentionally does not require the -// historical digest to equal the currently adopted config digest. -func VerifyArchivedConfig(event Event, genesis Genesis, meta StateMeta, sourceCreatedAt time.Time) (Verification, error) { - if err := VerifyAuthorityState(genesis, meta); err != nil { - return Verification{}, err - } - verification, err := VerifyWithAuthority(event, genesis, sourceCreatedAt) - if err != nil { - return Verification{}, err - } - if event.EventID != meta.EventID || event.EventEpoch != meta.EventEpoch || event.BaseRepository.ID != meta.BaseRepositoryID || meta.ConfigAuthorityDigest != event.DelegationDigest || meta.ReceiptAuthority != event.Receipts.SigningKey { - return Verification{}, errors.New("archived event config does not match protected event authorities") - } - return verification, nil -} - -// VerifyAuthorityState binds current metadata to the complete protected -// genesis without consulting a mutable configuration file. -func VerifyAuthorityState(genesis Genesis, meta StateMeta) error { - if err := genesis.Validate(); err != nil { - return err - } - if err := meta.Validate(); err != nil { - return err - } - if genesis.EventID != meta.EventID || genesis.EventEpoch != meta.EventEpoch || genesis.BaseRepositoryID != meta.BaseRepositoryID || meta.ConfigDigest != genesis.ConfigDigest || meta.ConfigAuthorityDigest != genesis.GenesisDelegationDigest || meta.ReceiptAuthority != genesis.ReceiptAuthority { - return errors.New("protected current state does not match genesis authorities") - } - return nil -} diff --git a/internal/config/authority_test.go b/internal/config/authority_test.go deleted file mode 100644 index 2e3c42d..0000000 --- a/internal/config/authority_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package config - -import ( - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -func TestArchivedConfigUsesSourceTimeAfterQueueDelayAndCurrentAuthorities(t *testing.T) { - t.Parallel() - signer := deterministicPair(t, 41) - event := validEvent(t) - event.DelegationDigest = strings.Repeat("a", 64) - event.Signatures = nil - var err error - event, err = Sign(event, signer.Private) - if err != nil { - t.Fatal(err) - } - digest, err := Digest(event) - if err != nil { - t.Fatal(err) - } - genesis := Genesis{ - SchemaVersion: 1, EventID: event.EventID, EventEpoch: event.EventEpoch, - BaseRepositoryID: event.BaseRepository.ID, ConfigDigest: digest, - GenesisDelegationDigest: event.DelegationDigest, - ConfigDelegationValidFrom: "2026-08-15T00:00:00Z", - ConfigDelegationExpiresAt: "2027-07-01T00:00:00Z", - ConfigAuthority: Authority{Threshold: 1, Keys: []identity.Public{signer.Public}}, - ReceiptAuthority: event.Receipts.SigningKey, - CreatedAt: "2026-08-04T00:00:00Z", OperationID: "00000000-0000-4000-8000-000000000001", - OrganizerActorID: "42", - TeamMinimumSize: event.Teams.MinimumSize, - TeamMaximumSize: event.Teams.MaximumSize, - TeamMaximumProposalsPerParticipant: event.Teams.MaximumProposalsPerParticipant, - SubmissionQuota: event.Submissions.MaximumAttemptsPerTeam, - SubmissionMaximumTotalAttempts: event.Submissions.MaximumTotalAttempts, - Writer: Writer{AppSlug: "pythonhk-event-state-writer", InstallationID: "1", Provenance: "local_bootstrap"}, - } - meta := StateMeta{ - Kind: "state_meta_view", Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: event.EventID, EventEpoch: event.EventEpoch, BaseRepositoryID: event.BaseRepository.ID, - ConfigDigest: digest, ConfigAuthorityDigest: event.DelegationDigest, - ReceiptAuthority: event.Receipts.SigningKey, Sequence: 9, JournalEventDigest: strings.Repeat("c", 64), - LifecyclePhase: "frozen", Enabled: true, DisabledReason: nil, - } - unsupportedMeta := meta - unsupportedMeta.EventEpoch = "2" - unsupportedRaw, err := canonical.Marshal(unsupportedMeta) - if err != nil { - t.Fatal(err) - } - if _, err := ParseStateMeta(unsupportedRaw); err == nil { - t.Fatal("accepted unsupported protected-state event epoch") - } - beyondLifetime := meta - beyondLifetime.Sequence = statepointer.MaxSequenceV1 + 1 - beyondLifetimeRaw, err := canonical.Marshal(beyondLifetime) - if err != nil { - t.Fatal(err) - } - if _, err := ParseStateMeta(beyondLifetimeRaw); err == nil { - t.Fatal("accepted protected state beyond the v1 lifetime bound") - } - if err := VerifyAuthorityState(genesis, beyondLifetime); err == nil { - t.Fatal("trusted protected state beyond the v1 lifetime bound") - } - sourceCreatedAt := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - if _, err := VerifyAdoptedConfig(event, genesis, meta, sourceCreatedAt); err != nil { - t.Fatalf("current config valid at trusted source time failed after queue delay: %v", err) - } - wrongProvenance := genesis - wrongProvenance.Writer.Provenance = "workflow_dispatch" - if err := wrongProvenance.Validate(); err == nil { - t.Fatal("accepted non-local genesis bootstrap provenance") - } - beforeDelegation := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) - if _, err := VerifyAdoptedConfig(event, genesis, meta, beforeDelegation); err == nil { - t.Fatal("accepted source before protected delegation validity") - } - afterDelegation := time.Date(2027, 7, 15, 0, 0, 0, 0, time.UTC) - if _, err := VerifyAdoptedConfig(event, genesis, meta, afterDelegation); err == nil { - t.Fatal("accepted source after protected delegation validity") - } - processedAfterExpiry := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) - if _, err := VerifyAdoptedConfig(event, genesis, meta, processedAfterExpiry); err == nil { - t.Fatal("config validity unexpectedly ignored the supplied verification time") - } - if _, err := VerifyArchivedConfig(event, genesis, meta, sourceCreatedAt); err != nil { - t.Fatalf("historical accepted config failed: %v", err) - } - for name, mutate := range map[string]func(*Genesis){ - "minimum team size": func(value *Genesis) { value.TeamMinimumSize++ }, - "maximum team size": func(value *Genesis) { value.TeamMaximumSize++ }, - "proposal limit": func(value *Genesis) { value.TeamMaximumProposalsPerParticipant++ }, - "per-team quota": func(value *Genesis) { value.SubmissionQuota++ }, - "global attempt limit": func(value *Genesis) { value.SubmissionMaximumTotalAttempts++ }, - } { - t.Run("policy mismatch "+name, func(t *testing.T) { - mismatched := genesis - mutate(&mismatched) - if _, err := VerifyWithAuthority(event, mismatched, sourceCreatedAt); err == nil { - t.Fatalf("accepted genesis/config %s mismatch", name) - } - }) - } - changed := meta - changed.ConfigDigest = strings.Repeat("d", 64) - if _, err := VerifyArchivedConfig(event, genesis, changed, sourceCreatedAt); err == nil { - t.Fatal("accepted superseded current config digest") - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index bb9416d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,602 +0,0 @@ -// Package config strictly parses, validates, signs, and verifies organizer YAML. -// Bash must treat configuration as opaque and invoke eventctl for these tasks. -package config - -import ( - "bytes" - "encoding/base64" - "errors" - "fmt" - "io" - "math" - "net/url" - "regexp" - "sort" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/scorer" - "github.com/pythonhk/eventctl/internal/statepointer" - "github.com/pythonhk/eventctl/internal/team" - "go.yaml.in/yaml/v3" -) - -const ( - Kind = "event_config" - SigningDomain = "event_config" - MaxBytes = 1 << 20 - MaxExternalJudgeURLBytes = 2_048 - MaxTeamProposalsPerParticipantV1 = 16 - MaxTotalSubmissionAttemptsV1 = 1_000 - MaxDerivedBusinessRecordsV1 = 2_028 - MaxDerivedStateRecordsV1 = statepointer.MaxSequenceV1 - maxYAMLDepth = 64 - maxSubmissionCiphertextBytes = 47_000_000 - maxSubmissionPlaintextBytes = 42_000_000 - maxSubmissionFileBytes = 42_000_000 -) - -var ( - idPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`) - appSlugPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,63}$`) - recipientIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,31}$`) - extensionPattern = regexp.MustCompile(`^\.[a-z0-9][a-z0-9._-]{0,15}$`) - semverPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) - base64SignaturePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{86}$`) - rfc3986ASCIIPattern = regexp.MustCompile(`^[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+$`) -) - -type Event struct { - Kind string `json:"kind" yaml:"kind"` - Protocol string `json:"protocol" yaml:"protocol"` - ProtocolVersion int `json:"protocol_version" yaml:"protocol_version"` - EventID string `json:"event_id" yaml:"event_id"` - EventEpoch string `json:"event_epoch" yaml:"event_epoch"` - BaseRepository envelope.Repository `json:"base_repository" yaml:"base_repository"` - ConfigEpoch uint64 `json:"config_epoch" yaml:"config_epoch"` - DelegationEpoch uint64 `json:"delegation_epoch" yaml:"delegation_epoch"` - PreviousConfigDigest *string `json:"previous_config_digest" yaml:"previous_config_digest"` - DelegationDigest string `json:"delegation_digest" yaml:"delegation_digest"` - IssuedAt string `json:"issued_at" yaml:"issued_at"` - ExpiresAt string `json:"expires_at" yaml:"expires_at"` - InitialState InitialState `json:"initial_state" yaml:"initial_state"` - Registration Registration `json:"registration" yaml:"registration"` - Teams Teams `json:"teams" yaml:"teams"` - Submissions Submissions `json:"submissions" yaml:"submissions"` - Scoring Scoring `json:"scoring" yaml:"scoring"` - State State `json:"state" yaml:"state"` - Receipts Receipts `json:"receipts" yaml:"receipts"` - Signatures []identity.Signature `json:"signatures" yaml:"signatures"` -} - -type InitialState struct { - Phase string `json:"phase" yaml:"phase"` - Enabled bool `json:"enabled" yaml:"enabled"` - DisabledReason string `json:"disabled_reason" yaml:"disabled_reason"` -} - -type Registration struct { - MaximumParticipants uint64 `json:"maximum_participants" yaml:"maximum_participants"` - RequestTTLSeconds uint64 `json:"request_ttl_seconds" yaml:"request_ttl_seconds"` - TermsDigest string `json:"terms_digest" yaml:"terms_digest"` - KeyAlgorithm string `json:"key_algorithm" yaml:"key_algorithm"` - KeyRotationPolicy string `json:"key_rotation_policy" yaml:"key_rotation_policy"` -} - -type Teams struct { - MinimumSize uint64 `json:"minimum_size" yaml:"minimum_size"` - MaximumSize uint64 `json:"maximum_size" yaml:"maximum_size"` - MaximumProposalsPerParticipant uint64 `json:"maximum_proposals_per_participant" yaml:"maximum_proposals_per_participant"` - ProposalTTLSeconds uint64 `json:"proposal_ttl_seconds" yaml:"proposal_ttl_seconds"` - MembershipLockPhase string `json:"membership_lock_phase" yaml:"membership_lock_phase"` -} - -type Submissions struct { - BaseRef string `json:"base_ref" yaml:"base_ref"` - MaximumAttemptsPerTeam uint64 `json:"maximum_attempts_per_team" yaml:"maximum_attempts_per_team"` - MaximumTotalAttempts uint64 `json:"maximum_total_attempts" yaml:"maximum_total_attempts"` - MaximumCiphertextBytes uint64 `json:"maximum_ciphertext_bytes" yaml:"maximum_ciphertext_bytes"` - MaximumPlaintextBytes uint64 `json:"maximum_plaintext_bytes" yaml:"maximum_plaintext_bytes"` - MaximumFileBytes uint64 `json:"maximum_file_bytes" yaml:"maximum_file_bytes"` - MaximumPlaintextFiles uint64 `json:"maximum_plaintext_files" yaml:"maximum_plaintext_files"` - EnvelopeTTLSeconds uint64 `json:"envelope_ttl_seconds" yaml:"envelope_ttl_seconds"` - DeliveryMode string `json:"delivery_mode" yaml:"delivery_mode"` - FailedConsumeQuota bool `json:"failed_attempts_consume_quota" yaml:"failed_attempts_consume_quota"` - AllowedExtensions []string `json:"allowed_extensions" yaml:"allowed_extensions"` - Encryption Encryption `json:"encryption" yaml:"encryption"` -} - -type Encryption struct { - Algorithm string `json:"algorithm" yaml:"algorithm"` - RecipientEpoch string `json:"recipient_epoch" yaml:"recipient_epoch"` - Recipients []Recipient `json:"recipients" yaml:"recipients"` -} - -type Recipient struct { - RecipientID string `json:"recipient_id" yaml:"recipient_id"` - PublicKey string `json:"public_key" yaml:"public_key"` -} - -type Scoring struct { - Mode string `json:"mode" yaml:"mode"` - ScorerID string `json:"scorer_id" yaml:"scorer_id"` - ScorerVersion string `json:"scorer_version" yaml:"scorer_version"` - PolicyDigest string `json:"policy_digest" yaml:"policy_digest"` - MaximumResultBytes uint64 `json:"maximum_result_bytes" yaml:"maximum_result_bytes"` - ResultKey identity.Public `json:"result_key" yaml:"result_key"` - ExternalJudgeURL *string `json:"external_judge_url" yaml:"external_judge_url"` -} - -type State struct { - Branch string `json:"branch" yaml:"branch"` - Public bool `json:"public" yaml:"public"` - WriterAppSlug string `json:"writer_app_slug" yaml:"writer_app_slug"` - WriterConcurrencyGroup string `json:"writer_concurrency_group" yaml:"writer_concurrency_group"` - JournalFormat string `json:"journal_format" yaml:"journal_format"` -} - -type Receipts struct { - SigningKey identity.Public `json:"signing_key" yaml:"signing_key"` -} - -type unsignedEvent struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepository envelope.Repository `json:"base_repository"` - ConfigEpoch uint64 `json:"config_epoch"` - DelegationEpoch uint64 `json:"delegation_epoch"` - PreviousConfigDigest *string `json:"previous_config_digest"` - DelegationDigest string `json:"delegation_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - InitialState InitialState `json:"initial_state"` - Registration Registration `json:"registration"` - Teams Teams `json:"teams"` - Submissions Submissions `json:"submissions"` - Scoring Scoring `json:"scoring"` - State State `json:"state"` - Receipts Receipts `json:"receipts"` -} - -type Verification struct { - Digest string `json:"digest"` - ValidKeyIDs []string `json:"valid_key_ids"` - Required int `json:"required"` -} - -// DerivedCapacity is the closed v1 upper bound on protected-state records. -// BusinessRecords is N + N*P + N*P*M + 2*A. StateRecords additionally -// includes the genesis record, two journal records per business record, six -// emergency-control records, and 32 journal-transition records. -type DerivedCapacity struct { - BusinessRecords uint64 - StateRecords uint64 -} - -// Parse strictly decodes one bounded YAML document and validates it. -func Parse(raw []byte) (Event, error) { - return parseYAML(raw, true) -} - -// ParseUnsigned is the only parser that permits an empty signatures array. It -// exists solely so config sign can start from a clean authoring document. -func ParseUnsigned(raw []byte) (Event, error) { - return parseYAML(raw, false) -} - -func parseYAML(raw []byte, requireSignatures bool) (Event, error) { - if len(raw) > MaxBytes { - return Event{}, fmt.Errorf("event config is %d bytes, limit is %d", len(raw), MaxBytes) - } - if !bytes.Equal(bytes.TrimSpace(raw), []byte("")) { - var root yaml.Node - decoder := yaml.NewDecoder(bytes.NewReader(raw)) - decoder.KnownFields(true) - if err := decoder.Decode(&root); err != nil { - return Event{}, fmt.Errorf("decode event YAML: %w", err) - } - if err := inspectYAML(&root, 0); err != nil { - return Event{}, err - } - var extra yaml.Node - if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { - if err == nil { - return Event{}, errors.New("event YAML contains multiple documents") - } - return Event{}, fmt.Errorf("read trailing YAML: %w", err) - } - } else { - return Event{}, errors.New("event config is empty") - } - decoder := yaml.NewDecoder(bytes.NewReader(raw)) - decoder.KnownFields(true) - var event Event - if err := decoder.Decode(&event); err != nil { - return Event{}, fmt.Errorf("decode typed event YAML: %w", err) - } - if err := event.validate(requireSignatures); err != nil { - return Event{}, err - } - return event, nil -} - -func (event Event) Validate() error { - return event.validate(true) -} - -func (event Event) validate(requireSignatures bool) error { - if event.Kind != Kind || event.Protocol != envelope.Protocol || event.ProtocolVersion != envelope.ProtocolVersion { - return errors.New("event config protocol discriminator is invalid") - } - if !envelope.IsEventID(event.EventID) { - return errors.New("event_id is invalid") - } - if event.EventEpoch != "1" { - return errors.New("v1 requires event_epoch 1") - } - if err := envelope.ValidateRepository(event.BaseRepository); err != nil { - return err - } - if event.ConfigEpoch != 1 { - return errors.New("v1 requires config_epoch 1") - } - if event.DelegationEpoch != 1 { - return errors.New("v1 requires delegation_epoch 1") - } - if event.PreviousConfigDigest != nil { - return errors.New("v1 requires null previous_config_digest") - } - if !envelope.IsDigest(event.DelegationDigest) { - return errors.New("delegation_digest is invalid") - } - issued, err := envelope.ParseTimestamp(event.IssuedAt) - if err != nil { - return err - } - expires, err := envelope.ParseTimestamp(event.ExpiresAt) - if err != nil || !expires.After(issued) { - return errors.New("config expires_at must be valid and after issued_at") - } - if err := validateInitialState(event.InitialState); err != nil { - return err - } - if err := validateRegistration(event.Registration); err != nil { - return err - } - if err := validateTeams(event.Teams); err != nil { - return err - } - if event.Teams.MinimumSize > event.Registration.MaximumParticipants || event.Teams.MaximumSize > event.Registration.MaximumParticipants { - return errors.New("team sizes must not exceed maximum_participants") - } - if err := validateSubmissions(event.Submissions); err != nil { - return err - } - if _, err := DeriveCapacity(event.Registration, event.Teams, event.Submissions); err != nil { - return err - } - if err := validateScoring(event.Scoring); err != nil { - return err - } - if event.State.Branch != "event-state" || !event.State.Public || !appSlugPattern.MatchString(event.State.WriterAppSlug) || event.State.WriterConcurrencyGroup != "event-state-writer" || event.State.JournalFormat != "hash-linked-json-v1" { - return errors.New("state configuration is invalid") - } - if err := event.Receipts.SigningKey.Validate(); err != nil { - return fmt.Errorf("receipt signing key: %w", err) - } - if event.Receipts.SigningKey.KeyID == event.Scoring.ResultKey.KeyID { - return errors.New("receipt and scorer result authorities must be distinct") - } - if (requireSignatures && len(event.Signatures) < 1) || len(event.Signatures) > 16 { - return errors.New("config must contain 1 to 16 signatures") - } - seen := map[string]struct{}{} - for i, sig := range event.Signatures { - if err := validateProtocolSignature(sig); err != nil { - return fmt.Errorf("signature %d: %w", i, err) - } - if _, ok := seen[sig.KeyID]; ok { - return errors.New("duplicate config signature key_id") - } - seen[sig.KeyID] = struct{}{} - if i > 0 && event.Signatures[i-1].KeyID >= sig.KeyID { - return errors.New("config signatures must be strictly sorted by key_id") - } - } - return nil -} - -// Digest returns SHA-256 of the strict config's canonical JSON representation. -func Digest(event Event) (string, error) { - if err := event.Validate(); err != nil { - return "", err - } - raw, err := canonical.Marshal(event) - if err != nil { - return "", err - } - return envelope.Digest(raw), nil -} - -// Sign replaces signatures with one signature from private. Threshold assembly -// can combine independently reviewed signatures before publication. -func Sign(event Event, private identity.Private) (Event, error) { - pairRaw, err := canonical.Marshal(private) - if err != nil { - return Event{}, err - } - pair, err := identity.ParsePrivate(pairRaw) - if err != nil { - return Event{}, err - } - if err := event.validate(false); err != nil { - return Event{}, err - } - sig, err := envelope.Sign(SigningDomain, unsigned(event), pair.Private) - if err != nil { - return Event{}, err - } - remaining := make([]identity.Signature, 0, len(event.Signatures)+1) - for _, existing := range event.Signatures { - if existing.KeyID != sig.KeyID { - remaining = append(remaining, existing) - } - } - event.Signatures = append(remaining, sig) - sort.Slice(event.Signatures, func(left, right int) bool { return event.Signatures[left].KeyID < event.Signatures[right].KeyID }) - if err := event.Validate(); err != nil { - return Event{}, err - } - return event, nil -} - -// Verify checks threshold distinct signatures against explicitly trusted keys. -func Verify(event Event, trusted []identity.Public, threshold int, now time.Time) (Verification, error) { - if err := event.Validate(); err != nil { - return Verification{}, err - } - if threshold < 1 || threshold > len(trusted) { - return Verification{}, errors.New("signature threshold is invalid") - } - issued, _ := envelope.ParseTimestamp(event.IssuedAt) - expires, _ := envelope.ParseTimestamp(event.ExpiresAt) - if !now.IsZero() && (now.UTC().Before(issued) || now.UTC().After(expires)) { - return Verification{}, errors.New("event config is outside its validity window") - } - trustedByID := map[string]identity.Public{} - for _, key := range trusted { - if err := key.Validate(); err != nil { - return Verification{}, err - } - trustedByID[key.KeyID] = key - } - valid := make([]string, 0, len(event.Signatures)) - seen := map[string]struct{}{} - for _, sig := range event.Signatures { - key, ok := trustedByID[sig.KeyID] - if !ok { - continue - } - if _, dup := seen[sig.KeyID]; dup { - continue - } - if envelope.Verify(SigningDomain, unsigned(event), sig, key) == nil { - seen[sig.KeyID] = struct{}{} - valid = append(valid, sig.KeyID) - } - } - if len(valid) < threshold { - return Verification{}, fmt.Errorf("got %d valid trusted signatures, require %d", len(valid), threshold) - } - sort.Strings(valid) - digest, err := Digest(event) - if err != nil { - return Verification{}, err - } - return Verification{Digest: digest, ValidKeyIDs: valid, Required: threshold}, nil -} - -func MarshalYAML(event Event) ([]byte, error) { - if err := event.Validate(); err != nil { - return nil, err - } - return yaml.Marshal(event) -} - -func unsigned(e Event) unsignedEvent { - return unsignedEvent{e.Kind, e.Protocol, e.ProtocolVersion, e.EventID, e.EventEpoch, e.BaseRepository, e.ConfigEpoch, e.DelegationEpoch, e.PreviousConfigDigest, e.DelegationDigest, e.IssuedAt, e.ExpiresAt, e.InitialState, e.Registration, e.Teams, e.Submissions, e.Scoring, e.State, e.Receipts} -} - -func inspectYAML(node *yaml.Node, depth int) error { - if depth > maxYAMLDepth { - return errors.New("event YAML nesting exceeds limit") - } - if node.Kind == yaml.AliasNode { - return errors.New("YAML aliases are not allowed") - } - if node.Kind == yaml.MappingNode { - seen := map[string]struct{}{} - for i := 0; i < len(node.Content); i += 2 { - key := node.Content[i] - if key.Kind != yaml.ScalarNode { - return errors.New("YAML mapping keys must be scalars") - } - if key.Value == "<<" { - return errors.New("YAML merge keys are not allowed") - } - if _, ok := seen[key.Value]; ok { - return fmt.Errorf("duplicate YAML key %q", key.Value) - } - seen[key.Value] = struct{}{} - } - } - for _, child := range node.Content { - if err := inspectYAML(child, depth+1); err != nil { - return err - } - } - return nil -} - -func validateInitialState(v InitialState) error { - if v.Phase != "draft" || v.Enabled || v.DisabledReason != "template_not_bootstrapped" { - return errors.New("initial_state must be draft, disabled, and template_not_bootstrapped") - } - return nil -} -func validateRegistration(v Registration) error { - if v.MaximumParticipants < 1 || v.MaximumParticipants > identity.MaxRegistryEntries || v.RequestTTLSeconds < 60 || v.RequestTTLSeconds > 86400 || !envelope.IsDigest(v.TermsDigest) || v.KeyAlgorithm != identity.Algorithm || v.KeyRotationPolicy != "unsupported" { - return errors.New("registration configuration is invalid") - } - return nil -} -func validateTeams(v Teams) error { - if v.MinimumSize < 1 || v.MaximumSize < 1 || v.MinimumSize > v.MaximumSize || v.MaximumSize > 64 || v.MaximumProposalsPerParticipant < 1 || v.MaximumProposalsPerParticipant > MaxTeamProposalsPerParticipantV1 || v.ProposalTTLSeconds < team.MinProposalTTLSeconds || v.ProposalTTLSeconds > team.MaxProposalTTLSeconds || v.MembershipLockPhase != "submissions_open" { - return errors.New("team configuration is invalid") - } - return nil -} -func validateSubmissions(v Submissions) error { - if envelope.ValidateRef(v.BaseRef) != nil || v.MaximumAttemptsPerTeam < 1 || v.MaximumAttemptsPerTeam > MaxTotalSubmissionAttemptsV1 || v.MaximumTotalAttempts < 1 || v.MaximumTotalAttempts > MaxTotalSubmissionAttemptsV1 || v.MaximumAttemptsPerTeam > v.MaximumTotalAttempts || v.MaximumCiphertextBytes < 1 || v.MaximumCiphertextBytes > maxSubmissionCiphertextBytes || v.MaximumPlaintextBytes < 1 || v.MaximumPlaintextBytes > maxSubmissionPlaintextBytes || v.MaximumCiphertextBytes < v.MaximumPlaintextBytes+4*1024*1024 || v.MaximumFileBytes < 1 || v.MaximumFileBytes > maxSubmissionFileBytes || v.MaximumFileBytes > v.MaximumPlaintextBytes || v.MaximumPlaintextFiles < 1 || v.MaximumPlaintextFiles > envelope.MaxSubmissionFilesV1 || v.EnvelopeTTLSeconds < 60 || v.EnvelopeTTLSeconds > 86400 || v.DeliveryMode != envelope.SubmissionDeliveryMode || !v.FailedConsumeQuota { - return errors.New("submission configuration is invalid") - } - if len(v.AllowedExtensions) < 1 || len(v.AllowedExtensions) > 64 { - return errors.New("allowed_extensions count is invalid") - } - seen := map[string]struct{}{} - for index, ext := range v.AllowedExtensions { - if !extensionPattern.MatchString(ext) { - return errors.New("allowed extension is invalid") - } - if _, ok := seen[ext]; ok { - return errors.New("duplicate allowed extension") - } - seen[ext] = struct{}{} - if index > 0 && v.AllowedExtensions[index-1] >= ext { - return errors.New("allowed_extensions must be strictly sorted") - } - } - if v.Encryption.Algorithm != "age-hybrid-mlkem768-x25519" || identity.ValidateDecimal(v.Encryption.RecipientEpoch, "recipient_epoch") != nil || len(v.Encryption.Recipients) < 1 || len(v.Encryption.Recipients) > 16 { - return errors.New("encryption configuration is invalid") - } - recipientSeen := map[string]struct{}{} - publicKeySeen := map[string]struct{}{} - for index, r := range v.Encryption.Recipients { - parsedRecipient, parseErr := age.ParseHybridRecipient(r.PublicKey) - if !recipientIDPattern.MatchString(r.RecipientID) || parseErr != nil || parsedRecipient.String() != r.PublicKey { - return errors.New("encryption recipient is invalid") - } - if _, ok := recipientSeen[r.RecipientID]; ok { - return errors.New("duplicate recipient_id") - } - recipientSeen[r.RecipientID] = struct{}{} - if _, ok := publicKeySeen[r.PublicKey]; ok { - return errors.New("duplicate recipient public_key") - } - publicKeySeen[r.PublicKey] = struct{}{} - if index > 0 && v.Encryption.Recipients[index-1].RecipientID >= r.RecipientID { - return errors.New("recipients must be strictly sorted by recipient_id") - } - } - return nil -} - -// DeriveCapacity calculates the v1 protected-state capacity bound with -// checked uint64 arithmetic. Callers may use the result only after the three -// policy sections have passed their ordinary field validation. -func DeriveCapacity(registration Registration, teams Teams, submissions Submissions) (DerivedCapacity, error) { - np, err := checkedMultiply(registration.MaximumParticipants, teams.MaximumProposalsPerParticipant) - if err != nil { - return DerivedCapacity{}, err - } - npm, err := checkedMultiply(np, teams.MaximumSize) - if err != nil { - return DerivedCapacity{}, err - } - twiceAttempts, err := checkedMultiply(2, submissions.MaximumTotalAttempts) - if err != nil { - return DerivedCapacity{}, err - } - business, err := checkedSum(registration.MaximumParticipants, np, npm, twiceAttempts) - if err != nil { - return DerivedCapacity{}, err - } - if business > MaxDerivedBusinessRecordsV1 { - return DerivedCapacity{}, fmt.Errorf("derived business record capacity %d exceeds v1 limit %d", business, MaxDerivedBusinessRecordsV1) - } - twiceBusiness, err := checkedMultiply(2, business) - if err != nil { - return DerivedCapacity{}, err - } - stateRecords, err := checkedSum(1, twiceBusiness, 6, 32) - if err != nil { - return DerivedCapacity{}, err - } - if stateRecords > MaxDerivedStateRecordsV1 { - return DerivedCapacity{}, fmt.Errorf("derived state record capacity %d exceeds v1 limit %d", stateRecords, MaxDerivedStateRecordsV1) - } - return DerivedCapacity{BusinessRecords: business, StateRecords: stateRecords}, nil -} - -func checkedMultiply(left, right uint64) (uint64, error) { - if left != 0 && right > math.MaxUint64/left { - return 0, errors.New("derived capacity arithmetic overflow") - } - return left * right, nil -} - -func checkedSum(values ...uint64) (uint64, error) { - var total uint64 - for _, value := range values { - if value > math.MaxUint64-total { - return 0, errors.New("derived capacity arithmetic overflow") - } - total += value - } - return total, nil -} -func validateScoring(v Scoring) error { - if v.Mode != "public_data" && v.Mode != "external_judge" { - return errors.New("scoring mode is invalid") - } - if !idPattern.MatchString(v.ScorerID) || len(v.ScorerVersion) > 64 || !semverPattern.MatchString(v.ScorerVersion) || !envelope.IsDigest(v.PolicyDigest) || v.MaximumResultBytes < scorer.MinResultBytes || v.MaximumResultBytes > scorer.MaxResultBytes { - return errors.New("scoring configuration is invalid") - } - if err := v.ResultKey.Validate(); err != nil { - return fmt.Errorf("scoring result key: %w", err) - } - if v.Mode == "external_judge" { - if v.ExternalJudgeURL == nil { - return errors.New("external judge URL is required") - } - if len(*v.ExternalJudgeURL) > MaxExternalJudgeURLBytes { - return fmt.Errorf("external judge URL exceeds %d-byte limit", MaxExternalJudgeURLBytes) - } - if !rfc3986ASCIIPattern.MatchString(*v.ExternalJudgeURL) { - return errors.New("external judge URL must contain only printable ASCII RFC3986 characters; Unicode must be percent-encoded") - } - parsed, err := url.Parse(*v.ExternalJudgeURL) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawFragment != "" || parsed.RawQuery != "" || parsed.ForceQuery { - return errors.New("external judge URL must be an absolute HTTPS endpoint without credentials, query, or fragment") - } - } else if v.ExternalJudgeURL != nil { - return errors.New("public_data scoring requires null external_judge_url") - } - return nil -} -func validateProtocolSignature(v identity.Signature) error { - if v.Algorithm != identity.Algorithm || !envelope.IsDigest(v.KeyID) || !base64SignaturePattern.MatchString(v.Value) { - return errors.New("protocol signature is malformed") - } - decoded, err := base64.RawURLEncoding.Strict().DecodeString(v.Value) - if err != nil || len(decoded) != 64 { - return errors.New("protocol signature encoding is invalid") - } - return nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index b2cadbc..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,443 +0,0 @@ -package config - -import ( - "bytes" - "strings" - "testing" - "time" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/scorer" - "github.com/pythonhk/eventctl/internal/team" -) - -func configPair(t *testing.T) identity.KeyPair { - t.Helper() - pair, err := identity.GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{8}, 64))) - if err != nil { - t.Fatal(err) - } - return pair -} - -func validEvent(t *testing.T) Event { - t.Helper() - pair := configPair(t) - receiptPair, err := identity.GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 64))) - if err != nil { - t.Fatal(err) - } - hybridIdentity, err := age.GenerateHybridIdentity() - if err != nil { - t.Fatal(err) - } - externalURL := "https://judge.example.invalid/v1/score" - return Event{ - Kind: Kind, Protocol: envelope.Protocol, ProtocolVersion: 1, - EventID: "example-event-2026", EventEpoch: "1", - BaseRepository: envelope.Repository{ID: "123", Owner: "pythonhk", Name: "example-event-2026"}, - ConfigEpoch: 1, DelegationEpoch: 1, DelegationDigest: strings.Repeat("1", 64), - IssuedAt: "2026-08-04T00:00:00Z", ExpiresAt: "2027-08-04T00:00:00Z", - InitialState: InitialState{Phase: "draft", Enabled: false, DisabledReason: "template_not_bootstrapped"}, - Registration: Registration{MaximumParticipants: 200, RequestTTLSeconds: 1800, TermsDigest: strings.Repeat("2", 64), KeyAlgorithm: "Ed25519", KeyRotationPolicy: "unsupported"}, - Teams: Teams{MinimumSize: 2, MaximumSize: 5, MaximumProposalsPerParticipant: 1, ProposalTTLSeconds: 604800, MembershipLockPhase: "submissions_open"}, - Submissions: Submissions{ - BaseRef: "main", - MaximumAttemptsPerTeam: 10, MaximumTotalAttempts: 100, - MaximumCiphertextBytes: 47_000_000, - MaximumPlaintextBytes: 42_000_000, MaximumFileBytes: 20_000_000, - MaximumPlaintextFiles: 4096, EnvelopeTTLSeconds: 1800, - DeliveryMode: envelope.SubmissionDeliveryMode, FailedConsumeQuota: true, - AllowedExtensions: []string{".csv"}, - Encryption: Encryption{Algorithm: "age-hybrid-mlkem768-x25519", RecipientEpoch: "1", Recipients: []Recipient{{RecipientID: "primary_judge", PublicKey: hybridIdentity.Recipient().String()}}}, - }, - Scoring: Scoring{Mode: "external_judge", ScorerID: "example_scorer", ScorerVersion: "v1.0.0", PolicyDigest: strings.Repeat("3", 64), MaximumResultBytes: 65536, ResultKey: pair.Public, ExternalJudgeURL: &externalURL}, - State: State{Branch: "event-state", Public: true, WriterAppSlug: "pythonhk-event-state-writer", WriterConcurrencyGroup: "event-state-writer", JournalFormat: "hash-linked-json-v1"}, - Receipts: Receipts{SigningKey: receiptPair.Public}, - Signatures: []identity.Signature{{Algorithm: "Ed25519", KeyID: pair.Public.KeyID, Value: strings.Repeat("A", 86)}}, - } -} - -func TestDerivedCapacityExactBoundaryAndOverflow(t *testing.T) { - t.Parallel() - event := validEvent(t) - event.Registration.MaximumParticipants = 10 - event.Teams.MinimumSize = 1 - event.Teams.MaximumProposalsPerParticipant = 1 - event.Teams.MaximumSize = 1 - event.Submissions.MaximumTotalAttempts = 999 - - capacity, err := DeriveCapacity(event.Registration, event.Teams, event.Submissions) - if err != nil { - t.Fatalf("exact v1 capacity boundary rejected: %v", err) - } - if capacity.BusinessRecords != MaxDerivedBusinessRecordsV1 || capacity.StateRecords != 4_095 { - t.Fatalf("derived capacity = %#v, want business=%d state=4095", capacity, MaxDerivedBusinessRecordsV1) - } - if err := event.Validate(); err != nil { - t.Fatalf("exact v1 capacity config rejected: %v", err) - } - - overLimit := event - overLimit.Registration.MaximumParticipants = 11 - overLimit.Submissions.MaximumTotalAttempts = 998 - if _, err := DeriveCapacity(overLimit.Registration, overLimit.Teams, overLimit.Submissions); err == nil || !strings.Contains(err.Error(), "2029") { - t.Fatalf("one-over derived capacity did not fail at 2029 records: %v", err) - } - if err := overLimit.Validate(); err == nil { - t.Fatal("validated a config above the v1 derived capacity boundary") - } - - if _, err := DeriveCapacity( - Registration{MaximumParticipants: ^uint64(0)}, - Teams{MaximumProposalsPerParticipant: 2, MaximumSize: 2}, - Submissions{MaximumTotalAttempts: 1}, - ); err == nil { - t.Fatal("derived capacity arithmetic wrapped on multiplication") - } - if _, err := checkedSum(^uint64(0), 1); err == nil { - t.Fatal("derived capacity arithmetic wrapped on addition") - } -} - -func TestTeamSizesCannotExceedParticipantCapacity(t *testing.T) { - t.Parallel() - for name, mutate := range map[string]func(*Event){ - "minimum": func(event *Event) { - event.Registration.MaximumParticipants = 1 - event.Teams.MinimumSize = 2 - }, - "maximum": func(event *Event) { - event.Registration.MaximumParticipants = 4 - event.Teams.MaximumSize = 5 - }, - } { - t.Run(name, func(t *testing.T) { - event := validEvent(t) - mutate(&event) - if err := event.Validate(); err == nil { - t.Fatalf("accepted team %s_size above maximum_participants", name) - } - }) - } -} - -func TestProposalAndSubmissionCapacityFieldBounds(t *testing.T) { - t.Parallel() - teamPolicy := validEvent(t).Teams - for _, value := range []uint64{1, MaxTeamProposalsPerParticipantV1} { - teamPolicy.MaximumProposalsPerParticipant = value - if err := validateTeams(teamPolicy); err != nil { - t.Fatalf("maximum_proposals_per_participant=%d rejected: %v", value, err) - } - } - for _, value := range []uint64{0, MaxTeamProposalsPerParticipantV1 + 1} { - teamPolicy.MaximumProposalsPerParticipant = value - if err := validateTeams(teamPolicy); err == nil { - t.Fatalf("maximum_proposals_per_participant=%d accepted", value) - } - } - - submissionPolicy := validEvent(t).Submissions - for _, value := range []uint64{submissionPolicy.MaximumAttemptsPerTeam, MaxTotalSubmissionAttemptsV1} { - submissionPolicy.MaximumTotalAttempts = value - if err := validateSubmissions(submissionPolicy); err != nil { - t.Fatalf("maximum_total_attempts=%d rejected: %v", value, err) - } - } - for _, value := range []uint64{0, MaxTotalSubmissionAttemptsV1 + 1} { - submissionPolicy.MaximumTotalAttempts = value - if err := validateSubmissions(submissionPolicy); err == nil { - t.Fatalf("maximum_total_attempts=%d accepted", value) - } - } - submissionPolicy.MaximumTotalAttempts = submissionPolicy.MaximumAttemptsPerTeam - 1 - if err := validateSubmissions(submissionPolicy); err == nil { - t.Fatal("accepted maximum_attempts_per_team above maximum_total_attempts") - } -} - -func TestTeamProposalTTLBounds(t *testing.T) { - t.Parallel() - teamPolicy := validEvent(t).Teams - for _, value := range []uint64{team.MinProposalTTLSeconds, 604_800, team.MaxProposalTTLSeconds} { - teamPolicy.ProposalTTLSeconds = value - if err := validateTeams(teamPolicy); err != nil { - t.Fatalf("proposal_ttl_seconds=%d rejected: %v", value, err) - } - } - for _, value := range []uint64{team.MinProposalTTLSeconds - 1, team.MaxProposalTTLSeconds + 1} { - teamPolicy.ProposalTTLSeconds = value - if err := validateTeams(teamPolicy); err == nil { - t.Fatalf("proposal_ttl_seconds=%d accepted", value) - } - } -} - -func TestSubmissionGitTransportLimits(t *testing.T) { - t.Parallel() - valid := validEvent(t).Submissions - valid.MaximumCiphertextBytes = 47_000_000 - valid.MaximumPlaintextBytes = 42_000_000 - valid.MaximumFileBytes = 42_000_000 - if err := validateSubmissions(valid); err != nil { - t.Fatalf("boundary-valid submission limits: %v", err) - } - for name, mutate := range map[string]func(*Submissions){ - "ciphertext": func(value *Submissions) { value.MaximumCiphertextBytes = 47_000_001 }, - "plaintext": func(value *Submissions) { value.MaximumPlaintextBytes = 42_000_001 }, - "file": func(value *Submissions) { value.MaximumFileBytes = 42_000_001 }, - "file count": func(value *Submissions) { value.MaximumPlaintextFiles = 4_097 }, - "overhead": func(value *Submissions) { - value.MaximumCiphertextBytes = 46_194_303 - value.MaximumPlaintextBytes = 42_000_000 - value.MaximumFileBytes = 1 - }, - } { - t.Run(name, func(t *testing.T) { - candidate := valid - mutate(&candidate) - if err := validateSubmissions(candidate); err == nil { - t.Fatal("accepted limits that can exceed the Git transport budget") - } - }) - } -} - -func TestRegistrationParticipantCapacity(t *testing.T) { - t.Parallel() - valid := validEvent(t).Registration - valid.MaximumParticipants = identity.MaxRegistryEntries - if err := validateRegistration(valid); err != nil { - t.Fatalf("1,000-participant v1 boundary rejected: %v", err) - } - valid.MaximumParticipants++ - if err := validateRegistration(valid); err == nil { - t.Fatal("1,001-participant v1 config accepted") - } -} - -func TestScoringTransportLimits(t *testing.T) { - t.Parallel() - valid := validEvent(t).Scoring - valid.MaximumResultBytes = scorer.MaxResultBytes - valid.ScorerVersion = "v" + strings.Repeat("1", 59) + ".0.0" - if got := len(valid.ScorerVersion); got != 64 { - t.Fatalf("boundary scorer version length = %d, want 64", got) - } - if err := validateScoring(valid); err != nil { - t.Fatalf("boundary-valid scoring limits: %v", err) - } - - tooLargeResult := valid - tooLargeResult.MaximumResultBytes = scorer.MaxResultBytes + 1 - if err := validateScoring(tooLargeResult); err == nil { - t.Fatalf("accepted maximum_result_bytes %d", tooLargeResult.MaximumResultBytes) - } - - tooLongVersion := valid - tooLongVersion.ScorerVersion = "v" + strings.Repeat("1", 60) + ".0.0" - if got := len(tooLongVersion.ScorerVersion); got != 65 { - t.Fatalf("over-limit scorer version length = %d, want 65", got) - } - if err := validateScoring(tooLongVersion); err == nil { - t.Fatal("accepted 65-byte scorer_version") - } -} - -func TestConfigPublicKeysMustBindKeyIDToPublicKey(t *testing.T) { - t.Parallel() - for name, mutate := range map[string]func(*Event){ - "scoring result": func(event *Event) { event.Scoring.ResultKey.KeyID = strings.Repeat("f", 64) }, - "receipt signing": func(event *Event) { event.Receipts.SigningKey.KeyID = strings.Repeat("e", 64) }, - } { - t.Run(name, func(t *testing.T) { - event := validEvent(t) - mutate(&event) - if err := event.Validate(); err == nil { - t.Fatalf("accepted %s key_id that does not derive from public_key", name) - } - }) - } -} - -func TestV1RejectsConfigAndEventEpochChanges(t *testing.T) { - t.Parallel() - for name, mutate := range map[string]func(*Event){ - "event epoch": func(event *Event) { event.EventEpoch = "2" }, - "config epoch": func(event *Event) { event.ConfigEpoch = 2 }, - "previous digest": func(event *Event) { - digest := strings.Repeat("f", 64) - event.PreviousConfigDigest = &digest - }, - } { - t.Run(name, func(t *testing.T) { - candidate := validEvent(t) - mutate(&candidate) - if err := candidate.Validate(); err == nil { - t.Fatal("accepted a v1 config authority rotation field") - } - }) - } -} - -func TestExternalJudgeURLRejectsAmbiguousOrSecretBearingForms(t *testing.T) { - t.Parallel() - for name, value := range map[string]string{ - "credentials": "https://user:secret@judge.example.invalid/v1/score", - "fragment": "https://judge.example.invalid/v1/score#token", - "query": "https://judge.example.invalid/v1/score?token=secret", - "opaque": "https:judge.example.invalid/v1/score", - "empty host": "https:///v1/score", - } { - t.Run(name, func(t *testing.T) { - event := validEvent(t) - event.Scoring.ExternalJudgeURL = &value - if err := event.Validate(); err == nil { - t.Fatalf("accepted unsafe external_judge_url form %q", value) - } - }) - } -} - -func TestExternalJudgeURLLengthBoundary(t *testing.T) { - t.Parallel() - const prefix = "https://judge.example.invalid/" - boundary := prefix + strings.Repeat("a", MaxExternalJudgeURLBytes-len(prefix)) - if got := len(boundary); got != MaxExternalJudgeURLBytes { - t.Fatalf("boundary URL length = %d, want %d", got, MaxExternalJudgeURLBytes) - } - event := validEvent(t) - event.Scoring.ExternalJudgeURL = &boundary - if err := event.Validate(); err != nil { - t.Fatalf("%d-byte external_judge_url rejected: %v", MaxExternalJudgeURLBytes, err) - } - - overLimit := boundary + "a" - event.Scoring.ExternalJudgeURL = &overLimit - if err := event.Validate(); err == nil { - t.Fatalf("%d-byte external_judge_url accepted", MaxExternalJudgeURLBytes+1) - } -} - -func TestExternalJudgeURLRequiresPrintableASCIIRFC3986(t *testing.T) { - t.Parallel() - for name, value := range map[string]string{ - "unicode path": "https://judge.example.invalid/cafĆ©", - "unicode host": "https://jüge.example.invalid/score", - "space": "https://judge.example.invalid/not canonical", - "non RFC3986 braces": "https://judge.example.invalid/{score}", - } { - t.Run(name, func(t *testing.T) { - event := validEvent(t) - event.Scoring.ExternalJudgeURL = &value - if err := event.Validate(); err == nil { - t.Fatalf("accepted non-canonical external_judge_url %q", value) - } - }) - } - - encodedUnicode := "https://judge.example.invalid/caf%C3%A9" - event := validEvent(t) - event.Scoring.ExternalJudgeURL = &encodedUnicode - if err := event.Validate(); err != nil { - t.Fatalf("rejected RFC3986 percent-encoded Unicode URL: %v", err) - } -} - -func TestYAMLStrictSignVerifyAndDigest(t *testing.T) { - t.Parallel() - event := validEvent(t) - pair := configPair(t) - signed, err := Sign(event, pair.Private) - if err != nil { - t.Fatal(err) - } - raw, err := MarshalYAML(signed) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte("key_id:")) || !bytes.Contains(raw, []byte("public_key:")) || bytes.Contains(raw, []byte("keyid:")) || bytes.Contains(raw, []byte("publickey:")) { - t.Fatalf("protocol public keys/signatures used non-schema YAML field names:\n%s", raw) - } - parsed, err := Parse(raw) - if err != nil { - t.Fatal(err) - } - verification, err := Verify(parsed, []identity.Public{pair.Public}, 1, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) - if err != nil { - t.Fatal(err) - } - if !envelope.IsDigest(verification.Digest) || len(verification.ValidKeyIDs) != 1 { - t.Fatalf("verification %#v", verification) - } -} - -func TestYAMLRejectsUnknownDuplicateAliasAndMultipleDocs(t *testing.T) { - t.Parallel() - raw, err := MarshalYAML(validEvent(t)) - if err != nil { - t.Fatal(err) - } - cases := [][]byte{ - append(append([]byte(nil), raw...), []byte("unknown: true\n")...), - []byte("kind: event_config\nkind: again\n"), - []byte("kind: &x event_config\nprotocol: *x\n"), - append(append([]byte(nil), raw...), []byte("---\nkind: second\n")...), - } - for _, input := range cases { - if _, err := Parse(input); err == nil { - t.Fatalf("Parse accepted invalid YAML: %s", input) - } - } -} - -func TestYAMLRequiresClosedCapacityFields(t *testing.T) { - t.Parallel() - raw, err := MarshalYAML(validEvent(t)) - if err != nil { - t.Fatal(err) - } - for _, field := range []string{"maximum_proposals_per_participant", "maximum_total_attempts"} { - t.Run("missing "+field, func(t *testing.T) { - without := removeYAMLFieldLine(t, raw, field) - if _, err := Parse(without); err == nil { - t.Fatalf("Parse accepted YAML missing required %s", field) - } - }) - t.Run("unknown "+field, func(t *testing.T) { - unknown := bytes.Replace(raw, []byte(field+":"), []byte(field+"_typo:"), 1) - if bytes.Equal(unknown, raw) { - t.Fatalf("fixture did not contain %s", field) - } - if _, err := Parse(unknown); err == nil { - t.Fatalf("Parse accepted unknown field derived from %s", field) - } - }) - } -} - -func removeYAMLFieldLine(t *testing.T, raw []byte, field string) []byte { - t.Helper() - lines := bytes.Split(raw, []byte{'\n'}) - for index, line := range lines { - if strings.HasPrefix(strings.TrimSpace(string(line)), field+":") { - return bytes.Join(append(lines[:index:index], lines[index+1:]...), []byte{'\n'}) - } - } - t.Fatalf("fixture did not contain %s", field) - return nil -} - -func FuzzParse(f *testing.F) { - f.Add([]byte("kind: event_config\n")) - f.Add([]byte("a: &x [1]\nb: *x\n")) - f.Fuzz(func(t *testing.T, raw []byte) { - if len(raw) > MaxBytes+1 { - return - } - _, _ = Parse(raw) - }) -} diff --git a/internal/config/delegation.go b/internal/config/delegation.go deleted file mode 100644 index c496f14..0000000 --- a/internal/config/delegation.go +++ /dev/null @@ -1,255 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "sort" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -const ( - DelegationKind = "config_delegation" - DelegationSigningDomain = "config_delegation" -) - -type DelegatedKey struct { - Algorithm string `json:"algorithm"` - KeyID string `json:"key_id"` - PublicKey string `json:"public_key"` - Role string `json:"role"` -} - -func (key DelegatedKey) Public() identity.Public { - return identity.Public{Algorithm: key.Algorithm, KeyID: key.KeyID, PublicKey: key.PublicKey} -} - -type Delegation struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepository envelope.Repository `json:"base_repository"` - DelegationEpoch uint64 `json:"delegation_epoch"` - PreviousDelegationDigest *string `json:"previous_delegation_digest"` - ValidFrom string `json:"valid_from"` - ExpiresAt string `json:"expires_at"` - Threshold int `json:"threshold"` - Keys []DelegatedKey `json:"keys"` - Signatures []identity.Signature `json:"signatures"` -} - -type unsignedDelegation struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepository envelope.Repository `json:"base_repository"` - DelegationEpoch uint64 `json:"delegation_epoch"` - PreviousDelegationDigest *string `json:"previous_delegation_digest"` - ValidFrom string `json:"valid_from"` - ExpiresAt string `json:"expires_at"` - Threshold int `json:"threshold"` - Keys []DelegatedKey `json:"keys"` -} - -type DelegationVerification struct { - Digest string - Authority Authority - RootKeyIDs []string - Delegation Delegation -} - -func ParseDelegation(raw []byte) (Delegation, error) { - return parseDelegation(raw, true) -} - -func ParseUnsignedDelegation(raw []byte) (Delegation, error) { - return parseDelegation(raw, false) -} - -func parseDelegation(raw []byte, requireSignatures bool) (Delegation, error) { - if len(raw) > MaxBytes { - return Delegation{}, errors.New("config delegation exceeds 1 MiB") - } - var delegation Delegation - if err := canonical.StrictUnmarshal(raw, &delegation); err != nil { - return Delegation{}, fmt.Errorf("decode config delegation: %w", err) - } - if err := delegation.validate(requireSignatures); err != nil { - return Delegation{}, err - } - return delegation, nil -} - -func (delegation Delegation) validate(requireSignatures bool) error { - if delegation.Kind != DelegationKind || delegation.Protocol != envelope.Protocol || delegation.ProtocolVersion != envelope.ProtocolVersion || !envelope.IsEventID(delegation.EventID) || delegation.EventEpoch != "1" || delegation.DelegationEpoch != 1 || delegation.PreviousDelegationDigest != nil { - return errors.New("config delegation v1 discriminator/epoch is invalid") - } - if err := envelope.ValidateRepository(delegation.BaseRepository); err != nil { - return err - } - validFrom, err := envelope.ParseTimestamp(delegation.ValidFrom) - if err != nil { - return err - } - expires, err := envelope.ParseTimestamp(delegation.ExpiresAt) - if err != nil || !expires.After(validFrom) { - return errors.New("config delegation validity window is invalid") - } - if delegation.Threshold < 1 || delegation.Threshold > 16 || len(delegation.Keys) < delegation.Threshold || len(delegation.Keys) > 16 { - return errors.New("config delegation threshold/key count is invalid") - } - for index, key := range delegation.Keys { - if key.Role != "event_config_signer" { - return errors.New("delegated key role is invalid") - } - if err := key.Public().Validate(); err != nil { - return fmt.Errorf("delegated key %d: %w", index, err) - } - if index > 0 && delegation.Keys[index-1].KeyID >= key.KeyID { - return errors.New("delegated keys must be strictly sorted by key_id") - } - } - if (requireSignatures && len(delegation.Signatures) < 1) || len(delegation.Signatures) > 16 { - return errors.New("config delegation must contain 1 to 16 root signatures") - } - for index, signature := range delegation.Signatures { - if signature.Algorithm != identity.Algorithm || !identity.IsDigest(signature.KeyID) { - return errors.New("config delegation signature is invalid") - } - if index > 0 && delegation.Signatures[index-1].KeyID >= signature.KeyID { - return errors.New("config delegation signatures must be strictly sorted by key_id") - } - } - return nil -} - -func delegationUnsigned(value Delegation) unsignedDelegation { - return unsignedDelegation{ - value.Kind, value.Protocol, value.ProtocolVersion, value.EventID, value.EventEpoch, - value.BaseRepository, value.DelegationEpoch, value.PreviousDelegationDigest, - value.ValidFrom, value.ExpiresAt, value.Threshold, value.Keys, - } -} - -// SignDelegation appends or replaces one explicit organizer-root signature. -func SignDelegation(delegation Delegation, root identity.Private) (Delegation, error) { - if err := delegation.validate(false); err != nil { - return Delegation{}, err - } - signature, err := envelope.Sign(DelegationSigningDomain, delegationUnsigned(delegation), root) - if err != nil { - return Delegation{}, err - } - remaining := make([]identity.Signature, 0, len(delegation.Signatures)+1) - for _, existing := range delegation.Signatures { - if existing.KeyID != signature.KeyID { - remaining = append(remaining, existing) - } - } - delegation.Signatures = append(remaining, signature) - sort.Slice(delegation.Signatures, func(left, right int) bool { - return delegation.Signatures[left].KeyID < delegation.Signatures[right].KeyID - }) - if err := delegation.validate(true); err != nil { - return Delegation{}, err - } - return delegation, nil -} - -// VerifyDelegationRootSignature verifies one named root signature without -// claiming that a multi-root delegation's complete trust set was supplied. -// It is used while independently assembling root signatures. -func VerifyDelegationRootSignature(delegation Delegation, root identity.Public) error { - if err := delegation.validate(true); err != nil { - return err - } - if err := root.Validate(); err != nil { - return err - } - for _, signature := range delegation.Signatures { - if signature.KeyID == root.KeyID { - return envelope.Verify(DelegationSigningDomain, delegationUnsigned(delegation), signature, root) - } - } - return errors.New("delegation has no signature from the supplied organizer root") -} - -// VerifyDelegation requires the exact supplied organizer-root set to have -// signed the delegation, then derives the only config authority callers may -// pin in protected genesis. -func VerifyDelegation(delegation Delegation, roots []identity.Public, expectedEventID, expectedRepositoryID string, now time.Time) (DelegationVerification, error) { - if err := delegation.validate(true); err != nil { - return DelegationVerification{}, err - } - if len(roots) < 1 || len(roots) > 16 { - return DelegationVerification{}, errors.New("explicit organizer root set must contain 1 to 16 keys") - } - if expectedEventID == "" || expectedRepositoryID == "" || delegation.EventID != expectedEventID || delegation.BaseRepository.ID != expectedRepositoryID { - return DelegationVerification{}, errors.New("config delegation does not match expected event/repository") - } - validFrom, _ := envelope.ParseTimestamp(delegation.ValidFrom) - expires, _ := envelope.ParseTimestamp(delegation.ExpiresAt) - if !now.IsZero() && (now.UTC().Before(validFrom) || now.UTC().After(expires)) { - return DelegationVerification{}, errors.New("config delegation is outside its validity window") - } - rootByID := make(map[string]identity.Public, len(roots)) - for _, root := range roots { - if err := root.Validate(); err != nil { - return DelegationVerification{}, fmt.Errorf("organizer root: %w", err) - } - if _, duplicate := rootByID[root.KeyID]; duplicate { - return DelegationVerification{}, errors.New("duplicate organizer root key") - } - rootByID[root.KeyID] = root - } - verifiedRoots := make(map[string]struct{}, len(delegation.Signatures)) - for _, signature := range delegation.Signatures { - root, ok := rootByID[signature.KeyID] - if !ok { - return DelegationVerification{}, errors.New("delegation contains a signature outside the explicit organizer root set") - } - if err := envelope.Verify(DelegationSigningDomain, delegationUnsigned(delegation), signature, root); err != nil { - return DelegationVerification{}, err - } - verifiedRoots[signature.KeyID] = struct{}{} - } - if len(verifiedRoots) != len(rootByID) { - return DelegationVerification{}, errors.New("not every explicit organizer root signed the delegation") - } - authority := Authority{Threshold: delegation.Threshold, Keys: make([]identity.Public, len(delegation.Keys))} - for index, key := range delegation.Keys { - authority.Keys[index] = key.Public() - } - digest, err := envelope.DocumentDigest(delegation) - if err != nil { - return DelegationVerification{}, err - } - rootIDs := make([]string, 0, len(verifiedRoots)) - for keyID := range verifiedRoots { - rootIDs = append(rootIDs, keyID) - } - sort.Strings(rootIDs) - return DelegationVerification{Digest: digest, Authority: authority, RootKeyIDs: rootIDs, Delegation: delegation}, nil -} - -func VerifyWithDelegation(event Event, delegation Delegation, roots []identity.Public, expectedEventID, expectedRepositoryID string, now time.Time) (Verification, DelegationVerification, error) { - delegationVerification, err := VerifyDelegation(delegation, roots, expectedEventID, expectedRepositoryID, now) - if err != nil { - return Verification{}, DelegationVerification{}, err - } - if event.EventID != delegation.EventID || event.EventEpoch != delegation.EventEpoch || event.BaseRepository != delegation.BaseRepository || event.DelegationEpoch != delegation.DelegationEpoch || event.DelegationDigest != delegationVerification.Digest { - return Verification{}, DelegationVerification{}, errors.New("event config does not bind the verified config delegation") - } - verification, err := Verify(event, delegationVerification.Authority.Keys, delegationVerification.Authority.Threshold, now) - if err != nil { - return Verification{}, DelegationVerification{}, err - } - return verification, delegationVerification, nil -} diff --git a/internal/config/delegation_test.go b/internal/config/delegation_test.go deleted file mode 100644 index 2da82d8..0000000 --- a/internal/config/delegation_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package config - -import ( - "bytes" - "sort" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -func deterministicPair(t *testing.T, value byte) identity.KeyPair { - t.Helper() - pair, err := identity.GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{value}, 64))) - if err != nil { - t.Fatal(err) - } - return pair -} - -func delegationFixture(t *testing.T, signers ...identity.Public) Delegation { - t.Helper() - keys := make([]DelegatedKey, len(signers)) - for index, signer := range signers { - keys[index] = DelegatedKey{Algorithm: signer.Algorithm, KeyID: signer.KeyID, PublicKey: signer.PublicKey, Role: "event_config_signer"} - } - sort.Slice(keys, func(left, right int) bool { return keys[left].KeyID < keys[right].KeyID }) - return Delegation{ - Kind: DelegationKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: "example-event-2026", EventEpoch: "1", - BaseRepository: envelope.Repository{ID: "123", Owner: "pythonhk", Name: "example-event-2026"}, - DelegationEpoch: 1, PreviousDelegationDigest: nil, - ValidFrom: "2026-08-04T00:00:00Z", ExpiresAt: "2027-08-04T00:00:00Z", - Threshold: len(keys), Keys: keys, - } -} - -func TestRootDelegationVerifiesDistinctConfigSigner(t *testing.T) { - t.Parallel() - root := deterministicPair(t, 21) - signer := deterministicPair(t, 22) - if root.Public.KeyID == signer.Public.KeyID { - t.Fatal("test root and delegated signer unexpectedly match") - } - delegation, err := SignDelegation(delegationFixture(t, signer.Public), root.Private) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - delegationVerification, err := VerifyDelegation(delegation, []identity.Public{root.Public}, delegation.EventID, delegation.BaseRepository.ID, now) - if err != nil { - t.Fatal(err) - } - if delegationVerification.Authority.Threshold != 1 || delegationVerification.Authority.Keys[0] != signer.Public { - t.Fatalf("derived authority = %#v", delegationVerification.Authority) - } - event := validEvent(t) - event.DelegationDigest = delegationVerification.Digest - event.Signatures = nil - event, err = Sign(event, signer.Private) - if err != nil { - t.Fatal(err) - } - if _, _, err := VerifyWithDelegation(event, delegation, []identity.Public{root.Public}, event.EventID, event.BaseRepository.ID, now); err != nil { - t.Fatal(err) - } -} - -func TestTwoRootDelegationAssemblyVerifiesSignedConfig(t *testing.T) { - t.Parallel() - firstRoot := deterministicPair(t, 23) - secondRoot := deterministicPair(t, 24) - signer := deterministicPair(t, 25) - if firstRoot.Public.KeyID == signer.Public.KeyID || secondRoot.Public.KeyID == signer.Public.KeyID { - t.Fatal("test roots and delegated signer unexpectedly match") - } - - delegation, err := SignDelegation(delegationFixture(t, signer.Public), firstRoot.Private) - if err != nil { - t.Fatal(err) - } - if err := VerifyDelegationRootSignature(delegation, firstRoot.Public); err != nil { - t.Fatalf("verify first independently assembled root signature: %v", err) - } - delegation, err = SignDelegation(delegation, secondRoot.Private) - if err != nil { - t.Fatal(err) - } - if err := VerifyDelegationRootSignature(delegation, secondRoot.Public); err != nil { - t.Fatalf("verify second independently assembled root signature: %v", err) - } - - now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - roots := []identity.Public{firstRoot.Public, secondRoot.Public} - delegationVerification, err := VerifyDelegation(delegation, roots, delegation.EventID, delegation.BaseRepository.ID, now) - if err != nil { - t.Fatal(err) - } - if len(delegationVerification.RootKeyIDs) != 2 { - t.Fatalf("verified root count = %d, want 2", len(delegationVerification.RootKeyIDs)) - } - - event := validEvent(t) - event.DelegationDigest = delegationVerification.Digest - event.Signatures = nil - event, err = Sign(event, signer.Private) - if err != nil { - t.Fatal(err) - } - if _, _, err := VerifyWithDelegation(event, delegation, roots, event.EventID, event.BaseRepository.ID, now); err != nil { - t.Fatal(err) - } -} - -func TestDelegationRejectsSubstitutionExpiryAndInsufficientThreshold(t *testing.T) { - t.Parallel() - root := deterministicPair(t, 31) - first := deterministicPair(t, 32) - second := deterministicPair(t, 33) - delegation, err := SignDelegation(delegationFixture(t, first.Public, second.Public), root.Private) - if err != nil { - t.Fatal(err) - } - inside := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - if _, err := VerifyDelegation(delegation, []identity.Public{root.Public}, delegation.EventID, delegation.BaseRepository.ID, time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC)); err == nil { - t.Fatal("accepted expired delegation") - } - substituted := delegation - substituted.Threshold = 1 - if _, err := VerifyDelegation(substituted, []identity.Public{root.Public}, substituted.EventID, substituted.BaseRepository.ID, inside); err == nil { - t.Fatal("accepted threshold substitution under old root signature") - } - verification, err := VerifyDelegation(delegation, []identity.Public{root.Public}, delegation.EventID, delegation.BaseRepository.ID, inside) - if err != nil { - t.Fatal(err) - } - event := validEvent(t) - event.DelegationDigest = verification.Digest - event.Signatures = nil - event, err = Sign(event, first.Private) - if err != nil { - t.Fatal(err) - } - if _, _, err := VerifyWithDelegation(event, delegation, []identity.Public{root.Public}, event.EventID, event.BaseRepository.ID, inside); err == nil { - t.Fatal("accepted config below delegated threshold") - } -} diff --git a/internal/envelope/envelope.go b/internal/envelope/envelope.go deleted file mode 100644 index 6d98911..0000000 --- a/internal/envelope/envelope.go +++ /dev/null @@ -1,521 +0,0 @@ -// Package envelope implements strict, domain-separated signatures and common -// actor-bound protocol types for eventctl v1. -package envelope - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/identity" -) - -const ( - Protocol = "pythonhk.github-native-event" - ProtocolVersion = 1 - SigningPrefix = "pythonhk:github-native-event:v1\x00" - ReplayKeyPrefix = "pythonhk.github-native-event/v1/replay-key\x00" - MaxDocumentBytes = 1 << 20 - MaxSubmissionFilesV1 = 4_096 - MaxGenericValidity = 24 * time.Hour - - RegistrationKind = "registration_request" - RegistrationDomain = "registration_request" -) - -var ( - eventIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,62}$`) - requestKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) - uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) - digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) - gitOIDPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) - ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) - repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`) - refPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) -) - -// Repository is the immutable numeric GitHub repository identity plus its -// human-readable address. -type Repository struct { - ID string `json:"id"` - Owner string `json:"owner"` - Name string `json:"name"` -} - -// Expected contains independently trusted workflow context. Empty fields are -// not compared by the library; machine-facing CLI verification requires all -// operation-relevant fields. -type Expected struct { - EventID string - EventEpoch string - RepositoryID string - ActorID string - ConfigDigest string - KeyEpoch string - KeyID string - Now time.Time -} - -// Fingerprint is the protected-state replay primitive. ReplayKey identifies a -// logical request independently of its signed contents; RequestDigest covers -// the complete domain-separated signing input. -type Fingerprint struct { - ReplayKey string `json:"replay_key"` - RequestDigest string `json:"request_digest"` -} - -type ReplayDisposition string - -const ( - ReplayNew ReplayDisposition = "new" - ReplayDuplicate ReplayDisposition = "duplicate" - ReplayConflict ReplayDisposition = "conflict" -) - -// Registration is the authoritative flat v1 registration wire document. -type Registration struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - BaseRepository Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - TermsDigest string `json:"terms_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - ParticipantKey identity.Public `json:"participant_key"` - Signature identity.Signature `json:"signature"` -} - -type registrationUnsigned struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - BaseRepository Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - TermsDigest string `json:"terms_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - ParticipantKey identity.Public `json:"participant_key"` -} - -// RegistrationParams are trusted local inputs for a registration request. -type RegistrationParams struct { - EventID string - EventEpoch string - OperationID string - ActorID string - KeyEpoch string - BaseRepository Repository - ConfigDigest string - TermsDigest string - IssuedAt time.Time - ExpiresAt time.Time -} - -// VerifiedRegistration is a verified request plus its replay fingerprint. -type VerifiedRegistration struct { - Document Registration - Fingerprint Fingerprint -} - -// ValidateUntrustedStructure validates the concrete registration schema and -// internal field relationships without authenticating its signature, trusted -// actor/config context, or current validity window. -func (value Registration) ValidateUntrustedStructure() error { - if err := validateRegistration(value, time.Time{}, MaxGenericValidity); err != nil { - return err - } - if value.ParticipantKey.KeyID != value.KeyID { - return errors.New("participant_key.key_id does not match key_id") - } - if err := value.Signature.ValidateEncoding(); err != nil { - return err - } - if value.Signature.KeyID != value.KeyID { - return errors.New("signature.key_id does not match key_id") - } - return nil -} - -// NewRequestID returns a lower-case RFC 4122 UUIDv4. -func NewRequestID() (string, error) { - value := make([]byte, 16) - if _, err := rand.Read(value); err != nil { - return "", fmt.Errorf("generate request ID: %w", err) - } - value[6] = (value[6] & 0x0f) | 0x40 - value[8] = (value[8] & 0x3f) | 0x80 - encoded := hex.EncodeToString(value) - return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil -} - -// NewRegistration creates a self-signed epoch-1 registration request. -func NewRegistration(params RegistrationParams, private identity.Private) ([]byte, error) { - pair, err := parsePrivate(private) - if err != nil { - return nil, err - } - if params.OperationID == "" { - params.OperationID, err = NewRequestID() - if err != nil { - return nil, err - } - } - if params.KeyEpoch == "" { - params.KeyEpoch = "1" - } - registration := Registration{ - Kind: RegistrationKind, Protocol: Protocol, ProtocolVersion: ProtocolVersion, - EventID: params.EventID, EventEpoch: params.EventEpoch, OperationID: params.OperationID, ActorID: params.ActorID, - KeyID: pair.Public.KeyID, KeyEpoch: params.KeyEpoch, - BaseRepository: params.BaseRepository, ConfigDigest: params.ConfigDigest, - TermsDigest: params.TermsDigest, IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), - ParticipantKey: pair.Public, - } - if err := validateRegistration(registration, time.Time{}, MaxGenericValidity); err != nil { - return nil, err - } - registration.Signature, err = Sign(RegistrationDomain, registrationUnsignedFrom(registration), pair.Private) - if err != nil { - return nil, err - } - return canonical.Marshal(registration) -} - -// VerifyRegistration verifies strict structure, trusted context, validity -// window, self-signature, and epoch-1 proof of possession. requestTTL must be -// read from the authenticated event config bound by expected.ConfigDigest. -func VerifyRegistration(raw []byte, expected Expected, requestTTL time.Duration) (VerifiedRegistration, error) { - if len(raw) > MaxDocumentBytes { - return VerifiedRegistration{}, errors.New("registration document exceeds 1 MiB") - } - var registration Registration - if err := canonical.StrictUnmarshal(raw, ®istration); err != nil { - return VerifiedRegistration{}, fmt.Errorf("decode registration: %w", err) - } - if err := validateRegistration(registration, expected.Now, requestTTL); err != nil { - return VerifiedRegistration{}, err - } - if err := compareExpected(registration.EventID, registration.EventEpoch, registration.BaseRepository.ID, registration.ActorID, registration.ConfigDigest, registration.KeyEpoch, registration.KeyID, expected); err != nil { - return VerifiedRegistration{}, err - } - if registration.ParticipantKey.KeyID != registration.KeyID { - return VerifiedRegistration{}, errors.New("participant_key.key_id does not match key_id") - } - if err := Verify(RegistrationDomain, registrationUnsignedFrom(registration), registration.Signature, registration.ParticipantKey); err != nil { - return VerifiedRegistration{}, err - } - intentDigest, err := SigningDigest(RegistrationDomain, registrationUnsignedFrom(registration)) - if err != nil { - return VerifiedRegistration{}, err - } - fingerprint, err := NewFingerprint(RegistrationDomain, registration.EventID, registration.OperationID, intentDigest) - if err != nil { - return VerifiedRegistration{}, err - } - return VerifiedRegistration{Document: registration, Fingerprint: fingerprint}, nil -} - -// Sign applies v1 domain separation and returns an Ed25519 protocol signature. -func Sign(domain string, unsigned any, private identity.Private) (identity.Signature, error) { - input, err := SigningInput(domain, unsigned) - if err != nil { - return identity.Signature{}, err - } - return identity.Sign(private, input) -} - -// Verify applies v1 domain separation and verifies a signature using trustedKey. -func Verify(domain string, unsigned any, signature identity.Signature, trustedKey identity.Public) error { - input, err := SigningInput(domain, unsigned) - if err != nil { - return err - } - if err := identity.Verify(trustedKey, input, signature); err != nil { - return fmt.Errorf("verify %s signature: %w", domain, err) - } - return nil -} - -// SigningInput returns the exact bytes covered by an operation signature. -func SigningInput(domain string, unsigned any) ([]byte, error) { - if domain == "" || strings.ContainsRune(domain, 0) { - return nil, errors.New("invalid signing domain") - } - encoded, err := canonical.Marshal(unsigned) - if err != nil { - return nil, fmt.Errorf("canonicalize signed intent: %w", err) - } - input := make([]byte, 0, len(SigningPrefix)+len(domain)+1+len(encoded)) - input = append(input, SigningPrefix...) - input = append(input, domain...) - input = append(input, 0) - input = append(input, encoded...) - return input, nil -} - -// SigningDigest returns raw lower-case SHA-256 hex of the signing input. -func SigningDigest(domain string, unsigned any) (string, error) { - input, err := SigningInput(domain, unsigned) - if err != nil { - return "", err - } - return Digest(input), nil -} - -// Digest returns raw lower-case SHA-256 hex. -func Digest(value []byte) string { - sum := sha256.Sum256(value) - return hex.EncodeToString(sum[:]) -} - -// DocumentDigest returns SHA-256 of a strict typed document's canonical JSON, -// including its signature fields. -func DocumentDigest(value any) (string, error) { - raw, err := canonical.Marshal(value) - if err != nil { - return "", err - } - return Digest(raw), nil -} - -// NewFingerprint derives the replay key from the operation kind, event, and -// logical request ID. Actor, repository, epochs, and config remain bound by -// RequestDigest and MUST be verified before a replay lookup is performed. -func NewFingerprint(requestKind, eventID, requestID, requestDigest string) (Fingerprint, error) { - if !requestKindPattern.MatchString(requestKind) || !IsEventID(eventID) || !IsUUID(requestID) || !IsDigest(requestDigest) { - return Fingerprint{}, errors.New("cannot fingerprint invalid verified request fields") - } - replayValue := struct { - EventID string `json:"event_id"` - RequestKind string `json:"request_kind"` - RequestID string `json:"request_id"` - }{eventID, requestKind, requestID} - encoded, err := canonical.Marshal(replayValue) - if err != nil { - return Fingerprint{}, err - } - input := make([]byte, 0, len(ReplayKeyPrefix)+len(encoded)) - input = append(input, ReplayKeyPrefix...) - input = append(input, encoded...) - return Fingerprint{ReplayKey: Digest(input), RequestDigest: requestDigest}, nil -} - -// ClassifyReplay performs stateless duplicate/conflict classification. -func ClassifyReplay(existing *Fingerprint, incoming Fingerprint) (ReplayDisposition, error) { - if err := incoming.Validate(); err != nil { - return "", fmt.Errorf("incoming fingerprint: %w", err) - } - if existing == nil { - return ReplayNew, nil - } - if err := existing.Validate(); err != nil { - return "", fmt.Errorf("existing fingerprint: %w", err) - } - if existing.ReplayKey != incoming.ReplayKey { - return "", errors.New("fingerprints have different replay keys") - } - if existing.RequestDigest == incoming.RequestDigest { - return ReplayDuplicate, nil - } - return ReplayConflict, nil -} - -func (fingerprint Fingerprint) Validate() error { - if !IsDigest(fingerprint.ReplayKey) || !IsDigest(fingerprint.RequestDigest) { - return errors.New("fingerprint fields must be raw lower-case SHA-256 hex") - } - return nil -} - -func ValidateRepository(repository Repository) error { - if err := ValidateRepositoryID(repository.ID); err != nil { - return err - } - if !ownerPattern.MatchString(repository.Owner) || len(repository.Owner) > 39 { - return errors.New("repository owner is invalid") - } - if !repositoryPattern.MatchString(repository.Name) { - return errors.New("repository name is invalid") - } - return nil -} - -func ValidateRepositoryID(value string) error { - return identity.ValidateDecimal(value, "repository_id") -} -func IsEventID(value string) bool { return eventIDPattern.MatchString(value) } -func IsUUID(value string) bool { return uuidPattern.MatchString(value) } -func IsDigest(value string) bool { return digestPattern.MatchString(value) } -func IsGitOID(value string) bool { return gitOIDPattern.MatchString(value) } - -func ValidateRef(value string) error { - if len(value) == 0 || len(value) > 255 || !refPattern.MatchString(value) || strings.HasPrefix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") || strings.ContainsAny(value, "~^:?*[") || strings.HasSuffix(value, ".") || strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".lock") || strings.Contains(value, ".lock/") { - return errors.New("Git ref is invalid or unsafe") - } - return nil -} - -func ParseTimestamp(value string) (time.Time, error) { - parsed, err := time.Parse("2006-01-02T15:04:05Z", value) - if err != nil || formatTime(parsed) != value { - return time.Time{}, fmt.Errorf("timestamp %q must be UTC RFC3339 with whole seconds", value) - } - return parsed, nil -} - -func ValidateWindow(issuedText, expiresText string, now time.Time) error { - return validateWindowWithin(issuedText, expiresText, now, MaxGenericValidity, "validity window exceeds 24 hours") -} - -// ValidateWindowWithin validates a protocol window against an operation-specific -// maximum. Callers must derive maximumValidity from independently trusted policy. -func ValidateWindowWithin(issuedText, expiresText string, now time.Time, maximumValidity time.Duration) error { - if maximumValidity <= 0 || maximumValidity%time.Second != 0 { - return errors.New("maximum validity window must be positive whole seconds") - } - return validateWindowWithin( - issuedText, - expiresText, - now, - maximumValidity, - fmt.Sprintf("validity window exceeds configured maximum of %s", maximumValidity), - ) -} - -func validateWindowWithin(issuedText, expiresText string, now time.Time, maximumValidity time.Duration, tooLongMessage string) error { - issued, err := ParseTimestamp(issuedText) - if err != nil { - return err - } - expires, err := ParseTimestamp(expiresText) - if err != nil { - return err - } - if !expires.After(issued) { - return errors.New("expires_at must be after issued_at") - } - if expires.Sub(issued) > maximumValidity { - return errors.New(tooLongMessage) - } - if !now.IsZero() { - now = now.UTC() - if now.Before(issued) { - return errors.New("request is not yet valid") - } - if now.After(expires) { - return errors.New("request has expired") - } - } - return nil -} - -func DefaultWindow() (time.Time, time.Time) { - issued := time.Now().UTC().Truncate(time.Second) - return issued, issued.Add(15 * time.Minute) -} - -func formatTime(value time.Time) string { - if value.IsZero() { - return "" - } - return value.UTC().Truncate(time.Second).Format("2006-01-02T15:04:05Z") -} - -func validateRegistration(value Registration, now time.Time, requestTTL time.Duration) error { - if value.Kind != RegistrationKind || value.Protocol != Protocol || value.ProtocolVersion != ProtocolVersion { - return errors.New("registration protocol discriminator is invalid") - } - if !IsEventID(value.EventID) { - return errors.New("event_id is invalid") - } - if err := identity.ValidateDecimal(value.EventEpoch, "event_epoch"); err != nil { - return err - } - if !IsUUID(value.OperationID) { - return errors.New("operation_id must be a lower-case UUIDv4") - } - if err := identity.ValidateDecimal(value.ActorID, "actor_id"); err != nil { - return err - } - if value.KeyEpoch != "1" { - return errors.New("registration v1 requires key_epoch 1") - } - if !IsDigest(value.KeyID) || !IsDigest(value.ConfigDigest) || !IsDigest(value.TermsDigest) { - return errors.New("key_id, config_digest, and terms_digest must be raw lower-case SHA-256 hex") - } - if err := ValidateRepository(value.BaseRepository); err != nil { - return err - } - if err := value.ParticipantKey.Validate(); err != nil { - return err - } - return validateRequestWindow(value.IssuedAt, value.ExpiresAt, now, requestTTL) -} - -func validateRequestWindow(issuedAt, expiresAt string, now time.Time, maximumValidity time.Duration) error { - if maximumValidity > MaxGenericValidity { - return errors.New("configured validity window exceeds protocol maximum of 24 hours") - } - if maximumValidity == MaxGenericValidity { - return ValidateWindow(issuedAt, expiresAt, now) - } - return ValidateWindowWithin(issuedAt, expiresAt, now, maximumValidity) -} - -func registrationUnsignedFrom(value Registration) registrationUnsigned { - return registrationUnsigned{ - value.Kind, value.Protocol, value.ProtocolVersion, value.EventID, value.EventEpoch, value.OperationID, - value.ActorID, value.KeyID, value.KeyEpoch, value.BaseRepository, value.ConfigDigest, - value.TermsDigest, value.IssuedAt, value.ExpiresAt, value.ParticipantKey, - } -} - -func compareExpected(eventID, eventEpoch, repositoryID, actorID, configDigest, keyEpoch, keyID string, expected Expected) error { - checks := []struct{ field, got, want string }{ - {"event_id", eventID, expected.EventID}, {"event_epoch", eventEpoch, expected.EventEpoch}, {"repository_id", repositoryID, expected.RepositoryID}, - {"actor_id", actorID, expected.ActorID}, {"config_digest", configDigest, expected.ConfigDigest}, - {"key_epoch", keyEpoch, expected.KeyEpoch}, {"key_id", keyID, expected.KeyID}, - } - for _, check := range checks { - if check.want != "" && check.got != check.want { - return fmt.Errorf("%s is %q, trusted value is %q", check.field, check.got, check.want) - } - } - return nil -} - -func parsePrivate(private identity.Private) (identity.KeyPair, error) { - raw, err := canonical.Marshal(private) - if err != nil { - return identity.KeyPair{}, err - } - return identity.ParsePrivate(raw) -} - -func ParsePositiveInt(value, field string) (int, error) { - parsed, err := strconv.Atoi(value) - if err != nil || parsed < 1 { - return 0, fmt.Errorf("%s must be a positive integer", field) - } - return parsed, nil -} diff --git a/internal/envelope/envelope_test.go b/internal/envelope/envelope_test.go deleted file mode 100644 index 93a21bc..0000000 --- a/internal/envelope/envelope_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package envelope - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/identity" -) - -const testDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - -func testPair(t *testing.T, fill byte) identity.KeyPair { - t.Helper() - pair, err := identity.GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{fill}, 64))) - if err != nil { - t.Fatal(err) - } - return pair -} - -func registrationParams() RegistrationParams { - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - return RegistrationParams{ - EventID: "summer-data-2026", EventEpoch: "1", OperationID: "123e4567-e89b-42d3-a456-426614174000", - ActorID: "42", KeyEpoch: "1", BaseRepository: Repository{ID: "123456789", Owner: "pythonhk", Name: "event"}, - ConfigDigest: testDigest, TermsDigest: strings.Repeat("a", 64), IssuedAt: issued, ExpiresAt: issued.Add(15 * time.Minute), - } -} - -func TestRegistrationRoundTripAndExpectedContext(t *testing.T) { - t.Parallel() - pair := testPair(t, 3) - raw, err := NewRegistration(registrationParams(), pair.Private) - if err != nil { - t.Fatal(err) - } - expected := Expected{EventID: "summer-data-2026", EventEpoch: "1", RepositoryID: "123456789", ActorID: "42", ConfigDigest: testDigest, Now: registrationParams().IssuedAt.Add(time.Minute)} - verified, err := VerifyRegistration(raw, expected, 15*time.Minute) - if err != nil { - t.Fatal(err) - } - if verified.Document.ParticipantKey != pair.Public { - t.Fatal("participant key mismatch") - } - if err := verified.Fingerprint.Validate(); err != nil { - t.Fatal(err) - } - expected.ActorID = "43" - if _, err := VerifyRegistration(raw, expected, 15*time.Minute); err == nil { - t.Fatal("accepted wrong trusted actor") - } -} - -func TestRegistrationMutationAndUnknownFieldFail(t *testing.T) { - t.Parallel() - pair := testPair(t, 4) - raw, err := NewRegistration(registrationParams(), pair.Private) - if err != nil { - t.Fatal(err) - } - mutated := bytes.Replace(raw, []byte(`"actor_id":"42"`), []byte(`"actor_id":"43"`), 1) - if _, err := VerifyRegistration(mutated, Expected{}, 15*time.Minute); err == nil { - t.Fatal("accepted mutated actor") - } - var object map[string]any - if err := json.Unmarshal(raw, &object); err != nil { - t.Fatal(err) - } - object["unknown"] = true - withUnknown, _ := json.Marshal(object) - if _, err := VerifyRegistration(withUnknown, Expected{}, 15*time.Minute); err == nil { - t.Fatal("accepted unknown field") - } -} - -func TestReplayClassificationConflictsAcrossChangedIntent(t *testing.T) { - t.Parallel() - first, err := NewFingerprint("registration_request", "summer-data-2026", "123e4567-e89b-42d3-a456-426614174000", strings.Repeat("1", 64)) - if err != nil { - t.Fatal(err) - } - if first.ReplayKey != "593f0bd3b4c189f58c59677cb9a4d66f7ddb202f9cf57b107d65a8dc8a36d95e" { - t.Fatalf("replay key = %q", first.ReplayKey) - } - if got, err := ClassifyReplay(&first, first); err != nil || got != ReplayDuplicate { - t.Fatalf("duplicate = %q, %v", got, err) - } - second := first - second.RequestDigest = strings.Repeat("2", 64) - if got, err := ClassifyReplay(&first, second); err != nil || got != ReplayConflict { - t.Fatalf("conflict = %q, %v", got, err) - } -} - -func TestRequestIDGeneration(t *testing.T) { - t.Parallel() - requestID, err := NewRequestID() - if err != nil { - t.Fatal(err) - } - if !IsUUID(requestID) { - t.Fatalf("request ID = %q", requestID) - } -} - -func TestRegistrationValidityUsesTrustedSourceTime(t *testing.T) { - t.Parallel() - pair := testPair(t, 17) - params := registrationParams() - params.IssuedAt = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - params.ExpiresAt = params.IssuedAt.Add(30 * time.Minute) - raw, err := NewRegistration(params, pair.Private) - if err != nil { - t.Fatal(err) - } - // A controller may process this durable source much later; the verifier's - // Now is the immutable GitHub source creation time, not processing time. - if _, err := VerifyRegistration(raw, Expected{Now: params.IssuedAt.Add(10 * time.Minute)}, 30*time.Minute); err != nil { - t.Fatalf("valid delayed source was rejected: %v", err) - } - if _, err := VerifyRegistration(raw, Expected{Now: params.IssuedAt.Add(-time.Second)}, 30*time.Minute); err == nil { - t.Fatal("pre-issued source time was accepted") - } - if _, err := VerifyRegistration(raw, Expected{Now: params.ExpiresAt.Add(time.Second)}, 30*time.Minute); err == nil { - t.Fatal("post-expiry source time was accepted") - } -} - -func TestRegistrationVerificationEnforcesConfiguredTTL(t *testing.T) { - t.Parallel() - pair := testPair(t, 18) - params := registrationParams() - configuredTTL := 15 * time.Minute - - params.ExpiresAt = params.IssuedAt.Add(configuredTTL) - exact, err := NewRegistration(params, pair.Private) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyRegistration(exact, Expected{Now: params.IssuedAt.Add(time.Minute)}, configuredTTL); err != nil { - t.Fatalf("exact configured registration TTL rejected: %v", err) - } - - params.ExpiresAt = params.IssuedAt.Add(configuredTTL + time.Second) - over, err := NewRegistration(params, pair.Private) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyRegistration(over, Expected{Now: params.IssuedAt.Add(time.Minute)}, configuredTTL); err == nil { - t.Fatal("registration above the signed config TTL was accepted") - } -} - -func TestValidateWindowPreservesGenericTwentyFourHourBoundary(t *testing.T) { - t.Parallel() - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - format := func(value time.Time) string { - return value.UTC().Format("2006-01-02T15:04:05Z") - } - if err := ValidateWindow(format(issued), format(issued.Add(24*time.Hour)), issued.Add(time.Minute)); err != nil { - t.Fatalf("exact 24-hour generic window rejected: %v", err) - } - if err := ValidateWindow(format(issued), format(issued.Add(24*time.Hour+time.Second)), issued.Add(time.Minute)); err == nil { - t.Fatal("generic window above 24 hours was accepted") - } -} diff --git a/internal/envelope/pack_record.go b/internal/envelope/pack_record.go deleted file mode 100644 index 794dc88..0000000 --- a/internal/envelope/pack_record.go +++ /dev/null @@ -1,89 +0,0 @@ -package envelope - -import ( - "errors" - "fmt" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/identity" -) - -const PackRecordKind = "submission_pack_record" - -type PackRecordBundle struct { - Path string `json:"path"` - Format string `json:"format"` - SizeBytes uint64 `json:"size_bytes"` - SHA256 string `json:"sha256"` - EnvelopeSHA256 string `json:"envelope_sha256"` - CiphertextSize uint64 `json:"ciphertext_size"` - CiphertextSHA256 string `json:"ciphertext_sha256"` -} - -type PackRecord struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RequestID string `json:"request_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - RecipientEpoch string `json:"recipient_epoch"` - RecipientKeyIDs []string `json:"recipient_key_ids"` - InnerManifestSHA256 string `json:"inner_manifest_sha256"` - FileCount uint32 `json:"file_count"` - PlaintextSize uint64 `json:"plaintext_size"` - Bundle PackRecordBundle `json:"bundle"` - CreatedAt string `json:"created_at"` -} - -func ParsePackRecord(raw []byte) (PackRecord, error) { - if len(raw) > MaxDocumentBytes { - return PackRecord{}, errors.New("pack record exceeds 1 MiB") - } - var record PackRecord - if err := canonical.StrictUnmarshal(raw, &record); err != nil { - return PackRecord{}, fmt.Errorf("decode pack record: %w", err) - } - if err := record.Validate(); err != nil { - return PackRecord{}, err - } - return record, nil -} - -func (r PackRecord) Validate() error { - if r.Kind != PackRecordKind || r.Protocol != Protocol || r.ProtocolVersion != ProtocolVersion { - return errors.New("pack record protocol discriminator is invalid") - } - if !IsEventID(r.EventID) || identity.ValidateDecimal(r.EventEpoch, "event_epoch") != nil || !IsUUID(r.RequestID) || !IsUUID(r.AttemptID) || identity.ValidateDecimal(r.ActorID, "actor_id") != nil || !IsDigest(r.KeyID) || identity.ValidateDecimal(r.KeyEpoch, "key_epoch") != nil || !IsUUID(r.TeamID) || !IsDigest(r.TeamProposalDigest) || ValidateRepositoryID(r.BaseRepositoryID) != nil || !IsDigest(r.ConfigDigest) || identity.ValidateDecimal(r.RecipientEpoch, "recipient_epoch") != nil { - return errors.New("pack record binding is invalid") - } - if len(r.RecipientKeyIDs) < 1 || len(r.RecipientKeyIDs) > 32 { - return errors.New("pack record recipient count is invalid") - } - for index, keyID := range r.RecipientKeyIDs { - if !IsDigest(keyID) { - return errors.New("recipient fingerprint is invalid") - } - if index > 0 && r.RecipientKeyIDs[index-1] >= keyID { - return errors.New("recipient fingerprints must be strictly sorted") - } - } - if !IsDigest(r.InnerManifestSHA256) || r.FileCount < 1 || r.FileCount > MaxSubmissionFilesV1 || r.PlaintextSize < 1 || r.PlaintextSize > 42_000_000 { - return errors.New("pack record manifest summary is invalid") - } - if r.Bundle.Path != "submission.eventctl" || r.Bundle.Format != SubmissionBundleFormat || r.Bundle.SizeBytes < 1 || r.Bundle.SizeBytes > 48_000_000 || !IsDigest(r.Bundle.SHA256) || !IsDigest(r.Bundle.EnvelopeSHA256) || r.Bundle.CiphertextSize < 1 || r.Bundle.CiphertextSize > 47_000_000 || r.Bundle.CiphertextSize >= r.Bundle.SizeBytes || !IsDigest(r.Bundle.CiphertextSHA256) { - return errors.New("pack record bundle summary is invalid") - } - if _, err := ParseTimestamp(r.CreatedAt); err != nil { - return err - } - return nil -} diff --git a/internal/envelope/pack_record_test.go b/internal/envelope/pack_record_test.go deleted file mode 100644 index e4b63e5..0000000 --- a/internal/envelope/pack_record_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package envelope - -import ( - "strings" - "testing" -) - -func TestPackRecordPlaintextTransportBoundary(t *testing.T) { - t.Parallel() - record := PackRecord{ - Kind: PackRecordKind, Protocol: Protocol, ProtocolVersion: ProtocolVersion, - EventID: "example-event-2026", EventEpoch: "1", - RequestID: "11111111-1111-4111-8111-111111111111", - AttemptID: "22222222-2222-4222-8222-222222222222", - ActorID: "42", KeyID: strings.Repeat("1", 64), KeyEpoch: "1", - TeamID: "33333333-3333-4333-8333-333333333333", - TeamProposalDigest: strings.Repeat("2", 64), BaseRepositoryID: "123", - ConfigDigest: strings.Repeat("3", 64), RecipientEpoch: "1", - RecipientKeyIDs: []string{strings.Repeat("4", 64)}, - InnerManifestSHA256: strings.Repeat("5", 64), FileCount: MaxSubmissionFilesV1, - PlaintextSize: 42_000_000, - Bundle: PackRecordBundle{ - Path: "submission.eventctl", Format: SubmissionBundleFormat, - SizeBytes: 48_000_000, SHA256: strings.Repeat("6", 64), - EnvelopeSHA256: strings.Repeat("7", 64), CiphertextSize: 47_000_000, - CiphertextSHA256: strings.Repeat("8", 64), - }, - CreatedAt: "2026-08-04T00:00:00Z", - } - if err := record.Validate(); err != nil { - t.Fatalf("42,000,000-byte plaintext boundary rejected: %v", err) - } - record.FileCount++ - if err := record.Validate(); err == nil { - t.Fatal("pack record accepted 4,097 files") - } - record.FileCount = MaxSubmissionFilesV1 - record.PlaintextSize++ - if err := record.Validate(); err == nil { - t.Fatal("pack record accepted plaintext above 42,000,000-byte cap") - } -} diff --git a/internal/envelope/pull_request_export.go b/internal/envelope/pull_request_export.go deleted file mode 100644 index 570277d..0000000 --- a/internal/envelope/pull_request_export.go +++ /dev/null @@ -1,7 +0,0 @@ -package envelope - -// ValidatePullRequestAddress exposes the strict v1 fork/PR/head-seal validator -// for trusted internal protocol packages such as isolated scoring. -func ValidatePullRequestAddress(value PullRequest) error { - return validatePullRequest(value) -} diff --git a/internal/envelope/submission.go b/internal/envelope/submission.go deleted file mode 100644 index 728a862..0000000 --- a/internal/envelope/submission.go +++ /dev/null @@ -1,331 +0,0 @@ -package envelope - -import ( - "errors" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/identity" -) - -const ( - SubmissionKind = "submission_envelope" - SubmissionDomain = "submission_envelope" - SubmissionDeliveryMode = "signed_pr_comment_after_push" - SubmissionBundleFormat = "eventctl-encrypted-bundle-v1" -) - -// PullRequest is the full fork address and immutable head object seal. -type PullRequest struct { - Number uint64 `json:"number"` - ID string `json:"id"` - BaseRepositoryID string `json:"base_repository_id"` - BaseRef string `json:"base_ref"` - HeadRepositoryID string `json:"head_repository_id"` - HeadOwner string `json:"head_owner"` - HeadRef string `json:"head_ref"` - HeadSHA string `json:"head_sha"` -} - -// PRMetadata is the bounded transport document supplied after resolving a PR -// through GitHub's API. eventctl never performs the GitHub lookup itself. -type PRMetadata struct { - Kind string `json:"kind"` - ActorID string `json:"actor_id"` - PullRequestAuthorID string `json:"pull_request_author_id"` - BaseRepository Repository `json:"base_repository"` - PullRequest PullRequest `json:"pull_request"` -} - -// BundleReference binds the exact committed encrypted bundle and its public -// envelope/ciphertext digests. -type BundleReference struct { - Path string `json:"path"` - SizeBytes uint64 `json:"size_bytes"` - SHA256 string `json:"sha256"` - EnvelopeSHA256 string `json:"envelope_sha256"` - CiphertextSize uint64 `json:"ciphertext_size"` - CiphertextSHA256 string `json:"ciphertext_sha256"` - Format string `json:"format"` -} - -// Submission is the actor-bound post-push submission request. -type Submission struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RequestID string `json:"request_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - BaseRepository Repository `json:"base_repository"` - PullRequest PullRequest `json:"pull_request"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - DeliveryMode string `json:"delivery_mode"` - Bundle BundleReference `json:"bundle"` - Signature identity.Signature `json:"signature"` -} - -// ValidateUntrustedStructure validates the concrete submission schema and -// internal field relationships without authenticating its signature, trusted -// actor/config context, GitHub metadata freshness, or current validity window. -func (value Submission) ValidateUntrustedStructure() error { - if err := validateSubmission(value, time.Time{}, MaxGenericValidity); err != nil { - return err - } - if err := value.Signature.ValidateEncoding(); err != nil { - return err - } - if value.Signature.KeyID != value.KeyID { - return errors.New("signature.key_id does not match key_id") - } - return nil -} - -type submissionUnsigned struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - RequestID string `json:"request_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - BaseRepository Repository `json:"base_repository"` - PullRequest PullRequest `json:"pull_request"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - DeliveryMode string `json:"delivery_mode"` - Bundle BundleReference `json:"bundle"` -} - -type SubmissionParams struct { - EventID string - EventEpoch string - RequestID string - AttemptID string - ActorID string - KeyEpoch string - TeamID string - TeamProposalDigest string - Metadata PRMetadata - ConfigDigest string - IssuedAt time.Time - ExpiresAt time.Time - Bundle BundleReference -} - -type VerifiedSubmission struct { - Document Submission - Fingerprint Fingerprint -} - -// MetadataEquivalent reconstructs the exact bounded transport metadata bound -// by a verified submission for comparison with a fresh GitHub API lookup. -func (submission Submission) MetadataEquivalent() PRMetadata { - return PRMetadata{Kind: "github_pr_metadata", ActorID: submission.ActorID, PullRequestAuthorID: submission.ActorID, BaseRepository: submission.BaseRepository, PullRequest: submission.PullRequest} -} - -// NewSubmission creates the post-push request. The caller must populate Bundle -// from a successful structural inspection of the committed bundle. -func NewSubmission(params SubmissionParams, private identity.Private) ([]byte, error) { - pair, err := parsePrivate(private) - if err != nil { - return nil, err - } - if params.RequestID == "" { - params.RequestID, err = NewRequestID() - if err != nil { - return nil, err - } - } - if params.KeyEpoch == "" { - params.KeyEpoch = "1" - } - if params.Metadata.Kind != "github_pr_metadata" || params.Metadata.ActorID != params.ActorID || params.Metadata.PullRequestAuthorID != params.ActorID { - return nil, errors.New("PR metadata actor and author must match submission actor") - } - submission := Submission{ - Kind: SubmissionKind, Protocol: Protocol, ProtocolVersion: ProtocolVersion, - EventID: params.EventID, EventEpoch: params.EventEpoch, RequestID: params.RequestID, AttemptID: params.AttemptID, - ActorID: params.ActorID, KeyID: pair.Public.KeyID, KeyEpoch: params.KeyEpoch, TeamID: params.TeamID, TeamProposalDigest: params.TeamProposalDigest, - BaseRepository: params.Metadata.BaseRepository, PullRequest: params.Metadata.PullRequest, - ConfigDigest: params.ConfigDigest, IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), - DeliveryMode: SubmissionDeliveryMode, Bundle: params.Bundle, - } - if err := validateSubmission(submission, time.Time{}, MaxGenericValidity); err != nil { - return nil, err - } - submission.Signature, err = Sign(SubmissionDomain, unsignedSubmission(submission), pair.Private) - if err != nil { - return nil, err - } - return canonical.Marshal(submission) -} - -// VerifySubmission authenticates a post-push request with the exact registered -// key. The intake must additionally fetch the head SHA, inspect the referenced -// bundle, and cross-check every bundle binding before state admission. -// envelopeTTL must be read from the authenticated event config bound by -// expected.ConfigDigest. -func VerifySubmission(raw []byte, expected Expected, envelopeTTL time.Duration, registry identity.Registry) (VerifiedSubmission, error) { - if len(raw) > MaxDocumentBytes { - return VerifiedSubmission{}, errors.New("submission document exceeds 1 MiB") - } - var submission Submission - if err := canonical.StrictUnmarshal(raw, &submission); err != nil { - return VerifiedSubmission{}, fmt.Errorf("decode submission: %w", err) - } - if err := validateSubmission(submission, expected.Now, envelopeTTL); err != nil { - return VerifiedSubmission{}, err - } - if err := compareExpected(submission.EventID, submission.EventEpoch, submission.BaseRepository.ID, submission.ActorID, submission.ConfigDigest, submission.KeyEpoch, submission.KeyID, expected); err != nil { - return VerifiedSubmission{}, err - } - trusted, ok := registry.Resolve(submission.ActorID, submission.KeyEpoch) - if !ok || trusted.KeyID != submission.KeyID { - return VerifiedSubmission{}, errors.New("submission signer does not match trusted registration") - } - if err := Verify(SubmissionDomain, unsignedSubmission(submission), submission.Signature, trusted); err != nil { - return VerifiedSubmission{}, err - } - intentDigest, err := SigningDigest(SubmissionDomain, unsignedSubmission(submission)) - if err != nil { - return VerifiedSubmission{}, err - } - fingerprint, err := NewFingerprint(SubmissionDomain, submission.EventID, submission.RequestID, intentDigest) - if err != nil { - return VerifiedSubmission{}, err - } - return VerifiedSubmission{Document: submission, Fingerprint: fingerprint}, nil -} - -func ParsePRMetadata(raw []byte) (PRMetadata, error) { - if len(raw) > 64*1024 { - return PRMetadata{}, errors.New("PR metadata exceeds 64 KiB") - } - var metadata PRMetadata - if err := canonical.StrictUnmarshal(raw, &metadata); err != nil { - return PRMetadata{}, fmt.Errorf("decode PR metadata: %w", err) - } - if err := ValidateRepository(metadata.BaseRepository); err != nil { - return PRMetadata{}, err - } - if metadata.Kind != "github_pr_metadata" { - return PRMetadata{}, errors.New("PR metadata kind is invalid") - } - if err := identity.ValidateDecimal(metadata.ActorID, "actor_id"); err != nil { - return PRMetadata{}, err - } - if metadata.PullRequestAuthorID != metadata.ActorID { - return PRMetadata{}, errors.New("v1 requires actor_id to equal pull_request_author_id") - } - if err := validatePullRequest(metadata.PullRequest); err != nil { - return PRMetadata{}, err - } - if metadata.BaseRepository.ID != metadata.PullRequest.BaseRepositoryID { - return PRMetadata{}, errors.New("PR base_repository_id does not match base_repository.id") - } - return metadata, nil -} - -func validateSubmission(value Submission, now time.Time, envelopeTTL time.Duration) error { - if value.Kind != SubmissionKind || value.Protocol != Protocol || value.ProtocolVersion != ProtocolVersion || value.DeliveryMode != SubmissionDeliveryMode { - return errors.New("submission protocol discriminator is invalid") - } - if !IsEventID(value.EventID) || !IsUUID(value.RequestID) || !IsUUID(value.AttemptID) || !IsUUID(value.TeamID) { - return errors.New("submission event/request/attempt/team identifier is invalid") - } - if err := identity.ValidateDecimal(value.EventEpoch, "event_epoch"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.ActorID, "actor_id"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.KeyEpoch, "key_epoch"); err != nil { - return err - } - if !IsDigest(value.KeyID) || !IsDigest(value.ConfigDigest) || !IsDigest(value.TeamProposalDigest) { - return errors.New("submission key_id/config_digest is invalid") - } - if err := ValidateRepository(value.BaseRepository); err != nil { - return err - } - if err := validatePullRequest(value.PullRequest); err != nil { - return err - } - if value.PullRequest.BaseRepositoryID != value.BaseRepository.ID { - return errors.New("pull_request.base_repository_id does not match base_repository.id") - } - if err := validateBundleReference(value.Bundle); err != nil { - return err - } - return validateRequestWindow(value.IssuedAt, value.ExpiresAt, now, envelopeTTL) -} - -func validatePullRequest(value PullRequest) error { - if value.Number == 0 { - return errors.New("pull request number must be positive") - } - if err := identity.ValidateDecimal(value.ID, "pull_request.id"); err != nil { - return err - } - if err := ValidateRepositoryID(value.BaseRepositoryID); err != nil { - return err - } - if err := ValidateRef(value.BaseRef); err != nil { - return fmt.Errorf("base_ref: %w", err) - } - if err := identity.ValidateDecimal(value.HeadRepositoryID, "head_repository_id"); err != nil { - return err - } - if !ownerPattern.MatchString(value.HeadOwner) || len(value.HeadOwner) > 39 { - return errors.New("head_owner is invalid") - } - if err := ValidateRef(value.HeadRef); err != nil { - return err - } - if !IsGitOID(value.HeadSHA) { - return errors.New("head_sha must be a lower-case Git SHA-1 or SHA-256 object ID") - } - return nil -} - -func validateBundleReference(value BundleReference) error { - clean := filepath.ToSlash(filepath.Clean(value.Path)) - if value.Path != "submission.eventctl" || clean != value.Path || filepath.IsAbs(value.Path) || strings.Contains(clean, "..") { - return errors.New("bundle.path must be exactly submission.eventctl") - } - if value.SizeBytes == 0 || value.SizeBytes > 48_000_000 { - return errors.New("bundle.size_bytes is outside the allowed range") - } - if value.CiphertextSize == 0 || value.CiphertextSize > 47_000_000 || value.CiphertextSize >= value.SizeBytes { - return errors.New("bundle.ciphertext_size is outside the allowed range") - } - if !IsDigest(value.SHA256) || !IsDigest(value.EnvelopeSHA256) || !IsDigest(value.CiphertextSHA256) { - return errors.New("bundle digests must be raw lower-case SHA-256 hex") - } - if value.Format != SubmissionBundleFormat { - return errors.New("bundle format is unsupported") - } - return nil -} - -func unsignedSubmission(v Submission) submissionUnsigned { - return submissionUnsigned{v.Kind, v.Protocol, v.ProtocolVersion, v.EventID, v.EventEpoch, v.RequestID, v.AttemptID, v.ActorID, v.KeyID, v.KeyEpoch, v.TeamID, v.TeamProposalDigest, v.BaseRepository, v.PullRequest, v.ConfigDigest, v.IssuedAt, v.ExpiresAt, v.DeliveryMode, v.Bundle} -} diff --git a/internal/envelope/submission_test.go b/internal/envelope/submission_test.go deleted file mode 100644 index d95706e..0000000 --- a/internal/envelope/submission_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package envelope - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/identity" -) - -func TestSubmissionBindsActorPRHeadAndBundle(t *testing.T) { - t.Parallel() - pair := testPair(t, 12) - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - metadata := PRMetadata{ - Kind: "github_pr_metadata", ActorID: "42", PullRequestAuthorID: "42", - BaseRepository: Repository{ID: "100", Owner: "pythonhk", Name: "event"}, - PullRequest: PullRequest{Number: 7, ID: "700", BaseRepositoryID: "100", BaseRef: "main", HeadRepositoryID: "200", HeadOwner: "participant", HeadRef: "attempt-one", HeadSHA: strings.Repeat("a", 40)}, - } - bundle := BundleReference{Path: "submission.eventctl", SizeBytes: 1000, SHA256: strings.Repeat("1", 64), EnvelopeSHA256: strings.Repeat("2", 64), CiphertextSize: 800, CiphertextSHA256: strings.Repeat("3", 64), Format: SubmissionBundleFormat} - params := SubmissionParams{EventID: "summer-data-2026", EventEpoch: "1", RequestID: "10000000-0000-4000-8000-000000000001", AttemptID: "20000000-0000-4000-8000-000000000002", ActorID: "42", KeyEpoch: "1", TeamID: "30000000-0000-4000-8000-000000000003", TeamProposalDigest: strings.Repeat("5", 64), Metadata: metadata, ConfigDigest: strings.Repeat("4", 64), IssuedAt: issued, ExpiresAt: issued.Add(15 * time.Minute), Bundle: bundle} - raw, err := NewSubmission(params, pair.Private) - if err != nil { - t.Fatal(err) - } - registryRaw, _ := json.Marshal(identity.Registry{Schema: identity.RegistrySchema, Identities: []identity.RegistryEntry{{ActorID: "42", KeyEpoch: "1", Identity: pair.Public}}}) - registry, err := identity.ParseRegistry(registryRaw) - if err != nil { - t.Fatal(err) - } - verified, err := VerifySubmission(raw, Expected{EventID: "summer-data-2026", EventEpoch: "1", RepositoryID: "100", ActorID: "42", ConfigDigest: strings.Repeat("4", 64), Now: issued.Add(time.Minute)}, 15*time.Minute, registry) - if err != nil { - t.Fatal(err) - } - if verified.Document.PullRequest.HeadSHA != metadata.PullRequest.HeadSHA || verified.Document.Bundle.SHA256 != bundle.SHA256 { - t.Fatal("binding lost") - } - mutated := bytes.Replace(raw, []byte(metadata.PullRequest.HeadSHA), []byte(strings.Repeat("b", 40)), 1) - if _, err := VerifySubmission(mutated, Expected{}, 15*time.Minute, registry); err == nil { - t.Fatal("accepted mutated head SHA") - } - params.ExpiresAt = issued.Add(15*time.Minute + time.Second) - overConfiguredTTL, err := NewSubmission(params, pair.Private) - if err != nil { - t.Fatal(err) - } - if _, err := VerifySubmission(overConfiguredTTL, Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, registry); err == nil { - t.Fatal("submission above the signed config TTL was accepted") - } -} - -func TestPRMetadataRejectsAddressMismatch(t *testing.T) { - t.Parallel() - metadata := PRMetadata{Kind: "github_pr_metadata", ActorID: "1", PullRequestAuthorID: "1", BaseRepository: Repository{ID: "1", Owner: "pythonhk", Name: "event"}, PullRequest: PullRequest{Number: 1, ID: "10", BaseRepositoryID: "2", BaseRef: "main", HeadRepositoryID: "3", HeadOwner: "u", HeadRef: "branch", HeadSHA: strings.Repeat("a", 40)}} - raw, _ := json.Marshal(metadata) - if _, err := ParsePRMetadata(raw); err == nil { - t.Fatal("accepted mismatched base repository") - } -} diff --git a/internal/identity/identity.go b/internal/identity/identity.go deleted file mode 100644 index 1ebadb2..0000000 --- a/internal/identity/identity.go +++ /dev/null @@ -1,386 +0,0 @@ -// Package identity manages eventctl Ed25519 key material and public identities. -package identity - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "io" - "regexp" - "sort" - "strings" - - "github.com/pythonhk/eventctl/internal/canonical" -) - -const ( - Algorithm = "Ed25519" - PrivateSchema = "pythonhk.eventctl/private-key/v1" - RegistrySchema = "pythonhk.eventctl/identity-registry/v1" - MaxRegistryEntries = 1_000 -) - -var ( - decimalPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`) - digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) -) - -// Public matches the protocol public_key schema. -type Public struct { - Algorithm string `json:"algorithm" yaml:"algorithm"` - KeyID string `json:"key_id" yaml:"key_id"` - PublicKey string `json:"public_key" yaml:"public_key"` -} - -// Signature matches the protocol signature schema. -type Signature struct { - Algorithm string `json:"algorithm" yaml:"algorithm"` - KeyID string `json:"key_id" yaml:"key_id"` - Value string `json:"value" yaml:"value"` -} - -// ValidateEncoding checks the closed protocol shape and canonical encoding of -// a signature. It does not authenticate the signature against any message. -func (signature Signature) ValidateEncoding() error { - if signature.Algorithm != Algorithm { - return fmt.Errorf("signature algorithm is %q, want %q", signature.Algorithm, Algorithm) - } - if !digestPattern.MatchString(signature.KeyID) { - return errors.New("signature key_id must be 64 lower-case hexadecimal characters") - } - if _, err := decodeBase64(signature.Value, ed25519.SignatureSize, "signature"); err != nil { - return err - } - return nil -} - -// Private is the portable on-disk representation of an Ed25519 private key. -// It stores only the 32-byte seed; callers must protect the containing file. -type Private struct { - Schema string `json:"schema"` - Algorithm string `json:"algorithm"` - Seed string `json:"seed"` - PublicKey string `json:"public_key"` -} - -// RegistryEntry binds a GitHub actor and key epoch to a trusted public key. -type RegistryEntry struct { - ActorID string `json:"actor_id"` - KeyEpoch string `json:"key_epoch"` - Identity Public `json:"identity"` -} - -// Registry is generated from active protected event state. Inactive historical -// epochs are not represented; each actor and key ID therefore appears once. -// Team and submission verification must use it rather than keys claimed by -// untrusted documents. -type Registry struct { - Schema string `json:"schema"` - Identities []RegistryEntry `json:"identities"` -} - -// KeyPair holds validated private and public key representations. -type KeyPair struct { - Private Private - Public Public -} - -// Generate creates a new key pair using crypto/rand. -func Generate() (KeyPair, error) { - return GenerateFrom(rand.Reader) -} - -// GenerateFrom creates a key pair using source. Production callers should use -// Generate; this form exists for deterministic tests. -func GenerateFrom(source io.Reader) (KeyPair, error) { - seed := make([]byte, ed25519.SeedSize) - if _, err := io.ReadFull(source, seed); err != nil { - return KeyPair{}, fmt.Errorf("read Ed25519 randomness: %w", err) - } - return FromSeed(seed) -} - -// FromSeed constructs a key pair from a 32-byte Ed25519 seed. -func FromSeed(seed []byte) (KeyPair, error) { - if len(seed) != ed25519.SeedSize { - return KeyPair{}, fmt.Errorf("Ed25519 seed is %d bytes, want %d", len(seed), ed25519.SeedSize) - } - privateKey := ed25519.NewKeyFromSeed(seed) - publicKey := privateKey.Public().(ed25519.PublicKey) - public := newPublic(publicKey) - return KeyPair{ - Private: Private{ - Schema: PrivateSchema, Algorithm: Algorithm, - Seed: encodeBase64(seed), PublicKey: public.PublicKey, - }, - Public: public, - }, nil -} - -// ParsePrivate strictly decodes and validates a private-key document. -func ParsePrivate(raw []byte) (KeyPair, error) { - var private Private - if err := canonical.StrictUnmarshal(raw, &private); err != nil { - return KeyPair{}, fmt.Errorf("decode private key: %w", err) - } - if private.Schema != PrivateSchema { - return KeyPair{}, fmt.Errorf("private key schema is %q, want %q", private.Schema, PrivateSchema) - } - if private.Algorithm != Algorithm { - return KeyPair{}, fmt.Errorf("private key algorithm is %q, want %q", private.Algorithm, Algorithm) - } - seed, err := decodeBase64(private.Seed, ed25519.SeedSize, "private seed") - if err != nil { - return KeyPair{}, err - } - pair, err := FromSeed(seed) - if err != nil { - return KeyPair{}, err - } - if subtle.ConstantTimeCompare([]byte(private.PublicKey), []byte(pair.Public.PublicKey)) != 1 { - return KeyPair{}, errors.New("private key public_key does not match its seed") - } - return pair, nil -} - -// MarshalPrivate validates and canonically encodes a private key. -func MarshalPrivate(private Private) ([]byte, error) { - pair, err := parsePrivateValue(private) - if err != nil { - return nil, err - } - return canonical.Marshal(pair.Private) -} - -// ParsePublic strictly decodes a public-key object. -func ParsePublic(raw []byte) (Public, error) { - var public Public - if err := canonical.StrictUnmarshal(raw, &public); err != nil { - return Public{}, fmt.Errorf("decode public identity: %w", err) - } - if err := public.Validate(); err != nil { - return Public{}, err - } - return public, nil -} - -// Validate validates a public identity and its derived key ID. -func (public Public) Validate() error { - if public.Algorithm != Algorithm { - return fmt.Errorf("public identity algorithm is %q, want %q", public.Algorithm, Algorithm) - } - decoded, err := decodeBase64(public.PublicKey, ed25519.PublicKeySize, "public key") - if err != nil { - return err - } - if !digestPattern.MatchString(public.KeyID) { - return errors.New("key_id must be 64 lower-case hexadecimal characters") - } - want := KeyID(ed25519.PublicKey(decoded)) - if subtle.ConstantTimeCompare([]byte(public.KeyID), []byte(want)) != 1 { - return errors.New("key_id does not match public_key") - } - return nil -} - -// VerificationKey returns a defensive copy of a validated Ed25519 public key. -func VerificationKey(public Public) (ed25519.PublicKey, error) { - if err := public.Validate(); err != nil { - return nil, err - } - decoded, err := decodeBase64(public.PublicKey, ed25519.PublicKeySize, "public key") - if err != nil { - return nil, err - } - return append(ed25519.PublicKey(nil), decoded...), nil -} - -// MarshalPublic validates and canonically encodes a public key. -func MarshalPublic(public Public) ([]byte, error) { - if err := public.Validate(); err != nil { - return nil, err - } - return canonical.Marshal(public) -} - -// KeyID derives the v1 protocol key identifier: SHA-256 of the algorithm tag, -// one NUL byte, and the decoded public-key bytes. -func KeyID(publicKey ed25519.PublicKey) string { - hash := sha256.New() - hash.Write([]byte(Algorithm)) - hash.Write([]byte{0}) - hash.Write(publicKey) - return hex.EncodeToString(hash.Sum(nil)) -} - -// Sign creates a protocol signature over message. -func Sign(private Private, message []byte) (Signature, error) { - key, pair, err := SigningKey(private) - if err != nil { - return Signature{}, err - } - value := ed25519.Sign(key, message) - return Signature{Algorithm: Algorithm, KeyID: pair.Public.KeyID, Value: encodeBase64(value)}, nil -} - -// SigningKey returns a defensive copy of the decoded Ed25519 private key and -// its validated pair. It is intended for internal bundle integration only. -func SigningKey(private Private) (ed25519.PrivateKey, KeyPair, error) { - pair, err := parsePrivateValue(private) - if err != nil { - return nil, KeyPair{}, err - } - seed, err := decodeBase64(pair.Private.Seed, ed25519.SeedSize, "private seed") - if err != nil { - return nil, KeyPair{}, err - } - key := ed25519.NewKeyFromSeed(seed) - return append(ed25519.PrivateKey(nil), key...), pair, nil -} - -// Verify validates and verifies a protocol signature. Lengths are checked before -// ed25519.Verify, preventing panics on malformed input. -func Verify(public Public, message []byte, signature Signature) error { - if err := public.Validate(); err != nil { - return err - } - if err := signature.ValidateEncoding(); err != nil { - return err - } - if signature.KeyID != public.KeyID { - return errors.New("signature key_id does not match trusted public key") - } - publicKey, err := decodeBase64(public.PublicKey, ed25519.PublicKeySize, "public key") - if err != nil { - return err - } - signatureBytes, err := decodeBase64(signature.Value, ed25519.SignatureSize, "signature") - if err != nil { - return err - } - if !ed25519.Verify(ed25519.PublicKey(publicKey), message, signatureBytes) { - return errors.New("Ed25519 signature is invalid") - } - return nil -} - -// ParseRegistry strictly decodes and validates a trusted identity registry. -func ParseRegistry(raw []byte) (Registry, error) { - var registry Registry - if err := canonical.StrictUnmarshal(raw, ®istry); err != nil { - return Registry{}, fmt.Errorf("decode identity registry: %w", err) - } - if err := registry.Validate(); err != nil { - return Registry{}, err - } - return registry, nil -} - -// Validate validates sorted, unique registry bindings and public keys. -func (registry Registry) Validate() error { - if registry.Schema != RegistrySchema { - return fmt.Errorf("identity registry schema is %q, want %q", registry.Schema, RegistrySchema) - } - if len(registry.Identities) == 0 || len(registry.Identities) > MaxRegistryEntries { - return fmt.Errorf("identity registry must contain between 1 and %d identities", MaxRegistryEntries) - } - seenActors := make(map[string]struct{}, len(registry.Identities)) - seenKeyIDs := make(map[string]string, len(registry.Identities)) - for index, entry := range registry.Identities { - if err := ValidateDecimal(entry.ActorID, "actor_id"); err != nil { - return fmt.Errorf("identity %d: %w", index, err) - } - if err := ValidateDecimal(entry.KeyEpoch, "key_epoch"); err != nil { - return fmt.Errorf("identity %d: %w", index, err) - } - if err := entry.Identity.Validate(); err != nil { - return fmt.Errorf("identity %d: %w", index, err) - } - if _, duplicate := seenActors[entry.ActorID]; duplicate { - return fmt.Errorf("identity registry actor_id %s has more than one active key epoch", entry.ActorID) - } - seenActors[entry.ActorID] = struct{}{} - if owner, duplicate := seenKeyIDs[entry.Identity.KeyID]; duplicate { - return fmt.Errorf("identity registry key_id is bound to multiple actors %s and %s", owner, entry.ActorID) - } - seenKeyIDs[entry.Identity.KeyID] = entry.ActorID - } - if !sort.SliceIsSorted(registry.Identities, func(left, right int) bool { - leftEntry, rightEntry := registry.Identities[left], registry.Identities[right] - actorOrder := CompareDecimal(leftEntry.ActorID, rightEntry.ActorID) - return actorOrder < 0 || (actorOrder == 0 && CompareDecimal(leftEntry.KeyEpoch, rightEntry.KeyEpoch) < 0) - }) { - return errors.New("identity registry must be sorted by numeric actor_id and key_epoch") - } - return nil -} - -// Resolve returns the exact trusted identity for actorID and keyEpoch. -func (registry Registry) Resolve(actorID, keyEpoch string) (Public, bool) { - for _, entry := range registry.Identities { - if entry.ActorID == actorID && entry.KeyEpoch == keyEpoch { - return entry.Identity, true - } - } - return Public{}, false -} - -// ValidateDecimal validates a positive GitHub-sized canonical decimal string. -func ValidateDecimal(value, field string) error { - if !decimalPattern.MatchString(value) { - return fmt.Errorf("%s must be a positive canonical decimal string of at most 20 digits", field) - } - return nil -} - -// IsDigest reports whether value is a lower-case raw SHA-256 hex digest. -func IsDigest(value string) bool { return digestPattern.MatchString(value) } - -// CompareDecimal compares validated positive canonical decimal strings. -func CompareDecimal(left, right string) int { - if len(left) < len(right) { - return -1 - } - if len(left) > len(right) { - return 1 - } - return strings.Compare(left, right) -} - -func newPublic(publicKey ed25519.PublicKey) Public { - return Public{Algorithm: Algorithm, KeyID: KeyID(publicKey), PublicKey: encodeBase64(publicKey)} -} - -func parsePrivateValue(private Private) (KeyPair, error) { - raw, err := canonical.Marshal(private) - if err != nil { - return KeyPair{}, err - } - return ParsePrivate(raw) -} - -func encodeBase64(value []byte) string { - return base64.RawURLEncoding.EncodeToString(value) -} - -func decodeBase64(value string, size int, label string) ([]byte, error) { - if strings.Contains(value, "=") { - return nil, fmt.Errorf("%s must use unpadded base64url", label) - } - decoded, err := base64.RawURLEncoding.Strict().DecodeString(value) - if err != nil { - return nil, fmt.Errorf("decode %s as base64url: %w", label, err) - } - if len(decoded) != size { - return nil, fmt.Errorf("%s is %d bytes, want %d", label, len(decoded), size) - } - if encodeBase64(decoded) != value { - return nil, fmt.Errorf("%s is not canonically encoded", label) - } - return decoded, nil -} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go deleted file mode 100644 index 7d14a1d..0000000 --- a/internal/identity/identity_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package identity - -import ( - "bytes" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "strings" - "testing" -) - -func deterministicPair(t *testing.T, fill byte) KeyPair { - t.Helper() - pair, err := GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{fill}, 64))) - if err != nil { - t.Fatal(err) - } - return pair -} - -func TestGenerateRoundTripAndSign(t *testing.T) { - t.Parallel() - pair := deterministicPair(t, 7) - raw, err := MarshalPrivate(pair.Private) - if err != nil { - t.Fatal(err) - } - parsed, err := ParsePrivate(raw) - if err != nil { - t.Fatal(err) - } - if parsed.Public != pair.Public { - t.Fatalf("parsed public = %#v, want %#v", parsed.Public, pair.Public) - } - message := []byte("eventctl protocol test") - signature, err := Sign(parsed.Private, message) - if err != nil { - t.Fatal(err) - } - if err := Verify(parsed.Public, message, signature); err != nil { - t.Fatal(err) - } - if err := Verify(parsed.Public, append(message, '!'), signature); err == nil { - t.Fatal("Verify unexpectedly accepted a mutated message") - } -} - -func TestKeyIDUsesRawHexAndAlgorithmDomain(t *testing.T) { - t.Parallel() - pair := deterministicPair(t, 4) - if len(pair.Public.KeyID) != 64 || strings.Contains(pair.Public.KeyID, ":") { - t.Fatalf("key ID = %q", pair.Public.KeyID) - } - if !IsDigest(pair.Public.KeyID) { - t.Fatalf("key ID is not a digest: %q", pair.Public.KeyID) - } -} - -func TestPublicRejectsNoncanonicalEncodingAndMismatchedID(t *testing.T) { - t.Parallel() - pair := deterministicPair(t, 9) - bad := pair.Public - bad.PublicKey += "=" - if err := bad.Validate(); err == nil { - t.Fatal("Validate accepted padded base64url") - } - bad = pair.Public - bad.KeyID = strings.Repeat("0", 64) - if err := bad.Validate(); err == nil { - t.Fatal("Validate accepted a mismatched key ID") - } -} - -func TestParsePrivateRejectsUnknownFields(t *testing.T) { - t.Parallel() - pair := deterministicPair(t, 5) - raw, err := json.Marshal(pair.Private) - if err != nil { - t.Fatal(err) - } - withUnknown := append(raw[:len(raw)-1], []byte(`,"unknown":true}`)...) - if _, err := ParsePrivate(withUnknown); err == nil { - t.Fatal("ParsePrivate accepted an unknown field") - } -} - -func TestRegistryRequiresSortedUniqueTrustedBindings(t *testing.T) { - t.Parallel() - first, second := deterministicPair(t, 1), deterministicPair(t, 2) - registry := Registry{Schema: RegistrySchema, Identities: []RegistryEntry{ - {ActorID: "2", KeyEpoch: "1", Identity: first.Public}, - {ActorID: "10", KeyEpoch: "1", Identity: second.Public}, - }} - raw, err := json.Marshal(registry) - if err != nil { - t.Fatal(err) - } - parsed, err := ParseRegistry(raw) - if err != nil { - t.Fatal(err) - } - if got, ok := parsed.Resolve("10", "1"); !ok || got != second.Public { - t.Fatalf("Resolve = %#v, %v", got, ok) - } - registry.Identities[0], registry.Identities[1] = registry.Identities[1], registry.Identities[0] - raw, err = json.Marshal(registry) - if err != nil { - t.Fatal(err) - } - if _, err := ParseRegistry(raw); err == nil { - t.Fatal("ParseRegistry accepted unsorted identities") - } -} - -func TestRegistryRejectsAmbiguousActiveBindings(t *testing.T) { - t.Parallel() - first, second := deterministicPair(t, 11), deterministicPair(t, 12) - for name, entries := range map[string][]RegistryEntry{ - "same actor has multiple active epochs": { - {ActorID: "2", KeyEpoch: "1", Identity: first.Public}, - {ActorID: "2", KeyEpoch: "2", Identity: second.Public}, - }, - "same key belongs to multiple actors": { - {ActorID: "2", KeyEpoch: "1", Identity: first.Public}, - {ActorID: "10", KeyEpoch: "1", Identity: first.Public}, - }, - } { - t.Run(name, func(t *testing.T) { - registry := Registry{Schema: RegistrySchema, Identities: entries} - if err := registry.Validate(); err == nil { - t.Fatalf("Registry.Validate accepted ambiguous bindings: %#v", entries) - } - }) - } -} - -func TestSignatureEncodingValidationDoesNotAuthenticate(t *testing.T) { - t.Parallel() - pair := deterministicPair(t, 13) - signature := Signature{ - Algorithm: Algorithm, - KeyID: pair.Public.KeyID, - Value: base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), - } - if err := signature.ValidateEncoding(); err != nil { - t.Fatalf("structurally valid signature rejected: %v", err) - } - if err := Verify(pair.Public, []byte("not signed"), signature); err == nil { - t.Fatal("encoding validation authenticated a cryptographically invalid signature") - } -} diff --git a/internal/keystore/keystore.go b/internal/keystore/keystore.go deleted file mode 100644 index 2de35f1..0000000 --- a/internal/keystore/keystore.go +++ /dev/null @@ -1,273 +0,0 @@ -// Package keystore encrypts private-key documents for storage on disk. -// -// Passphrases are supplied by callers as byte slices. This package never reads -// them from process arguments or the environment and never includes them in an -// error. Callers remain responsible for obtaining passphrases from a terminal -// or inherited file descriptor and clearing their own buffers when practical. -// The age API accepts passphrases as strings, so an immutable internal copy can -// remain until garbage collection. On Windows, age encryption is the -// confidentiality boundary because os.Chmod does not configure Windows ACLs. -package keystore - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "math" - "os" - "path/filepath" - - "filippo.io/age" -) - -const scryptWorkFactor = 18 - -var ( - ErrAuthentication = errors.New("keystore authentication failed") - ErrDestinationExists = errors.New("keystore destination already exists") - ErrInvalidFormat = errors.New("invalid keystore ciphertext") - ErrLimitExceeded = errors.New("keystore size limit exceeded") -) - -// Limits bounds private-key plaintext and attacker-controlled ciphertext. -// The zero value selects DefaultLimits. If either field is set, both must be -// positive. -type Limits struct { - MaxPlaintextBytes uint64 - MaxCiphertextBytes uint64 -} - -// DefaultLimits returns conservative bounds for private-key documents. The -// ciphertext allowance covers age framing and chunk authentication overhead. -func DefaultLimits() Limits { - return Limits{ - MaxPlaintextBytes: 1024 * 1024, - MaxCiphertextBytes: 2 * 1024 * 1024, - } -} - -// EncryptFile encrypts plaintext with an age scrypt recipient and atomically -// publishes a mode-0600 file at outputPath. outputPath must not already exist. -// The age writer is always closed after a successful initialization so its -// authenticated final chunk is emitted before publication. -func EncryptFile( - ctx context.Context, - outputPath string, - plaintext []byte, - passphrase []byte, - requestedLimits Limits, -) error { - limits, err := normalizeLimits(requestedLimits) - if err != nil { - return err - } - if err := validatePassphrase(passphrase); err != nil { - return err - } - if uint64(len(plaintext)) > limits.MaxPlaintextBytes { - return fmt.Errorf("%w: plaintext exceeds %d bytes", ErrLimitExceeded, limits.MaxPlaintextBytes) - } - if err := ctx.Err(); err != nil { - return err - } - if err := validateDestination(outputPath); err != nil { - return err - } - if _, err := os.Lstat(outputPath); err == nil { - return fmt.Errorf("%w: %s", ErrDestinationExists, outputPath) - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect keystore destination: %w", err) - } - - parent := filepath.Dir(outputPath) - base := filepath.Base(filepath.Clean(outputPath)) - workDirectory, err := os.MkdirTemp(parent, "."+base+".eventctl-work-*") - if err != nil { - return fmt.Errorf("create private keystore work directory: %w", err) - } - defer os.RemoveAll(workDirectory) - if err := os.Chmod(workDirectory, 0o700); err != nil { - return fmt.Errorf("secure private keystore work directory: %w", err) - } - temporaryPath := filepath.Join(workDirectory, "identity.age") - temporary, err := os.OpenFile(temporaryPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - return fmt.Errorf("create keystore temporary file: %w", err) - } - - recipient, err := age.NewScryptRecipient(string(passphrase)) - if err != nil { - temporary.Close() - return fmt.Errorf("initialize keystore recipient: %w", err) - } - recipient.SetWorkFactor(scryptWorkFactor) - encrypted, err := age.Encrypt(temporary, recipient) - if err != nil { - temporary.Close() - return fmt.Errorf("initialize keystore encryption: %w", err) - } - _, writeErr := io.Copy(encrypted, &contextReader{ctx: ctx, reader: bytes.NewReader(plaintext)}) - closeEncryptionErr := encrypted.Close() - if writeErr != nil { - temporary.Close() - return fmt.Errorf("encrypt keystore document: %w", writeErr) - } - if closeEncryptionErr != nil { - temporary.Close() - return fmt.Errorf("finalize keystore encryption: %w", closeEncryptionErr) - } - if err := ctx.Err(); err != nil { - temporary.Close() - return err - } - info, err := temporary.Stat() - if err != nil { - temporary.Close() - return fmt.Errorf("inspect encrypted keystore: %w", err) - } - if info.Size() < 0 || uint64(info.Size()) > limits.MaxCiphertextBytes { - temporary.Close() - return fmt.Errorf("%w: ciphertext exceeds %d bytes", ErrLimitExceeded, limits.MaxCiphertextBytes) - } - if err := temporary.Sync(); err != nil { - temporary.Close() - return fmt.Errorf("sync encrypted keystore: %w", err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("close encrypted keystore: %w", err) - } - - // A hard link within the destination directory is an atomic, no-replace - // publication primitive. Unlike os.Rename on Unix, it cannot overwrite a - // path created after the initial existence check. - if err := publishExclusive(temporaryPath, outputPath); err != nil { - return err - } - return nil -} - -// DecryptFile decrypts and authenticates the complete age stream at inputPath. -// It returns plaintext only after observing authenticated EOF, including the -// absence of trailing ciphertext. -func DecryptFile( - ctx context.Context, - inputPath string, - passphrase []byte, - requestedLimits Limits, -) ([]byte, error) { - limits, err := normalizeLimits(requestedLimits) - if err != nil { - return nil, err - } - if err := validatePassphrase(passphrase); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - ciphertext, err := os.Open(inputPath) - if err != nil { - return nil, fmt.Errorf("open encrypted keystore: %w", err) - } - defer ciphertext.Close() - info, err := ciphertext.Stat() - if err != nil { - return nil, fmt.Errorf("inspect encrypted keystore: %w", err) - } - if !info.Mode().IsRegular() || info.Size() < 0 { - return nil, fmt.Errorf("%w: ciphertext is not a regular file", ErrInvalidFormat) - } - if uint64(info.Size()) > limits.MaxCiphertextBytes { - return nil, fmt.Errorf("%w: ciphertext exceeds %d bytes", ErrLimitExceeded, limits.MaxCiphertextBytes) - } - - identity, err := age.NewScryptIdentity(string(passphrase)) - if err != nil { - return nil, fmt.Errorf("initialize keystore identity: %w", err) - } - identity.SetMaxWorkFactor(scryptWorkFactor) - boundedCiphertext := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: ciphertext}, N: info.Size()} - decrypted, err := age.Decrypt(boundedCiphertext, identity) - if err != nil { - var noMatch *age.NoIdentityMatchError - if errors.As(err, &noMatch) { - return nil, ErrAuthentication - } - return nil, fmt.Errorf("%w: %v", ErrInvalidFormat, err) - } - - boundedPlaintext := &io.LimitedReader{R: decrypted, N: int64(limits.MaxPlaintextBytes) + 1} - plaintext, err := io.ReadAll(boundedPlaintext) - if err != nil { - clear(plaintext) - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - return nil, fmt.Errorf("%w: %v", ErrAuthentication, err) - } - if uint64(len(plaintext)) > limits.MaxPlaintextBytes { - clear(plaintext) - return nil, fmt.Errorf("%w: plaintext exceeds %d bytes", ErrLimitExceeded, limits.MaxPlaintextBytes) - } - if boundedCiphertext.N != 0 { - clear(plaintext) - return nil, fmt.Errorf("%w: ciphertext did not reach authenticated EOF", ErrInvalidFormat) - } - return plaintext, nil -} - -func publishExclusive(sourcePath, destinationPath string) error { - if err := os.Link(sourcePath, destinationPath); err != nil { - if errors.Is(err, os.ErrExist) { - return fmt.Errorf("%w: %s", ErrDestinationExists, destinationPath) - } - return fmt.Errorf("publish encrypted keystore atomically: %w", err) - } - return nil -} - -func normalizeLimits(limits Limits) (Limits, error) { - if limits == (Limits{}) { - limits = DefaultLimits() - } - if limits.MaxPlaintextBytes == 0 || limits.MaxCiphertextBytes == 0 { - return Limits{}, fmt.Errorf("%w: both limits must be positive", ErrLimitExceeded) - } - if limits.MaxPlaintextBytes >= math.MaxInt64 || limits.MaxCiphertextBytes >= math.MaxInt64 { - return Limits{}, fmt.Errorf("%w: limits must fit signed 64-bit readers", ErrLimitExceeded) - } - return limits, nil -} - -func validatePassphrase(passphrase []byte) error { - if len(passphrase) == 0 { - return errors.New("keystore passphrase must not be empty") - } - return nil -} - -func validateDestination(path string) error { - if path == "" { - return errors.New("keystore destination is required") - } - base := filepath.Base(filepath.Clean(path)) - if base == "." || base == string(filepath.Separator) { - return errors.New("keystore destination must name a file") - } - return nil -} - -type contextReader struct { - ctx context.Context - reader io.Reader -} - -func (reader *contextReader) Read(buffer []byte) (int, error) { - if err := reader.ctx.Err(); err != nil { - return 0, err - } - return reader.reader.Read(buffer) -} diff --git a/internal/keystore/keystore_test.go b/internal/keystore/keystore_test.go deleted file mode 100644 index 884b32d..0000000 --- a/internal/keystore/keystore_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package keystore - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" -) - -var testPassphrase = []byte("correct horse battery staple") - -func TestEncryptDecryptRoundTripAndMode(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, "identity.age") - plaintext := bytes.Repeat([]byte("private-key-document\x00\xff"), 4096) - - if err := EncryptFile(context.Background(), path, plaintext, testPassphrase, Limits{}); err != nil { - t.Fatal(err) - } - info, err := os.Lstat(path) - if err != nil { - t.Fatal(err) - } - if !info.Mode().IsRegular() { - t.Fatalf("encrypted output mode = %v, want regular file", info.Mode()) - } - if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { - t.Fatalf("encrypted output permissions = %04o, want 0600", info.Mode().Perm()) - } - - got, err := DecryptFile(context.Background(), path, testPassphrase, Limits{}) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, plaintext) { - t.Fatalf("DecryptFile() = %q, want %q", got, plaintext) - } - assertNoTemporaryFiles(t, root) -} - -func TestDecryptRejectsTamperingTruncationAndWrongPassphrase(t *testing.T) { - root := t.TempDir() - validPath := filepath.Join(root, "valid.age") - if err := EncryptFile( - context.Background(), validPath, []byte("private key document"), testPassphrase, Limits{}, - ); err != nil { - t.Fatal(err) - } - valid, err := os.ReadFile(validPath) - if err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - ciphertext []byte - passphrase []byte - }{ - { - name: "payload tamper", - ciphertext: mutateLastByte(valid), - passphrase: testPassphrase, - }, - { - name: "truncation", - ciphertext: append([]byte(nil), valid[:len(valid)-1]...), - passphrase: testPassphrase, - }, - { - name: "wrong passphrase", - ciphertext: valid, - passphrase: []byte("this is not the passphrase"), - }, - { - name: "trailing ciphertext", - ciphertext: append(append([]byte(nil), valid...), 0), - passphrase: testPassphrase, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - path := filepath.Join(root, strings.ReplaceAll(test.name, " ", "-")+".age") - if err := os.WriteFile(path, test.ciphertext, 0o600); err != nil { - t.Fatal(err) - } - plaintext, err := DecryptFile(context.Background(), path, test.passphrase, Limits{}) - if !errors.Is(err, ErrAuthentication) { - t.Fatalf("DecryptFile() error = %v, want ErrAuthentication", err) - } - if plaintext != nil { - t.Fatalf("DecryptFile() exposed plaintext after failure: %q", plaintext) - } - if strings.Contains(err.Error(), string(test.passphrase)) { - t.Fatal("DecryptFile() error exposed the supplied passphrase") - } - }) - } -} - -func TestEncryptIsExclusiveAndFailureIsAtomic(t *testing.T) { - root := t.TempDir() - existing := filepath.Join(root, "existing.age") - original := []byte("preserve this file") - if err := os.WriteFile(existing, original, 0o600); err != nil { - t.Fatal(err) - } - if err := EncryptFile( - context.Background(), existing, []byte("replacement"), testPassphrase, Limits{}, - ); !errors.Is(err, ErrDestinationExists) { - t.Fatalf("EncryptFile() error = %v, want ErrDestinationExists", err) - } - got, err := os.ReadFile(existing) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, original) { - t.Fatalf("existing destination changed to %q, want %q", got, original) - } - - canceledPath := filepath.Join(root, "canceled.age") - canceled, cancel := context.WithCancel(context.Background()) - cancel() - if err := EncryptFile(canceled, canceledPath, []byte("secret"), testPassphrase, Limits{}); !errors.Is(err, context.Canceled) { - t.Fatalf("EncryptFile() error = %v, want context.Canceled", err) - } - if _, err := os.Lstat(canceledPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("failed encryption published a destination: %v", err) - } - assertNoTemporaryFiles(t, root) -} - -func TestLimitsAreEnforced(t *testing.T) { - root := t.TempDir() - tooSmall := Limits{MaxPlaintextBytes: 3, MaxCiphertextBytes: 1024 * 1024} - if err := EncryptFile( - context.Background(), filepath.Join(root, "oversize.age"), []byte("four"), testPassphrase, tooSmall, - ); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("EncryptFile() error = %v, want ErrLimitExceeded", err) - } - lateFailurePath := filepath.Join(root, "ciphertext-too-large.age") - if err := EncryptFile( - context.Background(), lateFailurePath, []byte("secret"), testPassphrase, - Limits{MaxPlaintextBytes: 1024, MaxCiphertextBytes: 1}, - ); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("EncryptFile() late error = %v, want ErrLimitExceeded", err) - } - if _, err := os.Lstat(lateFailurePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("late encryption failure published a destination: %v", err) - } - - validPath := filepath.Join(root, "valid.age") - if err := EncryptFile(context.Background(), validPath, []byte("secret"), testPassphrase, Limits{}); err != nil { - t.Fatal(err) - } - if _, err := DecryptFile( - context.Background(), validPath, testPassphrase, - Limits{MaxPlaintextBytes: 1024, MaxCiphertextBytes: 1}, - ); !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("DecryptFile() error = %v, want ErrLimitExceeded", err) - } - plaintext, err := DecryptFile( - context.Background(), validPath, testPassphrase, - Limits{MaxPlaintextBytes: 3, MaxCiphertextBytes: 1024 * 1024}, - ) - if !errors.Is(err, ErrLimitExceeded) { - t.Fatalf("DecryptFile() plaintext-limit error = %v, want ErrLimitExceeded", err) - } - if plaintext != nil { - t.Fatalf("DecryptFile() exposed over-limit plaintext: %q", plaintext) - } - assertNoTemporaryFiles(t, root) -} - -func TestExclusivePublicationRaceHasOneCompleteWinner(t *testing.T) { - root := t.TempDir() - first := filepath.Join(root, "first.tmp") - second := filepath.Join(root, "second.tmp") - destination := filepath.Join(root, "identity.age") - firstContents := bytes.Repeat([]byte("a"), 1024) - secondContents := bytes.Repeat([]byte("b"), 1024) - if err := os.WriteFile(first, firstContents, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(second, secondContents, 0o600); err != nil { - t.Fatal(err) - } - - start := make(chan struct{}) - results := make(chan error, 2) - var workers sync.WaitGroup - for _, source := range []string{first, second} { - workers.Add(1) - go func() { - defer workers.Done() - <-start - results <- publishExclusive(source, destination) - }() - } - close(start) - workers.Wait() - close(results) - - succeeded := 0 - existed := 0 - for err := range results { - switch { - case err == nil: - succeeded++ - case errors.Is(err, ErrDestinationExists): - existed++ - default: - t.Fatalf("publishExclusive() unexpected error: %v", err) - } - } - if succeeded != 1 || existed != 1 { - t.Fatalf("publish outcomes = %d success, %d exists; want one each", succeeded, existed) - } - got, err := os.ReadFile(destination) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, firstContents) && !bytes.Equal(got, secondContents) { - t.Fatal("exclusive publication produced partial or mixed contents") - } -} - -func mutateLastByte(input []byte) []byte { - mutated := append([]byte(nil), input...) - mutated[len(mutated)-1] ^= 0x80 - return mutated -} - -func assertNoTemporaryFiles(t *testing.T, directory string) { - t.Helper() - entries, err := os.ReadDir(directory) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.Contains(entry.Name(), ".eventctl-") { - t.Errorf("temporary file was not removed: %s", entry.Name()) - } - } -} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..aaa735d --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,1163 @@ +package protocol + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "filippo.io/age" + "github.com/samber/lo" +) + +const ( + Protocol = "eventctl/v2" + SchemaVersion = 2 + MaxBytes = 64 << 20 +) + +var ( + eventIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`) + decimalPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`) + digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + uuidV4Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) +) + +type TTLSeconds struct { + Registration uint64 `json:"registration"` + Team uint64 `json:"team"` + Submission uint64 `json:"submission"` +} + +type Limits struct { + TeamMinimum uint64 `json:"team_min"` + TeamMaximum uint64 `json:"team_max"` + AttemptsPerTeam uint64 `json:"attempts_per_team"` + AttemptsTotal uint64 `json:"attempts_total"` +} + +// EventBinding is public event policy from trusted main. The repository and +// its reviewed registry are the trust boundary; no app or organizer secret is +// part of normal eventctl operation. +type EventBinding struct { + Version int `json:"v"` + Kind string `json:"kind"` + Protocol string `json:"protocol"` + EventID string `json:"event_id"` + EventEpoch int `json:"event_epoch"` + RepositoryID string `json:"repository_id"` + ValidFrom string `json:"valid_from"` + ValidUntil string `json:"valid_until"` + TermsSHA256 string `json:"terms_sha256"` + TTLSeconds TTLSeconds `json:"ttl_seconds"` + Limits Limits `json:"limits"` +} + +type EventReference struct { + EventID string `json:"event_id"` + EventEpoch int `json:"event_epoch"` + RepositoryID string `json:"repository_id"` + BindingSHA256 string `json:"binding_sha256"` +} + +func (binding EventBinding) Validate() error { + if binding.Version != SchemaVersion || binding.Kind != "event-binding" || binding.Protocol != Protocol { + return errors.New("event binding protocol discriminator is invalid") + } + if !eventIDPattern.MatchString(binding.EventID) || binding.EventEpoch < 1 || !decimalPattern.MatchString(binding.RepositoryID) || !digestPattern.MatchString(binding.TermsSHA256) { + return errors.New("event binding identity is invalid") + } + validFrom, err := parseTimestamp(binding.ValidFrom) + if err != nil { + return fmt.Errorf("event binding valid_from: %w", err) + } + validUntil, err := parseTimestamp(binding.ValidUntil) + if err != nil || !validUntil.After(validFrom) { + return errors.New("event binding validity window is invalid") + } + if binding.TTLSeconds.Registration == 0 || binding.TTLSeconds.Team == 0 || binding.TTLSeconds.Submission == 0 { + return errors.New("event binding request TTL is invalid") + } + if binding.Limits.TeamMinimum < 1 || binding.Limits.TeamMaximum < binding.Limits.TeamMinimum || binding.Limits.TeamMaximum > 64 || binding.Limits.AttemptsPerTeam < 1 || binding.Limits.AttemptsTotal < binding.Limits.AttemptsPerTeam { + return errors.New("event binding limits are invalid") + } + return nil +} + +func (binding EventBinding) Reference() EventReference { + return EventReference{ + EventID: binding.EventID, + EventEpoch: binding.EventEpoch, + RepositoryID: binding.RepositoryID, + BindingSHA256: DigestJSON(binding), + } +} + +func (reference EventReference) Validate() error { + if !eventIDPattern.MatchString(reference.EventID) || reference.EventEpoch < 1 || !decimalPattern.MatchString(reference.RepositoryID) || !digestPattern.MatchString(reference.BindingSHA256) { + return errors.New("event reference is invalid") + } + return nil +} + +func (reference EventReference) Match(binding EventBinding) error { + if err := reference.Validate(); err != nil { + return err + } + if reference != binding.Reference() { + return errors.New("event reference does not match binding") + } + return nil +} + +func ReadEventBinding(path string) (EventBinding, error) { + var binding EventBinding + if err := ReadJSON(path, &binding); err != nil { + return binding, fmt.Errorf("read event binding: %w", err) + } + if err := binding.Validate(); err != nil { + return binding, err + } + return binding, nil +} + +type SigningPublic struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + PublicKey string `json:"public"` +} + +type signingPrivate struct { + Kind string `json:"kind"` + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + PrivateKey string `json:"private"` +} + +type RecipientPublic struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + PublicKey string `json:"recipient"` + Recipient age.Recipient `json:"-"` +} + +type recipientPrivate struct { + Kind string `json:"kind"` + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + Identity string `json:"identity"` +} + +type KeyGenerationResult struct { + SigningPrivate string `json:"signing_private_key"` + SigningPublic string `json:"signing_public_key"` + RecipientPrivate string `json:"recipient_private_key"` + RecipientPublic string `json:"recipient_public_key"` + SigningKeyID string `json:"signing_key_id"` + RecipientKeyID string `json:"recipient_key_id"` +} + +type SigningKey struct { + Public SigningPublic + Private ed25519.PrivateKey +} + +type RecipientKey struct { + Public RecipientPublic + Identity *age.HybridIdentity +} + +func GenerateKeyDirectory(directory, passphrase string) (KeyGenerationResult, error) { + if err := os.MkdirAll(directory, 0o700); err != nil { + return KeyGenerationResult{}, fmt.Errorf("create key directory: %w", err) + } + // Go 1.26 guarantees crypto/rand.Read either fills the buffer or crashes; + // this CLI's minimum Go version makes an ignored error impossible here. + seed := make([]byte, ed25519.SeedSize) + _, _ = rand.Read(seed) + private := ed25519.NewKeyFromSeed(seed) + public := private.Public().(ed25519.PublicKey) + signingID := fingerprint(public) + signingPublic := SigningPublic{Algorithm: "Ed25519", KeyID: signingID, PublicKey: base64.RawURLEncoding.EncodeToString(public)} + signingPrivate := signingPrivate{Kind: "signing-private", Algorithm: "Ed25519", KeyID: signingID, PrivateKey: base64.RawURLEncoding.EncodeToString(private)} + // age hybrid generation uses crypto/rand, whose failure exits this Go + // runtime before an error can be returned. + identity := lo.Must(age.GenerateHybridIdentity()) + recipientID := fingerprint([]byte(identity.Recipient().String())) + recipientPublic := RecipientPublic{Algorithm: "age-hybrid-mlkem768-x25519", KeyID: recipientID, PublicKey: identity.Recipient().String(), Recipient: identity.Recipient()} + recipientPrivate := recipientPrivate{Kind: "recipient-private", Algorithm: recipientPublic.Algorithm, KeyID: recipientID, Identity: identity.String()} + signingData := canonicalJSON(signingPrivate) + recipientData := canonicalJSON(recipientPrivate) + signingCiphertext := encryptWithPass(signingData, passphrase) + recipientCiphertext := encryptWithPass(recipientData, passphrase) + signingPublicData := canonicalJSON(signingPublic) + recipientPublicData := canonicalJSON(recipientPublic) + signingPrivatePath := filepath.Join(directory, "signing.private.age") + signingPublicPath := filepath.Join(directory, "signing.public.json") + recipientPrivatePath := filepath.Join(directory, "recipient.private.age") + recipientPublicPath := filepath.Join(directory, "recipient.public.json") + for _, file := range []struct { + path string + data []byte + mode os.FileMode + }{ + {signingPrivatePath, signingCiphertext, 0o600}, + {signingPublicPath, signingPublicData, 0o644}, + {recipientPrivatePath, recipientCiphertext, 0o600}, + {recipientPublicPath, recipientPublicData, 0o644}, + } { + if err := writeExclusive(file.path, file.data, file.mode); err != nil { + return KeyGenerationResult{}, fmt.Errorf("write key file %s: %w", file.path, err) + } + } + return KeyGenerationResult{SigningPrivate: signingPrivatePath, SigningPublic: signingPublicPath, RecipientPrivate: recipientPrivatePath, RecipientPublic: recipientPublicPath, SigningKeyID: signingID, RecipientKeyID: recipientID}, nil +} + +func LoadSigningPrivate(path, passphrase string) (SigningKey, error) { + data, err := readFile(path) + if err != nil { + return SigningKey{}, fmt.Errorf("read signing key: %w", err) + } + plain, err := decryptWithPass(data, passphrase) + if err != nil { + return SigningKey{}, fmt.Errorf("decrypt signing key: %w", err) + } + var document signingPrivate + if err := decodeJSON(plain, &document); err != nil { + return SigningKey{}, fmt.Errorf("decode signing key: %w", err) + } + key, err := base64.RawURLEncoding.DecodeString(document.PrivateKey) + if err != nil || len(key) != ed25519.PrivateKeySize { + return SigningKey{}, errors.New("invalid signing private key") + } + private := ed25519.PrivateKey(key) + public := private.Public().(ed25519.PublicKey) + if document.Kind != "signing-private" || document.Algorithm != "Ed25519" || document.KeyID != fingerprint(public) { + return SigningKey{}, errors.New("signing key metadata does not match") + } + return SigningKey{Public: SigningPublic{Algorithm: document.Algorithm, KeyID: document.KeyID, PublicKey: base64.RawURLEncoding.EncodeToString(public)}, Private: private}, nil +} + +func LoadSigningPublic(path string) (SigningPublic, error) { + var document SigningPublic + if err := ReadJSON(path, &document); err != nil { + return document, fmt.Errorf("read signing public key: %w", err) + } + if _, err := decodeSigningPublic(document); err != nil { + return document, err + } + return document, nil +} + +func LoadRecipientPublic(path string) (RecipientPublic, error) { + var document RecipientPublic + if err := ReadJSON(path, &document); err != nil { + return document, fmt.Errorf("read recipient public key: %w", err) + } + if err := validateRecipientPublic(&document); err != nil { + return document, err + } + return document, nil +} + +func LoadRecipientPrivate(path, passphrase string) (RecipientKey, error) { + data, err := readFile(path) + if err != nil { + return RecipientKey{}, fmt.Errorf("read recipient key: %w", err) + } + plain, err := decryptWithPass(data, passphrase) + if err != nil { + return RecipientKey{}, fmt.Errorf("decrypt recipient key: %w", err) + } + var document recipientPrivate + if err := decodeJSON(plain, &document); err != nil { + return RecipientKey{}, fmt.Errorf("decode recipient key: %w", err) + } + if document.Kind != "recipient-private" || document.Algorithm != "age-hybrid-mlkem768-x25519" { + return RecipientKey{}, errors.New("invalid recipient private key metadata") + } + identity, err := age.ParseHybridIdentity(document.Identity) + if err != nil { + return RecipientKey{}, fmt.Errorf("parse recipient private key: %w", err) + } + public := identity.Recipient().String() + if document.KeyID != fingerprint([]byte(public)) { + return RecipientKey{}, errors.New("recipient private key fingerprint mismatch") + } + return RecipientKey{Public: RecipientPublic{Algorithm: document.Algorithm, KeyID: document.KeyID, PublicKey: public, Recipient: identity.Recipient()}, Identity: identity}, nil +} + +type Signature struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + Value string `json:"value"` +} + +func Sign(domain string, value any, key SigningKey) Signature { + message := signedMessage(domain, value) + return Signature{Algorithm: "Ed25519", KeyID: key.Public.KeyID, Value: base64.RawURLEncoding.EncodeToString(ed25519.Sign(key.Private, message))} +} + +func Verify(domain string, value any, signature Signature, public SigningPublic) error { + key, err := decodeSigningPublic(public) + if err != nil { + return err + } + if signature.Algorithm != "Ed25519" || signature.KeyID != public.KeyID { + return errors.New("signature metadata does not match signer") + } + signed, err := base64.RawURLEncoding.DecodeString(signature.Value) + if err != nil || len(signed) != ed25519.SignatureSize { + return errors.New("invalid signature encoding") + } + if !ed25519.Verify(key, signedMessage(domain, value), signed) { + return errors.New("signature verification failed") + } + return nil +} + +type IdentityRegistration struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + RegistrationID string `json:"registration_id"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKey SigningPublic `json:"signing_key"` + RecipientKey RecipientPublic `json:"recipient_key"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Signature Signature `json:"signature"` +} + +type identityUnsigned struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + RegistrationID string `json:"registration_id"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKey SigningPublic `json:"signing_key"` + RecipientKey RecipientPublic `json:"recipient_key"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` +} + +func (document IdentityRegistration) unsigned() identityUnsigned { + return identityUnsigned{Version: document.Version, Kind: document.Kind, Event: document.Event, RegistrationID: document.RegistrationID, ActorID: document.ActorID, KeyEpoch: document.KeyEpoch, SigningKey: document.SigningKey, RecipientKey: document.RecipientKey, IssuedAt: document.IssuedAt, ExpiresAt: document.ExpiresAt} +} + +func RegisterIdentity(binding EventBinding, actorID string, keyEpoch int, registrationID string, signing SigningKey, recipient RecipientPublic, now time.Time) (IdentityRegistration, error) { + if !validActorID(actorID) || keyEpoch < 1 { + return IdentityRegistration{}, errors.New("identity registration actor or key epoch is invalid") + } + if registrationID == "" { + registrationID = newUUIDv4() + } + if !uuidV4Pattern.MatchString(registrationID) { + return IdentityRegistration{}, errors.New("identity registration ID is invalid") + } + issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Registration, now) + if err != nil { + return IdentityRegistration{}, err + } + document := IdentityRegistration{Version: SchemaVersion, Kind: "identity-registration", Event: binding.Reference(), RegistrationID: registrationID, ActorID: actorID, KeyEpoch: keyEpoch, SigningKey: signing.Public, RecipientKey: recipient, IssuedAt: issuedAt, ExpiresAt: expiresAt} + document.Signature = Sign("identity.register", document.unsigned(), signing) + return document, nil +} + +type IdentityRecord struct { + ActorID string `json:"actor_id"` + RegistrationID string `json:"registration_id"` + RegistrationSHA256 string `json:"registration_sha256"` + KeyEpoch int `json:"key_epoch"` + SigningKey SigningPublic `json:"signing_key"` + RecipientKey RecipientPublic `json:"recipient_key"` +} + +func VerifyIdentity(document IdentityRegistration, binding EventBinding, expectedActorID, sourceTime string) (IdentityRecord, error) { + if document.Version != SchemaVersion || document.Kind != "identity-registration" || !validActorID(document.ActorID) || document.ActorID != expectedActorID || document.KeyEpoch < 1 || !uuidV4Pattern.MatchString(document.RegistrationID) { + return IdentityRecord{}, errors.New("identity registration identity is invalid") + } + if err := document.Event.Match(binding); err != nil { + return IdentityRecord{}, err + } + at, err := parseTimestamp(sourceTime) + if err != nil { + return IdentityRecord{}, fmt.Errorf("identity registration source time: %w", err) + } + if err := validateDocumentWindow(document.IssuedAt, document.ExpiresAt, binding, binding.TTLSeconds.Registration, at); err != nil { + return IdentityRecord{}, err + } + if err := validateRecipientPublic(&document.RecipientKey); err != nil { + return IdentityRecord{}, err + } + if err := Verify("identity.register", document.unsigned(), document.Signature, document.SigningKey); err != nil { + return IdentityRecord{}, err + } + return IdentityRecord{ActorID: document.ActorID, RegistrationID: document.RegistrationID, RegistrationSHA256: DigestJSON(document), KeyEpoch: document.KeyEpoch, SigningKey: document.SigningKey, RecipientKey: document.RecipientKey}, nil +} + +type Registry struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + Revision uint64 `json:"revision"` + Phase string `json:"phase"` + Enabled bool `json:"enabled"` + DisabledReason string `json:"disabled_reason"` + Identities []IdentityRecord `json:"identities"` + Teams []TeamRecord `json:"teams"` + Attempts []AttemptRecord `json:"attempts"` +} + +type TeamRecord struct { + TeamID string `json:"team_id"` + ProposalSHA256 string `json:"proposal_sha256"` + Members []TeamMember `json:"members"` +} + +type AttemptRecord struct { + AttemptID string `json:"attempt_id"` + TeamID string `json:"team_id"` + ActorID string `json:"actor_id"` + SubmissionSHA256 string `json:"submission_sha256"` + PayloadSHA256 string `json:"payload_sha256"` +} + +func ReadRegistry(path string, binding EventBinding) (Registry, error) { + var registry Registry + if err := ReadJSON(path, ®istry); err != nil { + return registry, fmt.Errorf("read registry: %w", err) + } + if err := registry.Validate(binding); err != nil { + return registry, err + } + return registry, nil +} + +func (registry Registry) Validate(binding EventBinding) error { + if registry.Version != SchemaVersion || registry.Kind != "event-registry" { + return errors.New("registry protocol discriminator is invalid") + } + if err := registry.Event.Match(binding); err != nil { + return err + } + if !lo.Contains([]string{"draft", "registration_open", "formation_open", "submissions_open", "closed"}, registry.Phase) { + return errors.New("registry phase is invalid") + } + if registry.Enabled && registry.DisabledReason != "" { + return errors.New("enabled registry cannot have a disabled reason") + } + if !registry.Enabled && registry.DisabledReason == "" { + return errors.New("disabled registry requires a disabled reason") + } + for index, identity := range registry.Identities { + if !validActorID(identity.ActorID) || !uuidV4Pattern.MatchString(identity.RegistrationID) || !digestPattern.MatchString(identity.RegistrationSHA256) || identity.KeyEpoch < 1 { + return errors.New("registry identity is invalid") + } + if _, err := decodeSigningPublic(identity.SigningKey); err != nil { + return err + } + if err := validateRecipientPublic(&identity.RecipientKey); err != nil { + return err + } + if index > 0 && compareActorIDs(registry.Identities[index-1].ActorID, identity.ActorID) >= 0 { + return errors.New("registry identities must be strictly sorted") + } + } + activeMembers := make(map[string]bool) + for index, team := range registry.Teams { + if !uuidV4Pattern.MatchString(team.TeamID) || !digestPattern.MatchString(team.ProposalSHA256) || uint64(len(team.Members)) < binding.Limits.TeamMinimum || uint64(len(team.Members)) > binding.Limits.TeamMaximum { + return errors.New("registry team is invalid") + } + if index > 0 && registry.Teams[index-1].TeamID >= team.TeamID { + return errors.New("registry teams must be strictly sorted") + } + for memberIndex, member := range team.Members { + identity, err := registry.Lookup(member.ActorID) + if err != nil || member != memberFromIdentity(identity) || activeMembers[member.ActorID] { + return errors.New("registry team member does not match an available identity") + } + if memberIndex > 0 && compareActorIDs(team.Members[memberIndex-1].ActorID, member.ActorID) >= 0 { + return errors.New("registry team members must be strictly sorted") + } + activeMembers[member.ActorID] = true + } + } + for index, attempt := range registry.Attempts { + if !uuidV4Pattern.MatchString(attempt.AttemptID) || !uuidV4Pattern.MatchString(attempt.TeamID) || !validActorID(attempt.ActorID) || !digestPattern.MatchString(attempt.SubmissionSHA256) || !digestPattern.MatchString(attempt.PayloadSHA256) { + return errors.New("registry attempt is invalid") + } + if index > 0 && registry.Attempts[index-1].AttemptID >= attempt.AttemptID { + return errors.New("registry attempts must be strictly sorted") + } + team, err := registry.LookupTeam(attempt.TeamID) + if err != nil || !lo.ContainsBy(team.Members, func(member TeamMember) bool { return member.ActorID == attempt.ActorID }) { + return errors.New("registry attempt submitter is not an active team member") + } + } + return nil +} + +func (registry Registry) Lookup(actorID string) (IdentityRecord, error) { + for _, identity := range registry.Identities { + if identity.ActorID == actorID { + return identity, nil + } + } + return IdentityRecord{}, errors.New("actor has no active registered identity") +} + +func (registry Registry) LookupTeam(teamID string) (TeamRecord, error) { + for _, team := range registry.Teams { + if team.TeamID == teamID { + return team, nil + } + } + return TeamRecord{}, errors.New("team is not active") +} + +func (registry Registry) RequirePhase(phase string) error { + if !registry.Enabled || registry.Phase != phase { + return errors.New("registry is not open for this operation") + } + return nil +} + +func (registry Registry) teamAvailable(teamID string, members []TeamMember) error { + if _, err := registry.LookupTeam(teamID); err == nil { + return errors.New("team ID is already active") + } + for _, member := range members { + for _, team := range registry.Teams { + if lo.ContainsBy(team.Members, func(active TeamMember) bool { return active.ActorID == member.ActorID }) { + return errors.New("team member already belongs to an active team") + } + } + } + return nil +} + +func (registry Registry) hasAttempt(attemptID string) bool { + return lo.ContainsBy(registry.Attempts, func(attempt AttemptRecord) bool { return attempt.AttemptID == attemptID }) +} + +func (registry Registry) attemptsForTeam(teamID string) uint64 { + return uint64(len(lo.Filter(registry.Attempts, func(attempt AttemptRecord, _ int) bool { return attempt.TeamID == teamID }))) +} + +type TeamMember struct { + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKeyID string `json:"signing_kid"` + RecipientKeyID string `json:"recipient_kid"` +} + +type TeamProposal struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + Proposer TeamMember `json:"proposer"` + Members []TeamMember `json:"members"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Signature Signature `json:"signature"` +} + +type teamProposalUnsigned struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + Proposer TeamMember `json:"proposer"` + Members []TeamMember `json:"members"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` +} + +func (proposal TeamProposal) unsigned() teamProposalUnsigned { + return teamProposalUnsigned{Version: proposal.Version, Kind: proposal.Kind, Event: proposal.Event, TeamID: proposal.TeamID, Proposer: proposal.Proposer, Members: proposal.Members, IssuedAt: proposal.IssuedAt, ExpiresAt: proposal.ExpiresAt} +} + +type TeamConsent struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + ProposalSHA256 string `json:"proposal_sha256"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKeyID string `json:"signing_kid"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Signature Signature `json:"signature"` +} + +type teamConsentUnsigned struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + ProposalSHA256 string `json:"proposal_sha256"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKeyID string `json:"signing_kid"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` +} + +func (consent TeamConsent) unsigned() teamConsentUnsigned { + return teamConsentUnsigned{Version: consent.Version, Kind: consent.Kind, Event: consent.Event, TeamID: consent.TeamID, ProposalSHA256: consent.ProposalSHA256, ActorID: consent.ActorID, KeyEpoch: consent.KeyEpoch, SigningKeyID: consent.SigningKeyID, IssuedAt: consent.IssuedAt, ExpiresAt: consent.ExpiresAt} +} + +type TeamVerification struct { + TeamID string `json:"team_id"` + ProposalSHA256 string `json:"proposal_sha256"` + Members []TeamMember `json:"members"` + Verified bool `json:"verified"` +} + +func ProposeTeam(binding EventBinding, registry Registry, teamID, proposerActorID string, memberActorIDs []string, signing SigningKey, now time.Time) (TeamProposal, error) { + if err := registry.RequirePhase("formation_open"); err != nil { + return TeamProposal{}, err + } + if teamID == "" { + teamID = newUUIDv4() + } + if !uuidV4Pattern.MatchString(teamID) || !validActorID(proposerActorID) { + return TeamProposal{}, errors.New("team proposal ID or proposer is invalid") + } + memberActorIDs = normalizeActorIDs(memberActorIDs) + if uint64(len(memberActorIDs)) < binding.Limits.TeamMinimum || uint64(len(memberActorIDs)) > binding.Limits.TeamMaximum || !lo.Contains(memberActorIDs, proposerActorID) { + return TeamProposal{}, errors.New("team proposal members are invalid") + } + proposer, err := registry.Lookup(proposerActorID) + if err != nil { + return TeamProposal{}, err + } + if signing.Public.KeyID != proposer.SigningKey.KeyID { + return TeamProposal{}, errors.New("team proposer key is not the active registered key") + } + members := make([]TeamMember, 0, len(memberActorIDs)) + for _, actorID := range memberActorIDs { + identity, lookupErr := registry.Lookup(actorID) + if lookupErr != nil { + return TeamProposal{}, lookupErr + } + members = append(members, memberFromIdentity(identity)) + } + if err := registry.teamAvailable(teamID, members); err != nil { + return TeamProposal{}, err + } + issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Team, now) + if err != nil { + return TeamProposal{}, err + } + document := TeamProposal{Version: SchemaVersion, Kind: "team-proposal", Event: binding.Reference(), TeamID: teamID, Proposer: memberFromIdentity(proposer), Members: members, IssuedAt: issuedAt, ExpiresAt: expiresAt} + document.Signature = Sign("team.propose", document.unsigned(), signing) + return document, nil +} + +func ConsentTeam(binding EventBinding, registry Registry, proposal TeamProposal, actorID string, signing SigningKey, now time.Time) (TeamConsent, error) { + if err := registry.RequirePhase("formation_open"); err != nil { + return TeamConsent{}, err + } + issued := now.UTC().Truncate(time.Second) + if err := verifyProposal(proposal, binding, registry, issued); err != nil { + return TeamConsent{}, err + } + if err := registry.teamAvailable(proposal.TeamID, proposal.Members); err != nil { + return TeamConsent{}, err + } + identity, err := registry.Lookup(actorID) + if err != nil { + return TeamConsent{}, err + } + if !lo.ContainsBy(proposal.Members, func(member TeamMember) bool { return member.ActorID == actorID }) || identity.SigningKey.KeyID != signing.Public.KeyID { + return TeamConsent{}, errors.New("team consent actor is not an active proposed member") + } + document := TeamConsent{Version: SchemaVersion, Kind: "team-consent", Event: binding.Reference(), TeamID: proposal.TeamID, ProposalSHA256: DigestJSON(proposal), ActorID: actorID, KeyEpoch: identity.KeyEpoch, SigningKeyID: identity.SigningKey.KeyID, IssuedAt: issued.Format(time.RFC3339), ExpiresAt: proposal.ExpiresAt} + document.Signature = Sign("team.consent", document.unsigned(), signing) + return document, nil +} + +func VerifyTeam(binding EventBinding, registry Registry, proposal TeamProposal, proposalSourceTime string, consents []TeamConsent, consentSourceTimes map[string]string) (TeamVerification, error) { + if err := registry.RequirePhase("formation_open"); err != nil { + return TeamVerification{}, err + } + proposalAt, err := parseTimestamp(proposalSourceTime) + if err != nil { + return TeamVerification{}, fmt.Errorf("team proposal source time: %w", err) + } + if err := verifyProposal(proposal, binding, registry, proposalAt); err != nil { + return TeamVerification{}, err + } + if err := registry.teamAvailable(proposal.TeamID, proposal.Members); err != nil { + return TeamVerification{}, err + } + if len(consents) != len(proposal.Members) || len(consentSourceTimes) != len(proposal.Members) { + return TeamVerification{}, errors.New("team consents and source times must cover every member") + } + seen := make(map[string]bool, len(consents)) + proposalDigest := DigestJSON(proposal) + for _, consent := range consents { + if seen[consent.ActorID] { + return TeamVerification{}, errors.New("duplicate team consent") + } + atText, found := consentSourceTimes[consent.ActorID] + if !found { + return TeamVerification{}, errors.New("team consent source time is missing") + } + at, parseErr := parseTimestamp(atText) + if parseErr != nil { + return TeamVerification{}, fmt.Errorf("team consent source time: %w", parseErr) + } + if err := verifyConsent(consent, proposal, proposalDigest, binding, registry, at); err != nil { + return TeamVerification{}, err + } + seen[consent.ActorID] = true + } + return TeamVerification{TeamID: proposal.TeamID, ProposalSHA256: proposalDigest, Members: proposal.Members, Verified: true}, nil +} + +func verifyProposal(proposal TeamProposal, binding EventBinding, registry Registry, sourceTime time.Time) error { + if proposal.Version != SchemaVersion || proposal.Kind != "team-proposal" || !uuidV4Pattern.MatchString(proposal.TeamID) { + return errors.New("team proposal is invalid") + } + if err := proposal.Event.Match(binding); err != nil { + return err + } + if uint64(len(proposal.Members)) < binding.Limits.TeamMinimum || uint64(len(proposal.Members)) > binding.Limits.TeamMaximum { + return errors.New("team proposal size is invalid") + } + if err := validateDocumentWindow(proposal.IssuedAt, proposal.ExpiresAt, binding, binding.TTLSeconds.Team, sourceTime); err != nil { + return err + } + var proposer SigningPublic + proposerMatches := false + for index, member := range proposal.Members { + identity, err := registry.Lookup(member.ActorID) + if err != nil || member != memberFromIdentity(identity) { + return errors.New("team proposal member does not match active registry") + } + if index > 0 && compareActorIDs(proposal.Members[index-1].ActorID, member.ActorID) >= 0 { + return errors.New("team proposal members must be strictly sorted") + } + if member == proposal.Proposer { + proposer = identity.SigningKey + proposerMatches = true + } + } + if !proposerMatches { + return errors.New("team proposer is not a proposed member") + } + return Verify("team.propose", proposal.unsigned(), proposal.Signature, proposer) +} + +func verifyConsent(consent TeamConsent, proposal TeamProposal, proposalDigest string, binding EventBinding, registry Registry, sourceTime time.Time) error { + if consent.Version != SchemaVersion || consent.Kind != "team-consent" || consent.TeamID != proposal.TeamID || consent.ProposalSHA256 != proposalDigest || !validActorID(consent.ActorID) || consent.KeyEpoch < 1 || !digestPattern.MatchString(consent.SigningKeyID) { + return errors.New("team consent is invalid") + } + if err := consent.Event.Match(binding); err != nil { + return err + } + if err := validateDocumentWindow(consent.IssuedAt, consent.ExpiresAt, binding, binding.TTLSeconds.Team, sourceTime); err != nil { + return err + } + identity, err := registry.Lookup(consent.ActorID) + if err != nil || identity.KeyEpoch != consent.KeyEpoch || identity.SigningKey.KeyID != consent.SigningKeyID || !lo.ContainsBy(proposal.Members, func(member TeamMember) bool { return member.ActorID == consent.ActorID }) { + return errors.New("team consent signer does not match active proposed member") + } + return Verify("team.consent", consent.unsigned(), consent.Signature, identity.SigningKey) +} + +type Submission struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + AttemptID string `json:"attempt_id"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKeyID string `json:"signing_kid"` + PayloadSHA256 string `json:"payload_sha256"` + PayloadSize int64 `json:"payload_size"` + MetadataSHA256 string `json:"metadata_sha256,omitempty"` + MetadataSize int64 `json:"metadata_size,omitempty"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Signature Signature `json:"signature"` +} + +type submissionUnsigned struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + TeamID string `json:"team_id"` + AttemptID string `json:"attempt_id"` + ActorID string `json:"actor_id"` + KeyEpoch int `json:"key_epoch"` + SigningKeyID string `json:"signing_kid"` + PayloadSHA256 string `json:"payload_sha256"` + PayloadSize int64 `json:"payload_size"` + MetadataSHA256 string `json:"metadata_sha256,omitempty"` + MetadataSize int64 `json:"metadata_size,omitempty"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` +} + +func (submission Submission) unsigned() submissionUnsigned { + return submissionUnsigned{Version: submission.Version, Kind: submission.Kind, Event: submission.Event, TeamID: submission.TeamID, AttemptID: submission.AttemptID, ActorID: submission.ActorID, KeyEpoch: submission.KeyEpoch, SigningKeyID: submission.SigningKeyID, PayloadSHA256: submission.PayloadSHA256, PayloadSize: submission.PayloadSize, MetadataSHA256: submission.MetadataSHA256, MetadataSize: submission.MetadataSize, IssuedAt: submission.IssuedAt, ExpiresAt: submission.ExpiresAt} +} + +func PrepareSubmission(binding EventBinding, teamID, attemptID, actorID string, keyEpoch int, payload, metadata []byte, signing SigningKey, now time.Time) (Submission, error) { + if !uuidV4Pattern.MatchString(teamID) || !uuidV4Pattern.MatchString(attemptID) || !validActorID(actorID) || keyEpoch < 1 { + return Submission{}, errors.New("submission identity is invalid") + } + issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Submission, now) + if err != nil { + return Submission{}, err + } + document := Submission{Version: SchemaVersion, Kind: "submission", Event: binding.Reference(), TeamID: teamID, AttemptID: attemptID, ActorID: actorID, KeyEpoch: keyEpoch, SigningKeyID: signing.Public.KeyID, PayloadSHA256: digest(payload), PayloadSize: int64(len(payload)), IssuedAt: issuedAt, ExpiresAt: expiresAt} + if len(metadata) > 0 { + document.MetadataSHA256 = digest(metadata) + document.MetadataSize = int64(len(metadata)) + } + document.Signature = Sign("submission.prepare", document.unsigned(), signing) + return document, nil +} + +func VerifySubmission(document Submission, binding EventBinding, registry Registry, expectedActorID, sourceTime string, payload, metadata []byte) error { + if document.Version != SchemaVersion || document.Kind != "submission" || !uuidV4Pattern.MatchString(document.TeamID) || !uuidV4Pattern.MatchString(document.AttemptID) || !validActorID(document.ActorID) || document.ActorID != expectedActorID || document.KeyEpoch < 1 || !digestPattern.MatchString(document.SigningKeyID) || !digestPattern.MatchString(document.PayloadSHA256) || document.PayloadSize < 0 || (document.MetadataSHA256 != "" && !digestPattern.MatchString(document.MetadataSHA256)) || document.MetadataSize < 0 { + return errors.New("submission is invalid") + } + if err := document.Event.Match(binding); err != nil { + return err + } + at, err := parseTimestamp(sourceTime) + if err != nil { + return fmt.Errorf("submission source time: %w", err) + } + if err := validateDocumentWindow(document.IssuedAt, document.ExpiresAt, binding, binding.TTLSeconds.Submission, at); err != nil { + return err + } + if err := registry.RequirePhase("submissions_open"); err != nil { + return err + } + identity, err := registry.Lookup(document.ActorID) + if err != nil || identity.KeyEpoch != document.KeyEpoch || identity.SigningKey.KeyID != document.SigningKeyID { + return errors.New("submission signer does not match active registry") + } + if document.PayloadSHA256 != digest(payload) || document.PayloadSize != int64(len(payload)) || (document.MetadataSHA256 == "" && len(metadata) != 0) || (document.MetadataSHA256 != "" && (document.MetadataSHA256 != digest(metadata) || document.MetadataSize != int64(len(metadata)))) { + return errors.New("submission payload does not match signed document") + } + team, err := registry.LookupTeam(document.TeamID) + if err != nil || !lo.ContainsBy(team.Members, func(member TeamMember) bool { + return member.ActorID == document.ActorID && member.KeyEpoch == document.KeyEpoch && member.SigningKeyID == document.SigningKeyID + }) { + return errors.New("submission signer is not an active team member") + } + if registry.hasAttempt(document.AttemptID) || uint64(len(registry.Attempts)) >= binding.Limits.AttemptsTotal || registry.attemptsForTeam(document.TeamID) >= binding.Limits.AttemptsPerTeam { + return errors.New("submission attempt is not available") + } + return Verify("submission.prepare", document.unsigned(), document.Signature, identity.SigningKey) +} + +type StreamBinding struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event EventReference `json:"event"` + Purpose string `json:"purpose"` + TeamID string `json:"team_id"` + AttemptID string `json:"attempt_id"` + ArtifactID string `json:"artifact_id"` +} + +func (binding StreamBinding) Validate() error { + if binding.Version != SchemaVersion || binding.Kind != "stream-binding" || binding.Purpose == "" || !uuidV4Pattern.MatchString(binding.TeamID) || !uuidV4Pattern.MatchString(binding.AttemptID) || binding.ArtifactID == "" { + return errors.New("stream binding is invalid") + } + return binding.Event.Validate() +} + +func ReadStreamBinding(path string) (StreamBinding, error) { + var binding StreamBinding + if err := ReadJSON(path, &binding); err != nil { + return binding, fmt.Errorf("read stream binding: %w", err) + } + if err := binding.Validate(); err != nil { + return binding, err + } + return binding, nil +} + +func DigestJSON(value any) string { + return digest(canonicalJSON(value)) +} + +func WriteJSON(path string, value any) error { + return writeExclusive(path, canonicalJSON(value), 0o644) +} + +func ReadJSON(path string, value any) error { + data, err := readFile(path) + if err != nil { + return err + } + return decodeJSON(data, value) +} + +func ReadBytes(path string) ([]byte, error) { return readFile(path) } + +func WriteExclusive(path string, data []byte, mode os.FileMode) error { + return writeExclusive(path, data, mode) +} + +func memberFromIdentity(identity IdentityRecord) TeamMember { + return TeamMember{ActorID: identity.ActorID, KeyEpoch: identity.KeyEpoch, SigningKeyID: identity.SigningKey.KeyID, RecipientKeyID: identity.RecipientKey.KeyID} +} + +func normalizeActorIDs(values []string) []string { + values = lo.Map(values, func(value string, _ int) string { return strings.TrimSpace(value) }) + values = lo.Filter(values, func(value string, _ int) bool { return validActorID(value) }) + values = lo.Uniq(values) + sort.Slice(values, func(left, right int) bool { return compareActorIDs(values[left], values[right]) < 0 }) + return values +} + +func validateRecipientPublic(document *RecipientPublic) error { + if document.Algorithm != "age-hybrid-mlkem768-x25519" || !digestPattern.MatchString(document.KeyID) { + return errors.New("invalid recipient public key metadata") + } + recipient, err := age.ParseHybridRecipient(document.PublicKey) + if err != nil { + return fmt.Errorf("parse recipient public key: %w", err) + } + if document.KeyID != fingerprint([]byte(recipient.String())) { + return errors.New("recipient public key fingerprint mismatch") + } + document.Recipient = recipient + return nil +} + +func decodeSigningPublic(document SigningPublic) (ed25519.PublicKey, error) { + if document.Algorithm != "Ed25519" || !digestPattern.MatchString(document.KeyID) { + return nil, errors.New("invalid signing public key metadata") + } + key, err := base64.RawURLEncoding.DecodeString(document.PublicKey) + if err != nil || len(key) != ed25519.PublicKeySize || document.KeyID != fingerprint(key) { + return nil, errors.New("invalid signing public key") + } + return ed25519.PublicKey(key), nil +} + +func issueWindow(binding EventBinding, ttl uint64, now time.Time) (string, string, error) { + validFrom, _ := parseTimestamp(binding.ValidFrom) + validUntil, _ := parseTimestamp(binding.ValidUntil) + issued := now.UTC().Truncate(time.Second) + expires := issued.Add(time.Duration(ttl) * time.Second) + if issued.Before(validFrom) || expires.After(validUntil) { + return "", "", errors.New("request window falls outside event binding") + } + return issued.Format(time.RFC3339), expires.Format(time.RFC3339), nil +} + +func validateDocumentWindow(issuedText, expiresText string, binding EventBinding, ttl uint64, sourceTime time.Time) error { + issued, err := parseTimestamp(issuedText) + if err != nil { + return fmt.Errorf("invalid issued_at: %w", err) + } + expires, err := parseTimestamp(expiresText) + if err != nil || !expires.After(issued) || expires.Sub(issued) > time.Duration(ttl)*time.Second { + return errors.New("document validity window is invalid") + } + validFrom, _ := parseTimestamp(binding.ValidFrom) + validUntil, _ := parseTimestamp(binding.ValidUntil) + if issued.Before(validFrom) || expires.After(validUntil) || sourceTime.Before(issued) || sourceTime.After(expires) { + return errors.New("document is not valid at trusted source time") + } + return nil +} + +func parseTimestamp(value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil || parsed.UTC().Format(time.RFC3339) != value { + return time.Time{}, errors.New("timestamp must be UTC RFC3339 whole seconds") + } + return parsed.UTC(), nil +} + +func validActorID(value string) bool { return decimalPattern.MatchString(value) } + +func compareActorIDs(left, right string) int { + leftValue, _ := strconv.ParseUint(left, 10, 64) + rightValue, _ := strconv.ParseUint(right, 10, 64) + switch { + case leftValue < rightValue: + return -1 + case leftValue > rightValue: + return 1 + default: + return 0 + } +} + +func newUUIDv4() string { + value := make([]byte, 16) + _, _ = rand.Read(value) + value[6] = value[6]&0x0f | 0x40 + value[8] = value[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]) +} + +func fingerprint(value []byte) string { + hash := sha256.Sum256(value) + return hex.EncodeToString(hash[:]) +} + +func digest(value []byte) string { + hash := sha256.Sum256(value) + return hex.EncodeToString(hash[:]) +} + +func signedMessage(domain string, value any) []byte { + return append([]byte("eventctl:"+Protocol+":"+domain+"\x00"), canonicalJSON(value)...) +} + +func canonicalJSON(value any) []byte { + // Every caller supplies one of this package's concrete document structs. + // Their fields are JSON-encodable, so json.Marshal cannot fail here. + data := lo.Must(json.Marshal(value)) + return append(data, '\n') +} + +func encryptWithPass(data []byte, passphrase string) []byte { + // readPassphrase has already rejected the only invalid ScryptRecipient + // input, and bytes.Buffer never returns a write error. + recipient := lo.Must(age.NewScryptRecipient(passphrase)) + var output bytes.Buffer + writer := lo.Must(age.Encrypt(&output, recipient)) + lo.Must(writer.Write(data)) + lo.Must0(writer.Close()) + return output.Bytes() +} + +func decryptWithPass(data []byte, passphrase string) ([]byte, error) { + identity := lo.Must(age.NewScryptIdentity(passphrase)) + reader, err := age.Decrypt(bytes.NewReader(data), identity) + if err != nil { + return nil, err + } + plain, err := io.ReadAll(io.LimitReader(reader, MaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read decrypted key: %w", err) + } + return plain, nil +} + +func readFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, MaxBytes+1)) + if err != nil { + return nil, err + } + if len(data) > MaxBytes { + return nil, errors.New("file exceeds size limit") + } + return data, nil +} + +func decodeJSON(data []byte, value any) error { + if err := rejectDuplicateKeys(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + return nil +} + +func rejectDuplicateKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := scanJSONValue(decoder); err != nil { + return err + } + return requireEOF(decoder) +} + +func scanJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return nil + } + if delimiter == '{' { + seen := map[string]bool{} + for decoder.More() { + keyToken, keyErr := decoder.Token() + if keyErr != nil { + return keyErr + } + key, ok := keyToken.(string) + if !ok || seen[key] { + return errors.New("JSON object has duplicate or invalid key") + } + seen[key] = true + if err := scanJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + } + // Token returns only opening delimiters at a value boundary. The object + // case returned above, so the remaining delimiter is an array opener. + for decoder.More() { + if err := scanJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err +} + +func requireEOF(decoder *json.Decoder) error { + _, err := decoder.Token() + if err != io.EOF { + return errors.New("JSON document contains trailing data") + } + return nil +} + +func writeExclusive(path string, data []byte, mode os.FileMode) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + _, writeErr := file.Write(data) + return errors.Join(writeErr, file.Close()) +} diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go deleted file mode 100644 index 9945ad9..0000000 --- a/internal/receipt/receipt.go +++ /dev/null @@ -1,343 +0,0 @@ -// Package receipt parses and verifies signed receipts for committed event -// operations. It intentionally exposes no receipt-construction API: the state -// writer first commits an operation, and only the dedicated receipt signer may -// turn that committed record into a receipt. -package receipt - -import ( - "errors" - "fmt" - "regexp" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -const ( - Kind = "operation_receipt" - Domain = "operation_receipt" - CancellationKind = "submission_cancellation" - CancellationOperation = "submission.cancel" - MaxSignedBytes = 1_682 - MaxPaddedBase64Chars = 2_244 - MaxSignedBytesWithLF = MaxSignedBytes + 1 -) - -var reasonCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) - -type cancellationRequest struct { - AttemptID string `json:"attempt_id"` - ReservationReceiptDigest string `json:"reservation_receipt_digest"` - ReasonCode string `json:"reason_code"` -} - -// Receipt is the complete signed v1 committed-operation receipt. -type Receipt struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - ReceiptID string `json:"receipt_id"` - OperationID string `json:"operation_id"` - RequestKind string `json:"request_kind"` - ReplayKey string `json:"replay_key"` - RequestDigest string `json:"request_digest"` - RequestDocumentDigest *string `json:"request_document_digest"` - ActorID string `json:"actor_id"` - TeamID *string `json:"team_id"` - AttemptID *string `json:"attempt_id"` - Outcome string `json:"outcome"` - ReasonCode *string `json:"reason_code"` - QuotaCharged bool `json:"quota_charged"` - StateBefore statepointer.Pointer `json:"state_before"` - StateAfter statepointer.Pointer `json:"state_after"` - ScorerResultDigest *string `json:"scorer_result_digest"` - ReservationReceiptDigest *string `json:"reservation_receipt_digest"` - SourceCreatedAt string `json:"source_created_at"` - IssuedAt string `json:"issued_at"` - Signature identity.Signature `json:"signature"` -} - -type unsignedReceipt struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - ReceiptID string `json:"receipt_id"` - OperationID string `json:"operation_id"` - RequestKind string `json:"request_kind"` - ReplayKey string `json:"replay_key"` - RequestDigest string `json:"request_digest"` - RequestDocumentDigest *string `json:"request_document_digest"` - ActorID string `json:"actor_id"` - TeamID *string `json:"team_id"` - AttemptID *string `json:"attempt_id"` - Outcome string `json:"outcome"` - ReasonCode *string `json:"reason_code"` - QuotaCharged bool `json:"quota_charged"` - StateBefore statepointer.Pointer `json:"state_before"` - StateAfter statepointer.Pointer `json:"state_after"` - ScorerResultDigest *string `json:"scorer_result_digest"` - ReservationReceiptDigest *string `json:"reservation_receipt_digest"` - SourceCreatedAt string `json:"source_created_at"` - IssuedAt string `json:"issued_at"` -} - -// Expected is independently trusted context for receipt verification. -type Expected struct { - EventID string - EventEpoch string - BaseRepositoryID string - ConfigDigest string - SigningKey identity.Public - CurrentState statepointer.Pointer - Now time.Time -} - -// Verified contains the authenticated document and its complete canonical -// document digest. All outcome data remains inside Document and is signed. -type Verified struct { - Document Receipt - DocumentDigest string -} - -// Parse strictly decodes one bounded receipt without authenticating it. -func Parse(raw []byte) (Receipt, error) { - documentBytes, err := boundedSignedDocument(raw) - if err != nil { - return Receipt{}, err - } - var document Receipt - if err := canonical.StrictUnmarshal(documentBytes, &document); err != nil { - return Receipt{}, fmt.Errorf("decode receipt: %w", err) - } - if err := document.Validate(); err != nil { - return Receipt{}, err - } - return document, nil -} - -// boundedSignedDocument treats one terminal LF as file framing outside the -// signed-document limit. This lets CLI output remain POSIX text while keeping -// the canonical signed JSON bounded to MaxSignedBytes. -func boundedSignedDocument(raw []byte) ([]byte, error) { - if len(raw) <= MaxSignedBytes { - return raw, nil - } - if len(raw) == MaxSignedBytesWithLF && raw[MaxSignedBytes] == '\n' { - return raw[:MaxSignedBytes], nil - } - return nil, fmt.Errorf("signed receipt exceeds %d-byte document limit", MaxSignedBytes) -} - -// Verify authenticates a strict receipt with the exact configured receipt key -// and proves that its committed state-after pointer exactly matches the trusted -// protected-state head. -func Verify(raw []byte, expected Expected) (Verified, error) { - document, err := Parse(raw) - if err != nil { - return Verified{}, err - } - if document.EventID != expected.EventID || document.EventEpoch != expected.EventEpoch || document.BaseRepositoryID != expected.BaseRepositoryID || document.ConfigDigest != expected.ConfigDigest { - return Verified{}, errors.New("receipt event/repository/config binding does not match trusted config") - } - if err := expected.SigningKey.Validate(); err != nil { - return Verified{}, fmt.Errorf("configured receipt key: %w", err) - } - if err := envelope.Verify(Domain, unsigned(document), document.Signature, expected.SigningKey); err != nil { - return Verified{}, err - } - if err := expected.CurrentState.Validate(); err != nil { - return Verified{}, fmt.Errorf("trusted current state: %w", err) - } - if !document.StateAfter.Equal(expected.CurrentState) { - return Verified{}, errors.New("receipt committed state pointer does not exactly match trusted current state") - } - issuedAt, _ := envelope.ParseTimestamp(document.IssuedAt) - if !expected.Now.IsZero() && issuedAt.After(expected.Now.UTC().Add(5*time.Minute)) { - return Verified{}, errors.New("receipt issued_at is implausibly in the future") - } - digest, err := envelope.DocumentDigest(document) - if err != nil { - return Verified{}, err - } - return Verified{Document: document, DocumentDigest: digest}, nil -} - -// Validate enforces the closed v1 receipt schema and committed-state shape. -func (document Receipt) Validate() error { - if document.Kind != Kind || document.Protocol != envelope.Protocol || document.ProtocolVersion != envelope.ProtocolVersion { - return errors.New("receipt protocol discriminator is invalid") - } - if !envelope.IsEventID(document.EventID) || identity.ValidateDecimal(document.EventEpoch, "event_epoch") != nil || envelope.ValidateRepositoryID(document.BaseRepositoryID) != nil || !envelope.IsDigest(document.ConfigDigest) || !envelope.IsUUID(document.ReceiptID) || !envelope.IsUUID(document.OperationID) { - return errors.New("receipt event/operation identifier is invalid") - } - requestKinds := map[string]bool{ - envelope.RegistrationKind: true, - "team_proposal": true, - "team_consent": true, - envelope.SubmissionKind: true, - "scorer_result": true, - CancellationKind: true, - } - if !requestKinds[document.RequestKind] || !envelope.IsDigest(document.ReplayKey) || !envelope.IsDigest(document.RequestDigest) { - return errors.New("receipt request binding is invalid") - } - if document.RequestDocumentDigest != nil && !envelope.IsDigest(*document.RequestDocumentDigest) { - return errors.New("receipt request document digest is invalid") - } - if err := identity.ValidateDecimal(document.ActorID, "actor_id"); err != nil { - return err - } - if document.TeamID != nil && !envelope.IsUUID(*document.TeamID) { - return errors.New("receipt team_id is invalid") - } - if document.AttemptID != nil && !envelope.IsUUID(*document.AttemptID) { - return errors.New("receipt attempt_id is invalid") - } - if document.Outcome != "accepted" && document.Outcome != "failed" { - return errors.New("receipt outcome is invalid") - } - if document.Outcome == "accepted" && document.ReasonCode != nil { - return errors.New("accepted receipt must have null reason_code") - } - if document.Outcome == "failed" && (document.ReasonCode == nil || !reasonCodePattern.MatchString(*document.ReasonCode)) { - return errors.New("failed receipt requires a bounded reason_code") - } - if err := document.StateBefore.Validate(); err != nil { - return fmt.Errorf("state_before: %w", err) - } - if err := document.StateAfter.Validate(); err != nil { - return fmt.Errorf("state_after: %w", err) - } - if document.StateBefore.Sequence == ^uint64(0) || document.StateAfter.Sequence != document.StateBefore.Sequence+1 { - return errors.New("committed receipt state_after must immediately follow state_before") - } - if document.ScorerResultDigest != nil && !envelope.IsDigest(*document.ScorerResultDigest) { - return errors.New("receipt scorer_result_digest is invalid") - } - if document.ReservationReceiptDigest != nil && !envelope.IsDigest(*document.ReservationReceiptDigest) { - return errors.New("receipt reservation_receipt_digest is invalid") - } - switch document.RequestKind { - case "scorer_result": - if document.RequestDocumentDigest == nil || document.TeamID == nil || document.AttemptID == nil || document.QuotaCharged || document.ScorerResultDigest == nil || *document.ScorerResultDigest != *document.RequestDocumentDigest || document.ReservationReceiptDigest == nil { - return errors.New("scorer result receipt must bind result and reservation receipt digests") - } - if document.Outcome != "accepted" && document.Outcome != "failed" { - return errors.New("scorer result receipt outcome is invalid") - } - case CancellationKind: - if document.RequestDocumentDigest != nil || document.TeamID == nil || document.AttemptID == nil || document.Outcome != "failed" || document.QuotaCharged || document.ScorerResultDigest != nil || document.ReservationReceiptDigest == nil { - return errors.New("submission cancellation receipt must bind a failed reserved attempt without a request document") - } - requestDigest, err := cancellationRequestDigest(*document.AttemptID, *document.ReservationReceiptDigest, *document.ReasonCode) - if err != nil { - return err - } - if document.RequestDigest != requestDigest { - return errors.New("submission cancellation request_digest does not bind the canonical cancellation request") - } - default: - if document.RequestDocumentDigest == nil { - return errors.New("non-cancellation receipt requires request_document_digest") - } - if document.ScorerResultDigest != nil || document.ReservationReceiptDigest != nil { - return errors.New("non-scorer receipt must have null scorer/reservation receipt digests") - } - if document.Outcome == "failed" { - return errors.New("only a committed scorer result may have failed outcome") - } - if document.RequestKind == envelope.SubmissionKind { - if document.TeamID == nil || document.AttemptID == nil || !document.QuotaCharged { - return errors.New("submission reservation receipt requires team, attempt, and quota charge") - } - } else { - if document.QuotaCharged { - return errors.New("non-submission receipt must not charge quota") - } - switch document.RequestKind { - case envelope.RegistrationKind: - if document.TeamID != nil || document.AttemptID != nil { - return errors.New("registration receipt must not carry team or attempt IDs") - } - case "team_proposal", "team_consent": - if document.TeamID == nil || document.AttemptID != nil { - return errors.New("team receipt requires team_id and no attempt_id") - } - } - } - } - sourceCreatedAt, err := envelope.ParseTimestamp(document.SourceCreatedAt) - if err != nil { - return err - } - issuedAt, err := envelope.ParseTimestamp(document.IssuedAt) - if err != nil { - return err - } - if sourceCreatedAt.After(issuedAt) { - return errors.New("receipt source_created_at must not follow committed issued_at") - } - if document.RequestKind == CancellationKind && !sourceCreatedAt.Equal(issuedAt) { - return errors.New("submission cancellation source_created_at must equal committed issued_at") - } - if document.Signature.Algorithm != identity.Algorithm || !envelope.IsDigest(document.Signature.KeyID) { - return errors.New("receipt signature metadata is invalid") - } - return nil -} - -func cancellationRequestDigest(attemptID, reservationReceiptDigest, reasonCode string) (string, error) { - raw, err := canonical.Marshal(cancellationRequest{ - AttemptID: attemptID, ReservationReceiptDigest: reservationReceiptDigest, ReasonCode: reasonCode, - }) - if err != nil { - return "", fmt.Errorf("encode canonical submission cancellation request: %w", err) - } - return envelope.Digest(raw), nil -} - -func unsigned(document Receipt) unsignedReceipt { - return unsignedReceipt{ - document.Kind, document.Protocol, document.ProtocolVersion, document.EventID, - document.EventEpoch, document.BaseRepositoryID, document.ConfigDigest, - document.ReceiptID, document.OperationID, document.RequestKind, - document.ReplayKey, document.RequestDigest, document.RequestDocumentDigest, - document.ActorID, document.TeamID, document.AttemptID, - document.Outcome, document.ReasonCode, document.QuotaCharged, document.StateBefore, - document.StateAfter, document.ScorerResultDigest, document.ReservationReceiptDigest, - document.SourceCreatedAt, document.IssuedAt, - } -} - -// ValidateSourceWindow proves that the trusted immutable GitHub source time -// fell within the original participant-signed request window. It is timeless: -// current wall-clock time is deliberately irrelevant to archival verification. -func (document Receipt) ValidateSourceWindow(issuedAt, expiresAt string) error { - issued, err := envelope.ParseTimestamp(issuedAt) - if err != nil { - return err - } - expires, err := envelope.ParseTimestamp(expiresAt) - if err != nil || !expires.After(issued) { - return errors.New("original request window is invalid") - } - source, err := envelope.ParseTimestamp(document.SourceCreatedAt) - if err != nil { - return err - } - if source.Before(issued) || source.After(expires) { - return errors.New("receipt source_created_at is outside original request window") - } - return nil -} diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go deleted file mode 100644 index 3e27189..0000000 --- a/internal/receipt/receipt_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package receipt - -import ( - "bytes" - "encoding/base64" - "reflect" - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -func TestSignedReceiptExactTransportBoundary(t *testing.T) { - pair := receiptTestPair(t, 29) - claim := receiptTestClaim() - claim.EventID = "e" + strings.Repeat("1", 62) - claim.BaseRepositoryID = "18446744073709551615" - claim.ActorID = "18446744073709551615" - claim.Operation = "submission.finalize" - claim.RequestKind = "scorer_result" - claim.Outcome = "failed" - reason := "r" + strings.Repeat("1", 63) - claim.ReasonCode = &reason - claim.QuotaCharged = false - claim.StateBefore.Sequence = statepointer.MaxSequenceV1 - 1 - claim.StateAfter.Sequence = statepointer.MaxSequenceV1 - claim.ScorerResultDigest = claim.RequestDocumentDigest - reservationDigest := strings.Repeat("9", 64) - claim.ReservationReceiptDigest = &reservationDigest - expected := SignExpected{ - EventID: claim.EventID, EventEpoch: claim.EventEpoch, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - SigningKey: pair.Public, CurrentState: claim.StateAfter, - } - document, err := SignCommitted(claim, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - raw, err := canonical.Marshal(document) - if err != nil { - t.Fatal(err) - } - if len(raw) != MaxSignedBytes { - t.Fatalf("worst-case signed receipt is %d bytes, want %d", len(raw), MaxSignedBytes) - } - if base64.StdEncoding.EncodedLen(len(raw)) != MaxPaddedBase64Chars || base64.StdEncoding.EncodedLen(len(raw)+1) != MaxPaddedBase64Chars { - t.Fatalf("padded base64 limit does not cover the bounded document and one terminal LF") - } - - withLF := append(append([]byte(nil), raw...), '\n') - if _, err := Verify(withLF, Expected{ - EventID: claim.EventID, EventEpoch: claim.EventEpoch, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - SigningKey: pair.Public, CurrentState: claim.StateAfter, - }); err != nil { - t.Fatalf("boundary signed receipt with one framing LF did not round-trip: %v", err) - } - overLimit := append(append([]byte(nil), raw...), ' ') - if _, err := Parse(overLimit); err == nil { - t.Fatal("accepted a signed receipt above the raw document limit") - } - wrongFraming := append(append([]byte(nil), raw...), '\r') - if _, err := Parse(wrongFraming); err == nil { - t.Fatal("accepted over-limit signed receipt framing other than one terminal LF") - } -} - -func TestCommittedClaimKeepsGenericDocumentLimit(t *testing.T) { - claimRaw, err := canonical.Marshal(receiptTestClaim()) - if err != nil { - t.Fatal(err) - } - if len(claimRaw) >= MaxSignedBytesWithLF { - t.Fatalf("claim fixture unexpectedly too large: %d bytes", len(claimRaw)) - } - claimRaw = append(claimRaw, bytes.Repeat([]byte{' '}, MaxSignedBytesWithLF-len(claimRaw))...) - if _, err := ParseCommittedClaim(claimRaw); err != nil { - t.Fatalf("committed claim incorrectly inherited signed-receipt limit: %v", err) - } -} - -func TestSignCommittedVerifyGolden(t *testing.T) { - pair := receiptTestPair(t, 7) - claim := receiptTestClaim() - expected := receiptSignExpected(pair, claim.StateAfter) - document, err := SignCommitted(claim, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - raw, err := canonical.Marshal(document) - if err != nil { - t.Fatal(err) - } - verified, err := Verify(raw, Expected{ - EventID: claim.EventID, EventEpoch: claim.EventEpoch, SigningKey: pair.Public, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - CurrentState: expected.CurrentState, Now: time.Date(2030, 6, 1, 3, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(verified.Document, document) { - t.Fatalf("verified document differs: %#v", verified.Document) - } - const wantDocumentDigest = "2cc2bbd85d30bde5d71520a12c756899c66f81972bb74679d52f248cb381e33d" - if verified.DocumentDigest != wantDocumentDigest { - t.Fatalf("document digest = %s, want %s", verified.DocumentDigest, wantDocumentDigest) - } - if err := document.ValidateSourceWindow("2030-06-01T01:55:00Z", "2030-06-01T02:05:00Z"); err != nil { - t.Fatalf("ValidateSourceWindow() error = %v", err) - } - - retry, err := SignCommitted(claim, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - retryRaw, _ := canonical.Marshal(retry) - if !bytes.Equal(raw, retryRaw) { - t.Fatal("deterministic retry produced different signed receipt bytes") - } -} - -func TestReceiptRejectsTamperingAndUncommittedClaims(t *testing.T) { - pair := receiptTestPair(t, 7) - claim := receiptTestClaim() - expected := receiptSignExpected(pair, claim.StateAfter) - document, err := SignCommitted(claim, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - raw, _ := canonical.Marshal(document) - - t.Run("tampered signed actor", func(t *testing.T) { - tampered := document - tampered.ActorID = "43" - tamperedRaw, _ := canonical.Marshal(tampered) - if _, err := Verify(tamperedRaw, Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, SigningKey: pair.Public, CurrentState: claim.StateAfter}); err == nil { - t.Fatal("Verify() accepted tampered receipt") - } - }) - - t.Run("unknown field", func(t *testing.T) { - withUnknown := append([]byte(`{"unknown":true,`), raw[1:]...) - if _, err := Parse(withUnknown); err == nil { - t.Fatal("Parse() accepted unknown field") - } - }) - - t.Run("future state", func(t *testing.T) { - current := statepointer.Pointer{Sequence: claim.StateAfter.Sequence - 1, JournalEventDigest: claim.StateBefore.JournalEventDigest} - if _, err := Verify(raw, Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, SigningKey: pair.Public, CurrentState: current}); err == nil { - t.Fatal("Verify() accepted receipt ahead of protected state") - } - }) - - t.Run("older state without authenticated membership", func(t *testing.T) { - later := statepointer.Pointer{Sequence: 9, JournalEventDigest: strings.Repeat("9", 64)} - laterSignExpected := receiptSignExpected(pair, later) - if _, err := SignCommitted(claim, laterSignExpected, pair.Private); err == nil { - t.Fatal("SignCommitted() accepted state 5/f against trusted state 9/9") - } - if _, err := Verify(raw, Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, SigningKey: pair.Public, CurrentState: later}); err == nil { - t.Fatal("Verify() accepted state 5/f against trusted state 9/9") - } - }) - - t.Run("same sequence different digest", func(t *testing.T) { - current := statepointer.Pointer{Sequence: claim.StateAfter.Sequence, JournalEventDigest: strings.Repeat("9", 64)} - if _, err := SignCommitted(claim, receiptSignExpected(pair, current), pair.Private); err == nil { - t.Fatal("SignCommitted() accepted a different digest at the trusted sequence") - } - if _, err := Verify(raw, Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, SigningKey: pair.Public, CurrentState: current}); err == nil { - t.Fatal("Verify() accepted a different digest at the trusted sequence") - } - }) - - t.Run("wrong signer", func(t *testing.T) { - wrong := receiptTestPair(t, 9) - if _, err := Verify(raw, Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, SigningKey: wrong.Public, CurrentState: claim.StateAfter}); err == nil { - t.Fatal("Verify() accepted wrong configured signer") - } - }) - - t.Run("wrong repository", func(t *testing.T) { - context := Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: "987654321", ConfigDigest: claim.ConfigDigest, SigningKey: pair.Public, CurrentState: claim.StateAfter} - if _, err := Verify(raw, context); err == nil { - t.Fatal("Verify() accepted a receipt for another repository") - } - }) - - t.Run("wrong archived config", func(t *testing.T) { - context := Expected{EventID: claim.EventID, EventEpoch: claim.EventEpoch, BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: strings.Repeat("0", 64), SigningKey: pair.Public, CurrentState: claim.StateAfter} - if _, err := Verify(raw, context); err == nil { - t.Fatal("Verify() accepted a receipt under another config") - } - }) - - t.Run("pre-commit claim", func(t *testing.T) { - uncommitted := claim - uncommitted.CommitStatus = "rejected" - if _, err := SignCommitted(uncommitted, expected, pair.Private); err == nil { - t.Fatal("SignCommitted() signed a rejected claim") - } - }) - - t.Run("source after commit", func(t *testing.T) { - invalid := claim - invalid.SourceCreatedAt = "2030-06-01T02:01:00Z" - if _, err := SignCommitted(invalid, expected, pair.Private); err == nil { - t.Fatal("SignCommitted() accepted source time after commit") - } - }) - - t.Run("submission without quota charge", func(t *testing.T) { - invalid := claim - invalid.QuotaCharged = false - if _, err := SignCommitted(invalid, expected, pair.Private); err == nil { - t.Fatal("SignCommitted() accepted uncharged submission reservation") - } - }) - - t.Run("valid registration shape", func(t *testing.T) { - valid := claim - valid.Operation = "participant.register" - valid.RequestKind = envelope.RegistrationKind - valid.QuotaCharged = false - valid.TeamID = nil - valid.AttemptID = nil - if _, err := SignCommitted(valid, expected, pair.Private); err != nil { - t.Fatalf("SignCommitted() rejected a valid registration claim: %v", err) - } - }) - - t.Run("valid team shape", func(t *testing.T) { - valid := claim - valid.Operation = "team.propose" - valid.RequestKind = "team_proposal" - valid.QuotaCharged = false - valid.AttemptID = nil - if _, err := SignCommitted(valid, expected, pair.Private); err != nil { - t.Fatalf("SignCommitted() rejected a valid team claim: %v", err) - } - }) - - t.Run("registration carries team", func(t *testing.T) { - invalid := claim - invalid.Operation = "participant.register" - invalid.RequestKind = envelope.RegistrationKind - invalid.QuotaCharged = false - invalid.AttemptID = nil - if _, err := SignCommitted(invalid, expected, pair.Private); err == nil { - t.Fatal("SignCommitted() accepted a registration claim with team_id") - } - }) - - t.Run("team operation omits team", func(t *testing.T) { - invalid := claim - invalid.Operation = "team.propose" - invalid.RequestKind = "team_proposal" - invalid.QuotaCharged = false - invalid.TeamID = nil - invalid.AttemptID = nil - if _, err := SignCommitted(invalid, expected, pair.Private); err == nil { - t.Fatal("SignCommitted() accepted a team claim without team_id") - } - }) -} - -func TestCommittedReceiptOperationShapes(t *testing.T) { - pair := receiptTestPair(t, 17) - base := receiptTestClaim() - expected := receiptSignExpected(pair, base.StateAfter) - teamID := "22222222-2222-4222-8222-222222222222" - attemptID := "33333333-3333-4333-8333-333333333333" - resultDigest := *base.RequestDocumentDigest - reservationDigest := strings.Repeat("9", 64) - reasonCode := "judge_timeout" - - registration := base - registration.Operation = "participant.register" - registration.RequestKind = envelope.RegistrationKind - registration.TeamID = nil - registration.AttemptID = nil - registration.QuotaCharged = false - - teamProposal := base - teamProposal.Operation = "team.propose" - teamProposal.RequestKind = "team_proposal" - teamProposal.TeamID = &teamID - teamProposal.AttemptID = nil - teamProposal.QuotaCharged = false - - teamConsent := teamProposal - teamConsent.Operation = "team.consent" - teamConsent.RequestKind = "team_consent" - - submission := base - - scorerResult := base - scorerResult.Operation = "submission.finalize" - scorerResult.RequestKind = "scorer_result" - scorerResult.TeamID = &teamID - scorerResult.AttemptID = &attemptID - scorerResult.QuotaCharged = false - scorerResult.ScorerResultDigest = &resultDigest - scorerResult.ReservationReceiptDigest = &reservationDigest - - failedScorerResult := scorerResult - failedScorerResult.Outcome = "failed" - failedScorerResult.ReasonCode = &reasonCode - - tests := []struct { - name string - claim CommittedClaim - valid bool - }{ - {"registration", registration, true}, - {"registration without request document digest", mutateClaim(registration, func(value *CommittedClaim) { value.RequestDocumentDigest = nil }), false}, - {"registration with team", mutateClaim(registration, func(value *CommittedClaim) { value.TeamID = &teamID }), false}, - {"registration with attempt", mutateClaim(registration, func(value *CommittedClaim) { value.AttemptID = &attemptID }), false}, - {"team proposal", teamProposal, true}, - {"team consent", teamConsent, true}, - {"team without request document digest", mutateClaim(teamProposal, func(value *CommittedClaim) { value.RequestDocumentDigest = nil }), false}, - {"team without team", mutateClaim(teamProposal, func(value *CommittedClaim) { value.TeamID = nil }), false}, - {"team with attempt", mutateClaim(teamProposal, func(value *CommittedClaim) { value.AttemptID = &attemptID }), false}, - {"submission", submission, true}, - {"submission without request document digest", mutateClaim(submission, func(value *CommittedClaim) { value.RequestDocumentDigest = nil }), false}, - {"submission without team", mutateClaim(submission, func(value *CommittedClaim) { value.TeamID = nil }), false}, - {"submission without attempt", mutateClaim(submission, func(value *CommittedClaim) { value.AttemptID = nil }), false}, - {"submission without quota", mutateClaim(submission, func(value *CommittedClaim) { value.QuotaCharged = false }), false}, - {"participant with scorer digest", mutateClaim(submission, func(value *CommittedClaim) { value.ScorerResultDigest = &resultDigest }), false}, - {"failed participant", mutateClaim(submission, func(value *CommittedClaim) { value.Outcome = "failed"; value.ReasonCode = &reasonCode }), false}, - {"scorer result", scorerResult, true}, - {"failed scorer result", failedScorerResult, true}, - {"scorer result without request document digest", mutateClaim(scorerResult, func(value *CommittedClaim) { value.RequestDocumentDigest = nil }), false}, - {"scorer result without team", mutateClaim(scorerResult, func(value *CommittedClaim) { value.TeamID = nil }), false}, - {"scorer result without attempt", mutateClaim(scorerResult, func(value *CommittedClaim) { value.AttemptID = nil }), false}, - {"scorer result charging quota", mutateClaim(scorerResult, func(value *CommittedClaim) { value.QuotaCharged = true }), false}, - {"scorer result without result digest", mutateClaim(scorerResult, func(value *CommittedClaim) { value.ScorerResultDigest = nil }), false}, - {"scorer result with wrong result digest", mutateClaim(scorerResult, func(value *CommittedClaim) { wrong := strings.Repeat("8", 64); value.ScorerResultDigest = &wrong }), false}, - {"scorer result without reservation digest", mutateClaim(scorerResult, func(value *CommittedClaim) { value.ReservationReceiptDigest = nil }), false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := SignCommitted(test.claim, expected, pair.Private) - if test.valid && err != nil { - t.Fatalf("SignCommitted() rejected valid claim: %v", err) - } - if !test.valid && err == nil { - t.Fatal("SignCommitted() accepted invalid claim") - } - }) - } -} - -func TestSubmissionCancellationReceiptContract(t *testing.T) { - pair := receiptTestPair(t, 23) - base := receiptTestClaim() - reasonCode := "organizer_cancelled" - reservationDigest := strings.Repeat("9", 64) - claim := base - claim.Operation = CancellationOperation - claim.RequestKind = CancellationKind - claim.RequestDocumentDigest = nil - claim.Outcome = "failed" - claim.ReasonCode = &reasonCode - claim.QuotaCharged = false - claim.ReservationReceiptDigest = &reservationDigest - claim.SourceCreatedAt = claim.IssuedAt - const canonicalCancellation = `{"attempt_id":"33333333-3333-4333-8333-333333333333","reason_code":"organizer_cancelled","reservation_receipt_digest":"9999999999999999999999999999999999999999999999999999999999999999"}` - const requestDigest = "4695b99bc6a8bc85ccf117206fe36549c560ae717230e2d23220f274c8f213c5" - derivedDigest, err := cancellationRequestDigest(*claim.AttemptID, reservationDigest, reasonCode) - if err != nil { - t.Fatal(err) - } - if derivedDigest != requestDigest || envelope.Digest([]byte(canonicalCancellation)) != requestDigest { - t.Fatalf("cancellation request digest = %q, want no-LF canonical digest %q", derivedDigest, requestDigest) - } - claim.RequestDigest = requestDigest - requestDigestWithLF := envelope.Digest(append([]byte(canonicalCancellation), '\n')) - - document, err := SignCommitted(claim, receiptSignExpected(pair, claim.StateAfter), pair.Private) - if err != nil { - t.Fatalf("SignCommitted() rejected the exact cancellation operation/request pair: %v", err) - } - raw, err := canonical.Marshal(document) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte(`"request_document_digest":null`)) { - t.Fatalf("signed cancellation receipt does not encode a null request_document_digest: %s", raw) - } - if _, err := Verify(raw, Expected{ - EventID: claim.EventID, EventEpoch: claim.EventEpoch, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - SigningKey: pair.Public, CurrentState: claim.StateAfter, - }); err != nil { - t.Fatalf("Verify() rejected the signed cancellation receipt: %v", err) - } - - tests := []struct { - name string - mutate func(*CommittedClaim) - wantError string - }{ - {"cancellation operation with scorer result kind", func(value *CommittedClaim) { value.RequestKind = "scorer_result" }, "operation/request_kind binding"}, - {"finalization operation with cancellation kind", func(value *CommittedClaim) { value.Operation = "submission.finalize" }, "operation/request_kind binding"}, - {"request document digest", func(value *CommittedClaim) { value.RequestDocumentDigest = base.RequestDocumentDigest }, "cancellation receipt"}, - {"accepted outcome", func(value *CommittedClaim) { value.Outcome = "accepted"; value.ReasonCode = nil }, "cancellation receipt"}, - {"missing reason", func(value *CommittedClaim) { value.ReasonCode = nil }, "requires a bounded reason_code"}, - {"invalid reason", func(value *CommittedClaim) { invalid := "Organizer-Cancelled"; value.ReasonCode = &invalid }, "requires a bounded reason_code"}, - {"changed reason without matching digest", func(value *CommittedClaim) { changed := "manual_cancel"; value.ReasonCode = &changed }, "request_digest does not bind"}, - {"trailing LF request digest", func(value *CommittedClaim) { value.RequestDigest = requestDigestWithLF }, "request_digest does not bind"}, - {"source time before commit", func(value *CommittedClaim) { value.SourceCreatedAt = "2030-06-01T01:59:59Z" }, "source_created_at must equal"}, - {"quota charge", func(value *CommittedClaim) { value.QuotaCharged = true }, "cancellation receipt"}, - {"missing team", func(value *CommittedClaim) { value.TeamID = nil }, "cancellation receipt"}, - {"missing attempt", func(value *CommittedClaim) { value.AttemptID = nil }, "cancellation receipt"}, - {"scorer result digest", func(value *CommittedClaim) { value.ScorerResultDigest = base.RequestDocumentDigest }, "cancellation receipt"}, - {"missing reservation receipt digest", func(value *CommittedClaim) { value.ReservationReceiptDigest = nil }, "cancellation receipt"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - invalid := claim - test.mutate(&invalid) - _, err := SignCommitted(invalid, receiptSignExpected(pair, invalid.StateAfter), pair.Private) - if err == nil { - t.Fatal("SignCommitted() accepted an invalid cancellation claim") - } - if !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("SignCommitted() error = %q, want it to contain %q", err, test.wantError) - } - }) - } -} - -func mutateClaim(claim CommittedClaim, mutate func(*CommittedClaim)) CommittedClaim { - mutate(&claim) - return claim -} - -func receiptTestPair(t *testing.T, fill byte) identity.KeyPair { - t.Helper() - pair, err := identity.FromSeed(bytes.Repeat([]byte{fill}, 32)) - if err != nil { - t.Fatal(err) - } - return pair -} - -func receiptTestClaim() CommittedClaim { - teamID := "22222222-2222-4222-8222-222222222222" - attemptID := "33333333-3333-4333-8333-333333333333" - requestDocumentDigest := strings.Repeat("d", 64) - return CommittedClaim{ - Kind: ClaimKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: "pyhk-2030", EventEpoch: "1", BaseRepositoryID: "123456789", - ConfigDigest: strings.Repeat("a", 64), CommitStatus: "receipt_pending", - Operation: "submission.reserve", ReceiptID: "11111111-1111-4111-8111-111111111111", - OperationID: "11111111-1111-4111-8111-111111111111", - RequestKind: envelope.SubmissionKind, ReplayKey: strings.Repeat("b", 64), - RequestDigest: strings.Repeat("c", 64), RequestDocumentDigest: &requestDocumentDigest, - ActorID: "42", TeamID: &teamID, AttemptID: &attemptID, Outcome: "accepted", - QuotaCharged: true, - StateBefore: statepointer.Pointer{Sequence: 4, JournalEventDigest: strings.Repeat("e", 64)}, - StateAfter: statepointer.Pointer{Sequence: 5, JournalEventDigest: strings.Repeat("f", 64)}, - SourceCreatedAt: "2030-06-01T01:59:00Z", IssuedAt: "2030-06-01T02:00:00Z", - } -} - -func receiptSignExpected(pair identity.KeyPair, current statepointer.Pointer) SignExpected { - return SignExpected{ - EventID: "pyhk-2030", EventEpoch: "1", BaseRepositoryID: "123456789", - ConfigDigest: strings.Repeat("a", 64), SigningKey: pair.Public, CurrentState: current, - } -} - -func FuzzParseReceipt(f *testing.F) { - f.Add([]byte(`{}`)) - pair, _ := identity.FromSeed(bytes.Repeat([]byte{7}, 32)) - document, err := SignCommitted(receiptTestClaim(), receiptSignExpected(pair, receiptTestClaim().StateAfter), pair.Private) - if err == nil { - raw, _ := canonical.Marshal(document) - f.Add(raw) - } - f.Fuzz(func(t *testing.T, raw []byte) { - _, _ = Parse(raw) - }) -} diff --git a/internal/receipt/sign.go b/internal/receipt/sign.go deleted file mode 100644 index b7615c3..0000000 --- a/internal/receipt/sign.go +++ /dev/null @@ -1,178 +0,0 @@ -package receipt - -import ( - "errors" - "fmt" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -const ClaimKind = "committed_operation_receipt_claim" - -// CommittedClaim is the narrow, deterministic handoff from a successful -// protected-state CAS commit to the isolated receipt signer. issued_at is the -// committed journal event's recorded_at, never signer wall-clock time. -type CommittedClaim struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - BaseRepositoryID string `json:"base_repository_id"` - ConfigDigest string `json:"config_digest"` - CommitStatus string `json:"commit_status"` - Operation string `json:"operation"` - ReceiptID string `json:"receipt_id"` - OperationID string `json:"operation_id"` - RequestKind string `json:"request_kind"` - ReplayKey string `json:"replay_key"` - RequestDigest string `json:"request_digest"` - RequestDocumentDigest *string `json:"request_document_digest"` - ActorID string `json:"actor_id"` - TeamID *string `json:"team_id"` - AttemptID *string `json:"attempt_id"` - Outcome string `json:"outcome"` - ReasonCode *string `json:"reason_code"` - QuotaCharged bool `json:"quota_charged"` - StateBefore statepointer.Pointer `json:"state_before"` - StateAfter statepointer.Pointer `json:"state_after"` - ScorerResultDigest *string `json:"scorer_result_digest"` - ReservationReceiptDigest *string `json:"reservation_receipt_digest"` - SourceCreatedAt string `json:"source_created_at"` - IssuedAt string `json:"issued_at"` -} - -// SignExpected binds a committed claim to independently verified context. -type SignExpected struct { - EventID string - EventEpoch string - BaseRepositoryID string - ConfigDigest string - SigningKey identity.Public - CurrentState statepointer.Pointer -} - -// ParseCommittedClaim strictly decodes one bounded post-commit signer input. -func ParseCommittedClaim(raw []byte) (CommittedClaim, error) { - if len(raw) > envelope.MaxDocumentBytes { - return CommittedClaim{}, errors.New("committed receipt claim exceeds 1 MiB") - } - var claim CommittedClaim - if err := canonical.StrictUnmarshal(raw, &claim); err != nil { - return CommittedClaim{}, fmt.Errorf("decode committed receipt claim: %w", err) - } - if err := claim.Validate(); err != nil { - return CommittedClaim{}, err - } - return claim, nil -} - -// SignCommitted signs only a committed pending claim whose state-after pointer -// exactly matches the independently trusted protected-state head. The -// deterministic receipt_id and issued_at make retry bytes identical. -func SignCommitted(claim CommittedClaim, expected SignExpected, private identity.Private) (Receipt, error) { - if err := claim.Validate(); err != nil { - return Receipt{}, err - } - if claim.EventID != expected.EventID || claim.EventEpoch != expected.EventEpoch || claim.BaseRepositoryID != expected.BaseRepositoryID || claim.ConfigDigest != expected.ConfigDigest { - return Receipt{}, errors.New("receipt claim does not match trusted event/config/repository") - } - if err := expected.CurrentState.Validate(); err != nil { - return Receipt{}, fmt.Errorf("trusted current state: %w", err) - } - if !claim.StateAfter.Equal(expected.CurrentState) { - return Receipt{}, errors.New("receipt claim committed state does not exactly match trusted current state") - } - _, pair, err := identity.SigningKey(private) - if err != nil { - return Receipt{}, err - } - if err := expected.SigningKey.Validate(); err != nil { - return Receipt{}, fmt.Errorf("configured receipt key: %w", err) - } - if pair.Public != expected.SigningKey { - return Receipt{}, errors.New("private receipt key does not match configured signing key") - } - document := Receipt{ - Kind: Kind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: claim.EventID, EventEpoch: claim.EventEpoch, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - ReceiptID: claim.ReceiptID, - OperationID: claim.OperationID, RequestKind: claim.RequestKind, - ReplayKey: claim.ReplayKey, RequestDigest: claim.RequestDigest, - RequestDocumentDigest: claim.RequestDocumentDigest, - ActorID: claim.ActorID, TeamID: claim.TeamID, - AttemptID: claim.AttemptID, Outcome: claim.Outcome, ReasonCode: claim.ReasonCode, - QuotaCharged: claim.QuotaCharged, StateBefore: claim.StateBefore, - StateAfter: claim.StateAfter, ScorerResultDigest: claim.ScorerResultDigest, - ReservationReceiptDigest: claim.ReservationReceiptDigest, - SourceCreatedAt: claim.SourceCreatedAt, - IssuedAt: claim.IssuedAt, - Signature: identity.Signature{Algorithm: identity.Algorithm, KeyID: pair.Public.KeyID}, - } - if err := document.Validate(); err != nil { - return Receipt{}, err - } - document.Signature, err = envelope.Sign(Domain, unsigned(document), private) - if err != nil { - return Receipt{}, err - } - raw, err := canonical.Marshal(document) - if err != nil { - return Receipt{}, err - } - if len(raw) > MaxSignedBytes { - return Receipt{}, fmt.Errorf("signed receipt is %d bytes, limit is %d", len(raw), MaxSignedBytes) - } - return document, nil -} - -// Validate enforces the closed post-commit signer input schema. -func (claim CommittedClaim) Validate() error { - if claim.Kind != ClaimKind || claim.Protocol != envelope.Protocol || claim.ProtocolVersion != envelope.ProtocolVersion || claim.CommitStatus != "receipt_pending" { - return errors.New("receipt claim protocol/commit discriminator is invalid") - } - if envelope.ValidateRepositoryID(claim.BaseRepositoryID) != nil || !envelope.IsDigest(claim.ConfigDigest) { - return errors.New("receipt claim repository/config binding is invalid") - } - if claim.ReceiptID != claim.OperationID { - return errors.New("v1 receipt_id must equal committed operation_id for deterministic retry") - } - operationKinds := map[string]string{ - "participant.register": "registration_request", - "team.propose": "team_proposal", - "team.consent": "team_consent", - "submission.reserve": "submission_envelope", - "submission.finalize": "scorer_result", - CancellationOperation: CancellationKind, - } - if operationKinds[claim.Operation] != claim.RequestKind { - return errors.New("receipt claim operation/request_kind binding is invalid") - } - document := Receipt{ - Kind: Kind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: claim.EventID, EventEpoch: claim.EventEpoch, - BaseRepositoryID: claim.BaseRepositoryID, ConfigDigest: claim.ConfigDigest, - ReceiptID: claim.ReceiptID, - OperationID: claim.OperationID, RequestKind: claim.RequestKind, - ReplayKey: claim.ReplayKey, RequestDigest: claim.RequestDigest, - RequestDocumentDigest: claim.RequestDocumentDigest, - ActorID: claim.ActorID, TeamID: claim.TeamID, - AttemptID: claim.AttemptID, Outcome: claim.Outcome, ReasonCode: claim.ReasonCode, - QuotaCharged: claim.QuotaCharged, StateBefore: claim.StateBefore, - StateAfter: claim.StateAfter, ScorerResultDigest: claim.ScorerResultDigest, - ReservationReceiptDigest: claim.ReservationReceiptDigest, - SourceCreatedAt: claim.SourceCreatedAt, - IssuedAt: claim.IssuedAt, - Signature: identity.Signature{Algorithm: identity.Algorithm, KeyID: zeroDigest}, - } - if err := document.Validate(); err != nil { - return err - } - return nil -} - -const zeroDigest = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/internal/recipient/recipient.go b/internal/recipient/recipient.go deleted file mode 100644 index 4f1ea89..0000000 --- a/internal/recipient/recipient.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package recipient manages organizer submission-decryption identities. -// Submission keys are strictly age HybridIdentity values (ML-KEM-768+X25519); -// local private-key files are encrypted at rest with the keystore package's -// age-scrypt profile. -package recipient - -import ( - "bytes" - "context" - "errors" - "fmt" - "strings" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/bundle" - "github.com/pythonhk/eventctl/internal/keystore" -) - -const identityDocumentHeader = "eventctl age hybrid identity v1\n" - -var ErrInvalidIdentityDocument = errors.New("invalid hybrid identity document") - -// Public is safe to publish in a signed event configuration. -type Public struct { - Recipient string - Fingerprint string -} - -// GenerateIdentityFile generates a native age HybridIdentity, stores its -// versioned secret document in an exclusive encrypted keystore file, and -// returns only its public recipient data. -func GenerateIdentityFile( - ctx context.Context, - outputPath string, - passphrase []byte, - limits keystore.Limits, -) (Public, error) { - identity, err := age.GenerateHybridIdentity() - if err != nil { - return Public{}, fmt.Errorf("generate hybrid identity: %w", err) - } - public, err := Describe(identity) - if err != nil { - return Public{}, err - } - document := []byte(identityDocumentHeader + identity.String() + "\n") - defer clear(document) - if err := keystore.EncryptFile(ctx, outputPath, document, passphrase, limits); err != nil { - return Public{}, err - } - return public, nil -} - -// LoadIdentity decrypts and strictly parses a complete versioned identity -// document. The decrypted byte buffer is cleared before return. -func LoadIdentity( - ctx context.Context, - inputPath string, - passphrase []byte, - limits keystore.Limits, -) (*age.HybridIdentity, error) { - document, err := keystore.DecryptFile(ctx, inputPath, passphrase, limits) - if err != nil { - return nil, err - } - defer clear(document) - if !bytes.HasPrefix(document, []byte(identityDocumentHeader)) || - len(document) <= len(identityDocumentHeader)+1 || document[len(document)-1] != '\n' { - return nil, ErrInvalidIdentityDocument - } - encodedBytes := document[len(identityDocumentHeader) : len(document)-1] - if bytes.IndexByte(encodedBytes, '\n') >= 0 { - return nil, ErrInvalidIdentityDocument - } - identity, err := age.ParseHybridIdentity(string(encodedBytes)) - if err != nil || identity.String() != string(encodedBytes) { - return nil, ErrInvalidIdentityDocument - } - return identity, nil -} - -// Describe returns the canonical public recipient and its v1 fingerprint. -func Describe(identity *age.HybridIdentity) (Public, error) { - if identity == nil { - return Public{}, fmt.Errorf("hybrid identity is nil") - } - recipient := identity.Recipient() - encoded := recipient.String() - if !strings.HasPrefix(encoded, "age1pq1") { - return Public{}, fmt.Errorf("hybrid identity returned a non-hybrid recipient") - } - fingerprint, err := bundle.HybridRecipientFingerprint(recipient) - if err != nil { - return Public{}, err - } - return Public{Recipient: encoded, Fingerprint: fingerprint}, nil -} diff --git a/internal/recipient/recipient_test.go b/internal/recipient/recipient_test.go deleted file mode 100644 index 3bd1201..0000000 --- a/internal/recipient/recipient_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package recipient - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/keystore" -) - -func TestGenerateLoadAndDescribeRoundTrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "organizer-identity.age") - passphrase := []byte("correct horse battery staple") - public, err := GenerateIdentityFile(context.Background(), path, passphrase, keystore.Limits{}) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(public.Recipient, "age1pq1") || len(public.Recipient) != 1959 { - t.Fatalf("generated recipient has unexpected profile or length: %d", len(public.Recipient)) - } - if len(public.Fingerprint) != 64 { - t.Fatalf("generated fingerprint length = %d, want 64", len(public.Fingerprint)) - } - identity, err := LoadIdentity(context.Background(), path, passphrase, keystore.Limits{}) - if err != nil { - t.Fatal(err) - } - loadedPublic, err := Describe(identity) - if err != nil { - t.Fatal(err) - } - if loadedPublic != public { - t.Fatalf("loaded public data = %#v, want %#v", loadedPublic, public) - } -} - -func TestLoadRejectsWrongPassphraseAndLegacyIdentity(t *testing.T) { - root := t.TempDir() - passphrase := []byte("correct horse battery staple") - path := filepath.Join(root, "hybrid.age") - if _, err := GenerateIdentityFile(context.Background(), path, passphrase, keystore.Limits{}); err != nil { - t.Fatal(err) - } - if identity, err := LoadIdentity(context.Background(), path, []byte("wrong passphrase"), keystore.Limits{}); !errors.Is(err, keystore.ErrAuthentication) || identity != nil { - t.Fatalf("LoadIdentity() = %#v, %v; want nil ErrAuthentication", identity, err) - } - - legacy, err := age.GenerateX25519Identity() - if err != nil { - t.Fatal(err) - } - legacyDocument := []byte(identityDocumentHeader + legacy.String() + "\n") - legacyPath := filepath.Join(root, "legacy.age") - if err := keystore.EncryptFile(context.Background(), legacyPath, legacyDocument, passphrase, keystore.Limits{}); err != nil { - t.Fatal(err) - } - if identity, err := LoadIdentity(context.Background(), legacyPath, passphrase, keystore.Limits{}); !errors.Is(err, ErrInvalidIdentityDocument) || identity != nil { - t.Fatalf("LoadIdentity(legacy) = %#v, %v; want nil ErrInvalidIdentityDocument", identity, err) - } -} - -func TestGenerateDoesNotOverwriteExistingFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "existing.age") - if err := os.WriteFile(path, []byte("preserve"), 0o600); err != nil { - t.Fatal(err) - } - if _, err := GenerateIdentityFile( - context.Background(), path, []byte("correct horse battery staple"), keystore.Limits{}, - ); !errors.Is(err, keystore.ErrDestinationExists) { - t.Fatalf("GenerateIdentityFile() error = %v, want ErrDestinationExists", err) - } - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(contents) != "preserve" { - t.Fatalf("existing file changed to %q", contents) - } -} diff --git a/internal/scorer/scorer.go b/internal/scorer/scorer.go deleted file mode 100644 index c86f7c8..0000000 --- a/internal/scorer/scorer.go +++ /dev/null @@ -1,498 +0,0 @@ -// Package scorer defines and authenticates the strict v1 isolated-scorer wire -// artifacts. A scorer request is intentionally unsigned; its exact canonical -// bytes travel over the authenticated judge channel. The configured scorer key -// signs the result, which binds the complete request digest. -package scorer - -import ( - "errors" - "fmt" - "regexp" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -const ( - RequestKind = "scorer_request" - ResultKind = "scorer_result" - ResultDomain = "scorer_result" - BundleMediaType = "application/vnd.pythonhk.eventctl-bundle.v1" - Provenance = "protected_state_reservation" - MaxRequestBytes uint64 = 16_384 - MinResultBytes uint64 = 256 - MaxResultBytes uint64 = 65_536 - MaxSynchronousResponseBytes = 294_912 - MaxSynchronousResponseBase64Bytes = 393_216 - maxSafeInteger = int64(9007199254740991) - maxBundleBytes = uint64(48_000_000) - maxCiphertextBytes = uint64(47_000_000) -) - -var ( - idPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`) - semverPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) - metricNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,63}$`) - reasonCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) -) - -// Bundle binds the fixed encrypted submission artifact and all public digests. -type Bundle struct { - Path string `json:"path"` - MediaType string `json:"media_type"` - SizeBytes uint64 `json:"size_bytes"` - SHA256 string `json:"sha256"` - EnvelopeSHA256 string `json:"envelope_sha256"` - CiphertextSize uint64 `json:"ciphertext_size"` - CiphertextSHA256 string `json:"ciphertext_sha256"` -} - -// Identity names the exact configured scoring implementation and policy. -type Identity struct { - ID string `json:"id"` - Version string `json:"version"` - PolicyDigest string `json:"policy_digest"` -} - -// Request is the complete unsigned descriptor delivered through the -// authenticated judge channel after a protected-state reservation. -type Request struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - TeamID string `json:"team_id"` - TeamProposalDigest string `json:"team_proposal_digest"` - ConfigDigest string `json:"config_digest"` - SubmissionEnvelopeDigest string `json:"submission_envelope_digest"` - ReservationReceiptDigest string `json:"reservation_receipt_digest"` - Reservation statepointer.Pointer `json:"reservation"` - SourceCreatedAt string `json:"source_created_at"` - AcceptedAt string `json:"accepted_at"` - PullRequest envelope.PullRequest `json:"pull_request"` - Bundle Bundle `json:"bundle"` - Scorer Identity `json:"scorer"` - Provenance string `json:"provenance"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -// Metric is one bounded integer score component. V1 publishes no prose. -type Metric struct { - Name string `json:"name"` - Micropoints int64 `json:"micropoints"` -} - -// Result is the complete signed terminal scorer result. -type Result struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - ResultID string `json:"result_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - TeamID string `json:"team_id"` - ScorerRequestDigest string `json:"scorer_request_digest"` - SubmissionEnvelopeDigest string `json:"submission_envelope_digest"` - CiphertextDigest string `json:"ciphertext_digest"` - ConfigDigest string `json:"config_digest"` - Reservation statepointer.Pointer `json:"reservation"` - Scorer Identity `json:"scorer"` - Status string `json:"status"` - TotalMicropoints *int64 `json:"total_micropoints"` - Metrics []Metric `json:"metrics"` - ReasonCode *string `json:"reason_code"` - CompletedAt string `json:"completed_at"` - Signature identity.Signature `json:"signature"` -} - -// UnsignedResult is the exact payload covered by a scorer-result signature. -type UnsignedResult struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - ResultID string `json:"result_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - TeamID string `json:"team_id"` - ScorerRequestDigest string `json:"scorer_request_digest"` - SubmissionEnvelopeDigest string `json:"submission_envelope_digest"` - CiphertextDigest string `json:"ciphertext_digest"` - ConfigDigest string `json:"config_digest"` - Reservation statepointer.Pointer `json:"reservation"` - Scorer Identity `json:"scorer"` - Status string `json:"status"` - TotalMicropoints *int64 `json:"total_micropoints"` - Metrics []Metric `json:"metrics"` - ReasonCode *string `json:"reason_code"` - CompletedAt string `json:"completed_at"` -} - -// Expected is independently trusted config and protected-state context. -type Expected struct { - EventID string - EventEpoch string - BaseRepositoryID string - BaseRef string - ConfigDigest string - Scorer Identity - ResultKey identity.Public - MaximumResultBytes uint64 - MaximumCiphertextBytes uint64 - CurrentState statepointer.Pointer - Now time.Time -} - -// Verified contains the authenticated result and replay/digest primitives. -type Verified struct { - Request Request - ScorerRequestDigest string - Document Result - DocumentDigest string - Fingerprint envelope.Fingerprint -} - -// ParseRequest strictly decodes and validates one bounded scorer request. -func ParseRequest(raw []byte) (Request, error) { - if uint64(len(raw)) > MaxRequestBytes { - return Request{}, fmt.Errorf("scorer request exceeds %d-byte limit", MaxRequestBytes) - } - var request Request - if err := canonical.StrictUnmarshal(raw, &request); err != nil { - return Request{}, fmt.Errorf("decode scorer request: %w", err) - } - if err := request.Validate(); err != nil { - return Request{}, err - } - return request, nil -} - -// ParseResult strictly decodes and validates one bounded signed result. -func ParseResult(raw []byte, maximumBytes uint64) (Result, error) { - if maximumBytes < MinResultBytes || maximumBytes > MaxResultBytes { - return Result{}, errors.New("configured scorer result limit is invalid") - } - if uint64(len(raw)) > maximumBytes { - return Result{}, fmt.Errorf("scorer result exceeds configured %d-byte limit", maximumBytes) - } - var result Result - if err := canonical.StrictUnmarshal(raw, &result); err != nil { - return Result{}, fmt.Errorf("decode scorer result: %w", err) - } - if err := result.Validate(); err != nil { - return Result{}, err - } - return result, nil -} - -// ParseUnsignedResult strictly decodes a result payload before signing. -func ParseUnsignedResult(raw []byte, maximumBytes uint64) (UnsignedResult, error) { - if maximumBytes < MinResultBytes || maximumBytes > MaxResultBytes { - return UnsignedResult{}, errors.New("configured scorer result limit is invalid") - } - if uint64(len(raw)) > maximumBytes { - return UnsignedResult{}, fmt.Errorf("unsigned scorer result exceeds configured %d-byte limit", maximumBytes) - } - var result UnsignedResult - if err := canonical.StrictUnmarshal(raw, &result); err != nil { - return UnsignedResult{}, fmt.Errorf("decode unsigned scorer result: %w", err) - } - if err := validateUnsigned(result); err != nil { - return UnsignedResult{}, err - } - return result, nil -} - -// Verify authenticates a result against the exact canonical request and the -// scorer key pinned in the adopted event configuration. -func Verify(requestRaw, resultRaw []byte, expected Expected) (Verified, error) { - request, err := ParseRequest(requestRaw) - if err != nil { - return Verified{}, err - } - if err := VerifyRequestContext(request, expected); err != nil { - return Verified{}, err - } - result, err := ParseResult(resultRaw, expected.MaximumResultBytes) - if err != nil { - return Verified{}, err - } - if err := verifyResultContext(result, request, expected); err != nil { - return Verified{}, err - } - if err := expected.ResultKey.Validate(); err != nil { - return Verified{}, fmt.Errorf("configured scorer result key: %w", err) - } - if err := envelope.Verify(ResultDomain, unsigned(result), result.Signature, expected.ResultKey); err != nil { - return Verified{}, err - } - requestDigest, err := envelope.DocumentDigest(request) - if err != nil { - return Verified{}, err - } - if result.ScorerRequestDigest != requestDigest { - return Verified{}, errors.New("scorer result does not bind the exact canonical scorer request") - } - requestIntentDigest, err := envelope.SigningDigest(ResultDomain, unsigned(result)) - if err != nil { - return Verified{}, err - } - fingerprint, err := envelope.NewFingerprint(ResultDomain, result.EventID, result.ResultID, requestIntentDigest) - if err != nil { - return Verified{}, err - } - documentDigest, err := envelope.DocumentDigest(result) - if err != nil { - return Verified{}, err - } - return Verified{request, requestDigest, result, documentDigest, fingerprint}, nil -} - -// SignResult signs an already strict result payload after checking every -// request/config/state binding. It is intended for an isolated trusted judge, -// not participant or state-writer workflows. -func SignResult(payload UnsignedResult, request Request, expected Expected, private identity.Private) (Result, error) { - if err := request.Validate(); err != nil { - return Result{}, err - } - if err := VerifyRequestContext(request, expected); err != nil { - return Result{}, err - } - if err := validateUnsigned(payload); err != nil { - return Result{}, err - } - result := resultFromUnsigned(payload) - if err := verifyResultContext(result, request, expected); err != nil { - return Result{}, err - } - _, pair, err := identity.SigningKey(private) - if err != nil { - return Result{}, err - } - if pair.Public != expected.ResultKey { - return Result{}, errors.New("private scorer key does not match configured result key") - } - result.Signature, err = envelope.Sign(ResultDomain, payload, private) - if err != nil { - return Result{}, err - } - encoded, err := canonical.Marshal(result) - if err != nil { - return Result{}, err - } - if uint64(len(encoded)) > expected.MaximumResultBytes { - return Result{}, fmt.Errorf("signed scorer result exceeds configured %d-byte limit", expected.MaximumResultBytes) - } - return result, nil -} - -// Validate enforces the closed unsigned request schema. -func (request Request) Validate() error { - if request.Kind != RequestKind || request.Protocol != envelope.Protocol || request.ProtocolVersion != envelope.ProtocolVersion || request.Provenance != Provenance { - return errors.New("scorer request protocol discriminator is invalid") - } - if !envelope.IsEventID(request.EventID) || identity.ValidateDecimal(request.EventEpoch, "event_epoch") != nil || !envelope.IsUUID(request.AttemptID) || identity.ValidateDecimal(request.ActorID, "actor_id") != nil || !envelope.IsUUID(request.TeamID) { - return errors.New("scorer request event/actor/team/attempt binding is invalid") - } - if !envelope.IsDigest(request.TeamProposalDigest) || !envelope.IsDigest(request.ConfigDigest) || !envelope.IsDigest(request.SubmissionEnvelopeDigest) || !envelope.IsDigest(request.ReservationReceiptDigest) { - return errors.New("scorer request digest binding is invalid") - } - if err := request.Reservation.Validate(); err != nil { - return fmt.Errorf("scorer request reservation: %w", err) - } - if err := envelope.ValidatePullRequestAddress(request.PullRequest); err != nil { - return fmt.Errorf("scorer request pull request: %w", err) - } - if err := request.Bundle.Validate(); err != nil { - return err - } - if err := request.Scorer.Validate(); err != nil { - return err - } - if err := envelope.ValidateWindow(request.IssuedAt, request.ExpiresAt, time.Time{}); err != nil { - return err - } - issuedAt, _ := envelope.ParseTimestamp(request.IssuedAt) - expiresAt, _ := envelope.ParseTimestamp(request.ExpiresAt) - sourceCreatedAt, err := envelope.ParseTimestamp(request.SourceCreatedAt) - if err != nil { - return err - } - acceptedAt, err := envelope.ParseTimestamp(request.AcceptedAt) - if err != nil { - return err - } - if sourceCreatedAt.Before(issuedAt) || sourceCreatedAt.After(expiresAt) { - return errors.New("scorer request source_created_at is outside original submission window") - } - if acceptedAt.Before(sourceCreatedAt) { - return errors.New("scorer request accepted_at precedes immutable source creation") - } - return nil -} - -// Validate enforces the closed signed result schema before authentication. -func (result Result) Validate() error { - if err := validateUnsigned(unsigned(result)); err != nil { - return err - } - if result.Signature.Algorithm != identity.Algorithm || !envelope.IsDigest(result.Signature.KeyID) { - return errors.New("scorer result signature metadata is invalid") - } - return nil -} - -func (bundle Bundle) Validate() error { - if bundle.Path != "submission.eventctl" || bundle.MediaType != BundleMediaType { - return errors.New("scorer request bundle path/media_type is invalid") - } - if bundle.SizeBytes < 1 || bundle.SizeBytes > maxBundleBytes || bundle.CiphertextSize < 1 || bundle.CiphertextSize > maxCiphertextBytes || bundle.SizeBytes <= bundle.CiphertextSize { - return errors.New("scorer request bundle size is invalid") - } - if !envelope.IsDigest(bundle.SHA256) || !envelope.IsDigest(bundle.EnvelopeSHA256) || !envelope.IsDigest(bundle.CiphertextSHA256) { - return errors.New("scorer request bundle digest is invalid") - } - return nil -} - -func (scorer Identity) Validate() error { - if !idPattern.MatchString(scorer.ID) || len(scorer.Version) > 64 || !semverPattern.MatchString(scorer.Version) || !envelope.IsDigest(scorer.PolicyDigest) { - return errors.New("scorer identity/version/policy is invalid") - } - return nil -} - -func validateUnsigned(result UnsignedResult) error { - if result.Kind != ResultKind || result.Protocol != envelope.Protocol || result.ProtocolVersion != envelope.ProtocolVersion { - return errors.New("scorer result protocol discriminator is invalid") - } - if !envelope.IsEventID(result.EventID) || identity.ValidateDecimal(result.EventEpoch, "event_epoch") != nil || !envelope.IsUUID(result.ResultID) || !envelope.IsUUID(result.AttemptID) || identity.ValidateDecimal(result.ActorID, "actor_id") != nil || !envelope.IsUUID(result.TeamID) { - return errors.New("scorer result event/result/actor/team/attempt binding is invalid") - } - if !envelope.IsDigest(result.ScorerRequestDigest) || !envelope.IsDigest(result.SubmissionEnvelopeDigest) || !envelope.IsDigest(result.CiphertextDigest) || !envelope.IsDigest(result.ConfigDigest) { - return errors.New("scorer result digest binding is invalid") - } - if err := result.Reservation.Validate(); err != nil { - return fmt.Errorf("scorer result reservation: %w", err) - } - if err := result.Scorer.Validate(); err != nil { - return err - } - if _, err := envelope.ParseTimestamp(result.CompletedAt); err != nil { - return err - } - if len(result.Metrics) > 128 { - return errors.New("scorer result has more than 128 metrics") - } - seenMetrics := make(map[string]struct{}, len(result.Metrics)) - for index, metric := range result.Metrics { - if !metricNamePattern.MatchString(metric.Name) || metric.Micropoints < -maxSafeInteger || metric.Micropoints > maxSafeInteger { - return fmt.Errorf("scorer result metric %d is invalid", index) - } - if index > 0 && result.Metrics[index-1].Name >= metric.Name { - return errors.New("scorer result metrics must be strictly sorted by name") - } - if _, duplicate := seenMetrics[metric.Name]; duplicate { - return errors.New("scorer result metric names must be unique") - } - seenMetrics[metric.Name] = struct{}{} - } - switch result.Status { - case "scored": - if result.TotalMicropoints == nil || *result.TotalMicropoints < -maxSafeInteger || *result.TotalMicropoints > maxSafeInteger || result.ReasonCode != nil { - return errors.New("scored result requires a bounded total and null reason_code") - } - case "invalid", "timeout", "internal_error": - if result.TotalMicropoints != nil || len(result.Metrics) != 0 || result.ReasonCode == nil || !reasonCodePattern.MatchString(*result.ReasonCode) { - return errors.New("non-scored result requires no score/metrics and a bounded reason_code") - } - default: - return errors.New("scorer result status is invalid") - } - return nil -} - -// VerifyRequestContext binds an unsigned request to trusted configuration and -// protected state. It is intentionally timeless: source_created_at is checked -// against the original signed window, while delayed scoring can happen later. -func VerifyRequestContext(request Request, expected Expected) error { - if expected.MaximumResultBytes < MinResultBytes || expected.MaximumResultBytes > MaxResultBytes { - return errors.New("configured scorer result limit is invalid") - } - if request.EventID != expected.EventID || request.EventEpoch != expected.EventEpoch || request.ConfigDigest != expected.ConfigDigest || request.PullRequest.BaseRepositoryID != expected.BaseRepositoryID || request.PullRequest.BaseRef != expected.BaseRef || request.Scorer != expected.Scorer { - return errors.New("scorer request does not match trusted event/config/scoring policy") - } - if expected.MaximumCiphertextBytes < 1 || request.Bundle.CiphertextSize > expected.MaximumCiphertextBytes { - return errors.New("scorer request ciphertext exceeds configured limit") - } - maximumBundleBytes := expected.MaximumCiphertextBytes + 256*1024 + 8 + 4 + 64 - if maximumBundleBytes < expected.MaximumCiphertextBytes || request.Bundle.SizeBytes > maximumBundleBytes { - return errors.New("scorer request bundle exceeds configured limit") - } - if err := request.Reservation.IsAtOrBefore(expected.CurrentState); err != nil { - return fmt.Errorf("scorer request reservation: %w", err) - } - if err := envelope.ValidateWindow(request.IssuedAt, request.ExpiresAt, time.Time{}); err != nil { - return fmt.Errorf("scorer request validity window: %w", err) - } - return nil -} - -func verifyResultContext(result Result, request Request, expected Expected) error { - requestDigest, err := envelope.DocumentDigest(request) - if err != nil { - return err - } - if result.EventID != request.EventID || result.EventEpoch != request.EventEpoch || result.AttemptID != request.AttemptID || result.ActorID != request.ActorID || result.TeamID != request.TeamID || result.ScorerRequestDigest != requestDigest || result.SubmissionEnvelopeDigest != request.SubmissionEnvelopeDigest || result.CiphertextDigest != request.Bundle.CiphertextSHA256 || result.ConfigDigest != request.ConfigDigest || !result.Reservation.Equal(request.Reservation) || result.Scorer != request.Scorer { - return errors.New("scorer result does not exactly bind the scorer request") - } - if result.EventID != expected.EventID || result.EventEpoch != expected.EventEpoch || result.ConfigDigest != expected.ConfigDigest || result.Scorer != expected.Scorer { - return errors.New("scorer result does not match trusted event/config/scoring policy") - } - acceptedAt, _ := envelope.ParseTimestamp(request.AcceptedAt) - completedAt, err := envelope.ParseTimestamp(result.CompletedAt) - if err != nil { - return err - } - if completedAt.Before(acceptedAt) { - return errors.New("scorer result completed_at precedes accepted reservation") - } - if !expected.Now.IsZero() && completedAt.After(expected.Now.UTC().Add(5*time.Minute)) { - return errors.New("scorer result completed_at is implausibly in the future") - } - return nil -} - -func unsigned(result Result) UnsignedResult { - return UnsignedResult{ - result.Kind, result.Protocol, result.ProtocolVersion, result.EventID, result.EventEpoch, - result.ResultID, result.AttemptID, result.ActorID, result.TeamID, - result.ScorerRequestDigest, result.SubmissionEnvelopeDigest, result.CiphertextDigest, - result.ConfigDigest, result.Reservation, result.Scorer, result.Status, - result.TotalMicropoints, result.Metrics, result.ReasonCode, result.CompletedAt, - } -} - -func resultFromUnsigned(result UnsignedResult) Result { - return Result{ - Kind: result.Kind, Protocol: result.Protocol, ProtocolVersion: result.ProtocolVersion, - EventID: result.EventID, EventEpoch: result.EventEpoch, ResultID: result.ResultID, - AttemptID: result.AttemptID, ActorID: result.ActorID, TeamID: result.TeamID, - ScorerRequestDigest: result.ScorerRequestDigest, - SubmissionEnvelopeDigest: result.SubmissionEnvelopeDigest, - CiphertextDigest: result.CiphertextDigest, ConfigDigest: result.ConfigDigest, - Reservation: result.Reservation, Scorer: result.Scorer, Status: result.Status, - TotalMicropoints: result.TotalMicropoints, Metrics: result.Metrics, - ReasonCode: result.ReasonCode, CompletedAt: result.CompletedAt, - } -} diff --git a/internal/scorer/scorer_test.go b/internal/scorer/scorer_test.go deleted file mode 100644 index bbdc526..0000000 --- a/internal/scorer/scorer_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package scorer - -import ( - "bytes" - "encoding/base64" - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" - "github.com/pythonhk/eventctl/internal/statepointer" -) - -func TestSignAndVerifyDelayedScorerResultGolden(t *testing.T) { - pair := scorerTestPair(t, 11) - request := scorerTestRequest() - expected := scorerTestExpected(pair.Public) - requestDigest, err := envelope.DocumentDigest(request) - if err != nil { - t.Fatal(err) - } - total := int64(1250000) - payload := UnsignedResult{ - Kind: ResultKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: request.EventID, EventEpoch: request.EventEpoch, - ResultID: "77777777-7777-4777-8777-777777777777", AttemptID: request.AttemptID, - ActorID: request.ActorID, TeamID: request.TeamID, ScorerRequestDigest: requestDigest, - SubmissionEnvelopeDigest: request.SubmissionEnvelopeDigest, - CiphertextDigest: request.Bundle.CiphertextSHA256, ConfigDigest: request.ConfigDigest, - Reservation: request.Reservation, Scorer: request.Scorer, Status: "scored", - TotalMicropoints: &total, Metrics: []Metric{{Name: "correctness", Micropoints: total}}, - CompletedAt: "2030-06-02T03:00:00Z", - } - document, err := SignResult(payload, request, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - requestRaw, _ := canonical.Marshal(request) - resultRaw, _ := canonical.Marshal(document) - verified, err := Verify(requestRaw, resultRaw, expected) - if err != nil { - t.Fatal(err) - } - const wantRequestDigest = "940161da12a674103009c9b6908ab56f9a4fc7a5aef25fe60784cc3e220159b7" - const wantDocumentDigest = "6526a4b0fbb5a9df6ef640a6d36f0e680455493e2d19b84b713dc448bd3c184d" - const wantReplayDigest = "63a928509e6bfa575b0dadf3c376dcf3d76963b2d18745947155e20230bb92f5" - if verified.ScorerRequestDigest != wantRequestDigest { - t.Fatalf("scorer request digest = %s, want %s", verified.ScorerRequestDigest, wantRequestDigest) - } - if verified.DocumentDigest != wantDocumentDigest { - t.Fatalf("result document digest = %s, want %s", verified.DocumentDigest, wantDocumentDigest) - } - if verified.Fingerprint.RequestDigest != wantReplayDigest { - t.Fatalf("result request digest = %s, want %s", verified.Fingerprint.RequestDigest, wantReplayDigest) - } -} - -func TestScorerRejectsBindingAndSchemaAttacks(t *testing.T) { - pair := scorerTestPair(t, 11) - request := scorerTestRequest() - expected := scorerTestExpected(pair.Public) - requestDigest, _ := envelope.DocumentDigest(request) - reason := "judge_timeout" - payload := UnsignedResult{ - Kind: ResultKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: request.EventID, EventEpoch: request.EventEpoch, - ResultID: "77777777-7777-4777-8777-777777777777", AttemptID: request.AttemptID, - ActorID: request.ActorID, TeamID: request.TeamID, ScorerRequestDigest: requestDigest, - SubmissionEnvelopeDigest: request.SubmissionEnvelopeDigest, - CiphertextDigest: request.Bundle.CiphertextSHA256, ConfigDigest: request.ConfigDigest, - Reservation: request.Reservation, Scorer: request.Scorer, Status: "timeout", - Metrics: []Metric{}, ReasonCode: &reason, CompletedAt: "2030-06-02T03:00:00Z", - } - document, err := SignResult(payload, request, expected, pair.Private) - if err != nil { - t.Fatal(err) - } - requestRaw, _ := canonical.Marshal(request) - resultRaw, _ := canonical.Marshal(document) - - t.Run("tampered exact request", func(t *testing.T) { - tampered := request - tampered.Bundle.SHA256 = strings.Repeat("1", 64) - tamperedRaw, _ := canonical.Marshal(tampered) - if _, err := Verify(tamperedRaw, resultRaw, expected); err == nil { - t.Fatal("Verify() accepted a different scorer request") - } - }) - - t.Run("wrong configured signer", func(t *testing.T) { - wrong := expected - wrong.ResultKey = scorerTestPair(t, 12).Public - if _, err := Verify(requestRaw, resultRaw, wrong); err == nil { - t.Fatal("Verify() accepted wrong scorer key") - } - }) - - t.Run("unknown result field", func(t *testing.T) { - withUnknown := append([]byte(`{"unknown":true,`), resultRaw[1:]...) - if _, err := Verify(requestRaw, withUnknown, expected); err == nil { - t.Fatal("Verify() accepted unknown result field") - } - }) - - t.Run("score on failure", func(t *testing.T) { - total := int64(1) - invalid := payload - invalid.TotalMicropoints = &total - if _, err := SignResult(invalid, request, expected, pair.Private); err == nil { - t.Fatal("SignResult() accepted score for timeout") - } - }) - - t.Run("source outside original window", func(t *testing.T) { - invalid := request - invalid.SourceCreatedAt = "2030-06-01T02:16:00Z" - if err := invalid.Validate(); err == nil { - t.Fatal("Request.Validate() accepted late immutable source") - } - }) - - t.Run("completion before acceptance", func(t *testing.T) { - invalid := payload - invalid.CompletedAt = "2030-06-01T02:04:59Z" - if _, err := SignResult(invalid, request, expected, pair.Private); err == nil { - t.Fatal("SignResult() accepted result before reservation") - } - }) - - t.Run("signed form exceeds configured result bytes", func(t *testing.T) { - tight := expected - tight.MaximumResultBytes = MinResultBytes - if _, err := SignResult(payload, request, tight, pair.Private); err == nil { - t.Fatal("SignResult() emitted a signed document above the configured byte cap") - } - }) - - t.Run("unsorted metrics", func(t *testing.T) { - total := int64(2) - invalid := payload - invalid.Status = "scored" - invalid.TotalMicropoints = &total - invalid.ReasonCode = nil - invalid.Metrics = []Metric{{Name: "z_metric", Micropoints: 1}, {Name: "a_metric", Micropoints: 1}} - if _, err := SignResult(invalid, request, expected, pair.Private); err == nil { - t.Fatal("SignResult() accepted non-canonical metric ordering") - } - }) -} - -func TestScorerSynchronousTransportBudget(t *testing.T) { - t.Parallel() - const ( - responsePrefix = `{"kind":"scorer_response","protocol":"pythonhk.github-native-event","protocol_version":1,"scorer_request":` - responseMiddle = `,"scorer_result":` - responseSuffix = `}` - ) - worstCaseRaw := uint64(len(responsePrefix)+len(responseMiddle)+len(responseSuffix)) + MaxRequestBytes + MaxResultBytes - if worstCaseRaw > MaxSynchronousResponseBytes { - t.Fatalf("worst-case strict scorer response = %d bytes, transport cap = %d", worstCaseRaw, MaxSynchronousResponseBytes) - } - t.Logf("worst-case strict scorer response uses %d of %d raw bytes", worstCaseRaw, MaxSynchronousResponseBytes) - worstCaseBase64 := base64.StdEncoding.EncodedLen(int(worstCaseRaw)) - if worstCaseBase64 > MaxSynchronousResponseBase64Bytes { - t.Fatalf("worst-case base64 scorer response = %d bytes, transport cap = %d", worstCaseBase64, MaxSynchronousResponseBase64Bytes) - } - if got := base64.StdEncoding.EncodedLen(MaxSynchronousResponseBytes); got != MaxSynchronousResponseBase64Bytes { - t.Fatalf("raw/base64 transport caps disagree: encoded raw cap = %d, base64 cap = %d", got, MaxSynchronousResponseBase64Bytes) - } -} - -func TestScorerParserEnforcesFrozenTransportLimits(t *testing.T) { - t.Parallel() - if _, err := ParseRequest(bytes.Repeat([]byte{' '}, int(MaxRequestBytes+1))); err == nil { - t.Fatalf("ParseRequest accepted more than %d bytes", MaxRequestBytes) - } - if _, err := ParseResult([]byte(`{}`), MaxResultBytes+1); err == nil { - t.Fatalf("ParseResult accepted configured cap %d", MaxResultBytes+1) - } - if _, err := ParseUnsignedResult([]byte(`{}`), MaxResultBytes+1); err == nil { - t.Fatalf("ParseUnsignedResult accepted configured cap %d", MaxResultBytes+1) - } - if _, err := ParseResult([]byte(`{}`), MinResultBytes-1); err == nil { - t.Fatalf("ParseResult accepted configured cap %d", MinResultBytes-1) - } - - identity := scorerTestRequest().Scorer - identity.Version = "v" + strings.Repeat("1", 59) + ".0.0" - if got := len(identity.Version); got != 64 { - t.Fatalf("boundary scorer version length = %d, want 64", got) - } - if err := identity.Validate(); err != nil { - t.Fatalf("64-byte scorer version rejected: %v", err) - } - identity.Version = "v" + strings.Repeat("1", 60) + ".0.0" - if err := identity.Validate(); err == nil { - t.Fatal("65-byte scorer version accepted") - } -} - -func scorerTestPair(t *testing.T, fill byte) identity.KeyPair { - t.Helper() - pair, err := identity.FromSeed(bytes.Repeat([]byte{fill}, 32)) - if err != nil { - t.Fatal(err) - } - return pair -} - -func scorerTestRequest() Request { - return Request{ - Kind: RequestKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: "pyhk-2030", EventEpoch: "1", AttemptID: "33333333-3333-4333-8333-333333333333", - ActorID: "42", TeamID: "22222222-2222-4222-8222-222222222222", - TeamProposalDigest: strings.Repeat("1", 64), ConfigDigest: strings.Repeat("2", 64), - SubmissionEnvelopeDigest: strings.Repeat("3", 64), ReservationReceiptDigest: strings.Repeat("4", 64), - Reservation: statepointer.Pointer{Sequence: 5, JournalEventDigest: strings.Repeat("5", 64)}, - SourceCreatedAt: "2030-06-01T02:01:00Z", AcceptedAt: "2030-06-01T02:05:00Z", - PullRequest: envelope.PullRequest{ - Number: 7, ID: "700", BaseRepositoryID: "123456789", BaseRef: "main", - HeadRepositoryID: "987654321", HeadOwner: "participant", HeadRef: "event-submission", - HeadSHA: strings.Repeat("6", 40), - }, - Bundle: Bundle{Path: "submission.eventctl", MediaType: BundleMediaType, SizeBytes: 2048, - SHA256: strings.Repeat("7", 64), EnvelopeSHA256: strings.Repeat("8", 64), - CiphertextSize: 1024, CiphertextSHA256: strings.Repeat("9", 64)}, - Scorer: Identity{ID: "reference_scorer", Version: "v1.2.3", PolicyDigest: strings.Repeat("a", 64)}, - Provenance: Provenance, IssuedAt: "2030-06-01T02:00:00Z", ExpiresAt: "2030-06-01T02:15:00Z", - } -} - -func scorerTestExpected(public identity.Public) Expected { - request := scorerTestRequest() - return Expected{ - EventID: request.EventID, EventEpoch: request.EventEpoch, BaseRepositoryID: request.PullRequest.BaseRepositoryID, - BaseRef: request.PullRequest.BaseRef, ConfigDigest: request.ConfigDigest, Scorer: request.Scorer, - ResultKey: public, MaximumResultBytes: MaxResultBytes, MaximumCiphertextBytes: 47_000_000, - CurrentState: statepointer.Pointer{Sequence: 9, JournalEventDigest: strings.Repeat("b", 64)}, - Now: time.Date(2030, 6, 2, 4, 0, 0, 0, time.UTC), - } -} - -func FuzzParseScorerArtifacts(f *testing.F) { - f.Add([]byte(`{}`)) - requestRaw, _ := canonical.Marshal(scorerTestRequest()) - f.Add(requestRaw) - f.Fuzz(func(t *testing.T, raw []byte) { - _, _ = ParseRequest(raw) - _, _ = ParseResult(raw, MaxResultBytes) - _, _ = ParseUnsignedResult(raw, MaxResultBytes) - }) -} diff --git a/internal/statepointer/statepointer.go b/internal/statepointer/statepointer.go deleted file mode 100644 index be0dbda..0000000 --- a/internal/statepointer/statepointer.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package statepointer defines the small, hash-linked protected-state pointer -// shared by receipts and isolated-scoring artifacts. -package statepointer - -import ( - "errors" - - "github.com/pythonhk/eventctl/internal/envelope" -) - -const MaxSequenceV1 uint64 = 4_096 - -// Pointer names one committed protected-state journal event. -type Pointer struct { - Sequence uint64 `json:"sequence"` - JournalEventDigest string `json:"journal_event_digest"` -} - -// Validate rejects the zero/genesis sentinel and malformed journal digests. -func (pointer Pointer) Validate() error { - if pointer.Sequence < 1 || pointer.Sequence > MaxSequenceV1 { - return errors.New("state pointer sequence is outside the v1 lifetime bound") - } - if !envelope.IsDigest(pointer.JournalEventDigest) { - return errors.New("state pointer journal_event_digest is invalid") - } - return nil -} - -// Equal reports exact state-pointer equality. -func (pointer Pointer) Equal(other Pointer) bool { - return pointer.Sequence == other.Sequence && pointer.JournalEventDigest == other.JournalEventDigest -} - -// IsAtOrBefore verifies that pointer does not claim a future state. When both -// pointers name the same sequence, the journal digest must also be identical. -func (pointer Pointer) IsAtOrBefore(current Pointer) error { - if err := pointer.Validate(); err != nil { - return err - } - if err := current.Validate(); err != nil { - return err - } - if pointer.Sequence > current.Sequence { - return errors.New("state pointer is ahead of trusted current state") - } - if pointer.Sequence == current.Sequence && pointer.JournalEventDigest != current.JournalEventDigest { - return errors.New("state pointer digest disagrees with trusted current state") - } - return nil -} diff --git a/internal/statepointer/statepointer_test.go b/internal/statepointer/statepointer_test.go deleted file mode 100644 index 5413302..0000000 --- a/internal/statepointer/statepointer_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package statepointer - -import ( - "strings" - "testing" -) - -func TestPointerV1SequenceBoundary(t *testing.T) { - t.Parallel() - digest := strings.Repeat("a", 64) - if err := (Pointer{Sequence: MaxSequenceV1, JournalEventDigest: digest}).Validate(); err != nil { - t.Fatalf("maximum v1 sequence rejected: %v", err) - } - for _, sequence := range []uint64{0, MaxSequenceV1 + 1} { - if err := (Pointer{Sequence: sequence, JournalEventDigest: digest}).Validate(); err == nil { - t.Fatalf("sequence %d accepted", sequence) - } - } -} diff --git a/internal/stream/stream.go b/internal/stream/stream.go new file mode 100644 index 0000000..86eb0be --- /dev/null +++ b/internal/stream/stream.go @@ -0,0 +1,133 @@ +package stream + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + + "filippo.io/age" + "github.com/pythonhk/eventctl/internal/protocol" + "github.com/samber/lo" +) + +var magic = [8]byte{'E', 'V', 'T', 'C', 'T', 'L', 1, 0} + +type Header struct { + Protocol string `json:"protocol"` + Binding protocol.StreamBinding `json:"binding"` + Signer protocol.SigningPublic `json:"signer"` + RecipientKeyIDs []string `json:"recipient_key_ids"` + PayloadSize int64 `json:"payload_size"` + PayloadSHA256 string `json:"payload_sha256"` + Signature protocol.Signature `json:"signature"` +} + +type Result struct { + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + RecipientCount int `json:"recipient_count"` +} + +func SealFile(input, output string, binding protocol.StreamBinding, signingKey protocol.SigningKey, recipients []protocol.RecipientPublic, identities []age.Recipient) (Result, error) { + payload, err := protocol.ReadBytes(input) + if err != nil { + return Result{}, fmt.Errorf("read stream: %w", err) + } + digest := sha256.Sum256(payload) + ids := make([]string, len(recipients)) + for index, recipient := range recipients { + ids[index] = recipient.KeyID + } + sort.Strings(ids) + unsigned := Header{Protocol: protocol.Protocol, Binding: binding, Signer: signingKey.Public, RecipientKeyIDs: ids, PayloadSize: int64(len(payload)), PayloadSHA256: hex.EncodeToString(digest[:])} + signature := protocol.Sign("stream.sigcrypt", unsigned, signingKey) + header := unsigned + header.Signature = signature + headerBytes := lo.Must(json.Marshal(header)) + var cipher bytes.Buffer + writer, encryptErr := age.Encrypt(&cipher, identities...) + if encryptErr != nil { + return Result{}, fmt.Errorf("encrypt stream: %w", encryptErr) + } + // The authenticated writer targets bytes.Buffer, whose writes cannot fail. + lo.Must(writer.Write(payload)) + lo.Must0(writer.Close()) + container := make([]byte, 0, len(magic)+4+len(headerBytes)+cipher.Len()) + container = append(container, magic[:]...) + headerLength := [4]byte{} + binary.BigEndian.PutUint32(headerLength[:], uint32(len(headerBytes))) + container = append(container, headerLength[:]...) + container = append(container, headerBytes...) + container = append(container, cipher.Bytes()...) + if err := protocol.WriteExclusive(output, container, 0o600); err != nil { + return Result{}, fmt.Errorf("write encrypted stream: %w", err) + } + return Result{int64(len(payload)), hex.EncodeToString(digest[:]), len(recipients)}, nil +} + +func OpenFile(input, output string, expected protocol.StreamBinding, signer protocol.SigningPublic, recipient protocol.RecipientKey) (Result, error) { + data, err := protocol.ReadBytes(input) + if err != nil { + return Result{}, fmt.Errorf("read encrypted stream: %w", err) + } + if len(data) < len(magic)+4 || !bytes.Equal(data[:len(magic)], magic[:]) { + return Result{}, errors.New("invalid eventctl stream header") + } + headerSize := binary.BigEndian.Uint32(data[len(magic) : len(magic)+4]) + start := len(magic) + 4 + end := start + int(headerSize) + if end > len(data) || headerSize > protocol.MaxBytes { + return Result{}, errors.New("invalid eventctl stream header size") + } + var header Header + if err := json.Unmarshal(data[start:end], &header); err != nil { + return Result{}, fmt.Errorf("decode stream header: %w", err) + } + if header.Protocol != protocol.Protocol || header.Binding != expected { + return Result{}, errors.New("stream binding mismatch") + } + if err := protocol.Verify("stream.sigcrypt", Header{header.Protocol, header.Binding, header.Signer, header.RecipientKeyIDs, header.PayloadSize, header.PayloadSHA256, protocol.Signature{}}, header.Signature, signer); err != nil { + return Result{}, err + } + if header.Signer.KeyID != signer.KeyID { + return Result{}, errors.New("stream signer mismatch") + } + if !contains(header.RecipientKeyIDs, recipient.Public.KeyID) { + return Result{}, errors.New("recipient is not authorized for stream") + } + reader, err := age.Decrypt(bytes.NewReader(data[end:]), recipient.Identity) + if err != nil { + return Result{}, fmt.Errorf("decrypt stream: %w", err) + } + payload, err := io.ReadAll(io.LimitReader(reader, protocol.MaxBytes+1)) + if err != nil { + return Result{}, err + } + if len(payload) > protocol.MaxBytes || int64(len(payload)) != header.PayloadSize { + return Result{}, errors.New("stream size does not match signed header") + } + digest := sha256.Sum256(payload) + actual := hex.EncodeToString(digest[:]) + if actual != header.PayloadSHA256 { + return Result{}, errors.New("stream digest does not match signed header") + } + if err := protocol.WriteExclusive(output, payload, 0o600); err != nil { + return Result{}, fmt.Errorf("write plaintext stream: %w", err) + } + return Result{int64(len(payload)), actual, 1}, nil +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/internal/team/team.go b/internal/team/team.go deleted file mode 100644 index b13b3cb..0000000 --- a/internal/team/team.go +++ /dev/null @@ -1,498 +0,0 @@ -// Package team implements immutable proposals and unanimous individual consent -// using the flat v1 protocol documents. -package team - -import ( - "errors" - "fmt" - "sort" - "time" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -const ( - ProposalKind = "team_proposal" - ProposalDomain = "team_proposal" - ConsentKind = "team_consent" - ConsentDomain = "team_consent" - MinProposalTTLSeconds uint64 = 300 - MaxProposalTTLSeconds uint64 = 1_209_600 - MinProposalTTL = time.Duration(MinProposalTTLSeconds) * time.Second - MaxProposalTTL = time.Duration(MaxProposalTTLSeconds) * time.Second -) - -// Proposal is the authoritative flat team-proposal wire document. -type Proposal struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - TeamID string `json:"team_id"` - ProposerActorID string `json:"proposer_actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - MemberActorIDs []string `json:"member_actor_ids"` - BaseRepository envelope.Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature identity.Signature `json:"signature"` -} - -type proposalUnsigned struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - TeamID string `json:"team_id"` - ProposerActorID string `json:"proposer_actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - MemberActorIDs []string `json:"member_actor_ids"` - BaseRepository envelope.Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -// Consent is one participant's consent to the digest of an exact proposal. -type Consent struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - TeamID string `json:"team_id"` - ProposalDigest string `json:"proposal_digest"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - Decision string `json:"decision"` - BaseRepository envelope.Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature identity.Signature `json:"signature"` -} - -// ValidateUntrustedStructure validates the concrete team-proposal schema and -// internal field relationships without authenticating its signature, trusted -// actor/config context, or current validity window. -func (value Proposal) ValidateUntrustedStructure() error { - if err := validateProposal(value, time.Time{}, MaxProposalTTL); err != nil { - return err - } - if err := value.Signature.ValidateEncoding(); err != nil { - return err - } - if value.Signature.KeyID != value.KeyID { - return errors.New("signature.key_id does not match key_id") - } - return nil -} - -// ValidateUntrustedStructure validates the concrete team-consent schema and -// internal field relationships without authenticating its signature, trusted -// actor/config context, proposal binding, or current validity window. -func (value Consent) ValidateUntrustedStructure() error { - if err := validateConsent(value, time.Time{}, MaxProposalTTL); err != nil { - return err - } - if err := value.Signature.ValidateEncoding(); err != nil { - return err - } - if value.Signature.KeyID != value.KeyID { - return errors.New("signature.key_id does not match key_id") - } - return nil -} - -type consentUnsigned struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - OperationID string `json:"operation_id"` - TeamID string `json:"team_id"` - ProposalDigest string `json:"proposal_digest"` - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - KeyEpoch string `json:"key_epoch"` - Decision string `json:"decision"` - BaseRepository envelope.Repository `json:"base_repository"` - ConfigDigest string `json:"config_digest"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -type ProposalParams struct { - EventID string - EventEpoch string - OperationID string - TeamID string - ProposerActorID string - KeyEpoch string - MemberActorIDs []string - BaseRepository envelope.Repository - ConfigDigest string - IssuedAt time.Time - ExpiresAt time.Time -} - -type ConsentParams struct { - OperationID string - ActorID string - KeyEpoch string - IssuedAt time.Time - ExpiresAt time.Time -} - -type VerifiedProposal struct { - Document Proposal - ProposalDigest string - Fingerprint envelope.Fingerprint -} - -type VerifiedConsent struct { - Document Consent - Fingerprint envelope.Fingerprint -} - -// ActivationCandidate proves cryptographic unanimity. Protected state must -// still atomically recheck lifecycle and membership before activation. -type ActivationCandidate struct { - TeamID string `json:"team_id"` - MemberActorIDs []string `json:"member_actor_ids"` - ProposalDigest string `json:"proposal_digest"` - Proposal envelope.Fingerprint `json:"proposal"` - ConsentRequests []envelope.Fingerprint `json:"consent_requests"` -} - -func NewProposal(params ProposalParams, private identity.Private) ([]byte, error) { - pair, err := privatePair(private) - if err != nil { - return nil, err - } - if params.OperationID == "" { - params.OperationID, err = envelope.NewRequestID() - if err != nil { - return nil, err - } - } - if params.TeamID == "" { - params.TeamID, err = envelope.NewRequestID() - if err != nil { - return nil, err - } - } - if params.KeyEpoch == "" { - params.KeyEpoch = "1" - } - members := append([]string(nil), params.MemberActorIDs...) - sort.Slice(members, func(i, j int) bool { return identity.CompareDecimal(members[i], members[j]) < 0 }) - proposal := Proposal{ - Kind: ProposalKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: params.EventID, EventEpoch: params.EventEpoch, OperationID: params.OperationID, TeamID: params.TeamID, - ProposerActorID: params.ProposerActorID, KeyID: pair.Public.KeyID, KeyEpoch: params.KeyEpoch, - MemberActorIDs: members, BaseRepository: params.BaseRepository, ConfigDigest: params.ConfigDigest, - IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), - } - if err := validateProposal(proposal, time.Time{}, MaxProposalTTL); err != nil { - return nil, err - } - proposal.Signature, err = envelope.Sign(ProposalDomain, unsignedProposal(proposal), pair.Private) - if err != nil { - return nil, err - } - return canonical.Marshal(proposal) -} - -// VerifyProposal authenticates a proposal and enforces the proposal TTL from -// the signed event config bound by expected.ConfigDigest. -func VerifyProposal(raw []byte, expected envelope.Expected, proposalTTL time.Duration, registry identity.Registry) (VerifiedProposal, error) { - if len(raw) > envelope.MaxDocumentBytes { - return VerifiedProposal{}, errors.New("team proposal exceeds 1 MiB") - } - var proposal Proposal - if err := canonical.StrictUnmarshal(raw, &proposal); err != nil { - return VerifiedProposal{}, fmt.Errorf("decode team proposal: %w", err) - } - if err := validateProposal(proposal, expected.Now, proposalTTL); err != nil { - return VerifiedProposal{}, err - } - if err := compareExpected(proposal.EventID, proposal.EventEpoch, proposal.BaseRepository.ID, proposal.ProposerActorID, proposal.ConfigDigest, proposal.KeyEpoch, proposal.KeyID, expected); err != nil { - return VerifiedProposal{}, err - } - trusted, ok := registry.Resolve(proposal.ProposerActorID, proposal.KeyEpoch) - if !ok || trusted.KeyID != proposal.KeyID { - return VerifiedProposal{}, errors.New("proposal signer does not match trusted registration") - } - if err := envelope.Verify(ProposalDomain, unsignedProposal(proposal), proposal.Signature, trusted); err != nil { - return VerifiedProposal{}, err - } - canonicalProposal, err := canonical.Marshal(proposal) - if err != nil { - return VerifiedProposal{}, err - } - proposalDigest := envelope.Digest(canonicalProposal) - intentDigest, err := envelope.SigningDigest(ProposalDomain, unsignedProposal(proposal)) - if err != nil { - return VerifiedProposal{}, err - } - fingerprint, err := envelope.NewFingerprint(ProposalDomain, proposal.EventID, proposal.OperationID, intentDigest) - if err != nil { - return VerifiedProposal{}, err - } - return VerifiedProposal{Document: proposal, ProposalDigest: proposalDigest, Fingerprint: fingerprint}, nil -} - -// NewConsent verifies the proposal against trusted registrations before signing -// one exact participant's separate consent. -func NewConsent(proposalRaw []byte, params ConsentParams, private identity.Private, registry identity.Registry) ([]byte, error) { - pair, err := privatePair(private) - if err != nil { - return nil, err - } - proposal, err := VerifyProposal(proposalRaw, envelope.Expected{}, MaxProposalTTL, registry) - if err != nil { - return nil, fmt.Errorf("verify proposal before consent: %w", err) - } - if params.OperationID == "" { - params.OperationID, err = envelope.NewRequestID() - if err != nil { - return nil, err - } - } - if params.KeyEpoch == "" { - params.KeyEpoch = "1" - } - consent := Consent{ - Kind: ConsentKind, Protocol: envelope.Protocol, ProtocolVersion: envelope.ProtocolVersion, - EventID: proposal.Document.EventID, EventEpoch: proposal.Document.EventEpoch, OperationID: params.OperationID, TeamID: proposal.Document.TeamID, - ProposalDigest: proposal.ProposalDigest, ActorID: params.ActorID, KeyID: pair.Public.KeyID, KeyEpoch: params.KeyEpoch, - Decision: "consent", BaseRepository: proposal.Document.BaseRepository, ConfigDigest: proposal.Document.ConfigDigest, - IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), - } - if !contains(proposal.Document.MemberActorIDs, consent.ActorID) { - return nil, errors.New("consent actor is not a proposal member") - } - trusted, ok := registry.Resolve(consent.ActorID, consent.KeyEpoch) - if !ok || trusted != pair.Public { - return nil, errors.New("consent signing key does not match trusted registration") - } - if err := validateConsent(consent, time.Time{}, MaxProposalTTL); err != nil { - return nil, err - } - consent.Signature, err = envelope.Sign(ConsentDomain, unsignedConsent(consent), pair.Private) - if err != nil { - return nil, err - } - return canonical.Marshal(consent) -} - -// VerifyUnanimous verifies the proposal and every member consent under the -// proposal TTL from the signed event config. -func VerifyUnanimous(proposalRaw []byte, consentDocuments [][]byte, expected envelope.Expected, proposalTTL time.Duration, registry identity.Registry) (ActivationCandidate, error) { - proposal, err := VerifyProposal(proposalRaw, expected, proposalTTL, registry) - if err != nil { - return ActivationCandidate{}, err - } - if len(consentDocuments) != len(proposal.Document.MemberActorIDs) { - return ActivationCandidate{}, fmt.Errorf("got %d consents, want exactly %d", len(consentDocuments), len(proposal.Document.MemberActorIDs)) - } - seen := make(map[string]struct{}, len(consentDocuments)) - fingerprints := make([]envelope.Fingerprint, 0, len(consentDocuments)) - for index, raw := range consentDocuments { - consent, err := verifyConsent(raw, proposal, expected, proposalTTL, registry) - if err != nil { - return ActivationCandidate{}, fmt.Errorf("consent %d: %w", index, err) - } - if _, duplicate := seen[consent.Document.ActorID]; duplicate { - return ActivationCandidate{}, fmt.Errorf("duplicate consent from actor_id %s", consent.Document.ActorID) - } - seen[consent.Document.ActorID] = struct{}{} - fingerprints = append(fingerprints, consent.Fingerprint) - } - for _, actorID := range proposal.Document.MemberActorIDs { - if _, ok := seen[actorID]; !ok { - return ActivationCandidate{}, fmt.Errorf("missing consent from actor_id %s", actorID) - } - } - sort.Slice(fingerprints, func(i, j int) bool { return fingerprints[i].ReplayKey < fingerprints[j].ReplayKey }) - return ActivationCandidate{ - TeamID: proposal.Document.TeamID, MemberActorIDs: append([]string(nil), proposal.Document.MemberActorIDs...), - ProposalDigest: proposal.ProposalDigest, Proposal: proposal.Fingerprint, ConsentRequests: fingerprints, - }, nil -} - -// VerifyConsent verifies one consent against one exact proposal and trusted -// registry under the proposal TTL from the signed event config. Unanimity -// remains a protected-state aggregation concern. -func VerifyConsent(proposalRaw, consentRaw []byte, expected envelope.Expected, proposalTTL time.Duration, registry identity.Registry) (VerifiedConsent, error) { - proposal, err := VerifyProposal(proposalRaw, expected, proposalTTL, registry) - if err != nil { - return VerifiedConsent{}, err - } - return verifyConsent(consentRaw, proposal, expected, proposalTTL, registry) -} - -func verifyConsent(raw []byte, proposal VerifiedProposal, expected envelope.Expected, proposalTTL time.Duration, registry identity.Registry) (VerifiedConsent, error) { - if len(raw) > envelope.MaxDocumentBytes { - return VerifiedConsent{}, errors.New("team consent exceeds 1 MiB") - } - var consent Consent - if err := canonical.StrictUnmarshal(raw, &consent); err != nil { - return VerifiedConsent{}, fmt.Errorf("decode team consent: %w", err) - } - if err := validateConsent(consent, expected.Now, proposalTTL); err != nil { - return VerifiedConsent{}, err - } - if consent.EventID != proposal.Document.EventID || consent.EventEpoch != proposal.Document.EventEpoch || consent.TeamID != proposal.Document.TeamID || consent.ProposalDigest != proposal.ProposalDigest || consent.BaseRepository != proposal.Document.BaseRepository || consent.ConfigDigest != proposal.Document.ConfigDigest { - return VerifiedConsent{}, errors.New("consent does not bind the exact proposal context") - } - if !contains(proposal.Document.MemberActorIDs, consent.ActorID) { - return VerifiedConsent{}, errors.New("consent actor is not a proposal member") - } - if err := compareExpected(consent.EventID, consent.EventEpoch, consent.BaseRepository.ID, consent.ActorID, consent.ConfigDigest, consent.KeyEpoch, consent.KeyID, expectedWithoutActor(expected)); err != nil { - return VerifiedConsent{}, err - } - trusted, ok := registry.Resolve(consent.ActorID, consent.KeyEpoch) - if !ok || trusted.KeyID != consent.KeyID { - return VerifiedConsent{}, errors.New("consent signer does not match trusted registration") - } - if err := envelope.Verify(ConsentDomain, unsignedConsent(consent), consent.Signature, trusted); err != nil { - return VerifiedConsent{}, err - } - intentDigest, err := envelope.SigningDigest(ConsentDomain, unsignedConsent(consent)) - if err != nil { - return VerifiedConsent{}, err - } - fingerprint, err := envelope.NewFingerprint(ConsentDomain, consent.EventID, consent.OperationID, intentDigest) - if err != nil { - return VerifiedConsent{}, err - } - return VerifiedConsent{Document: consent, Fingerprint: fingerprint}, nil -} - -func validateProposal(value Proposal, now time.Time, proposalTTL time.Duration) error { - if value.Kind != ProposalKind || value.Protocol != envelope.Protocol || value.ProtocolVersion != envelope.ProtocolVersion { - return errors.New("team proposal protocol discriminator is invalid") - } - if !envelope.IsEventID(value.EventID) || !envelope.IsUUID(value.OperationID) || !envelope.IsUUID(value.TeamID) { - return errors.New("proposal event_id, operation_id, or team_id is invalid") - } - if err := identity.ValidateDecimal(value.EventEpoch, "event_epoch"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.ProposerActorID, "proposer_actor_id"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.KeyEpoch, "key_epoch"); err != nil { - return err - } - if !envelope.IsDigest(value.KeyID) || !envelope.IsDigest(value.ConfigDigest) { - return errors.New("proposal key_id/config_digest is invalid") - } - if err := envelope.ValidateRepository(value.BaseRepository); err != nil { - return err - } - if len(value.MemberActorIDs) == 0 || len(value.MemberActorIDs) > 64 { - return errors.New("proposal must have 1 to 64 members") - } - for index, actorID := range value.MemberActorIDs { - if err := identity.ValidateDecimal(actorID, "member_actor_id"); err != nil { - return err - } - if index > 0 && identity.CompareDecimal(value.MemberActorIDs[index-1], actorID) >= 0 { - return errors.New("member_actor_ids must be strictly numerically sorted and unique") - } - } - if !contains(value.MemberActorIDs, value.ProposerActorID) { - return errors.New("proposer must be a member") - } - return validateTeamWindow(value.IssuedAt, value.ExpiresAt, now, proposalTTL) -} - -func validateConsent(value Consent, now time.Time, proposalTTL time.Duration) error { - if value.Kind != ConsentKind || value.Protocol != envelope.Protocol || value.ProtocolVersion != envelope.ProtocolVersion || value.Decision != "consent" { - return errors.New("team consent protocol discriminator is invalid") - } - if !envelope.IsEventID(value.EventID) || !envelope.IsUUID(value.OperationID) || !envelope.IsUUID(value.TeamID) { - return errors.New("consent event_id, operation_id, or team_id is invalid") - } - if err := identity.ValidateDecimal(value.EventEpoch, "event_epoch"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.ActorID, "actor_id"); err != nil { - return err - } - if err := identity.ValidateDecimal(value.KeyEpoch, "key_epoch"); err != nil { - return err - } - if !envelope.IsDigest(value.KeyID) || !envelope.IsDigest(value.ProposalDigest) || !envelope.IsDigest(value.ConfigDigest) { - return errors.New("consent digest/key fields are invalid") - } - if err := envelope.ValidateRepository(value.BaseRepository); err != nil { - return err - } - return validateTeamWindow(value.IssuedAt, value.ExpiresAt, now, proposalTTL) -} - -func validateTeamWindow(issuedAt, expiresAt string, now time.Time, proposalTTL time.Duration) error { - if proposalTTL < MinProposalTTL || proposalTTL > MaxProposalTTL || proposalTTL%time.Second != 0 { - return fmt.Errorf("team proposal TTL must be %d to %d whole seconds", MinProposalTTLSeconds, MaxProposalTTLSeconds) - } - return envelope.ValidateWindowWithin(issuedAt, expiresAt, now, proposalTTL) -} - -func unsignedProposal(v Proposal) proposalUnsigned { - return proposalUnsigned{v.Kind, v.Protocol, v.ProtocolVersion, v.EventID, v.EventEpoch, v.OperationID, v.TeamID, v.ProposerActorID, v.KeyID, v.KeyEpoch, append([]string(nil), v.MemberActorIDs...), v.BaseRepository, v.ConfigDigest, v.IssuedAt, v.ExpiresAt} -} -func unsignedConsent(v Consent) consentUnsigned { - return consentUnsigned{v.Kind, v.Protocol, v.ProtocolVersion, v.EventID, v.EventEpoch, v.OperationID, v.TeamID, v.ProposalDigest, v.ActorID, v.KeyID, v.KeyEpoch, v.Decision, v.BaseRepository, v.ConfigDigest, v.IssuedAt, v.ExpiresAt} -} -func formatTime(v time.Time) string { - if v.IsZero() { - return "" - } - return v.UTC().Truncate(time.Second).Format("2006-01-02T15:04:05Z") -} -func contains(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} -func privatePair(private identity.Private) (identity.KeyPair, error) { - raw, err := canonical.Marshal(private) - if err != nil { - return identity.KeyPair{}, err - } - return identity.ParsePrivate(raw) -} -func compareExpected(eventID, eventEpoch, repoID, actorID, configDigest, keyEpoch, keyID string, e envelope.Expected) error { - checks := [][3]string{{"event_id", eventID, e.EventID}, {"event_epoch", eventEpoch, e.EventEpoch}, {"repository_id", repoID, e.RepositoryID}, {"actor_id", actorID, e.ActorID}, {"config_digest", configDigest, e.ConfigDigest}, {"key_epoch", keyEpoch, e.KeyEpoch}, {"key_id", keyID, e.KeyID}} - for _, c := range checks { - if c[2] != "" && c[1] != c[2] { - return fmt.Errorf("%s is %q, trusted value is %q", c[0], c[1], c[2]) - } - } - return nil -} -func expectedWithoutActor(e envelope.Expected) envelope.Expected { - e.ActorID = "" - e.KeyEpoch = "" - e.KeyID = "" - return e -} diff --git a/internal/team/team_test.go b/internal/team/team_test.go deleted file mode 100644 index 02c9fe7..0000000 --- a/internal/team/team_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package team - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -func pair(t *testing.T, fill byte) identity.KeyPair { - t.Helper() - p, err := identity.GenerateFrom(bytes.NewReader(bytes.Repeat([]byte{fill}, 64))) - if err != nil { - t.Fatal(err) - } - return p -} -func registry(t *testing.T, entries []identity.RegistryEntry) identity.Registry { - t.Helper() - raw, _ := json.Marshal(identity.Registry{Schema: identity.RegistrySchema, Identities: entries}) - r, err := identity.ParseRegistry(raw) - if err != nil { - t.Fatal(err) - } - return r -} - -func TestUnanimousTeamRequiresEveryMemberIncludingProposer(t *testing.T) { - t.Parallel() - a2, a10 := pair(t, 2), pair(t, 10) - r := registry(t, []identity.RegistryEntry{{ActorID: "2", KeyEpoch: "1", Identity: a2.Public}, {ActorID: "10", KeyEpoch: "1", Identity: a10.Public}}) - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - proposal, err := NewProposal(ProposalParams{EventID: "summer-data-2026", EventEpoch: "1", OperationID: "10000000-0000-4000-8000-000000000001", TeamID: "11000000-0000-4000-8000-000000000001", ProposerActorID: "2", KeyEpoch: "1", MemberActorIDs: []string{"10", "2"}, BaseRepository: envelope.Repository{ID: "9001", Owner: "pythonhk", Name: "event"}, ConfigDigest: strings.Repeat("a", 64), IssuedAt: issued, ExpiresAt: issued.Add(15 * time.Minute)}, a2.Private) - if err != nil { - t.Fatal(err) - } - verified, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r) - if err != nil { - t.Fatal(err) - } - if verified.Document.MemberActorIDs[0] != "2" || verified.Document.MemberActorIDs[1] != "10" { - t.Fatalf("not numeric sort: %#v", verified.Document.MemberActorIDs) - } - c2, err := NewConsent(proposal, ConsentParams{OperationID: "20000000-0000-4000-8000-000000000002", ActorID: "2", KeyEpoch: "1", IssuedAt: issued, ExpiresAt: issued.Add(15 * time.Minute)}, a2.Private, r) - if err != nil { - t.Fatal(err) - } - c10, err := NewConsent(proposal, ConsentParams{OperationID: "30000000-0000-4000-8000-000000000003", ActorID: "10", KeyEpoch: "1", IssuedAt: issued, ExpiresAt: issued.Add(15 * time.Minute)}, a10.Private, r) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyUnanimous(proposal, [][]byte{c10}, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r); err == nil { - t.Fatal("accepted missing proposer consent") - } - candidate, err := VerifyUnanimous(proposal, [][]byte{c10, c2}, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r) - if err != nil { - t.Fatal(err) - } - if len(candidate.ConsentRequests) != 2 { - t.Fatalf("candidate %#v", candidate) - } -} - -func TestProposalRejectsAttackerKeyForTrustedActor(t *testing.T) { - t.Parallel() - attacker, victim := pair(t, 1), pair(t, 2) - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - r := registry(t, []identity.RegistryEntry{{ActorID: "1", KeyEpoch: "1", Identity: victim.Public}}) - proposal, err := NewProposal(ProposalParams{EventID: "summer-data-2026", EventEpoch: "1", OperationID: "40000000-0000-4000-8000-000000000004", TeamID: "41000000-0000-4000-8000-000000000004", ProposerActorID: "1", MemberActorIDs: []string{"1"}, BaseRepository: envelope.Repository{ID: "9", Owner: "pythonhk", Name: "event"}, ConfigDigest: strings.Repeat("b", 64), IssuedAt: issued, ExpiresAt: issued.Add(time.Minute)}, attacker.Private) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyProposal(proposal, envelope.Expected{}, 15*time.Minute, r); err == nil { - t.Fatal("accepted attacker key") - } -} - -func TestDefaultSevenDayProposalWindow(t *testing.T) { - t.Parallel() - proposer := pair(t, 11) - r := registry(t, []identity.RegistryEntry{{ActorID: "11", KeyEpoch: "1", Identity: proposer.Public}}) - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - proposal, err := NewProposal(ProposalParams{ - EventID: "summer-data-2026", EventEpoch: "1", - OperationID: "50000000-0000-4000-8000-000000000005", TeamID: "51000000-0000-4000-8000-000000000005", - ProposerActorID: "11", KeyEpoch: "1", MemberActorIDs: []string{"11"}, - BaseRepository: envelope.Repository{ID: "9", Owner: "pythonhk", Name: "event"}, - ConfigDigest: strings.Repeat("c", 64), IssuedAt: issued, ExpiresAt: issued.Add(7 * 24 * time.Hour), - }, proposer.Private) - if err != nil { - t.Fatalf("create default seven-day proposal: %v", err) - } - if _, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, 7*24*time.Hour, r); err != nil { - t.Fatalf("verify default seven-day proposal: %v", err) - } - consent, err := NewConsent(proposal, ConsentParams{ - OperationID: "52000000-0000-4000-8000-000000000005", ActorID: "11", KeyEpoch: "1", - IssuedAt: issued, ExpiresAt: issued.Add(7 * 24 * time.Hour), - }, proposer.Private, r) - if err != nil { - t.Fatalf("create default seven-day consent: %v", err) - } - if _, err := VerifyConsent(proposal, consent, envelope.Expected{Now: issued.Add(time.Minute)}, 7*24*time.Hour, r); err != nil { - t.Fatalf("verify default seven-day consent: %v", err) - } -} - -func TestTeamVerificationRejectsInvalidOrExceededConfiguredTTL(t *testing.T) { - t.Parallel() - proposer := pair(t, 12) - r := registry(t, []identity.RegistryEntry{{ActorID: "12", KeyEpoch: "1", Identity: proposer.Public}}) - issued := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) - base := ProposalParams{ - EventID: "summer-data-2026", EventEpoch: "1", - OperationID: "60000000-0000-4000-8000-000000000006", TeamID: "61000000-0000-4000-8000-000000000006", - ProposerActorID: "12", KeyEpoch: "1", MemberActorIDs: []string{"12"}, - BaseRepository: envelope.Repository{ID: "9", Owner: "pythonhk", Name: "event"}, - ConfigDigest: strings.Repeat("d", 64), IssuedAt: issued, - } - - overConfigured := base - overConfigured.ExpiresAt = issued.Add(30 * time.Minute) - proposal, err := NewProposal(overConfigured, proposer.Private) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r); err == nil { - t.Fatal("accepted proposal above the signed config TTL") - } - if _, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, MaxProposalTTL+time.Second, r); err == nil { - t.Fatal("accepted an out-of-protocol configured proposal TTL") - } - - compliant := base - compliant.OperationID = "62000000-0000-4000-8000-000000000006" - compliant.TeamID = "63000000-0000-4000-8000-000000000006" - compliant.ExpiresAt = issued.Add(15 * time.Minute) - proposal, err = NewProposal(compliant, proposer.Private) - if err != nil { - t.Fatal(err) - } - consent, err := NewConsent(proposal, ConsentParams{ - OperationID: "64000000-0000-4000-8000-000000000006", ActorID: "12", KeyEpoch: "1", - IssuedAt: issued, ExpiresAt: issued.Add(30 * time.Minute), - }, proposer.Private, r) - if err != nil { - t.Fatal(err) - } - if _, err := VerifyConsent(proposal, consent, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r); err == nil { - t.Fatal("accepted consent above the signed config TTL") - } -} diff --git a/internal/team/view.go b/internal/team/view.go deleted file mode 100644 index 2613c8a..0000000 --- a/internal/team/view.go +++ /dev/null @@ -1,159 +0,0 @@ -package team - -import ( - "errors" - "fmt" - "sort" - - "github.com/pythonhk/eventctl/internal/canonical" - "github.com/pythonhk/eventctl/internal/envelope" - "github.com/pythonhk/eventctl/internal/identity" -) - -// TeamsView is the protected, materialized team view fetched from the same -// immutable event-state commit as current state metadata and the registry. -type TeamsView struct { - Kind string `json:"kind"` - Protocol string `json:"protocol"` - ProtocolVersion int `json:"protocol_version"` - EventID string `json:"event_id"` - EventEpoch string `json:"event_epoch"` - ConfigDigest string `json:"config_digest"` - Sequence uint64 `json:"sequence"` - JournalEventDigest string `json:"journal_event_digest"` - Teams []ViewEntry `json:"teams"` -} - -type ViewEntry struct { - TeamID string `json:"team_id"` - ProposalDigest string `json:"proposal_digest"` - ProposerActorID string `json:"proposer_actor_id"` - MemberActorIDs []string `json:"member_actor_ids"` - Consents []ViewConsent `json:"consents"` - Status string `json:"status"` - ProposedAtSequence uint64 `json:"proposed_at_sequence"` - ActivatedAtSequence *uint64 `json:"activated_at_sequence"` - ExpiresAt string `json:"expires_at"` -} - -type ViewConsent struct { - ActorID string `json:"actor_id"` - KeyID string `json:"key_id"` - RequestDigest string `json:"request_digest"` - RecordedAtSequence uint64 `json:"recorded_at_sequence"` -} - -// ParseTeamsView strictly validates a bounded protected teams view. -func ParseTeamsView(raw []byte) (TeamsView, error) { - if len(raw) > 16<<20 { - return TeamsView{}, errors.New("teams view exceeds 16 MiB") - } - var view TeamsView - if err := canonical.StrictUnmarshal(raw, &view); err != nil { - return TeamsView{}, fmt.Errorf("decode protected teams view: %w", err) - } - if err := view.Validate(); err != nil { - return TeamsView{}, err - } - return view, nil -} - -func (view TeamsView) Validate() error { - if view.Kind != "teams_view" || view.Protocol != envelope.Protocol || view.ProtocolVersion != envelope.ProtocolVersion || !envelope.IsEventID(view.EventID) || view.EventEpoch != "1" || !envelope.IsDigest(view.ConfigDigest) || view.Sequence < 1 || !envelope.IsDigest(view.JournalEventDigest) { - return errors.New("protected teams view header is invalid") - } - if len(view.Teams) > 100000 { - return errors.New("protected teams view has too many teams") - } - for index := range view.Teams { - if err := view.Teams[index].validate(view.Sequence); err != nil { - return fmt.Errorf("team %d: %w", index, err) - } - if index > 0 && view.Teams[index-1].TeamID >= view.Teams[index].TeamID { - return errors.New("teams must be strictly sorted by team_id") - } - } - return nil -} - -func (entry ViewEntry) validate(viewSequence uint64) error { - if !envelope.IsUUID(entry.TeamID) || !envelope.IsDigest(entry.ProposalDigest) || identity.ValidateDecimal(entry.ProposerActorID, "proposer_actor_id") != nil || entry.ProposedAtSequence < 1 { - return errors.New("team identity/proposal fields are invalid") - } - if _, err := envelope.ParseTimestamp(entry.ExpiresAt); err != nil { - return err - } - if len(entry.MemberActorIDs) < 1 || len(entry.MemberActorIDs) > 64 || !containsSortedUniqueActors(entry.MemberActorIDs, entry.ProposerActorID) { - return errors.New("team member_actor_ids are invalid, unsorted, or omit the proposer") - } - if len(entry.Consents) > len(entry.MemberActorIDs) { - return errors.New("team has more consents than members") - } - memberSet := make(map[string]struct{}, len(entry.MemberActorIDs)) - for _, actorID := range entry.MemberActorIDs { - memberSet[actorID] = struct{}{} - } - consentingActors := make(map[string]struct{}, len(entry.Consents)) - for index, consent := range entry.Consents { - if identity.ValidateDecimal(consent.ActorID, "consent actor_id") != nil || !envelope.IsDigest(consent.KeyID) || !envelope.IsDigest(consent.RequestDigest) || consent.RecordedAtSequence < entry.ProposedAtSequence || consent.RecordedAtSequence > viewSequence { - return errors.New("team consent is invalid") - } - if index > 0 && identity.CompareDecimal(entry.Consents[index-1].ActorID, consent.ActorID) >= 0 { - return errors.New("team consents must be strictly sorted by numeric actor_id") - } - if _, ok := memberSet[consent.ActorID]; !ok { - return errors.New("team consent actor is not a member") - } - if _, duplicate := consentingActors[consent.ActorID]; duplicate { - return errors.New("team has duplicate consent actors") - } - consentingActors[consent.ActorID] = struct{}{} - } - switch entry.Status { - case "pending": - if entry.ActivatedAtSequence != nil { - return errors.New("pending team has activated_at_sequence") - } - case "active": - if entry.ActivatedAtSequence == nil || *entry.ActivatedAtSequence < entry.ProposedAtSequence || *entry.ActivatedAtSequence > viewSequence || len(entry.Consents) != len(entry.MemberActorIDs) { - return errors.New("active team lacks unanimous, ordered activation evidence") - } - for _, actorID := range entry.MemberActorIDs { - if _, ok := consentingActors[actorID]; !ok { - return errors.New("active team consent set does not equal member set") - } - } - case "expired", "conflicted": - // Terminal teams are never accepted for submission packing. - default: - return errors.New("team status is invalid") - } - return nil -} - -func containsSortedUniqueActors(actorIDs []string, required string) bool { - found := false - for index, actorID := range actorIDs { - if identity.ValidateDecimal(actorID, "member_actor_id") != nil { - return false - } - if index > 0 && identity.CompareDecimal(actorIDs[index-1], actorID) >= 0 { - return false - } - found = found || actorID == required - } - return found && sort.SliceIsSorted(actorIDs, func(left, right int) bool { - return identity.CompareDecimal(actorIDs[left], actorIDs[right]) < 0 - }) -} - -// FindActiveTeam returns an active team by exact ID. -func (view TeamsView) FindActiveTeam(teamID string) (ViewEntry, bool) { - index := sort.Search(len(view.Teams), func(index int) bool { - return view.Teams[index].TeamID >= teamID - }) - if index == len(view.Teams) || view.Teams[index].TeamID != teamID || view.Teams[index].Status != "active" { - return ViewEntry{}, false - } - return view.Teams[index], true -} diff --git a/internal/team/view_test.go b/internal/team/view_test.go deleted file mode 100644 index 190b608..0000000 --- a/internal/team/view_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package team - -import ( - "strings" - "testing" - - "github.com/pythonhk/eventctl/internal/canonical" -) - -func TestParseTeamsViewAndSelectActiveTeam(t *testing.T) { - t.Parallel() - activation := uint64(4) - view := TeamsView{ - Kind: "teams_view", Protocol: "pythonhk.github-native-event", ProtocolVersion: 1, - EventID: "summer-data-2026", EventEpoch: "1", ConfigDigest: strings.Repeat("1", 64), - Sequence: 4, JournalEventDigest: strings.Repeat("2", 64), - Teams: []ViewEntry{{ - TeamID: "123e4567-e89b-42d3-a456-426614174000", ProposalDigest: strings.Repeat("3", 64), - ProposerActorID: "2", MemberActorIDs: []string{"2", "10"}, Status: "active", - ProposedAtSequence: 2, ActivatedAtSequence: &activation, ExpiresAt: "2020-01-01T00:00:00Z", - Consents: []ViewConsent{ - {ActorID: "2", KeyID: strings.Repeat("6", 64), RequestDigest: strings.Repeat("7", 64), RecordedAtSequence: 3}, - {ActorID: "10", KeyID: strings.Repeat("4", 64), RequestDigest: strings.Repeat("5", 64), RecordedAtSequence: 4}, - }, - }}, - } - raw, err := canonical.Marshal(view) - if err != nil { - t.Fatal(err) - } - parsed, err := ParseTeamsView(raw) - if err != nil { - t.Fatal(err) - } - entry, ok := parsed.FindActiveTeam(view.Teams[0].TeamID) - if !ok || entry.ProposalDigest != view.Teams[0].ProposalDigest { - t.Fatalf("active team selection = %#v, %v", entry, ok) - } - view.Teams[0].Consents[0], view.Teams[0].Consents[1] = view.Teams[0].Consents[1], view.Teams[0].Consents[0] - if err := view.Validate(); err == nil { - t.Fatal("accepted non-deterministically ordered team consents") - } -} - -func TestTeamsViewRejectsHeaderAndNonUnanimousActiveTeam(t *testing.T) { - t.Parallel() - activation := uint64(4) - view := TeamsView{ - Kind: "teams_view", Protocol: "pythonhk.github-native-event", ProtocolVersion: 1, - EventID: "summer-data-2026", EventEpoch: "1", ConfigDigest: strings.Repeat("1", 64), - Sequence: 4, JournalEventDigest: strings.Repeat("2", 64), - Teams: []ViewEntry{{ - TeamID: "123e4567-e89b-42d3-a456-426614174000", ProposalDigest: strings.Repeat("3", 64), - ProposerActorID: "2", MemberActorIDs: []string{"2", "10"}, Status: "active", - ProposedAtSequence: 2, ActivatedAtSequence: &activation, ExpiresAt: "2026-08-05T00:00:00Z", - Consents: []ViewConsent{{ActorID: "2", KeyID: strings.Repeat("6", 64), RequestDigest: strings.Repeat("7", 64), RecordedAtSequence: 3}}, - }}, - } - if err := view.Validate(); err == nil { - t.Fatal("accepted active team without unanimous consent") - } - view.Teams = nil - view.EventEpoch = "2" - if err := view.Validate(); err == nil { - t.Fatal("accepted unsupported protected-state event epoch") - } - view.EventEpoch = "1" - view.ConfigDigest = strings.Repeat("A", 64) - if err := view.Validate(); err == nil { - t.Fatal("accepted malformed header digest") - } -} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..630b8b7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,30 @@ +[tools] +go = "1.26" + +[env] +COVERAGE_DIR = "coverage" +E2E_COVERAGE_DIR = "{{ env.COVERAGE_DIR }}/e2e" +E2E_COVERAGE_PROFILE = "{{ env.COVERAGE_DIR }}/e2e.profile" +E2E_COVERAGE_HTML = "{{ env.COVERAGE_DIR }}/e2e.html" + +[tasks.format-code] +description = "Format Go sources" +run = "go fmt ./..." + +[tasks.test] +description = "Run the instrumented CLI E2E suite and enforce 100% coverage" +run = """ + rm -rf "$E2E_COVERAGE_DIR" "$E2E_COVERAGE_PROFILE" "$E2E_COVERAGE_HTML" + mkdir -p "$E2E_COVERAGE_DIR" + GOCOVERDIR="$PWD/$E2E_COVERAGE_DIR" go test ./tests/e2e -tags=e2e -v -p 1 -count=1 -timeout 10m + go tool covdata textfmt -i="$PWD/$E2E_COVERAGE_DIR" -o "$PWD/$E2E_COVERAGE_PROFILE" + report="$(go tool cover -func="$PWD/$E2E_COVERAGE_PROFILE")" + printf '%s\n' "$report" + coverage="$(printf '%s\n' "$report" | awk '$1 == "total:" { print $3 }')" + test "$coverage" = "100.0%" +""" + +[tasks.coverage-html] +description = "Render the CLI E2E coverage report as HTML" +depends = ["test"] +run = 'go tool cover -html="$PWD/$E2E_COVERAGE_PROFILE" -o "$PWD/$E2E_COVERAGE_HTML"' diff --git a/scripts/check.sh b/scripts/check.sh deleted file mode 100755 index fc50192..0000000 --- a/scripts/check.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repository_root" - -# Trusted repository-owned toolchain contract. -# shellcheck disable=SC1091 -source "$repository_root/scripts/release/tool-versions.env" - -required_commands=(go gofmt govulncheck) -for command_name in "${required_commands[@]}"; do - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "required command not found: $command_name" >&2 - exit 127 - fi -done - -actual_go_version="$(go env GOVERSION)" -if [[ $actual_go_version != "go$GO_VERSION" ]]; then - echo "Go $GO_VERSION is required; found ${actual_go_version#go}" >&2 - exit 1 -fi -actual_govulncheck_version="$(govulncheck -version | awk '$1 == "Scanner:" { print $2 }')" -if [[ $actual_govulncheck_version != "govulncheck@$GOVULNCHECK_VERSION" ]]; then - echo "govulncheck $GOVULNCHECK_VERSION is required; found ${actual_govulncheck_version:-unknown}" >&2 - exit 1 -fi - -unformatted="$(gofmt -l .)" -if [[ -n "$unformatted" ]]; then - echo "gofmt is required for:" >&2 - echo "$unformatted" >&2 - exit 1 -fi - -go mod tidy -diff -go mod verify -go vet -mod=readonly ./... -go test -mod=readonly -count=1 ./... -go test -mod=readonly -race -count=1 ./... -GOFLAGS=-mod=readonly govulncheck ./... diff --git a/scripts/release/actionpins/main.go b/scripts/release/actionpins/main.go deleted file mode 100644 index 4fd2cd9..0000000 --- a/scripts/release/actionpins/main.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" - - "go.yaml.in/yaml/v3" -) - -var ( - canonicalUsesLine = regexp.MustCompile(`^\s*(?:-\s+)?uses:\s*([^\s#]+)(?:\s+#.*)?$`) - pinnedActionRef = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$`) -) - -type workflowScanner struct { - usesCount int - failureCount int -} - -func (scanner *workflowScanner) report(path string, node *yaml.Node, format string, values ...any) { - message := fmt.Sprintf(format, values...) - fmt.Fprintf(os.Stderr, "%s:%d:%d: %s\n", path, node.Line, node.Column, message) - scanner.failureCount++ -} - -func (scanner *workflowScanner) checkUses(path string, lines []string, key, value *yaml.Node) { - scanner.usesCount++ - if key.Line < 1 || key.Line > len(lines) { - scanner.report(path, key, "could not locate uses key in workflow source") - return - } - - lineMatch := canonicalUsesLine.FindStringSubmatch(lines[key.Line-1]) - if len(lineMatch) != 2 || value.Kind != yaml.ScalarNode || lineMatch[1] != value.Value { - scanner.report(path, key, "action reference must use one canonical block-style uses line") - return - } - - actionRef := value.Value - if strings.HasPrefix(actionRef, "./") { - return - } - if !pinnedActionRef.MatchString(actionRef) { - scanner.report(path, value, "action is not pinned to a full commit SHA: %s", actionRef) - } -} - -func resolveAlias(node *yaml.Node) *yaml.Node { - seen := make(map[*yaml.Node]struct{}) - for node != nil && node.Kind == yaml.AliasNode && node.Alias != nil { - if _, exists := seen[node]; exists { - return node - } - seen[node] = struct{}{} - node = node.Alias - } - return node -} - -func (scanner *workflowScanner) walk(path string, lines []string, node *yaml.Node) { - if node.Kind == yaml.MappingNode { - for index := 0; index+1 < len(node.Content); index += 2 { - key := node.Content[index] - value := node.Content[index+1] - resolvedKey := resolveAlias(key) - if resolvedKey != nil && resolvedKey.Kind == yaml.ScalarNode && resolvedKey.Value == "uses" { - scanner.checkUses(path, lines, key, value) - } - scanner.walk(path, lines, key) - scanner.walk(path, lines, value) - } - return - } - if node.Kind == yaml.AliasNode && node.Alias != nil { - scanner.walk(path, lines, node.Alias) - return - } - for _, child := range node.Content { - scanner.walk(path, lines, child) - } -} - -func (scanner *workflowScanner) scanFile(path string) error { - source, err := os.ReadFile(path) - if err != nil { - return err - } - lines := strings.Split(string(source), "\n") - decoder := yaml.NewDecoder(bytes.NewReader(source)) - for { - var document yaml.Node - err = decoder.Decode(&document) - if err == io.EOF { - return nil - } - if err != nil { - return err - } - scanner.walk(path, lines, &document) - } -} - -func run(workflowDir string) int { - scanner := &workflowScanner{} - err := filepath.WalkDir(workflowDir, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - extension := strings.ToLower(filepath.Ext(path)) - if extension != ".yml" && extension != ".yaml" { - return nil - } - if err := scanner.scanFile(path); err != nil { - return fmt.Errorf("parse workflow %s: %w", path, err) - } - return nil - }) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } - if scanner.usesCount == 0 { - fmt.Fprintf(os.Stderr, "no action references found under %s\n", workflowDir) - return 1 - } - if scanner.failureCount != 0 { - return 1 - } - fmt.Printf("verified %d action references are commit-pinned\n", scanner.usesCount) - return 0 -} - -func main() { - workflowDir := ".github/workflows" - if len(os.Args) > 2 { - fmt.Fprintln(os.Stderr, "usage: check-action-pins [workflow-directory]") - os.Exit(2) - } - if len(os.Args) == 2 { - workflowDir = os.Args[1] - } - info, err := os.Stat(workflowDir) - if err != nil || !info.IsDir() { - fmt.Fprintf(os.Stderr, "workflow directory not found: %s\n", workflowDir) - os.Exit(1) - } - os.Exit(run(workflowDir)) -} diff --git a/scripts/release/check-action-pins.sh b/scripts/release/check-action-pins.sh deleted file mode 100755 index 42d732d..0000000 --- a/scripts/release/check-action-pins.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -workflow_dir=${1:-.github/workflows} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -repo_dir=$(cd -- "$script_dir/../.." && pwd) - -if [[ ! -d $workflow_dir ]]; then - printf 'workflow directory not found: %s\n' "$workflow_dir" >&2 - exit 1 -fi - -workflow_dir=$(cd -- "$workflow_dir" && pwd) -cd "$repo_dir" -exec go run -mod=readonly "$script_dir/actionpins/main.go" "$workflow_dir" diff --git a/scripts/release/check-reproducible.sh b/scripts/release/check-reproducible.sh deleted file mode 100755 index 0e1adc1..0000000 --- a/scripts/release/check-reproducible.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# Release builds pin Syft explicitly; its independent update check would add an -# unnecessary network dependency and cannot change this run's installed binary. -export SYFT_CHECK_FOR_APP_UPDATE=false - -version=${1:-} -build_mode=${2:-snapshot} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - printf 'invalid snapshot version: %s\n' "$version" >&2 - exit 1 -fi -case "$build_mode" in - snapshot | release) ;; - *) - printf 'invalid reproducibility build mode: %s\n' "$build_mode" >&2 - exit 1 - ;; -esac - -comparison_dir=$(mktemp -d "${TMPDIR:-/tmp}/eventctl-reproducible.XXXXXX") -cleanup() { - rm -rf -- "$comparison_dir" -} -trap cleanup EXIT HUP INT TERM - -archive_hashes() { - output_file=$1 - if command -v sha256sum >/dev/null 2>&1; then - for archive in dist/*.tar.gz dist/*.zip; do - sha256sum "$archive" - done | LC_ALL=C sort -k2 >"$output_file" - else - for archive in dist/*.tar.gz dist/*.zip; do - shasum -a 256 "$archive" - done | LC_ALL=C sort -k2 >"$output_file" - fi -} - -goreleaser_args=(release --clean --skip=publish) -if [[ $build_mode == snapshot ]]; then - goreleaser_args+=(--snapshot) -fi - -# Force both builds through independent empty Go build caches. The module -# source cache may be shared because go.sum and -mod=readonly authenticate it. -first_go_cache="$comparison_dir/go-cache-first" -second_go_cache="$comparison_dir/go-cache-second" -GOCACHE="$first_go_cache" goreleaser "${goreleaser_args[@]}" -"$script_dir/verify-dist.sh" dist "$version" -archive_hashes "$comparison_dir/first" - -GOCACHE="$second_go_cache" goreleaser "${goreleaser_args[@]}" -"$script_dir/verify-dist.sh" dist "$version" -archive_hashes "$comparison_dir/second" - -if ! diff -u "$comparison_dir/first" "$comparison_dir/second"; then - printf 'release archives are not reproducible across identical builds\n' >&2 - exit 1 -fi - -printf 'release archives are byte-for-byte reproducible\n' diff --git a/scripts/release/finalize-release.sh b/scripts/release/finalize-release.sh deleted file mode 100755 index 8469783..0000000 --- a/scripts/release/finalize-release.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -tag=${1:-} -repo=${2:-${GITHUB_REPOSITORY:-}} -dist_dir=${3:-dist} -expected_source_digest=${4:-} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -if [[ $repo != pythonhk/eventctl ]]; then - printf 'refusing to finalize unexpected repository: %s\n' "$repo" >&2 - exit 1 -fi -semver_regex='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' -if [[ ! $tag =~ $semver_regex ]]; then - printf 'refusing invalid release tag: %s\n' "$tag" >&2 - exit 1 -fi -if [[ ! $expected_source_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - printf 'expected source digest must be a full Git commit digest: %s\n' \ - "$expected_source_digest" >&2 - exit 1 -fi -"$script_dir/require-gh-version.sh" - -version=${tag#v} -"$script_dir/verify-dist.sh" "$dist_dir" "$version" - -assets=("$dist_dir/SHA256SUMS") -for target in \ - darwin_amd64.tar.gz \ - darwin_arm64.tar.gz \ - linux_amd64.tar.gz \ - linux_arm64.tar.gz \ - windows_amd64.zip \ - windows_arm64.zip; do - archive="$dist_dir/eventctl_${version}_${target}" - assets+=("$archive" "$archive.spdx.json") -done - -expected_assets=$(mktemp "${TMPDIR:-/tmp}/eventctl-finalize-expected.XXXXXX") -actual_assets=$(mktemp "${TMPDIR:-/tmp}/eventctl-finalize-actual.XXXXXX") -# Invoked by the trap below. -# shellcheck disable=SC2329 -cleanup() { - # shellcheck disable=SC2317 # Reached indirectly through the EXIT/signal trap. - rm -f -- "$expected_assets" "$actual_assets" -} -trap cleanup EXIT HUP INT TERM - -hash_file() { - local path=$1 - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$path" | awk '{print $1}' - else - shasum -a 256 "$path" | awk '{print $1}' - fi -} - -for asset in "${assets[@]}"; do - if [[ ! -f $asset || -L $asset ]]; then - printf 'release asset is missing or unsafe: %s\n' "$asset" >&2 - exit 1 - fi - asset_name=$(basename -- "$asset") - asset_digest=$(hash_file "$asset") - asset_size=$(wc -c <"$asset" | tr -d '[:space:]') - printf '%s\tsha256:%s\t%s\n' "$asset_name" "$asset_digest" "$asset_size" -done | LC_ALL=C sort >"$expected_assets" - -read_server_assets() { - local release_id=$1 - local output_file=$2 - gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/releases/$release_id/assets?per_page=100" \ - --jq '.[] | select(.state == "uploaded") | [.name, .digest, (.size | tostring)] | @tsv' | - LC_ALL=C sort >"$output_file" -} - -resolve_remote_tag_commit() { - local object_record - local object_type - local object_digest - local peel_attempt=1 - object_record=$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/git/ref/tags/$tag" \ - --jq '[.object.type, .object.sha] | @tsv') - IFS=$'\t' read -r object_type object_digest <<<"$object_record" - while ((peel_attempt <= 8)); do - if [[ ! $object_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - printf 'remote tag contains an invalid object digest\n' >&2 - return 1 - fi - case "$object_type" in - commit) - printf '%s\n' "$object_digest" - return 0 - ;; - tag) - object_record=$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/git/tags/$object_digest" \ - --jq '[.object.type, .object.sha] | @tsv') - IFS=$'\t' read -r object_type object_digest <<<"$object_record" - ;; - *) - printf 'remote release tag resolves to unsupported object type: %s\n' \ - "$object_type" >&2 - return 1 - ;; - esac - peel_attempt=$((peel_attempt + 1)) - done - printf 'remote release tag nesting exceeds the supported limit\n' >&2 - return 1 -} - -assert_remote_tag_commit() { - local remote_source_digest - remote_source_digest=$(resolve_remote_tag_commit) - if [[ $remote_source_digest != "$expected_source_digest" ]]; then - printf 'remote tag commit %s differs from built source %s\n' \ - "$remote_source_digest" "$expected_source_digest" >&2 - return 1 - fi -} - -is_prerelease=false -if [[ $tag == *-* ]]; then - is_prerelease=true -fi - -release_json=$(gh release view "$tag" --repo "$repo" \ - --json databaseId,isDraft,isPrerelease,tagName,assets) -printf '%s' "$release_json" | jq -e --arg tag "$tag" \ - --argjson prerelease "$is_prerelease" \ - '.isDraft == true and .isPrerelease == $prerelease and - .tagName == $tag and .databaseId > 0 and - ([.assets[].name] | length) == 13' >/dev/null -release_id=$(printf '%s' "$release_json" | jq -er '.databaseId') - -# The prior draft-upload readback is not a publication authorization: drafts -# remain mutable. Re-read every server-computed digest and size, plus the -# peeled remote tag target, immediately before the irreversible publish call. -read_server_assets "$release_id" "$actual_assets" -if ! cmp -s "$expected_assets" "$actual_assets"; then - printf 'draft release assets changed after their initial upload readback\n' >&2 - diff -u "$expected_assets" "$actual_assets" >&2 || true - exit 1 -fi -assert_remote_tag_commit - -edit_flags=(--repo "$repo" --draft=false --verify-tag) -if [[ $is_prerelease == true ]]; then - edit_flags+=(--prerelease --latest=false) -else - edit_flags+=(--prerelease=false --latest) -fi -gh release edit "$tag" "${edit_flags[@]}" - -attempt=1 -release_json='' -while ((attempt <= 15)); do - if release_json=$(gh release view "$tag" --repo "$repo" \ - --json databaseId,isDraft,isImmutable,isPrerelease,tagName,assets) && - printf '%s' "$release_json" | jq -e --arg tag "$tag" \ - --argjson release_id "$release_id" \ - --argjson prerelease "$is_prerelease" \ - '.databaseId == $release_id and - .isDraft == false and .isImmutable == true and - .isPrerelease == $prerelease and .tagName == $tag and - ([.assets[].name] | length) == 13' >/dev/null && - assert_remote_tag_commit && - read_server_assets "$release_id" "$actual_assets" && - cmp -s "$expected_assets" "$actual_assets"; then - printf 'published immutable digest-complete release %s\n' "$tag" - exit 0 - fi - sleep 2 - attempt=$((attempt + 1)) -done - -printf 'release was published but immutable digest/tag equality was not proven; supersede it with a new version\n' >&2 -printf '%s\n' "$release_json" >&2 -diff -u "$expected_assets" "$actual_assets" >&2 || true -exit 1 diff --git a/scripts/release/fuzz-smoke.sh b/scripts/release/fuzz-smoke.sh deleted file mode 100755 index ffdc6e4..0000000 --- a/scripts/release/fuzz-smoke.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -fuzz_time=${FUZZ_TIME:-5s} -if [[ ! $fuzz_time =~ ^[1-9][0-9]*(ms|s|m)$ ]]; then - printf 'invalid FUZZ_TIME: %s\n' "$fuzz_time" >&2 - exit 1 -fi - -target_count=0 -while IFS=: read -r test_file target_line; do - [[ -n $test_file && -n $target_line ]] || continue - target_name=${target_line#func } - target_name=${target_name%%(*} - package_dir=$(dirname -- "$test_file") - target_count=$((target_count + 1)) - printf 'fuzz smoke: %s (%s)\n' "$target_name" "$package_dir" - ( - cd "$package_dir" - go test -mod=readonly . -run '^$' -fuzz "^${target_name}$" -fuzztime "$fuzz_time" - ) -done < <( - find . -type f -name '*_test.go' -not -path './vendor/*' -exec \ - grep -H -E '^func Fuzz[A-Za-z0-9_]+\(' {} + 2>/dev/null || true -) - -if ((target_count == 0)); then - printf 'no Go fuzz targets found; at least one security-boundary fuzz target is required\n' >&2 - exit 1 -fi diff --git a/scripts/release/install-shellcheck.sh b/scripts/release/install-shellcheck.sh deleted file mode 100755 index 85bc2a4..0000000 --- a/scripts/release/install-shellcheck.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# Trusted repository-owned release toolchain contract. -# shellcheck disable=SC1091 -source "$script_dir/tool-versions.env" - -if [[ $# -ne 1 ]]; then - printf 'usage: %s ABSOLUTE_BIN_DIRECTORY\n' "${0##*/}" >&2 - exit 2 -fi - -install_bin_dir=$1 -if [[ $install_bin_dir != /* ]]; then - printf 'ShellCheck install directory must be absolute: %s\n' "$install_bin_dir" >&2 - exit 2 -fi -if [[ ! $SHELLCHECK_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'invalid SHELLCHECK_VERSION in tool-versions.env: %s\n' \ - "$SHELLCHECK_VERSION" >&2 - exit 1 -fi -if [[ ! $SHELLCHECK_LINUX_X86_64_ARCHIVE_SHA256 =~ ^[0-9a-f]{64}$ ]]; then - printf 'invalid ShellCheck archive SHA-256 in tool-versions.env\n' >&2 - exit 1 -fi -if [[ $(uname -s) != Linux || $(uname -m) != x86_64 ]]; then - printf 'the pinned ShellCheck installer supports only Linux x86_64\n' >&2 - exit 1 -fi - -for required_command in awk curl install sha256sum tar; do - if ! command -v "$required_command" >/dev/null 2>&1; then - printf 'required command not found: %s\n' "$required_command" >&2 - exit 127 - fi -done - -temp_parent=${RUNNER_TEMP:-${TMPDIR:-/tmp}} -if [[ ! -d $temp_parent ]]; then - printf 'temporary directory does not exist: %s\n' "$temp_parent" >&2 - exit 1 -fi - -umask 077 -temp_dir=$(mktemp -d "$temp_parent/eventctl-shellcheck-install.XXXXXX") -cleanup() { - rm -rf -- "$temp_dir" -} -trap cleanup EXIT HUP INT TERM - -archive_name="shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" -archive_path="$temp_dir/$archive_name" -extract_dir="$temp_dir/extract" -download_url="https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${archive_name}" - -if [[ -e $install_bin_dir || -L $install_bin_dir ]]; then - printf 'ShellCheck install directory must not already exist: %s\n' "$install_bin_dir" >&2 - exit 1 -fi - -curl \ - --fail \ - --location \ - --max-filesize 16777216 \ - --max-time 60 \ - --proto '=https' \ - --retry 3 \ - --retry-all-errors \ - --show-error \ - --silent \ - --tlsv1.2 \ - --output "$archive_path" \ - "$download_url" - -actual_archive_sha256=$(sha256sum -- "$archive_path" | awk '{print $1}') -if [[ $actual_archive_sha256 != "$SHELLCHECK_LINUX_X86_64_ARCHIVE_SHA256" ]]; then - printf 'ShellCheck archive checksum mismatch: expected %s, found %s\n' \ - "$SHELLCHECK_LINUX_X86_64_ARCHIVE_SHA256" \ - "${actual_archive_sha256:-missing}" >&2 - exit 1 -fi - -mkdir -p "$extract_dir" -tar -xJf "$archive_path" \ - -C "$extract_dir" \ - "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" -extracted_binary="$extract_dir/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" -if [[ ! -f $extracted_binary || -L $extracted_binary ]]; then - printf 'verified ShellCheck archive did not contain a regular binary\n' >&2 - exit 1 -fi - -mkdir -m 0700 "$install_bin_dir" -installed_binary="$install_bin_dir/shellcheck" -install -m 0755 "$extracted_binary" "$installed_binary" - -actual_version=$("$installed_binary" --version | awk -F ': ' '$1 == "version" {print $2}') -if [[ $actual_version != "$SHELLCHECK_VERSION" ]]; then - printf 'ShellCheck version mismatch: expected %s, found %s\n' \ - "$SHELLCHECK_VERSION" "${actual_version:-unknown}" >&2 - exit 1 -fi - -printf 'ShellCheck %s installed at %s\n' "$actual_version" "$installed_binary" diff --git a/scripts/release/load-tool-versions.sh b/scripts/release/load-tool-versions.sh deleted file mode 100755 index 4149a3e..0000000 --- a/scripts/release/load-tool-versions.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# This file is trusted repository configuration, never participant input. -# The path is resolved from this script, not the caller's working directory. -# shellcheck disable=SC1091 -source "$script_dir/tool-versions.env" - -for tool_name in \ - GO_VERSION \ - GORELEASER_VERSION \ - SYFT_VERSION \ - GOVULNCHECK_VERSION \ - ACTIONLINT_VERSION \ - SHFMT_VERSION; do - tool_value=${!tool_name} - if [[ ! $tool_value =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'invalid %s in tool-versions.env: %s\n' "$tool_name" "$tool_value" >&2 - exit 1 - fi -done - -if [[ ! $GH_MIN_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'invalid GH_MIN_VERSION in tool-versions.env: %s\n' "$GH_MIN_VERSION" >&2 - exit 1 -fi - -if [[ -n ${GITHUB_OUTPUT:-} ]]; then - { - printf 'go=%s\n' "$GO_VERSION" - printf 'goreleaser=%s\n' "$GORELEASER_VERSION" - printf 'syft=%s\n' "$SYFT_VERSION" - printf 'govulncheck=%s\n' "$GOVULNCHECK_VERSION" - printf 'actionlint=%s\n' "$ACTIONLINT_VERSION" - printf 'shfmt=%s\n' "$SHFMT_VERSION" - printf 'gh_min=%s\n' "$GH_MIN_VERSION" - } >>"$GITHUB_OUTPUT" -else - printf 'Go %s; GoReleaser %s; Syft %s; GitHub CLI >= %s\n' \ - "$GO_VERSION" "$GORELEASER_VERSION" "$SYFT_VERSION" "$GH_MIN_VERSION" -fi diff --git a/scripts/release/publish-draft.sh b/scripts/release/publish-draft.sh deleted file mode 100755 index 38088b8..0000000 --- a/scripts/release/publish-draft.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -tag=${1:-} -dist_dir=${2:-dist} -repo=${3:-${GITHUB_REPOSITORY:-}} -version=${tag#v} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -if [[ $repo != pythonhk/eventctl ]]; then - printf 'refusing to publish to unexpected repository: %s\n' "$repo" >&2 - exit 1 -fi -semver_regex='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' -if [[ ! $tag =~ $semver_regex ]]; then - printf 'refusing invalid release tag: %s\n' "$tag" >&2 - exit 1 -fi -"$script_dir/require-gh-version.sh" -"$script_dir/verify-dist.sh" "$dist_dir" "$version" - -if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then - printf 'release already exists and will not be modified: %s\n' "$tag" >&2 - exit 1 -fi - -assets=("$dist_dir/SHA256SUMS") -for target in \ - darwin_amd64.tar.gz \ - darwin_arm64.tar.gz \ - linux_amd64.tar.gz \ - linux_arm64.tar.gz \ - windows_amd64.zip \ - windows_arm64.zip; do - archive="$dist_dir/eventctl_${version}_${target}" - assets+=("$archive" "$archive.spdx.json") -done - -for asset in "${assets[@]}"; do - [[ -f $asset ]] || { - printf 'missing release asset: %s\n' "$asset" >&2 - exit 1 - } -done - -release_flags=( - --repo "$repo" - --draft - --verify-tag - --generate-notes - --title "eventctl $tag" -) -is_prerelease=false -if [[ $tag == *-* ]]; then - release_flags+=(--prerelease --latest=false) - is_prerelease=true -fi -gh release create "$tag" "${assets[@]}" "${release_flags[@]}" - -release_json=$(gh release view "$tag" --repo "$repo" \ - --json databaseId,isDraft,isPrerelease,tagName,assets) -printf '%s' "$release_json" | jq -e --arg tag "$tag" \ - --argjson prerelease "$is_prerelease" \ - '.isDraft == true and .isPrerelease == $prerelease and - .tagName == $tag and - (.databaseId | type == "number" and . > 0 and floor == .) and - (.assets | length) == 13' >/dev/null -release_id=$(printf '%s' "$release_json" | jq -er '.databaseId') - -expected_assets=$(mktemp "${TMPDIR:-/tmp}/eventctl-assets-expected.XXXXXX") -actual_assets=$(mktemp "${TMPDIR:-/tmp}/eventctl-assets-actual.XXXXXX") -# Invoked by the trap below. -# shellcheck disable=SC2329 -cleanup() { - # shellcheck disable=SC2317 # Reached indirectly through the EXIT/signal trap. - rm -f -- "$expected_assets" "$actual_assets" -} -trap cleanup EXIT HUP INT TERM - -hash_file() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print $1}' - else - shasum -a 256 "$1" | awk '{print $1}' - fi -} - -for asset in "${assets[@]}"; do - asset_name=$(basename -- "$asset") - asset_digest=$(hash_file "$asset") - asset_size=$(wc -c <"$asset" | tr -d '[:space:]') - printf '%s\tsha256:%s\t%s\n' "$asset_name" "$asset_digest" "$asset_size" -done | LC_ALL=C sort >"$expected_assets" - -# `gh release create` returning successfully is not the upload-integrity -# boundary. Read the server-computed digest and size for every draft asset, -# including SPDX documents and SHA256SUMS, before the release is published. -attempt=1 -while ((attempt <= 10)); do - if gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/releases/$release_id/assets?per_page=100" \ - --jq '.[] | select(.state == "uploaded") | [.name, .digest, (.size | tostring)] | @tsv' | - LC_ALL=C sort >"$actual_assets" && - cmp -s "$expected_assets" "$actual_assets"; then - printf 'created and digest-verified asset-complete draft release %s\n' "$tag" - while IFS= read -r asset_record; do - printf 'draft asset\t%s\n' "$asset_record" - done <"$actual_assets" - exit 0 - fi - sleep 2 - attempt=$((attempt + 1)) -done - -printf 'draft release asset digest readback did not match local files\n' >&2 -diff -u "$expected_assets" "$actual_assets" >&2 || true -exit 1 diff --git a/scripts/release/require-gh-version.sh b/scripts/release/require-gh-version.sh deleted file mode 100755 index cd790be..0000000 --- a/scripts/release/require-gh-version.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# Trusted repository configuration, resolved independently of the caller. -# shellcheck disable=SC1091 -source "$script_dir/tool-versions.env" - -if [[ ! $GH_MIN_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - printf 'invalid GitHub CLI security floor: %s\n' "$GH_MIN_VERSION" >&2 - exit 1 -fi -required_major=${BASH_REMATCH[1]} -required_minor=${BASH_REMATCH[2]} -required_patch=${BASH_REMATCH[3]} - -if ! command -v gh >/dev/null 2>&1; then - printf 'GitHub CLI >= %s is required\n' "$GH_MIN_VERSION" >&2 - exit 1 -fi -if ! version_output=$(gh --version 2>&1); then - printf 'could not determine the GitHub CLI version\n' >&2 - exit 1 -fi -version_line=${version_output%%$'\n'*} -if [[ ! $version_line =~ ^gh[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)\.([0-9]+)([[:space:]]|$) ]]; then - printf 'unrecognized GitHub CLI version output: %s\n' "$version_line" >&2 - exit 1 -fi -actual_major=${BASH_REMATCH[1]} -actual_minor=${BASH_REMATCH[2]} -actual_patch=${BASH_REMATCH[3]} -actual_version=$actual_major.$actual_minor.$actual_patch - -if ((actual_major < required_major)) || - ((actual_major == required_major && actual_minor < required_minor)) || - ((actual_major == required_major && actual_minor == required_minor && actual_patch < required_patch)); then - printf 'GitHub CLI %s is unsafe; version %s or newer is required\n' \ - "$actual_version" "$GH_MIN_VERSION" >&2 - exit 1 -fi - -printf 'GitHub CLI %s satisfies security floor %s\n' \ - "$actual_version" "$GH_MIN_VERSION" diff --git a/scripts/release/test-release-boundaries.sh b/scripts/release/test-release-boundaries.sh deleted file mode 100755 index fd4699d..0000000 --- a/scripts/release/test-release-boundaries.sh +++ /dev/null @@ -1,528 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -repo_dir=$(cd -- "$script_dir/../.." && pwd) -# shellcheck disable=SC1091 -source "$script_dir/tool-versions.env" - -temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/eventctl-release-tests.XXXXXX") -cleanup() { - rm -rf -- "$temp_dir" -} -trap cleanup EXIT HUP INT TERM - -assertion_count=0 -fail() { - printf 'not ok - %s\n' "$1" >&2 - exit 1 -} - -pass() { - assertion_count=$((assertion_count + 1)) - printf 'ok - %s\n' "$1" -} - -expect_success() { - local label=$1 - shift - if ! "$@" >"$temp_dir/command.out" 2>&1; then - cat "$temp_dir/command.out" >&2 - fail "$label" - fi - pass "$label" -} - -expect_failure() { - local label=$1 - shift - if "$@" >"$temp_dir/command.out" 2>&1; then - cat "$temp_dir/command.out" >&2 - fail "$label" - fi - pass "$label" -} - -assert_contains() { - local label=$1 - local needle=$2 - local path=$3 - grep -Fq -- "$needle" "$path" || fail "$label" - pass "$label" -} - -assert_not_contains() { - local label=$1 - local needle=$2 - local path=$3 - if grep -Fq -- "$needle" "$path"; then - fail "$label" - fi - pass "$label" -} - -assert_before() { - local label=$1 - local first=$2 - local second=$3 - local path=$4 - local first_line - local second_line - first_line=$(grep -nF -- "$first" "$path" | head -n1 | cut -d: -f1) - second_line=$(grep -nF -- "$second" "$path" | head -n1 | cut -d: -f1) - [[ -n $first_line && -n $second_line && $first_line -lt $second_line ]] || fail "$label" - pass "$label" -} - -assert_attestation_pairs() { - local workflow_job=$1 - local subjects="$temp_dir/attestation-subjects" - local expected_subjects="$temp_dir/expected-attestation-subjects" - if ! awk ' - /^[[:space:]]+subject-path:[[:space:]]+/ { - subject = $0 - sub(/^[[:space:]]+subject-path:[[:space:]]+/, "", subject) - if (getline sbom_line <= 0 || sbom_line !~ /^[[:space:]]+sbom-path:[[:space:]]+/) { - failed = 1 - next - } - sbom = sbom_line - sub(/^[[:space:]]+sbom-path:[[:space:]]+/, "", sbom) - if (sbom != subject ".spdx.json") { - failed = 1 - } - print subject - count++ - } - END { - if (failed || count != 6) { - exit 1 - } - } - ' "$workflow_job" >"$subjects"; then - fail "six hosted SPDX attestations must pair each archive with its own SBOM" - fi - # GitHub expressions are intentionally matched literally. - # shellcheck disable=SC2016 - printf '%s\n' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_darwin_amd64.tar.gz' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_darwin_arm64.tar.gz' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_linux_amd64.tar.gz' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_linux_arm64.tar.gz' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_windows_amd64.zip' \ - 'dist/eventctl_${{ needs.build.outputs.version }}_windows_arm64.zip' | - LC_ALL=C sort >"$expected_subjects" - LC_ALL=C sort "$subjects" >"$subjects.sorted" - cmp -s "$expected_subjects" "$subjects.sorted" || - fail "hosted SPDX attestation subjects must equal the six release archives" - pass "six hosted SPDX attestations pair every release archive with its own SBOM" -} - -hash_file() { - local path=$1 - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$path" | awk '{print $1}' - else - shasum -a 256 "$path" | awk '{print $1}' - fi -} - -create_release_dist() { - local dist_dir=$1 - local version=$2 - local stage_dir="$temp_dir/archive-stage" - local sums_file="$dist_dir/SHA256SUMS" - local target - local platform - local architecture - local extension - local binary_name - local asset_name - local asset_path - local archive_hash - - mkdir -p "$dist_dir" "$stage_dir" - cp "$repo_dir/LICENSE" "$stage_dir/LICENSE" - printf '#!/usr/bin/env bash\nexit 0\n' >"$stage_dir/eventctl" - printf '#!/usr/bin/env bash\nexit 0\n' >"$stage_dir/eventctl.exe" - chmod 0755 "$stage_dir/eventctl" "$stage_dir/eventctl.exe" - : >"$sums_file" - - for target in \ - darwin/amd64/tar.gz \ - darwin/arm64/tar.gz \ - linux/amd64/tar.gz \ - linux/arm64/tar.gz \ - windows/amd64/zip \ - windows/arm64/zip; do - IFS=/ read -r platform architecture extension <<<"$target" - binary_name=eventctl - if [[ $platform == windows ]]; then - binary_name=eventctl.exe - fi - asset_name="eventctl_${version}_${platform}_${architecture}.${extension}" - asset_path="$dist_dir/$asset_name" - if [[ $extension == zip ]]; then - ( - cd "$stage_dir" - zip -q "$asset_path" LICENSE "$binary_name" - ) - else - tar -C "$stage_dir" -czf "$asset_path" LICENSE "$binary_name" - fi - archive_hash=$(hash_file "$asset_path") - printf '%s %s\n' "$archive_hash" "$asset_name" >>"$sums_file" - jq -n \ - --arg asset_name "$asset_name" \ - --arg archive_hash "$archive_hash" \ - --arg creator "Tool: syft-${SYFT_VERSION#v}" \ - '{ - spdxVersion: "SPDX-2.3", - SPDXID: "SPDXRef-DOCUMENT", - name: $asset_name, - creationInfo: {creators: [$creator]}, - packages: [{ - SPDXID: "SPDXRef-ArchivePackage", - name: $asset_name, - versionInfo: ("sha256:" + $archive_hash), - primaryPackagePurpose: "FILE", - checksums: [{algorithm: "SHA256", checksumValue: $archive_hash}] - }], - relationships: [{ - spdxElementId: "SPDXRef-DOCUMENT", - relationshipType: "DESCRIBES", - relatedSpdxElement: "SPDXRef-ArchivePackage" - }] - }' >"$asset_path.spdx.json" - done - LC_ALL=C sort -o "$sums_file" "$sums_file" -} - -test_action_pin_parser() { - local fixture_dir="$temp_dir/action-pins" - local workflow_file="$fixture_dir/workflow.yml" - mkdir -p "$fixture_dir" - printf '%s\n' \ - 'steps:' \ - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1' \ - >"$workflow_file" - expect_success "canonical full action pin is accepted" \ - "$script_dir/check-action-pins.sh" "$fixture_dir" - - for variant in \ - ' - uses: actions/checkout@v7' \ - ' - uses : actions/checkout@v7' \ - ' - {uses: actions/checkout@v7}' \ - ' - "uses": actions/checkout@v7'; do - printf 'steps:\n%s\n' "$variant" >"$workflow_file" - expect_failure "noncanonical action reference is rejected: $variant" \ - "$script_dir/check-action-pins.sh" "$fixture_dir" - done - for hidden_uses in \ - ' - "us\u0065s": actions/setup-go@v7' \ - ' - {"us\u0065s": actions/setup-go@v7}'; do - printf '%s\n' \ - 'steps:' \ - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1' \ - "$hidden_uses" \ - >"$workflow_file" - expect_failure "escaped semantic uses key cannot hide beside a valid pin: $hidden_uses" \ - "$script_dir/check-action-pins.sh" "$fixture_dir" - done - printf '%s\n' \ - 'steps:' \ - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1' \ - " - ? \"u\\" \ - ' ses"' \ - ' : actions/setup-go@v7' \ - >"$workflow_file" - expect_failure "multiline semantic uses key cannot hide beside a valid pin" \ - "$script_dir/check-action-pins.sh" "$fixture_dir" - printf '%s\n' \ - 'uses_key: &uses_key uses' \ - 'steps:' \ - ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1' \ - ' - ? *uses_key' \ - ' : actions/setup-go@v7' \ - >"$workflow_file" - expect_failure "aliased semantic uses key cannot hide beside a valid pin" \ - "$script_dir/check-action-pins.sh" "$fixture_dir" -} - -test_sbom_binding() { - local dist_dir="$temp_dir/dist" - local sbom="$dist_dir/eventctl_1.2.3_linux_amd64.tar.gz.spdx.json" - local backup="$temp_dir/linux-amd64.spdx.json" - local changed="$temp_dir/changed.spdx.json" - create_release_dist "$dist_dir" 1.2.3 - expect_success "six paired archive SPDX documents are accepted" \ - "$script_dir/verify-dist.sh" "$dist_dir" 1.2.3 - cp "$sbom" "$backup" - jq '.name = "eventctl_1.2.3_linux_arm64.tar.gz"' "$backup" >"$changed" - mv "$changed" "$sbom" - expect_failure "SPDX document name must equal its paired archive" \ - "$script_dir/verify-dist.sh" "$dist_dir" 1.2.3 - cp "$backup" "$sbom" - jq '.packages[0].versionInfo = "sha256:0000000000000000000000000000000000000000000000000000000000000000"' \ - "$backup" >"$changed" - mv "$changed" "$sbom" - expect_failure "SPDX package digest must equal its paired archive" \ - "$script_dir/verify-dist.sh" "$dist_dir" 1.2.3 - cp "$backup" "$sbom" - jq '.relationships[0].relatedSpdxElement = "SPDXRef-UnrelatedPackage"' \ - "$backup" >"$changed" - mv "$changed" "$sbom" - expect_failure "SPDX DESCRIBES relationship must target the paired archive package" \ - "$script_dir/verify-dist.sh" "$dist_dir" 1.2.3 - cp "$backup" "$sbom" -} - -create_native_fixture() { - local fixture_repo=$1 - local fixture_dist=$2 - local commit_date=$3 - local stage_dir="$temp_dir/native-stage" - mkdir -p "$fixture_repo/scripts/release" "$fixture_dist" "$stage_dir" - cp "$script_dir/verify-asset.sh" "$fixture_repo/scripts/release/verify-asset.sh" - cp "$repo_dir/LICENSE" "$fixture_repo/LICENSE" - git -C "$fixture_repo" init -q - git -C "$fixture_repo" add LICENSE - GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ - git -C "$fixture_repo" \ - -c user.name='eventctl tests' \ - -c user.email='eventctl-tests@example.invalid' \ - commit -q -m fixture - - cp "$repo_dir/LICENSE" "$stage_dir/LICENSE" - cp "$script_dir/testdata/fake-eventctl.sh" "$stage_dir/eventctl" - chmod 0755 "$stage_dir/eventctl" - tar -C "$stage_dir" -czf \ - "$fixture_dist/eventctl_1.2.3_linux_amd64.tar.gz" \ - LICENSE eventctl - printf '%s %s\n' \ - "$(hash_file "$fixture_dist/eventctl_1.2.3_linux_amd64.tar.gz")" \ - 'eventctl_1.2.3_linux_amd64.tar.gz' \ - >"$fixture_dist/SHA256SUMS" -} - -test_native_metadata() { - local fixture_repo="$temp_dir/native-repo" - local fixture_dist="$fixture_repo/dist" - local commit_date=2030-01-01T00:00:00Z - local commit_digest - create_native_fixture "$fixture_repo" "$fixture_dist" "$commit_date" - commit_digest=$(git -C "$fixture_repo" rev-parse HEAD) - expect_success "native verifier binds version, commit date, OS, and architecture" \ - env \ - FIXTURE_VERSION=1.2.3 \ - FIXTURE_COMMIT="$commit_digest" \ - FIXTURE_DATE="$commit_date" \ - FIXTURE_OS=linux \ - FIXTURE_ARCH=amd64 \ - "$fixture_repo/scripts/release/verify-asset.sh" \ - "$fixture_dist" 1.2.3 linux amd64 "$commit_digest" "$commit_date" - expect_failure "binary build date must equal the independently derived commit date" \ - env \ - FIXTURE_VERSION=1.2.3 \ - FIXTURE_COMMIT="$commit_digest" \ - FIXTURE_DATE=2030-01-02T00:00:00Z \ - FIXTURE_OS=linux \ - FIXTURE_ARCH=amd64 \ - "$fixture_repo/scripts/release/verify-asset.sh" \ - "$fixture_dist" 1.2.3 linux amd64 "$commit_digest" "$commit_date" - expect_failure "native verifier rejects a mismatched runtime architecture" \ - env \ - FIXTURE_VERSION=1.2.3 \ - FIXTURE_COMMIT="$commit_digest" \ - FIXTURE_DATE="$commit_date" \ - FIXTURE_OS=linux \ - FIXTURE_ARCH=arm64 \ - "$fixture_repo/scripts/release/verify-asset.sh" \ - "$fixture_dist" 1.2.3 linux amd64 "$commit_digest" "$commit_date" -} - -build_server_asset_table() { - local dist_dir=$1 - local output_file=$2 - local asset - for asset in "$dist_dir"/*; do - printf '%s\tsha256:%s\t%s\n' \ - "$(basename -- "$asset")" \ - "$(hash_file "$asset")" \ - "$(wc -c <"$asset" | tr -d '[:space:]')" - done | LC_ALL=C sort >"$output_file" -} - -run_finalize() { - local dist_dir=$1 - local expected_digest=$2 - local mutation=$3 - local tag_digest=$4 - local tag_mutation=${5:-none} - local mock_bin="$temp_dir/mock-bin" - local asset_table="$temp_dir/server-assets.tsv" - local edit_state="$temp_dir/release-edited" - local api_count="$temp_dir/api-count" - mkdir -p "$mock_bin" - cp "$script_dir/testdata/mock-gh.sh" "$mock_bin/gh" - cp "$script_dir/testdata/noop-sleep.sh" "$mock_bin/sleep" - chmod 0755 "$mock_bin/gh" "$mock_bin/sleep" - build_server_asset_table "$dist_dir" "$asset_table" - rm -f -- "$edit_state" "$api_count" - env \ - PATH="$mock_bin:$PATH" \ - MOCK_ASSET_TABLE="$asset_table" \ - MOCK_EDIT_STATE="$edit_state" \ - MOCK_API_COUNT="$api_count" \ - MOCK_ASSET_MUTATION="$mutation" \ - MOCK_TAG_DIGEST="$tag_digest" \ - MOCK_TAG_MUTATION="$tag_mutation" \ - "$script_dir/finalize-release.sh" \ - v1.2.3 pythonhk/eventctl "$dist_dir" "$expected_digest" -} - -test_finalization_boundaries() { - local dist_dir="$temp_dir/dist" - local expected_digest=1111111111111111111111111111111111111111 - local wrong_digest=2222222222222222222222222222222222222222 - local edit_state="$temp_dir/release-edited" - local api_count="$temp_dir/api-count" - local count - expect_success "finalization proves matching assets and tag before and after publish" \ - run_finalize "$dist_dir" "$expected_digest" none "$expected_digest" - [[ -f $edit_state ]] || fail "successful finalization publishes the draft" - read -r count <"$api_count" - [[ $count -ge 2 ]] || fail "successful finalization rereads assets after publish" - pass "successful finalization rereads assets after publish" - - expect_failure "changed draft asset blocks publication" \ - run_finalize "$dist_dir" "$expected_digest" always "$expected_digest" - [[ ! -e $edit_state ]] || fail "changed draft asset must fail before publication" - pass "changed draft asset fails before publication" - - expect_failure "remote tag mismatch blocks publication" \ - run_finalize "$dist_dir" "$expected_digest" none "$wrong_digest" - [[ ! -e $edit_state ]] || fail "remote tag mismatch must fail before publication" - pass "remote tag mismatch fails before publication" - - expect_failure "post-publication asset mismatch is detected" \ - run_finalize "$dist_dir" "$expected_digest" after-publish "$expected_digest" - [[ -f $edit_state ]] || fail "post-publication mismatch fixture must reach publication" - pass "post-publication mismatch is detected after publication" - - expect_failure "post-publication tag rebind is detected" \ - run_finalize "$dist_dir" "$expected_digest" none "$expected_digest" after-publish - [[ -f $edit_state ]] || fail "post-publication tag rebind fixture must reach publication" - pass "post-publication tag rebind is detected after publication" -} - -test_release_workflow_boundaries() { - local workflow="$repo_dir/.github/workflows/release.yml" - local ci_workflow="$repo_dir/.github/workflows/ci.yml" - local installer="$repo_dir/scripts/release/install-shellcheck.sh" - local tool_versions="$repo_dir/scripts/release/tool-versions.env" - local build_job="$temp_dir/release-build.yml" - local native_job="$temp_dir/release-native-preflight.yml" - local publish_job="$temp_dir/release-publish.yml" - local published_record_job="$temp_dir/release-published-record.yml" - local published_job="$temp_dir/release-published-native.yml" - awk '$0 == " build:" { copy = 1 } - copy && $0 == " native-preflight:" { exit } - copy { print }' "$workflow" >"$build_job" - awk '$0 == " published-native:" { copy = 1 } - copy { print }' "$workflow" >"$published_job" - awk '$0 == " native-preflight:" { copy = 1 } - copy && $0 == " publish:" { exit } - copy { print }' "$workflow" >"$native_job" - awk '$0 == " publish:" { copy = 1 } - copy && $0 == " published-release-record:" { exit } - copy { print }' "$workflow" >"$publish_job" - awk '$0 == " published-release-record:" { copy = 1 } - copy && $0 == " published-native:" { exit } - copy { print }' "$workflow" >"$published_record_job" - - assert_contains "tag build reruns the exact full Go gate" \ - 'scripts/check.sh' "$build_job" - assert_contains "tag build reruns fuzzing for ten seconds" \ - 'FUZZ_TIME: 10s' "$build_job" - assert_contains "tag build reruns the fuzz harness" \ - 'scripts/release/fuzz-smoke.sh' "$build_job" - assert_contains "ShellCheck version is pinned in repository configuration" \ - 'SHELLCHECK_VERSION=0.11.0' "$tool_versions" - assert_contains "ShellCheck Linux archive checksum is pinned in repository configuration" \ - 'SHELLCHECK_LINUX_X86_64_ARCHIVE_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198' \ - "$tool_versions" - assert_contains "ShellCheck installer downloads only over HTTPS" \ - "--proto '=https'" "$installer" - assert_before "ShellCheck installer verifies the archive before extracting it" \ - "actual_archive_sha256=\$(sha256sum" \ - "tar -xJf \"\$archive_path\"" \ - "$installer" - assert_contains "ShellCheck installer reads back the installed version" \ - "actual_version=\$(\"\$installed_binary\" --version" "$installer" - assert_contains "tag build installs pinned ShellCheck in runner temp" \ - 'scripts/release/install-shellcheck.sh' "$build_job" - assert_before "tag build installs ShellCheck before invoking it" \ - 'scripts/release/install-shellcheck.sh' \ - "xargs -0 \"\$RUNNER_TEMP/eventctl-shellcheck-bin/shellcheck\"" \ - "$build_job" - assert_not_contains "tag build does not invoke ambient ShellCheck" \ - 'xargs -0 shellcheck' "$build_job" - assert_contains "tag build reruns shfmt" \ - 'mvdan.cc/sh/v3/cmd/shfmt@' "$build_job" - assert_contains "tag build reruns actionlint" \ - 'github.com/rhysd/actionlint/cmd/actionlint@' "$build_job" - assert_contains "tag build reruns the action-pin gate" \ - 'scripts/release/check-action-pins.sh' "$build_job" - assert_contains "tag build reruns release-boundary regressions" \ - 'scripts/release/test-release-boundaries.sh' "$build_job" - # GitHub expressions are intentionally matched literally. - # shellcheck disable=SC2016 - assert_contains "tag build performs a non-snapshot reproducibility proof" \ - '${{ steps.metadata.outputs.version }}" release' "$build_job" - assert_contains "CI runs release-boundary regressions" \ - 'scripts/release/test-release-boundaries.sh' "$ci_workflow" - assert_contains "CI installs pinned ShellCheck in runner temp" \ - 'scripts/release/install-shellcheck.sh' "$ci_workflow" - assert_before "CI installs ShellCheck before invoking it" \ - 'scripts/release/install-shellcheck.sh' \ - "xargs -0 \"\$RUNNER_TEMP/eventctl-shellcheck-bin/shellcheck\"" \ - "$ci_workflow" - assert_not_contains "CI does not invoke ambient ShellCheck" \ - 'xargs -0 shellcheck' "$ci_workflow" - assert_contains "tag release reruns native source tests on every release runner" \ - 'go test -mod=readonly -count=1 ./...' "$native_job" - assert_contains "publication waits for native source and binary preflight" \ - 'native-preflight' "$publish_job" - assert_attestation_pairs "$publish_job" - - assert_contains "published release-record verification can read attestations" \ - 'attestations: read' "$published_record_job" - assert_contains "published native verification can read attestations" \ - 'attestations: read' "$published_job" - assert_contains "published native checks depend on immutable record verification" \ - 'published-release-record' "$published_job" - # GitHub expressions are intentionally matched literally. - # shellcheck disable=SC2016 - assert_before "immutable release verification precedes public asset download" \ - 'gh release verify "${GITHUB_REF_NAME}"' \ - 'gh release download "${GITHUB_REF_NAME}"' \ - "$published_job" - assert_before "immutable asset verification precedes binary execution" \ - 'gh release verify-asset' \ - 'scripts/release/verify-asset.sh' \ - "$published_job" - assert_before "hosted attestations precede binary execution" \ - 'scripts/release/verify-attestations.sh' \ - 'scripts/release/verify-asset.sh' \ - "$published_job" -} - -test_action_pin_parser -test_sbom_binding -test_native_metadata -test_finalization_boundaries -test_release_workflow_boundaries - -printf '# %d release-boundary assertion(s), all passed\n' "$assertion_count" diff --git a/scripts/release/testdata/fake-eventctl.sh b/scripts/release/testdata/fake-eventctl.sh deleted file mode 100755 index 375e343..0000000 --- a/scripts/release/testdata/fake-eventctl.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -case "${1:-}" in - version) - jq -n \ - --arg version "${FIXTURE_VERSION:?}" \ - --arg commit "${FIXTURE_COMMIT:?}" \ - --arg date "${FIXTURE_DATE:?}" \ - '{version: $version, commit: $commit, date: $date}' - ;; - doctor) - jq -n \ - --arg operating_system "${FIXTURE_OS:?}" \ - --arg architecture "${FIXTURE_ARCH:?}" \ - '{ - output_version: "pythonhk.eventctl/output/v1", - ok: true, - command: "doctor", - error: null, - result: { - status: "healthy", - operating_system: $operating_system, - architecture: $architecture - } - }' - ;; - *) - printf 'unexpected fixture command: %s\n' "${1:-}" >&2 - exit 1 - ;; -esac diff --git a/scripts/release/testdata/mock-gh.sh b/scripts/release/testdata/mock-gh.sh deleted file mode 100755 index fb56b72..0000000 --- a/scripts/release/testdata/mock-gh.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -if [[ ${1:-} == --version ]]; then - printf 'gh version 2.99.0 (fixture)\n' - exit 0 -fi - -: "${MOCK_ASSET_TABLE:?}" -: "${MOCK_EDIT_STATE:?}" -: "${MOCK_API_COUNT:?}" -: "${MOCK_TAG_DIGEST:?}" - -release_assets_json() { - cut -f1 "$MOCK_ASSET_TABLE" | - jq -R -s 'split("\n") | map(select(length > 0) | {name: .})' -} - -mutated_asset_table() { - awk -F '\t' ' - BEGIN { OFS = "\t" } - NR == 1 { $2 = "sha256:0000000000000000000000000000000000000000000000000000000000000000" } - { print } - ' "$MOCK_ASSET_TABLE" -} - -case "${1:-}/${2:-}" in - release/view) - assets_json=$(release_assets_json) - if [[ -f $MOCK_EDIT_STATE ]]; then - jq -n --argjson assets "$assets_json" \ - '{ - databaseId: 123, - isDraft: false, - isImmutable: true, - isPrerelease: false, - tagName: "v1.2.3", - assets: $assets - }' - else - jq -n --argjson assets "$assets_json" \ - '{ - databaseId: 123, - isDraft: true, - isImmutable: false, - isPrerelease: false, - tagName: "v1.2.3", - assets: $assets - }' - fi - ;; - release/edit) - : >"$MOCK_EDIT_STATE" - ;; - api/*) - api_path='' - for argument in "$@"; do - if [[ $argument == repos/* ]]; then - api_path=$argument - break - fi - done - case "$api_path" in - */releases/123/assets*) - count=0 - if [[ -f $MOCK_API_COUNT ]]; then - read -r count <"$MOCK_API_COUNT" - fi - count=$((count + 1)) - printf '%s\n' "$count" >"$MOCK_API_COUNT" - case "${MOCK_ASSET_MUTATION:-none}" in - always) mutated_asset_table ;; - after-publish) - if [[ -f $MOCK_EDIT_STATE ]]; then - mutated_asset_table - else - cat "$MOCK_ASSET_TABLE" - fi - ;; - none) cat "$MOCK_ASSET_TABLE" ;; - *) - printf 'unexpected asset mutation fixture: %s\n' "$MOCK_ASSET_MUTATION" >&2 - exit 1 - ;; - esac - ;; - */git/ref/tags/v1.2.3) - case "${MOCK_TAG_MUTATION:-none}" in - none) - printf 'commit\t%s\n' "$MOCK_TAG_DIGEST" - ;; - after-publish) - if [[ -f $MOCK_EDIT_STATE ]]; then - printf 'commit\t2222222222222222222222222222222222222222\n' - else - printf 'commit\t%s\n' "$MOCK_TAG_DIGEST" - fi - ;; - *) - printf 'unexpected tag mutation fixture: %s\n' "$MOCK_TAG_MUTATION" >&2 - exit 1 - ;; - esac - ;; - *) - printf 'unexpected fixture API path: %s\n' "$api_path" >&2 - exit 1 - ;; - esac - ;; - *) - printf 'unexpected fixture gh command: %s\n' "$*" >&2 - exit 1 - ;; -esac diff --git a/scripts/release/testdata/noop-sleep.sh b/scripts/release/testdata/noop-sleep.sh deleted file mode 100755 index 4760c10..0000000 --- a/scripts/release/testdata/noop-sleep.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -exit 0 diff --git a/scripts/release/tool-versions.env b/scripts/release/tool-versions.env deleted file mode 100644 index 69de108..0000000 --- a/scripts/release/tool-versions.env +++ /dev/null @@ -1,13 +0,0 @@ -# Trusted, repository-owned release toolchain contract. -# Keep the workflow action pins separate: `uses:` cannot reference variables. -GO_VERSION=1.26.5 -GORELEASER_VERSION=v2.17.1 -SYFT_VERSION=v1.50.0 -GOVULNCHECK_VERSION=v1.6.0 -ACTIONLINT_VERSION=v1.7.12 -SHFMT_VERSION=v3.13.1 -SHELLCHECK_VERSION=0.11.0 -SHELLCHECK_LINUX_X86_64_ARCHIVE_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 -# GitHub CLI 2.93.0 fixes GHSA-8xvp-7hj6-mcj9. Earlier versions must not -# perform release-record or artifact-attestation verification. -GH_MIN_VERSION=2.93.0 diff --git a/scripts/release/verify-asset.sh b/scripts/release/verify-asset.sh deleted file mode 100755 index 883c5a8..0000000 --- a/scripts/release/verify-asset.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -dist_dir=${1:-} -version=${2:-} -platform=${3:-} -architecture=${4:-} -expected_commit=${5:-} -expected_date=${6:-} -derived_date='' -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -repo_dir=$(cd -- "$script_dir/../.." && pwd) -source_license="$repo_dir/LICENSE" - -license_matches_source() { - candidate=$1 - if command -v git >/dev/null 2>&1 && - git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then - git -C "$repo_dir" cat-file -e HEAD:LICENSE >/dev/null 2>&1 && - git -C "$repo_dir" show HEAD:LICENSE | cmp -s - "$candidate" - return - fi - cmp -s "$source_license" "$candidate" -} - -case "$platform/$architecture" in - darwin/amd64 | darwin/arm64 | linux/amd64 | linux/arm64 | windows/amd64 | windows/arm64) ;; - *) - printf 'unsupported verification target: %s/%s\n' "$platform" "$architecture" >&2 - exit 1 - ;; -esac -if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - printf 'invalid version: %s\n' "$version" >&2 - exit 1 -fi -if [[ -n $expected_commit && ! $expected_commit =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - printf 'expected commit must be a full 40- or 64-character digest: %s\n' \ - "$expected_commit" >&2 - exit 1 -fi -if [[ ! -f $source_license ]]; then - printf 'source license not found: %s\n' "$source_license" >&2 - exit 1 -fi -if [[ -n $expected_commit ]]; then - if ! derived_date=$(TZ=UTC git -C "$repo_dir" show -s \ - --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ \ - "$expected_commit"); then - printf 'could not derive the expected UTC commit date for %s\n' \ - "$expected_commit" >&2 - exit 1 - fi - if [[ ! $derived_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then - printf 'derived commit date is not canonical UTC RFC3339: %s\n' \ - "$derived_date" >&2 - exit 1 - fi - if [[ -n $expected_date && $expected_date != "$derived_date" ]]; then - printf 'provided commit date %s differs from independently derived date %s\n' \ - "$expected_date" "$derived_date" >&2 - exit 1 - fi - expected_date=$derived_date -elif [[ -n $expected_date ]]; then - printf 'an expected commit date requires an expected commit digest\n' >&2 - exit 1 -fi - -extension=tar.gz -binary_name=eventctl -if [[ $platform == windows ]]; then - extension=zip - binary_name=eventctl.exe -fi -asset_name="eventctl_${version}_${platform}_${architecture}.${extension}" -asset_path="$dist_dir/$asset_name" - -[[ -f $asset_path && -f $dist_dir/SHA256SUMS ]] || { - printf 'missing %s or SHA256SUMS under %s\n' "$asset_name" "$dist_dir" >&2 - exit 1 -} - -checksum_line=$(awk -v name="$asset_name" '$2 == name || $2 == "*" name { print }' \ - "$dist_dir/SHA256SUMS") -if [[ -z $checksum_line || $checksum_line == *$'\n'* ]]; then - printf 'expected exactly one checksum for %s\n' "$asset_name" >&2 - exit 1 -fi -expected_hash=${checksum_line%%[[:space:]]*} -if command -v sha256sum >/dev/null 2>&1; then - actual_hash=$(sha256sum "$asset_path" | awk '{print $1}') -else - actual_hash=$(shasum -a 256 "$asset_path" | awk '{print $1}') -fi -if [[ ! $expected_hash =~ ^[0-9a-f]{64}$ || $actual_hash != "$expected_hash" ]]; then - printf 'checksum mismatch for %s\n' "$asset_name" >&2 - exit 1 -fi - -extract_dir=$(mktemp -d "${TMPDIR:-/tmp}/eventctl-verify.XXXXXX") -cleanup() { - rm -rf -- "$extract_dir" -} -trap cleanup EXIT HUP INT TERM - -if [[ $extension == zip ]]; then - archive_entries=$(unzip -Z1 "$asset_path") - actual_entries=$(printf '%s\n' "$archive_entries" | LC_ALL=C sort) - expected_entries=$(printf '%s\n' LICENSE "$binary_name" | LC_ALL=C sort) - [[ $actual_entries == "$expected_entries" ]] || { - printf 'unsafe or unexpected archive entries: %s\n' "$archive_entries" >&2 - exit 1 - } - unzip -q "$asset_path" -d "$extract_dir" -else - archive_entries=$(tar -tzf "$asset_path") - actual_entries=$(printf '%s\n' "$archive_entries" | LC_ALL=C sort) - expected_entries=$(printf '%s\n' LICENSE "$binary_name" | LC_ALL=C sort) - [[ $actual_entries == "$expected_entries" ]] || { - printf 'unsafe or unexpected archive entries: %s\n' "$archive_entries" >&2 - exit 1 - } - tar -xzf "$asset_path" -C "$extract_dir" -fi - -binary_path="$extract_dir/$binary_name" -license_path="$extract_dir/LICENSE" -[[ -f $binary_path && ! -L $binary_path && -f $license_path && ! -L $license_path ]] || { - printf 'archive did not extract one regular binary and LICENSE\n' >&2 - exit 1 -} -if ! license_matches_source "$license_path"; then - printf 'archive LICENSE does not match the tagged source\n' >&2 - exit 1 -fi -chmod 0755 "$binary_path" -version_json=$("$binary_path" version --json) - -printf '%s' "$version_json" | jq -e --arg version "$version" \ - --arg expected_date "$expected_date" \ - '.version == $version and - (.commit | type == "string" and test("^([0-9a-f]{40}|[0-9a-f]{64})$")) and - (.date | type == "string") and - (if $expected_date == "" then - (.date | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$")) - else .date == $expected_date end)' >/dev/null - -if [[ -n $expected_commit ]]; then - actual_commit=$(printf '%s' "$version_json" | jq -r '.commit') - if [[ $actual_commit != "$expected_commit" ]]; then - printf 'binary commit %s does not match expected commit %s\n' \ - "$actual_commit" "$expected_commit" >&2 - exit 1 - fi -fi - -doctor_json=$("$binary_path" doctor) -printf '%s' "$doctor_json" | jq -e \ - --arg platform "$platform" \ - --arg architecture "$architecture" \ - '.output_version == "pythonhk.eventctl/output/v1" and - .ok == true and .command == "doctor" and .error == null and - .result.status == "healthy" and - .result.operating_system == $platform and - .result.architecture == $architecture' >/dev/null - -printf 'verified and executed %s\n' "$asset_name" diff --git a/scripts/release/verify-attestations.sh b/scripts/release/verify-attestations.sh deleted file mode 100755 index f0e2c84..0000000 --- a/scripts/release/verify-attestations.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -asset_path=${1:-} -repo=${2:-pythonhk/eventctl} -tag=${3:-} -source_digest=${4:-} -signer_workflow="$repo/.github/workflows/release.yml" -source_ref="refs/tags/$tag" -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -[[ -f $asset_path ]] || { - printf 'asset not found: %s\n' "$asset_path" >&2 - exit 1 -} -if [[ $repo != pythonhk/eventctl ]]; then - printf 'refusing to verify attestations from unexpected repository: %s\n' "$repo" >&2 - exit 1 -fi -if [[ ! $source_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - printf 'invalid source commit digest: %s\n' "$source_digest" >&2 - exit 1 -fi -"$script_dir/require-gh-version.sh" - -verify_predicate() { - predicate_type=$1 - attempt=1 - while ((attempt <= 15)); do - if gh attestation verify "$asset_path" \ - --repo "$repo" \ - --deny-self-hosted-runners \ - --signer-digest "$source_digest" \ - --signer-workflow "$signer_workflow" \ - --source-ref "$source_ref" \ - --source-digest "$source_digest" \ - --predicate-type "$predicate_type"; then - return 0 - fi - sleep 3 - attempt=$((attempt + 1)) - done - return 1 -} - -verify_predicate https://slsa.dev/provenance/v1 -verify_predicate https://spdx.dev/Document/v2.3 -printf 'verified provenance and SPDX attestations for %s\n' "$asset_path" diff --git a/scripts/release/verify-dist.sh b/scripts/release/verify-dist.sh deleted file mode 100755 index c02c10c..0000000 --- a/scripts/release/verify-dist.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -dist_dir=${1:-dist} -version=${2:-} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -source_license="$script_dir/../../LICENSE" -# Trusted repository-owned toolchain contract. -# shellcheck disable=SC1091 -source "$script_dir/tool-versions.env" -syft_creator="Tool: syft-${SYFT_VERSION#v}" - -if [[ ! -d $dist_dir ]]; then - printf 'release directory not found: %s\n' "$dist_dir" >&2 - exit 1 -fi -if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - printf 'invalid release version: %s\n' "$version" >&2 - exit 1 -fi -if [[ ! -f $dist_dir/SHA256SUMS ]]; then - printf 'missing checksum contract: %s/SHA256SUMS\n' "$dist_dir" >&2 - exit 1 -fi -if [[ ! -f $source_license ]]; then - printf 'source license not found: %s\n' "$source_license" >&2 - exit 1 -fi - -hash_file() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print $1}' - else - shasum -a 256 "$1" | awk '{print $1}' - fi -} - -verify_one() { - platform=$1 - architecture=$2 - extension=$3 - binary_name=$4 - asset_name="eventctl_${version}_${platform}_${architecture}.${extension}" - asset_path="$dist_dir/$asset_name" - sbom_path="$asset_path.spdx.json" - - [[ -f $asset_path ]] || { - printf 'missing archive: %s\n' "$asset_path" >&2 - return 1 - } - [[ -f $sbom_path ]] || { - printf 'missing SPDX SBOM: %s\n' "$sbom_path" >&2 - return 1 - } - - checksum_line=$(awk -v name="$asset_name" '$2 == name || $2 == "*" name { print }' \ - "$dist_dir/SHA256SUMS") - if [[ -z $checksum_line || $checksum_line == *$'\n'* ]]; then - printf 'expected exactly one checksum for %s\n' "$asset_name" >&2 - return 1 - fi - expected_hash=${checksum_line%%[[:space:]]*} - actual_hash=$(hash_file "$asset_path") - if [[ ! $expected_hash =~ ^[0-9a-f]{64}$ || $actual_hash != "$expected_hash" ]]; then - printf 'checksum mismatch for %s\n' "$asset_name" >&2 - return 1 - fi - - if [[ $extension == zip ]]; then - archive_entries=$(unzip -Z1 "$asset_path") - license_matches=$(unzip -p "$asset_path" LICENSE | cmp -s - "$source_license" && printf true || printf false) - else - archive_entries=$(tar -tzf "$asset_path") - license_matches=$(tar -xOzf "$asset_path" LICENSE | cmp -s - "$source_license" && printf true || printf false) - fi - actual_entries=$(printf '%s\n' "$archive_entries" | LC_ALL=C sort) - expected_entries=$(printf '%s\n' LICENSE "$binary_name" | LC_ALL=C sort) - if [[ $actual_entries != "$expected_entries" ]]; then - printf 'archive %s must contain only LICENSE and %s; got: %s\n' \ - "$asset_name" "$binary_name" "$archive_entries" >&2 - return 1 - fi - if [[ $license_matches != true ]]; then - printf 'archive %s does not contain the tagged source LICENSE\n' \ - "$asset_name" >&2 - return 1 - fi - - jq -e \ - --arg asset_name "$asset_name" \ - --arg archive_hash "$actual_hash" \ - --arg syft_creator "$syft_creator" \ - '.spdxVersion == "SPDX-2.3" and - (.SPDXID | type == "string") and - .name == $asset_name and - (.creationInfo.creators | type == "array" and index($syft_creator) != null) and - (.packages | type == "array" and length > 0) and - (.SPDXID as $document_id | - ([.packages[] | - select( - .name == $asset_name and - .versionInfo == ("sha256:" + $archive_hash) and - .primaryPackagePurpose == "FILE" and - ([.checksums[]? | - select(.algorithm == "SHA256" and .checksumValue == $archive_hash)] | - length) == 1 - )]) as $archive_packages | - ($archive_packages | length) == 1 and - ($archive_packages[0].SPDXID | type == "string") and - ([.relationships[]? | - select( - .spdxElementId == $document_id and - .relationshipType == "DESCRIBES" and - .relatedSpdxElement == $archive_packages[0].SPDXID - )] | length) == 1)' \ - "$sbom_path" >/dev/null -} - -verify_one darwin amd64 tar.gz eventctl -verify_one darwin arm64 tar.gz eventctl -verify_one linux amd64 tar.gz eventctl -verify_one linux arm64 tar.gz eventctl -verify_one windows amd64 zip eventctl.exe -verify_one windows arm64 zip eventctl.exe - -checksum_count=$(wc -l <"$dist_dir/SHA256SUMS" | tr -d '[:space:]') -if [[ $checksum_count != 6 ]]; then - printf 'SHA256SUMS must contain exactly six archives; got %s lines\n' \ - "$checksum_count" >&2 - exit 1 -fi - -printf 'validated six archives, checksums, and SPDX SBOMs for %s\n' "$version" diff --git a/scripts/release/verify-release-record.sh b/scripts/release/verify-release-record.sh deleted file mode 100755 index 18f6647..0000000 --- a/scripts/release/verify-release-record.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -tag=${1:-} -repo=${2:-pythonhk/eventctl} -expected_source_digest=${3:-} -version=${tag#v} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -if [[ $repo != pythonhk/eventctl ]]; then - printf 'refusing to verify an unexpected repository: %s\n' "$repo" >&2 - exit 1 -fi -if [[ ! $expected_source_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - printf 'expected source digest must be a full Git commit digest: %s\n' \ - "$expected_source_digest" >&2 - exit 1 -fi -"$script_dir/require-gh-version.sh" - -resolve_remote_tag_commit() { - local object_record - local object_type - local object_digest - local peel_attempt=1 - object_record=$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/git/ref/tags/$tag" \ - --jq '[.object.type, .object.sha] | @tsv') - IFS=$'\t' read -r object_type object_digest <<<"$object_record" - while ((peel_attempt <= 8)); do - if [[ ! $object_digest =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then - return 1 - fi - case "$object_type" in - commit) - printf '%s\n' "$object_digest" - return 0 - ;; - tag) - object_record=$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$repo/git/tags/$object_digest" \ - --jq '[.object.type, .object.sha] | @tsv') - IFS=$'\t' read -r object_type object_digest <<<"$object_record" - ;; - *) return 1 ;; - esac - peel_attempt=$((peel_attempt + 1)) - done - return 1 -} - -tag_matches_source() { - local remote_source_digest - remote_source_digest=$(resolve_remote_tag_commit) || return 1 - [[ $remote_source_digest == "$expected_source_digest" ]] -} - -expected_assets_json=$( - printf '%s\n' \ - SHA256SUMS \ - "eventctl_${version}_darwin_amd64.tar.gz" \ - "eventctl_${version}_darwin_amd64.tar.gz.spdx.json" \ - "eventctl_${version}_darwin_arm64.tar.gz" \ - "eventctl_${version}_darwin_arm64.tar.gz.spdx.json" \ - "eventctl_${version}_linux_amd64.tar.gz" \ - "eventctl_${version}_linux_amd64.tar.gz.spdx.json" \ - "eventctl_${version}_linux_arm64.tar.gz" \ - "eventctl_${version}_linux_arm64.tar.gz.spdx.json" \ - "eventctl_${version}_windows_amd64.zip" \ - "eventctl_${version}_windows_amd64.zip.spdx.json" \ - "eventctl_${version}_windows_arm64.zip" \ - "eventctl_${version}_windows_arm64.zip.spdx.json" | - jq -R -s 'split("\n") | map(select(length > 0)) | sort' -) - -attempt=1 -while ((attempt <= 20)); do - if release_json=$(gh release view "$tag" --repo "$repo" \ - --json isDraft,isImmutable,tagName,assets) && - printf '%s' "$release_json" | jq -e --arg tag "$tag" \ - --argjson expected "$expected_assets_json" \ - '.isDraft == false and .isImmutable == true and - .tagName == $tag and - ([.assets[].name] | sort) == $expected' >/dev/null && - tag_matches_source && - gh release verify "$tag" --repo "$repo"; then - printf 'verified immutable GitHub release record for %s\n' "$tag" - exit 0 - fi - sleep 3 - attempt=$((attempt + 1)) -done - -printf 'could not verify immutable release record for %s\n' "$tag" >&2 -exit 1 diff --git a/scripts/release/verify-tag.sh b/scripts/release/verify-tag.sh deleted file mode 100755 index 66ac480..0000000 --- a/scripts/release/verify-tag.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -tag=${1:-${GITHUB_REF_NAME:-}} -expected_repo=${2:-pythonhk/eventctl} -actual_repo=${GITHUB_REPOSITORY:-$expected_repo} -CDPATH='' -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -semver_regex='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' -if [[ ! $tag =~ $semver_regex ]]; then - printf 'release tag must be SemVer with a v prefix: %s\n' "$tag" >&2 - exit 1 -fi -if [[ $actual_repo != "$expected_repo" ]]; then - printf 'refusing to release from %s; expected %s\n' "$actual_repo" "$expected_repo" >&2 - exit 1 -fi -if [[ $(git cat-file -t "$tag") != tag ]]; then - printf 'release tag must be annotated: %s\n' "$tag" >&2 - exit 1 -fi - -tag_commit=$(git rev-parse --verify "${tag}^{commit}") -head_commit=$(git rev-parse --verify HEAD) -if [[ $tag_commit != "$head_commit" ]]; then - printf 'checked-out commit %s does not match %s (%s)\n' \ - "$head_commit" "$tag" "$tag_commit" >&2 - exit 1 -fi -if ! git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main; then - printf 'tag commit is not reachable from origin/main: %s\n' "$tag_commit" >&2 - exit 1 -fi -if [[ -n $(git status --porcelain --untracked-files=all) ]]; then - printf 'tracked or untracked files changed before release build\n' >&2 - git status --short >&2 - exit 1 -fi - -"$script_dir/require-gh-version.sh" -if gh release view "$tag" --repo "$expected_repo" >/dev/null 2>&1; then - printf 'release already exists and will not be overwritten: %s\n' "$tag" >&2 - exit 1 -fi - -printf 'validated release tag %s at %s\n' "$tag" "$tag_commit" diff --git a/tests/e2e/eventctl_test.go b/tests/e2e/eventctl_test.go new file mode 100644 index 0000000..f3f2961 --- /dev/null +++ b/tests/e2e/eventctl_test.go @@ -0,0 +1,1103 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "filippo.io/age" + "github.com/stretchr/testify/suite" +) + +type response struct { + OK bool `json:"ok"` + Command string `json:"command"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} + +type keyResult struct { + SigningPrivate string `json:"signing_private_key"` + SigningPublic string `json:"signing_public_key"` + RecipientPrivate string `json:"recipient_private_key"` + RecipientPublic string `json:"recipient_public_key"` + SigningKeyID string `json:"signing_key_id"` + RecipientKeyID string `json:"recipient_key_id"` +} + +type streamEventReference struct { + EventID string `json:"event_id"` + EventEpoch int `json:"event_epoch"` + RepositoryID string `json:"repository_id"` + BindingSHA256 string `json:"binding_sha256"` +} + +type streamBinding struct { + Version int `json:"v"` + Kind string `json:"kind"` + Event streamEventReference `json:"event"` + Purpose string `json:"purpose"` + TeamID string `json:"team_id"` + AttemptID string `json:"attempt_id"` + ArtifactID string `json:"artifact_id"` +} + +type streamSigningPublic struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + PublicKey string `json:"public"` +} + +type streamSignature struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` + Value string `json:"value"` +} + +type streamHeader struct { + Protocol string `json:"protocol"` + Binding streamBinding `json:"binding"` + Signer streamSigningPublic `json:"signer"` + RecipientKeyIDs []string `json:"recipient_key_ids"` + PayloadSize int64 `json:"payload_size"` + PayloadSHA256 string `json:"payload_sha256"` + Signature streamSignature `json:"signature"` +} + +type eventSuite struct { + suite.Suite + root string + work string + binary string + coverage string + passphrase string + event string + registry string + submissionRegistry string + bundle string + metadata string + captain keyResult + teammate keyResult + outsider keyResult + captainReg string + teammateReg string + proposal string + captainConsent string + teammateConsent string + teamVerification string + teamID string + attemptID string + submission string + streamBinding string + stream string +} + +func TestEventctl(t *testing.T) { suite.Run(t, new(eventSuite)) } + +func (s *eventSuite) SetupSuite() { + _, currentFile, _, ok := runtime.Caller(0) + s.Require().True(ok) + s.root = filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../..")) + s.work = s.T().TempDir() + s.binary = filepath.Join(s.work, "eventctl") + s.coverage = os.Getenv("GOCOVERDIR") + if s.coverage == "" { + s.coverage = filepath.Join(s.work, "coverage") + } + s.Require().NoError(os.MkdirAll(s.coverage, 0o755)) + build := exec.Command("go", "build", "-cover", "-coverpkg=./...", "-o", s.binary, "./cmd/eventctl") + build.Dir = s.root + build.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + buildOutput, err := build.CombinedOutput() + s.Require().NoError(err, string(buildOutput)) + + s.passphrase = filepath.Join(s.work, "passphrase.txt") + s.Require().NoError(os.WriteFile(s.passphrase, []byte("correct horse battery staple\n"), 0o600)) + s.event = filepath.Join(s.work, "event.json") + s.writeJSON(s.event, map[string]any{ + "v": 2, + "kind": "event-binding", + "protocol": "eventctl/v2", + "event_id": "pycon-hk-2026", + "event_epoch": 1, + "repository_id": "12345", + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2030-01-01T00:00:00Z", + "terms_sha256": strings.Repeat("a", 64), + "ttl_seconds": map[string]any{"registration": 604800, "team": 604800, "submission": 86400}, + "limits": map[string]any{"team_min": 1, "team_max": 5, "attempts_per_team": 10, "attempts_total": 200}, + }) + s.bundle = filepath.Join(s.work, "exploit-package.zip") + s.metadata = filepath.Join(s.work, "metadata.json") + s.Require().NoError(os.WriteFile(s.bundle, []byte("safe exploit package\n"), 0o600)) + s.Require().NoError(os.WriteFile(s.metadata, []byte(`{"lab":1,"language":"python"}`), 0o600)) + + s.captain = s.keyGen("captain") + s.teammate = s.keyGen("teammate") + s.outsider = s.keyGen("outsider") + s.captainReg = s.register("100", "10000000-0000-4000-8000-000000000001", s.captain) + s.teammateReg = s.register("200", "20000000-0000-4000-8000-000000000002", s.teammate) + s.registry = filepath.Join(s.work, "registry.json") + identities := s.verifiedIdentities(s.captainReg, s.teammateReg) + s.writeRegistry(s.registry, "formation_open", identities, nil, nil) + s.teamID = "30000000-0000-4000-8000-000000000003" + s.attemptID = "40000000-0000-4000-8000-000000000004" + s.proposal = filepath.Join(s.work, "proposal.json") + s.success("team", "propose", "--event", s.event, "--registry", s.registry, "--team-id", s.teamID, "--actor-id", "100", "--member", "100", "--member", "200", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.proposal) + s.captainConsent = filepath.Join(s.work, "captain-consent.json") + s.teammateConsent = filepath.Join(s.work, "teammate-consent.json") + s.success("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.captainConsent) + s.success("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "200", "--sig-private-key", s.teammate.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.teammateConsent) + s.teamVerification = filepath.Join(s.work, "team-verification.json") + s.success("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", s.teamVerification) + s.submissionRegistry = filepath.Join(s.work, "submission-registry.json") + verification := s.readObject(s.teamVerification) + s.writeRegistry(s.submissionRegistry, "submissions_open", identities, []any{map[string]any{"team_id": verification["team_id"], "proposal_sha256": verification["proposal_sha256"], "members": verification["members"]}}, nil) + s.submission = filepath.Join(s.work, "submission.json") + s.success("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", s.metadata, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.submission) + s.streamBinding = filepath.Join(s.work, "stream-binding.json") + s.writeJSON(s.streamBinding, map[string]any{ + "v": 2, + "kind": "stream-binding", + "event": s.readObject(s.captainReg)["event"], + "purpose": "judge-log", + "team_id": s.teamID, + "attempt_id": s.attemptID, + "artifact_id": "run-1", + }) + judgeLog := filepath.Join(s.work, "judge.log") + s.Require().NoError(os.WriteFile(judgeLog, []byte("private judge output\n"), 0o600)) + s.stream = filepath.Join(s.work, "judge.log.eventctl") + s.success("sigcrypt", "--input", judgeLog, "--output", s.stream, "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic, "--enc-public-key", s.teammate.RecipientPublic, "--enc-public-key", s.captain.RecipientPublic) +} + +func (s *eventSuite) TearDownSuite() { + entries, err := os.ReadDir(s.coverage) + s.Require().NoError(err) + meta, counters := false, false + for _, entry := range entries { + meta = meta || strings.HasPrefix(entry.Name(), "covmeta.") + counters = counters || strings.HasPrefix(entry.Name(), "covcounters.") + } + s.Require().True(meta, "instrumented binary did not emit covmeta") + s.Require().True(counters, "instrumented binary did not emit covcounters") +} + +func (s *eventSuite) TestVersionDoctorAndEventBinding() { + version := s.success("version") + s.Contains(string(version.Result), `"protocol":"eventctl/v2"`) + defaultDoctor := s.successWithEnv(map[string]string{"EVENTCTL_EVENT_ID": "weekend-1", "EVENTCTL_EVENT_EPOCH": "2"}, "doctor") + s.Contains(string(defaultDoctor.Result), `"event_id":"weekend-1"`) + eventDoctor := s.success("doctor", "--event", s.event) + s.Contains(string(eventDoctor.Result), `"repository_id":"12345"`) + registryDoctor := s.success("doctor", "--event", s.event, "--registry", s.submissionRegistry) + s.Contains(string(registryDoctor.Result), `"phase":"submissions_open"`) + s.failure("doctor", "--registry", s.registry) + s.failure("doctor", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json")) + s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_EPOCH": "0"}, "doctor") + s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_EPOCH": "not-a-number"}, "doctor") + s.raw(0, "--help") +} + +func (s *eventSuite) TestPublicBindingAndJSONFailures() { + cases := []struct { + name string + mutate func(map[string]any) + }{ + {"discriminator", func(value map[string]any) { value["kind"] = "wrong" }}, + {"event-identity", func(value map[string]any) { value["event_id"] = "NO" }}, + {"valid-from", func(value map[string]any) { value["valid_from"] = "not-a-time" }}, + {"valid-until", func(value map[string]any) { value["valid_until"] = "2025-01-01T00:00:00Z" }}, + {"ttl", func(value map[string]any) { value["ttl_seconds"].(map[string]any)["team"] = 0 }}, + {"limits", func(value map[string]any) { value["limits"].(map[string]any)["team_max"] = 0 }}, + } + for _, test := range cases { + s.Run(test.name, func() { + path := filepath.Join(s.work, "bad-event-"+test.name+".json") + value := s.copyObject(s.event) + test.mutate(value) + s.writeJSON(path, value) + s.failure("doctor", "--event", path) + }) + } + + s.failure("doctor", "--event", filepath.Join(s.work, "missing-event.json")) + s.failure("doctor", "--event", s.work) + + tooLarge := filepath.Join(s.work, "too-large-event.json") + file, err := os.Create(tooLarge) + s.Require().NoError(err) + s.Require().NoError(file.Truncate((64 << 20) + 1)) + s.Require().NoError(file.Close()) + s.failure("doctor", "--event", tooLarge) + + for name, data := range map[string][]byte{ + "empty": nil, + "invalid": []byte("{"), + "key": []byte(`{"event"`), + "invalid-key": []byte(`{"event`), + "primitive": []byte("1"), + "nested-object": []byte(`{"event":{`), + "nested-array": []byte(`{"items":[`), + "invalid-element": []byte(`{"items":[}`), + "invalid-array": []byte(`{"items":[1,]}`), + "duplicate": []byte(`{"v":2,"v":2}`), + "trailing": append(bytes.TrimSpace(s.readBytes(s.event)), []byte("\n{}")...), + "unknown": append(bytes.TrimSuffix(bytes.TrimSpace(s.readBytes(s.event)), []byte("}")), []byte(`,"unexpected":true}`)...), + } { + path := filepath.Join(s.work, "bad-json-"+name+".json") + s.Require().NoError(os.WriteFile(path, data, 0o600)) + s.failure("doctor", "--event", path) + } + + for name, mutate := range map[string]func(map[string]any){ + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "bad" + }, + "mismatched-reference": func(value map[string]any) { + value["event"].(map[string]any)["binding_sha256"] = strings.Repeat("b", 64) + }, + } { + path := filepath.Join(s.work, "bad-registration-event-"+name+".json") + value := s.copyObject(s.captainReg) + mutate(value) + s.writeJSON(path, value) + s.failure("identity", "verify", "--event", s.event, "--input", path, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "bad-registration-event-"+name+".verified.json")) + } +} + +func (s *eventSuite) TestCommandInputFailures() { + missingPassphrase := filepath.Join(s.work, "missing-passphrase.txt") + fileOutput := filepath.Join(s.work, "not-a-directory") + s.Require().NoError(os.WriteFile(fileOutput, []byte("file"), 0o600)) + s.failure("key-gen", "--out", filepath.Join(s.work, "missing-passphrase-key"), "--passphrase-file", missingPassphrase) + s.failure("key-gen", "--out", filepath.Join(s.work, "captain"), "--passphrase-file", s.passphrase) + s.failure("key-gen", "--out", fileOutput, "--passphrase-file", s.passphrase) + + badRecipientPublic := filepath.Join(s.work, "bad-recipient.public.json") + s.writeJSON(badRecipientPublic, map[string]any{"alg": "wrong", "kid": strings.Repeat("a", 64), "recipient": "wrong"}) + badSigningPublic := filepath.Join(s.work, "bad-signing.public.json") + s.writeJSON(badSigningPublic, map[string]any{"alg": "wrong", "kid": strings.Repeat("a", 64), "public": "wrong"}) + badSigningPrivate := filepath.Join(s.work, "bad-signing.private.age") + s.writePassphraseEncrypted(badSigningPrivate, []byte(`{"kind":"wrong"}`)) + badRecipientPrivate := filepath.Join(s.work, "bad-recipient.private.age") + s.writePassphraseEncrypted(badRecipientPrivate, []byte(`{"kind":"wrong"}`)) + + missingStreamBinding := filepath.Join(s.work, "missing-stream-binding.json") + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-context.eventctl"), "--context", missingStreamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-pass.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--enc-public-key", s.captain.RecipientPublic) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-signing.eventctl"), "--context", s.streamBinding, "--sig-private-key", filepath.Join(s.work, "missing-signing.private.age"), "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-signing.eventctl"), "--context", s.streamBinding, "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-recipient.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", badRecipientPublic) + s.failure("sigcrypt", "--input", filepath.Join(s.work, "missing-input"), "--output", filepath.Join(s.work, "missing-input.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + s.failure("sigcrypt", "--input", s.bundle, "--output", s.stream, "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-decrypt-context"), "--context", missingStreamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-public"), "--context", s.streamBinding, "--ver-public-key", filepath.Join(s.work, "missing-signing.public.json"), "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "bad-public"), "--context", s.streamBinding, "--ver-public-key", badSigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-decrypt-pass"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", missingPassphrase) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-recipient"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", filepath.Join(s.work, "missing-recipient.private.age"), "--passphrase-file", s.passphrase) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "bad-recipient"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", badRecipientPrivate, "--passphrase-file", s.passphrase) + s.failure("decverify", "--input", filepath.Join(s.work, "missing-encrypted-stream"), "--output", filepath.Join(s.work, "missing-encrypted-output"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + + s.identityCommandFailures(missingPassphrase, badSigningPrivate, badRecipientPublic) + s.teamCommandFailures(missingPassphrase, badSigningPrivate) + s.submissionCommandFailures(missingPassphrase, badSigningPrivate) +} + +func (s *eventSuite) TestKeyMaterialRejections() { + invalidCiphertext := filepath.Join(s.work, "invalid-ciphertext.age") + s.Require().NoError(os.WriteFile(invalidCiphertext, []byte("not an age file"), 0o600)) + corruptCiphertext := filepath.Join(s.work, "corrupt-ciphertext.age") + corrupt := append([]byte(nil), s.readBytes(s.captain.SigningPrivate)...) + corrupt[len(corrupt)-1] ^= 1 + s.Require().NoError(os.WriteFile(corruptCiphertext, corrupt, 0o600)) + invalidJSON := filepath.Join(s.work, "invalid-json.age") + s.writePassphraseEncrypted(invalidJSON, []byte("{")) + _, private, err := ed25519.GenerateKey(nil) + s.Require().NoError(err) + metadataMismatch := filepath.Join(s.work, "metadata-mismatch-signing.age") + s.writePassphraseEncrypted(metadataMismatch, []byte(`{"kind":"wrong","alg":"Ed25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","private":"`+base64.RawURLEncoding.EncodeToString(private)+`"}`)) + + register := func(signingPath, recipientPath, output string) { + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--registration-id", "50000000-0000-4000-8000-000000000005", "--sig-private-key", signingPath, "--recipient-public-key", recipientPath, "--passphrase-file", s.passphrase, "--output", output) + } + register(invalidCiphertext, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-cipher.json")) + register(corruptCiphertext, s.captain.RecipientPublic, filepath.Join(s.work, "corrupt-signing-cipher.json")) + register(invalidJSON, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-json.json")) + register(metadataMismatch, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-metadata.json")) + + badRecipientParse := filepath.Join(s.work, "bad-recipient-parse.json") + s.writeJSON(badRecipientParse, map[string]any{"alg": "age-hybrid-mlkem768-x25519", "kid": strings.Repeat("a", 64), "recipient": "not-a-recipient"}) + recipientIdentity, err := age.GenerateHybridIdentity() + s.Require().NoError(err) + badRecipientFingerprint := filepath.Join(s.work, "bad-recipient-fingerprint.json") + s.writeJSON(badRecipientFingerprint, map[string]any{"alg": "age-hybrid-mlkem768-x25519", "kid": strings.Repeat("a", 64), "recipient": recipientIdentity.Recipient().String()}) + register(s.captain.SigningPrivate, badRecipientParse, filepath.Join(s.work, "invalid-recipient-parse.json")) + register(s.captain.SigningPrivate, badRecipientFingerprint, filepath.Join(s.work, "invalid-recipient-fingerprint.json")) + badSigningFingerprint := filepath.Join(s.work, "bad-signing-fingerprint.json") + s.writeJSON(badSigningFingerprint, map[string]any{"alg": "Ed25519", "kid": strings.Repeat("a", 64), "public": base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize))}) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "invalid-signing-fingerprint.log"), "--context", s.streamBinding, "--ver-public-key", badSigningFingerprint, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + + recipientMetadata := filepath.Join(s.work, "recipient-metadata.age") + s.writePassphraseEncrypted(recipientMetadata, []byte(`{"kind":"wrong"}`)) + recipientParse := filepath.Join(s.work, "recipient-parse.age") + s.writePassphraseEncrypted(recipientParse, []byte(`{"kind":"recipient-private","alg":"age-hybrid-mlkem768-x25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","identity":"not-an-identity"}`)) + recipientFingerprint := filepath.Join(s.work, "recipient-fingerprint.age") + s.writePassphraseEncrypted(recipientFingerprint, []byte(`{"kind":"recipient-private","alg":"age-hybrid-mlkem768-x25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","identity":"`+recipientIdentity.String()+`"}`)) + decrypt := func(recipientPath, output string) { + s.failure("decverify", "--input", s.stream, "--output", output, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", recipientPath, "--passphrase-file", s.passphrase) + } + decrypt(invalidCiphertext, filepath.Join(s.work, "invalid-recipient-cipher.log")) + decrypt(invalidJSON, filepath.Join(s.work, "invalid-recipient-json.log")) + decrypt(recipientMetadata, filepath.Join(s.work, "invalid-recipient-metadata.log")) + decrypt(recipientParse, filepath.Join(s.work, "invalid-recipient-parse.log")) + decrypt(recipientFingerprint, filepath.Join(s.work, "invalid-recipient-fingerprint.log")) +} + +func (s *eventSuite) TestRequestWindowRejections() { + registrationEvent := filepath.Join(s.work, "registration-window-event.json") + registrationBinding := s.copyObject(s.event) + registrationBinding["ttl_seconds"].(map[string]any)["registration"] = 200000000 + s.writeJSON(registrationEvent, registrationBinding) + s.failure("identity", "register", "--event", registrationEvent, "--actor-id", "100", "--registration-id", "50000000-0000-4000-8000-000000000005", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "registration-window.json")) + + submissionEvent := filepath.Join(s.work, "submission-window-event.json") + submissionBinding := s.copyObject(s.event) + submissionBinding["ttl_seconds"].(map[string]any)["submission"] = 200000000 + s.writeJSON(submissionEvent, submissionBinding) + s.failure("submission", "prepare", "--event", submissionEvent, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "submission-window.json")) + + teamEvent := filepath.Join(s.work, "team-window-event.json") + teamBinding := s.copyObject(s.event) + teamBinding["ttl_seconds"].(map[string]any)["team"] = 200000000 + s.writeJSON(teamEvent, teamBinding) + registration := filepath.Join(s.work, "team-window-registration.json") + s.success("identity", "register", "--event", teamEvent, "--actor-id", "100", "--registration-id", "60000000-0000-4000-8000-000000000006", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", registration) + verified := filepath.Join(s.work, "team-window-identity.json") + s.success("identity", "verify", "--event", teamEvent, "--input", registration, "--expect-actor-id", "100", "--source-time", s.issuedAt(registration), "--output", verified) + registry := filepath.Join(s.work, "team-window-registry.json") + s.writeJSON(registry, map[string]any{"v": 2, "kind": "event-registry", "event": s.readObject(registration)["event"], "revision": 0, "phase": "formation_open", "enabled": true, "disabled_reason": "", "identities": []any{s.readObject(verified)}, "teams": []any{}, "attempts": []any{}}) + s.failure("team", "propose", "--event", teamEvent, "--registry", registry, "--team-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "team-window.json")) +} + +func (s *eventSuite) identityCommandFailures(missingPassphrase, badSigningPrivate, badRecipientPublic string) { + output := filepath.Join(s.work, "identity-command-output.json") + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", missingPassphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", filepath.Join(s.work, "missing-signing.private.age"), "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", filepath.Join(s.work, "missing-recipient.public.json"), "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", badRecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "0", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--key-epoch", "0", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--registration-id", "not-a-uuid", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) + + generated := filepath.Join(s.work, "generated-registration.json") + s.success("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", generated) + s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", generated) + + s.failure("identity", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) + s.failure("identity", "verify", "--event", s.event, "--input", filepath.Join(s.work, "missing-registration.json"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) + s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) + s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) +} + +func (s *eventSuite) teamCommandFailures(missingPassphrase, badSigningPrivate string) { + output := filepath.Join(s.work, "team-command-output.json") + s.failure("team", "propose", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("team", "propose", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) + s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + + s.failure("team", "consent", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("team", "consent", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", filepath.Join(s.work, "missing-proposal.json"), "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) + s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + + s.failure("team", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) + s.failure("team", "verify", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", filepath.Join(s.work, "missing-proposal.json"), "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--output", output) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", filepath.Join(s.work, "missing-consent.json"), "--output", output) + s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", output) +} + +func (s *eventSuite) submissionCommandFailures(missingPassphrase, badSigningPrivate string) { + output := filepath.Join(s.work, "submission-command-output.json") + s.failure("submission", "prepare", "--event", filepath.Join(s.work, "missing-event.json"), "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("submission", "prepare", "--event", s.event, "--input", filepath.Join(s.work, "missing-bundle"), "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", filepath.Join(s.work, "missing-metadata"), "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) + s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) + + s.failure("submission", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--request", s.submission, "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) + s.failure("submission", "verify", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--request", s.submission, "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", filepath.Join(s.work, "missing-submission.json"), "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", filepath.Join(s.work, "missing-bundle"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", filepath.Join(s.work, "missing-metadata"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) + s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) +} + +func (s *eventSuite) TestIdentityRegistrationAndVerification() { + verified := filepath.Join(s.work, "captain-verified.json") + s.success("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", verified) + s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "200", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "wrong-actor.json")) + s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", "2031-01-01T00:00:00Z", "--output", filepath.Join(s.work, "expired.json")) + bad := filepath.Join(s.work, "bad-registration.json") + data, err := os.ReadFile(s.captainReg) + s.Require().NoError(err) + data = bytes.Replace(data, []byte(`"actor_id":"100"`), []byte(`"actor_id":"999"`), 1) + s.Require().NoError(os.WriteFile(bad, data, 0o600)) + s.failure("identity", "verify", "--event", s.event, "--input", bad, "--expect-actor-id", "999", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "bad-signature.json")) +} + +func (s *eventSuite) TestIdentityAndRegistryRejections() { + identityCases := map[string]func(map[string]any){ + "identity": func(value map[string]any) { + value["v"] = 1 + }, + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "A" + }, + "window": func(value map[string]any) { + value["expires_at"] = value["issued_at"] + }, + "issued-at": func(value map[string]any) { + value["issued_at"] = "not-a-time" + }, + "signing-key": func(value map[string]any) { + value["signing_key"].(map[string]any)["alg"] = "wrong" + }, + "recipient-key": func(value map[string]any) { + value["recipient_key"].(map[string]any)["alg"] = "wrong" + }, + "signature-metadata": func(value map[string]any) { + value["signature"].(map[string]any)["alg"] = "wrong" + }, + "signature-encoding": func(value map[string]any) { + value["signature"].(map[string]any)["value"] = "!" + }, + "signature": func(value map[string]any) { + value["actor_id"] = "999" + }, + } + for name, mutate := range identityCases { + path := filepath.Join(s.work, "rejected-identity-"+name+".json") + value := s.copyObject(s.captainReg) + mutate(value) + s.writeJSON(path, value) + actorID := "100" + if name == "signature" { + actorID = "999" + } + s.failure(s.identityVerifyArgs(path, actorID, s.issuedAt(s.captainReg), filepath.Join(s.work, "rejected-identity-"+name+".verified.json"))...) + } + s.failure(s.identityVerifyArgs(s.captainReg, "100", "not-a-time", filepath.Join(s.work, "rejected-identity-time.verified.json"))...) + + registryCases := map[string]func(map[string]any){ + "discriminator": func(value map[string]any) { + value["kind"] = "wrong" + }, + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "A" + }, + "empty": func(value map[string]any) { + value["identities"] = []any{} + }, + "identity": func(value map[string]any) { + value["identities"].([]any)[0].(map[string]any)["actor_id"] = "0" + }, + "signing-key": func(value map[string]any) { + value["identities"].([]any)[0].(map[string]any)["signing_key"].(map[string]any)["alg"] = "wrong" + }, + "recipient-key": func(value map[string]any) { + value["identities"].([]any)[0].(map[string]any)["recipient_key"].(map[string]any)["recipient"] = "wrong" + }, + "sorted": func(value map[string]any) { + identities := value["identities"].([]any) + value["identities"] = []any{identities[1], identities[0]} + }, + "duplicate": func(value map[string]any) { + identities := value["identities"].([]any) + identities[1].(map[string]any)["actor_id"] = identities[0].(map[string]any)["actor_id"] + }, + } + for name, mutate := range registryCases { + path := filepath.Join(s.work, "rejected-registry-"+name+".json") + value := s.copyObject(s.registry) + mutate(value) + s.writeJSON(path, value) + s.failure(s.teamProposeArgs(path, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-registry-"+name+".proposal.json"), "100")...) + } + + s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "999", s.captain.SigningPrivate, filepath.Join(s.work, "missing-proposer.proposal.json"), "999")...) + s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "missing-member.proposal.json"), "100", "999")...) + s.failure(s.teamProposeArgs(s.registry, "not-a-uuid", "100", s.captain.SigningPrivate, filepath.Join(s.work, "bad-id.proposal.json"), "100")...) + s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "captain-not-member.proposal.json"), "200")...) + + generated := filepath.Join(s.work, "generated-team.proposal.json") + s.success(s.teamProposeArgs(s.registry, "", "100", s.captain.SigningPrivate, generated, "100", "200")...) + s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, generated, "100")...) +} + +func (s *eventSuite) TestRegistryBoundTeamLifecycle() { + verified := filepath.Join(s.work, "team-verified.json") + s.success("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", verified) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--output", filepath.Join(s.work, "missing-consent.json")) + s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--team-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--member", "100", "--sig-private-key", s.outsider.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "wrong-key-proposal.json")) + s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "200", "--sig-private-key", s.outsider.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "wrong-key-consent.json")) +} + +func (s *eventSuite) TestRegistryStateRejections() { + registryCases := map[string]func(map[string]any){ + "phase": func(value map[string]any) { + value["phase"] = "wrong" + }, + "enabled-reason": func(value map[string]any) { + value["disabled_reason"] = "wrong" + }, + "disabled-reason": func(value map[string]any) { + value["enabled"] = false + }, + "team": func(value map[string]any) { + value["teams"].([]any)[0].(map[string]any)["team_id"] = "wrong" + }, + "teams-sorted": func(value map[string]any) { + team := value["teams"].([]any)[0] + value["teams"] = []any{team, team} + }, + "team-member": func(value map[string]any) { + value["teams"].([]any)[0].(map[string]any)["members"].([]any)[0].(map[string]any)["actor_id"] = "999" + }, + "team-members-sorted": func(value map[string]any) { + members := value["teams"].([]any)[0].(map[string]any)["members"].([]any) + value["teams"].([]any)[0].(map[string]any)["members"] = []any{members[1], members[0]} + }, + "team-member-reused": func(value map[string]any) { + team := value["teams"].([]any)[0].(map[string]any) + duplicate := s.copyValue(team) + duplicate["team_id"] = "50000000-0000-4000-8000-000000000005" + value["teams"] = []any{team, duplicate} + }, + "attempt": func(value map[string]any) { + value["attempts"] = []any{map[string]any{"attempt_id": "wrong"}} + }, + "attempts-sorted": func(value map[string]any) { + attempt := s.attemptRecord("40000000-0000-4000-8000-000000000004") + value["attempts"] = []any{attempt, attempt} + }, + "attempt-member": func(value map[string]any) { + attempt := s.attemptRecord("40000000-0000-4000-8000-000000000004") + attempt["actor_id"] = "999" + value["attempts"] = []any{attempt} + }, + } + for name, mutate := range registryCases { + path := filepath.Join(s.work, "rejected-state-"+name+".json") + value := s.copyObject(s.submissionRegistry) + mutate(value) + s.writeJSON(path, value) + s.failure(s.teamProposeArgs(path, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-state-"+name+".proposal.json"), "100")...) + } +} + +func (s *eventSuite) TestRegistryPhaseMembershipAndReplayRejections() { + disabled := filepath.Join(s.work, "disabled-registry.json") + disabledValue := s.copyObject(s.registry) + disabledValue["enabled"] = false + disabledValue["disabled_reason"] = "maintenance" + s.writeJSON(disabled, disabledValue) + s.failure(s.teamProposeArgs(disabled, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "disabled.proposal.json"), "100")...) + s.failure(s.teamConsentArgs(disabled, s.proposal, "100", s.captain.SigningPrivate, filepath.Join(s.work, "disabled.consent.json"))...) + s.failure(s.teamVerifyArgsWithRegistry(disabled, s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "disabled.verified.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)})...) + s.failure(s.submissionVerifyArgsWithRegistry(s.registry, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "formation-submission.json"))...) + + activeFormation := filepath.Join(s.work, "active-formation-registry.json") + activeFormationValue := s.copyObject(s.submissionRegistry) + activeFormationValue["phase"] = "formation_open" + s.writeJSON(activeFormation, activeFormationValue) + s.failure(s.teamProposeArgs(activeFormation, s.teamID, "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-team-id.proposal.json"), "100", "200")...) + s.failure(s.teamProposeArgs(activeFormation, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-member.proposal.json"), "100")...) + s.failure(s.teamConsentArgs(activeFormation, s.proposal, "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-team.consent.json"))...) + s.failure(s.teamVerifyArgsWithRegistry(activeFormation, s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "active-team.verified.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)})...) + + missingTeam := filepath.Join(s.work, "missing-team-registry.json") + missingTeamValue := s.copyObject(s.submissionRegistry) + missingTeamValue["teams"] = []any{} + s.writeJSON(missingTeam, missingTeamValue) + s.failure(s.submissionVerifyArgsWithRegistry(missingTeam, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "missing-team-submission.json"))...) + + replayed := filepath.Join(s.work, "replayed-attempt-registry.json") + replayedValue := s.copyObject(s.submissionRegistry) + replayedValue["attempts"] = []any{s.attemptRecord(s.attemptID)} + s.writeJSON(replayed, replayedValue) + s.failure(s.submissionVerifyArgsWithRegistry(replayed, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "replayed-submission.json"))...) + + perTeam := filepath.Join(s.work, "per-team-quota-registry.json") + perTeamValue := s.copyObject(s.submissionRegistry) + attempts := make([]any, 0, 10) + for index := range 10 { + attempts = append(attempts, s.attemptRecord(fmt.Sprintf("%08x-0000-4000-8000-%012x", 0x41000000+index, index))) + } + perTeamValue["attempts"] = attempts + s.writeJSON(perTeam, perTeamValue) + s.failure(s.submissionVerifyArgsWithRegistry(perTeam, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "per-team-submission.json"))...) + + total := filepath.Join(s.work, "total-quota-registry.json") + totalValue := s.copyObject(s.submissionRegistry) + attempts = make([]any, 0, 200) + for index := range 200 { + attempts = append(attempts, s.attemptRecord(fmt.Sprintf("%08x-0000-4000-8000-%012x", 0x42000000+index, index))) + } + totalValue["attempts"] = attempts + s.writeJSON(total, totalValue) + s.failure(s.submissionVerifyArgsWithRegistry(total, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "total-submission.json"))...) +} + +func (s *eventSuite) TestTeamAndSubmissionRejections() { + proposalCases := map[string]func(map[string]any){ + "identity": func(value map[string]any) { + value["v"] = 1 + }, + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "A" + }, + "size": func(value map[string]any) { + value["members"] = []any{} + }, + "window": func(value map[string]any) { + value["expires_at"] = value["issued_at"] + }, + "member": func(value map[string]any) { + value["members"].([]any)[0].(map[string]any)["signing_kid"] = strings.Repeat("b", 64) + }, + "sorted": func(value map[string]any) { + members := value["members"].([]any) + value["members"] = []any{members[1], members[0]} + }, + "proposer": func(value map[string]any) { + value["proposer"].(map[string]any)["actor_id"] = "999" + }, + "signature": func(value map[string]any) { + value["signature"].(map[string]any)["alg"] = "wrong" + }, + } + for name, mutate := range proposalCases { + path := filepath.Join(s.work, "rejected-proposal-"+name+".json") + value := s.copyObject(s.proposal) + mutate(value) + s.writeJSON(path, value) + s.failure(s.teamConsentArgs(s.registry, path, "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-proposal-"+name+".consent.json"))...) + } + s.failure(s.teamConsentArgs(s.registry, s.proposal, "999", s.captain.SigningPrivate, filepath.Join(s.work, "missing-consenter.consent.json"))...) + + consentOutput := filepath.Join(s.work, "existing-consent.json") + s.Require().NoError(os.WriteFile(consentOutput, []byte("already exists"), 0o600)) + s.failure(s.teamConsentArgs(s.registry, s.proposal, "100", s.captain.SigningPrivate, consentOutput)...) + + validTimes := []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)} + s.failure(s.teamVerifyArgs(s.proposal, "not-a-time", filepath.Join(s.work, "bad-proposal-time.json"), []string{s.captainConsent, s.teammateConsent}, validTimes)...) + s.failure(s.teamVerifyArgs(filepath.Join(s.work, "rejected-proposal-identity.json"), s.issuedAt(s.proposal), filepath.Join(s.work, "bad-proposal.json"), []string{s.captainConsent, s.teammateConsent}, validTimes)...) + s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "duplicate-consent.json"), []string{s.captainConsent, s.captainConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "999=" + s.issuedAt(s.captainConsent)})...) + s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "missing-consent-time.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "999=" + s.issuedAt(s.captainConsent)})...) + s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "bad-consent-time.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=not-a-time", "200=" + s.issuedAt(s.teammateConsent)})...) + + consentCases := map[string]func(map[string]any){ + "identity": func(value map[string]any) { + value["v"] = 1 + }, + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "A" + }, + "window": func(value map[string]any) { + value["expires_at"] = value["issued_at"] + }, + "signer": func(value map[string]any) { + value["key_epoch"] = 2 + }, + "signature-metadata": func(value map[string]any) { + value["signature"].(map[string]any)["alg"] = "wrong" + }, + "signature-encoding": func(value map[string]any) { + value["signature"].(map[string]any)["value"] = "!" + }, + } + for name, mutate := range consentCases { + path := filepath.Join(s.work, "rejected-consent-"+name+".json") + value := s.copyObject(s.captainConsent) + mutate(value) + s.writeJSON(path, value) + s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "rejected-consent-"+name+".verified.json"), []string{path, s.teammateConsent}, validTimes)...) + } + + prepareOutput := filepath.Join(s.work, "existing-submission.json") + s.Require().NoError(os.WriteFile(prepareOutput, []byte("already exists"), 0o600)) + s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", s.metadata, "--team-id", s.teamID, "--attempt-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", prepareOutput) + s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", "not-a-uuid", "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "bad-submission-identity.json")) + + submissionCases := map[string]func(map[string]any){ + "identity": func(value map[string]any) { + value["v"] = 1 + }, + "invalid-reference": func(value map[string]any) { + value["event"].(map[string]any)["event_id"] = "A" + }, + "window": func(value map[string]any) { + value["expires_at"] = value["issued_at"] + }, + "signer": func(value map[string]any) { + value["key_epoch"] = 2 + }, + "signature": func(value map[string]any) { + value["signature"].(map[string]any)["alg"] = "wrong" + }, + } + for name, mutate := range submissionCases { + path := filepath.Join(s.work, "rejected-submission-"+name+".json") + value := s.copyObject(s.submission) + mutate(value) + s.writeJSON(path, value) + s.failure(s.submissionVerifyArgs(path, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "rejected-submission-"+name+".verified.json"))...) + } + s.failure(s.submissionVerifyArgs(s.submission, s.bundle, s.metadata, "100", "not-a-time", filepath.Join(s.work, "bad-submission-time.verified.json"))...) + s.failure(s.submissionVerifyArgs(s.submission, s.bundle, "", "100", s.issuedAt(s.submission), filepath.Join(s.work, "missing-submission-metadata.verified.json"))...) +} + +func (s *eventSuite) TestSubmissionVerification() { + verified := filepath.Join(s.work, "submission-verified.json") + s.success("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", verified) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "200", "--source-time", s.issuedAt(s.submission), "--output", filepath.Join(s.work, "wrong-submitter.json")) + tampered := filepath.Join(s.work, "tampered-bundle.zip") + s.Require().NoError(os.WriteFile(tampered, []byte("tampered"), 0o600)) + s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", tampered, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", filepath.Join(s.work, "tampered-submission.json")) +} + +func (s *eventSuite) TestSignedEncryptedStream() { + for _, member := range []keyResult{s.captain, s.teammate} { + plain := filepath.Join(s.work, member.SigningKeyID+".log") + s.success("decverify", "--input", s.stream, "--output", plain, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", member.RecipientPrivate, "--passphrase-file", s.passphrase) + data, err := os.ReadFile(plain) + s.Require().NoError(err) + s.Equal([]byte("private judge output\n"), data) + } + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "outsider.log"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.outsider.RecipientPrivate, "--passphrase-file", s.passphrase) + wrongContext := filepath.Join(s.work, "wrong-stream-binding.json") + wrong := s.readObject(s.streamBinding) + wrong["artifact_id"] = "different-run" + s.writeJSON(wrongContext, wrong) + s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "wrong-context.log"), "--context", wrongContext, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) +} + +func (s *eventSuite) TestStreamEnvelopeRejections() { + badBinding := filepath.Join(s.work, "bad-stream-binding.json") + value := s.copyObject(s.streamBinding) + value["purpose"] = "" + s.writeJSON(badBinding, value) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-binding.eventctl"), "--context", badBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) + + invalidMagic := filepath.Join(s.work, "invalid-magic.eventctl") + s.Require().NoError(os.WriteFile(invalidMagic, []byte("not an eventctl stream"), 0o600)) + s.failure(s.decverifyArgs(invalidMagic, filepath.Join(s.work, "invalid-magic.log"))...) + + invalidSize := filepath.Join(s.work, "invalid-size.eventctl") + s.writeRawStream(invalidSize, uint32((64<<20)+1), nil, nil) + s.failure(s.decverifyArgs(invalidSize, filepath.Join(s.work, "invalid-size.log"))...) + + invalidHeader := filepath.Join(s.work, "invalid-header.eventctl") + s.writeRawStream(invalidHeader, 1, []byte("{"), nil) + s.failure(s.decverifyArgs(invalidHeader, filepath.Join(s.work, "invalid-header.log"))...) + + invalidSignature := filepath.Join(s.work, "invalid-signature.eventctl") + s.mutateStream(invalidSignature, func(header map[string]any) { + header["signature"].(map[string]any)["value"] = "!" + }, func(payload []byte) []byte { return payload }) + s.failure(s.decverifyArgs(invalidSignature, filepath.Join(s.work, "invalid-signature.log"))...) + + invalidCiphertext := filepath.Join(s.work, "invalid-stream-ciphertext.eventctl") + s.mutateStream(invalidCiphertext, func(map[string]any) {}, func([]byte) []byte { return []byte("not an age payload") }) + s.failure(s.decverifyArgs(invalidCiphertext, filepath.Join(s.work, "invalid-stream-ciphertext.log"))...) + + tamperedCiphertext := filepath.Join(s.work, "tampered-stream-ciphertext.eventctl") + s.mutateStream(tamperedCiphertext, func(map[string]any) {}, func(payload []byte) []byte { + copy := append([]byte(nil), payload...) + copy[len(copy)-1] ^= 1 + return copy + }) + s.failure(s.decverifyArgs(tamperedCiphertext, filepath.Join(s.work, "tampered-stream-ciphertext.log"))...) + + wrongSigner := filepath.Join(s.work, "wrong-header-signer.eventctl") + s.writeSignedStream(wrongSigner, func(header *streamHeader) { + header.Signer.KeyID = strings.Repeat("b", 64) + }) + s.failure(s.decverifyArgs(wrongSigner, filepath.Join(s.work, "wrong-header-signer.log"))...) + + wrongSize := filepath.Join(s.work, "wrong-header-size.eventctl") + s.writeSignedStream(wrongSize, func(header *streamHeader) { + header.PayloadSize++ + }) + s.failure(s.decverifyArgs(wrongSize, filepath.Join(s.work, "wrong-header-size.log"))...) + + wrongDigest := filepath.Join(s.work, "wrong-header-digest.eventctl") + s.writeSignedStream(wrongDigest, func(header *streamHeader) { + header.PayloadSHA256 = strings.Repeat("b", 64) + }) + s.failure(s.decverifyArgs(wrongDigest, filepath.Join(s.work, "wrong-header-digest.log"))...) + s.failure(s.decverifyArgs(s.stream, s.stream)...) + +} + +func (s *eventSuite) TestInputFailures() { + s.failure("key-gen", "--out", filepath.Join(s.work, "no-passphrase")) + empty := filepath.Join(s.work, "empty-passphrase.txt") + s.Require().NoError(os.WriteFile(empty, nil, 0o600)) + s.failure("key-gen", "--out", filepath.Join(s.work, "empty-passphrase"), "--passphrase-file", empty) + s.failure("identity", "register", "--event", filepath.Join(s.work, "missing-event.json"), "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "missing-event-registration.json")) + s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent-source-time", "wrong", "--output", filepath.Join(s.work, "bad-consent-time.json")) + s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "no-recipients.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase) +} + +func (s *eventSuite) keyGen(name string) keyResult { + directory := filepath.Join(s.work, name) + result := s.success("key-gen", "--out", directory, "--passphrase-file", s.passphrase) + var value keyResult + s.Require().NoError(json.Unmarshal(result.Result, &value)) + return value +} + +func (s *eventSuite) register(actorID, registrationID string, key keyResult) string { + request := filepath.Join(s.work, actorID+".registration.json") + s.success("identity", "register", "--event", s.event, "--actor-id", actorID, "--registration-id", registrationID, "--sig-private-key", key.SigningPrivate, "--recipient-public-key", key.RecipientPublic, "--passphrase-file", s.passphrase, "--output", request) + return request +} + +func (s *eventSuite) verifiedIdentities(registrations ...string) []any { + identities := make([]any, 0, len(registrations)) + for _, registration := range registrations { + verified := filepath.Join(s.work, filepath.Base(registration)+".verified.json") + actorID := s.readObject(registration)["actor_id"].(string) + s.success("identity", "verify", "--event", s.event, "--input", registration, "--expect-actor-id", actorID, "--source-time", s.issuedAt(registration), "--output", verified) + identities = append(identities, s.readObject(verified)) + } + return identities +} + +func (s *eventSuite) writeRegistry(path, phase string, identities, teams, attempts []any) { + s.writeJSON(path, map[string]any{"v": 2, "kind": "event-registry", "event": s.readObject(s.captainReg)["event"], "revision": 0, "phase": phase, "enabled": true, "disabled_reason": "", "identities": identities, "teams": teams, "attempts": attempts}) +} + +func (s *eventSuite) issuedAt(path string) string { return s.readObject(path)["issued_at"].(string) } + +func (s *eventSuite) readObject(path string) map[string]any { + data := s.readBytes(path) + var value map[string]any + s.Require().NoError(json.Unmarshal(data, &value)) + return value +} + +func (s *eventSuite) copyObject(path string) map[string]any { + return s.copyValue(s.readObject(path)) +} + +func (s *eventSuite) copyValue(value map[string]any) map[string]any { + data, err := json.Marshal(value) + s.Require().NoError(err) + var copy map[string]any + s.Require().NoError(json.Unmarshal(data, ©)) + return copy +} + +func (s *eventSuite) readBytes(path string) []byte { + data, err := os.ReadFile(path) + s.Require().NoError(err) + return data +} + +func (s *eventSuite) success(args ...string) response { return s.invoke(true, nil, args...) } + +func (s *eventSuite) successWithEnv(values map[string]string, args ...string) response { + return s.invoke(true, values, args...) +} + +func (s *eventSuite) failure(args ...string) response { return s.invoke(false, nil, args...) } + +func (s *eventSuite) failureWithEnv(values map[string]string, args ...string) response { + return s.invoke(false, values, args...) +} + +func (s *eventSuite) invoke(wantSuccess bool, values map[string]string, args ...string) response { + command := exec.Command(s.binary, args...) + command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + for key, value := range values { + command.Env = append(command.Env, key+"="+value) + } + output, err := command.CombinedOutput() + if wantSuccess { + s.Require().NoError(err, "%s\n%s", strings.Join(args, " "), output) + } else { + s.Require().Error(err, "%s unexpectedly succeeded\n%s", strings.Join(args, " "), output) + } + var value response + s.Require().NoError(json.Unmarshal(bytes.TrimSpace(output), &value), "%s", output) + s.Equal(wantSuccess, value.OK, "%s", output) + if !wantSuccess { + s.NotEmpty(value.Error) + } + return value +} + +func (s *eventSuite) raw(expectedExit int, args ...string) []byte { + command := exec.Command(s.binary, args...) + command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + output, err := command.CombinedOutput() + if expectedExit == 0 { + s.Require().NoError(err, "%s\n%s", strings.Join(args, " "), output) + } else { + s.Require().Error(err) + } + return output +} + +func (s *eventSuite) writeJSON(path string, value any) { + data, err := json.Marshal(value) + s.Require().NoError(err) + s.Require().NoError(os.WriteFile(path, append(data, '\n'), 0o600)) +} + +func (s *eventSuite) writePassphraseEncrypted(path string, data []byte) { + passphrase := strings.TrimSpace(string(s.readBytes(s.passphrase))) + recipient, err := age.NewScryptRecipient(passphrase) + s.Require().NoError(err) + var encrypted bytes.Buffer + writer, err := age.Encrypt(&encrypted, recipient) + s.Require().NoError(err) + _, err = writer.Write(data) + s.Require().NoError(err) + s.Require().NoError(writer.Close()) + s.Require().NoError(os.WriteFile(path, encrypted.Bytes(), 0o600)) +} + +func (s *eventSuite) identityVerifyArgs(input, actorID, sourceTime, output string) []string { + return []string{"identity", "verify", "--event", s.event, "--input", input, "--expect-actor-id", actorID, "--source-time", sourceTime, "--output", output} +} + +func (s *eventSuite) teamProposeArgs(registry, teamID, actorID, signingPath, output string, members ...string) []string { + args := []string{"team", "propose", "--event", s.event, "--registry", registry} + if teamID != "" { + args = append(args, "--team-id", teamID) + } + args = append(args, "--actor-id", actorID) + for _, member := range members { + args = append(args, "--member", member) + } + return append(args, "--sig-private-key", signingPath, "--passphrase-file", s.passphrase, "--output", output) +} + +func (s *eventSuite) teamConsentArgs(registry, proposal, actorID, signingPath, output string) []string { + return []string{"team", "consent", "--event", s.event, "--registry", registry, "--proposal", proposal, "--actor-id", actorID, "--sig-private-key", signingPath, "--passphrase-file", s.passphrase, "--output", output} +} + +func (s *eventSuite) teamVerifyArgs(proposal, proposalTime, output string, consents, consentTimes []string) []string { + return s.teamVerifyArgsWithRegistry(s.registry, proposal, proposalTime, output, consents, consentTimes) +} + +func (s *eventSuite) teamVerifyArgsWithRegistry(registry, proposal, proposalTime, output string, consents, consentTimes []string) []string { + args := []string{"team", "verify", "--event", s.event, "--registry", registry, "--proposal", proposal, "--proposal-source-time", proposalTime} + for _, consent := range consents { + args = append(args, "--consent", consent) + } + for _, consentTime := range consentTimes { + args = append(args, "--consent-source-time", consentTime) + } + return append(args, "--output", output) +} + +func (s *eventSuite) submissionVerifyArgs(request, bundle, metadata, actorID, sourceTime, output string) []string { + return s.submissionVerifyArgsWithRegistry(s.submissionRegistry, request, bundle, metadata, actorID, sourceTime, output) +} + +func (s *eventSuite) submissionVerifyArgsWithRegistry(registry, request, bundle, metadata, actorID, sourceTime, output string) []string { + args := []string{"submission", "verify", "--event", s.event, "--registry", registry, "--request", request, "--bundle", bundle} + if metadata != "" { + args = append(args, "--metadata", metadata) + } + return append(args, "--expect-actor-id", actorID, "--source-time", sourceTime, "--output", output) +} + +func (s *eventSuite) attemptRecord(attemptID string) map[string]any { + return map[string]any{"attempt_id": attemptID, "team_id": s.teamID, "actor_id": "100", "submission_sha256": strings.Repeat("a", 64), "payload_sha256": strings.Repeat("b", 64)} +} + +func (s *eventSuite) decverifyArgs(input, output string) []string { + return []string{"decverify", "--input", input, "--output", output, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase} +} + +func (s *eventSuite) writeRawStream(path string, headerSize uint32, header, payload []byte) { + data := make([]byte, 12+len(header)+len(payload)) + copy(data, []byte{'E', 'V', 'T', 'C', 'T', 'L', 1, 0}) + binary.BigEndian.PutUint32(data[8:12], headerSize) + copy(data[12:], header) + copy(data[12+len(header):], payload) + s.Require().NoError(os.WriteFile(path, data, 0o600)) +} + +func (s *eventSuite) mutateStream(path string, mutateHeader func(map[string]any), mutatePayload func([]byte) []byte) { + data := s.readBytes(s.stream) + headerSize := binary.BigEndian.Uint32(data[8:12]) + headerEnd := 12 + int(headerSize) + var header map[string]any + s.Require().NoError(json.Unmarshal(data[12:headerEnd], &header)) + mutateHeader(header) + headerData, err := json.Marshal(header) + s.Require().NoError(err) + s.writeRawStream(path, uint32(len(headerData)), headerData, mutatePayload(data[headerEnd:])) +} + +func (s *eventSuite) writeSignedStream(path string, mutate func(*streamHeader)) { + data := s.readBytes(s.stream) + headerSize := binary.BigEndian.Uint32(data[8:12]) + headerEnd := 12 + int(headerSize) + var header streamHeader + s.Require().NoError(json.Unmarshal(data[12:headerEnd], &header)) + mutate(&header) + unsigned := header + unsigned.Signature = streamSignature{} + unsignedData, err := json.Marshal(unsigned) + s.Require().NoError(err) + message := append([]byte("eventctl:eventctl/v2:stream.sigcrypt\x00"), append(unsignedData, '\n')...) + private := s.readSigningPrivate() + header.Signature = streamSignature{Algorithm: "Ed25519", KeyID: s.captain.SigningKeyID, Value: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, message))} + headerData, err := json.Marshal(header) + s.Require().NoError(err) + s.writeRawStream(path, uint32(len(headerData)), headerData, data[headerEnd:]) +} + +func (s *eventSuite) readSigningPrivate() ed25519.PrivateKey { + identity, err := age.NewScryptIdentity(strings.TrimSpace(string(s.readBytes(s.passphrase)))) + s.Require().NoError(err) + reader, err := age.Decrypt(bytes.NewReader(s.readBytes(s.captain.SigningPrivate)), identity) + s.Require().NoError(err) + plain, err := io.ReadAll(reader) + s.Require().NoError(err) + var value struct { + PrivateKey string `json:"private"` + } + s.Require().NoError(json.Unmarshal(plain, &value)) + private, err := base64.RawURLEncoding.DecodeString(value.PrivateKey) + s.Require().NoError(err) + return ed25519.PrivateKey(private) +}