From d7a0088c1a143498c1a927e214b66cee07bd78bf Mon Sep 17 00:00:00 2001 From: Alex Au Date: Wed, 5 Aug 2026 03:20:20 +0800 Subject: [PATCH 1/5] fix: support runner ShellCheck cleanup analysis --- scripts/release/finalize-release.sh | 1 + scripts/release/publish-draft.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/release/finalize-release.sh b/scripts/release/finalize-release.sh index 8e36597..8469783 100755 --- a/scripts/release/finalize-release.sh +++ b/scripts/release/finalize-release.sh @@ -44,6 +44,7 @@ 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 diff --git a/scripts/release/publish-draft.sh b/scripts/release/publish-draft.sh index c870d2b..38088b8 100755 --- a/scripts/release/publish-draft.sh +++ b/scripts/release/publish-draft.sh @@ -73,6 +73,7 @@ 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 From 864e14a8552d69f5399d4fca07ea815ae3684da2 Mon Sep 17 00:00:00 2001 From: Alex Au Date: Wed, 5 Aug 2026 03:53:50 +0800 Subject: [PATCH 2/5] chore: add independent release code owners --- .github/CODEOWNERS | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 92950f3..c9386ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,9 @@ -* @alex-au-922 +* @alex-au-922 @DebugTsang @sammyfung -/.github/ @alex-au-922 -/.goreleaser.yaml @alex-au-922 -/cmd/ @alex-au-922 -/internal/ @alex-au-922 -/scripts/release/ @alex-au-922 -/go.mod @alex-au-922 -/go.sum @alex-au-922 +/.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 From f23288efefeb10f00fc574f6d6b440238ade0e4a Mon Sep 17 00:00:00 2001 From: Alex Au Date: Wed, 5 Aug 2026 04:49:50 +0800 Subject: [PATCH 3/5] harden release tool and lock verification --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 8 +- docs/release.md | 79 ++++++++++----- scripts/release/install-shellcheck.sh | 107 +++++++++++++++++++++ scripts/release/test-release-boundaries.sh | 41 +++++++- scripts/release/tool-versions.env | 2 + 6 files changed, 219 insertions(+), 27 deletions(-) create mode 100755 scripts/release/install-shellcheck.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0410811..e02fb4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: 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: @@ -75,7 +80,9 @@ jobs: run: go run golang.org/x/vuln/cmd/govulncheck@${{ steps.tools.outputs.govulncheck }} ./... - name: ShellCheck - run: find scripts -type f -name '*.sh' -print0 | xargs -0 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49cfc3c..570c9d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,11 @@ jobs: 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: @@ -74,7 +79,8 @@ jobs: - name: Run complete Bash and workflow gates run: | - find scripts -type f -name '*.sh' -print0 | xargs -0 shellcheck + 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 diff --git a/docs/release.md b/docs/release.md index 6dd5def..9a60d8f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -56,12 +56,20 @@ state and are not supplied by the source tree. 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 a tag ruleset for `v*` that restricts creation to release maintainers - and blocks update and deletion. Published immutable releases also lock their +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. + `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. @@ -69,7 +77,7 @@ state and are not supplied by the source tree. available before the first public release. Before approving the first `release` environment deployment, an administrator -must read back the immutable-release setting and the tag ruleset from GitHub. +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. @@ -138,59 +146,80 @@ requires GitHub CLI 2.93.0 or newer. Versions through 2.92.0 are affected by and must not be used for release or attestation verification. ```bash -tag=v1.2.3 -version=${tag#v} -asset="eventctl_${version}_darwin_arm64.tar.gz" -# Read this full 40- or 64-character value from the reviewed event lock. -source_digest="$EVENTCTL_SOURCE_DIGEST" -signer_digest="$EVENTCTL_SIGNER_DIGEST" -# Read the exact UTC source commit date from the same reviewed lock. -source_date="$EVENTCTL_SOURCE_DATE" +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 pythonhk/eventctl +gh release verify "$tag" --repo "$repository" gh release download "$tag" \ - --repo pythonhk/eventctl \ + --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 pythonhk/eventctl -gh release verify-asset "$tag" SHA256SUMS --repo pythonhk/eventctl +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 pythonhk/eventctl \ + --repo "$repository" \ --deny-self-hosted-runners \ --signer-digest "$signer_digest" \ - --signer-workflow pythonhk/eventctl/.github/workflows/release.yml \ - --source-ref "refs/tags/$tag" \ + --signer-workflow "$signer_workflow" \ + --source-ref "$source_ref" \ --source-digest "$source_digest" \ - --predicate-type https://slsa.dev/provenance/v1 + --predicate-type "$provenance_predicate" gh attestation verify "$asset" \ - --repo pythonhk/eventctl \ + --repo "$repository" \ --deny-self-hosted-runners \ --signer-digest "$signer_digest" \ - --signer-workflow pythonhk/eventctl/.github/workflows/release.yml \ - --source-ref "refs/tags/$tag" \ + --signer-workflow "$signer_workflow" \ + --source-ref "$source_ref" \ --source-digest "$source_digest" \ - --predicate-type https://spdx.dev/Document/v2.3 + --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) @@ -201,6 +230,8 @@ 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" \ diff --git a/scripts/release/install-shellcheck.sh b/scripts/release/install-shellcheck.sh new file mode 100755 index 0000000..85bc2a4 --- /dev/null +++ b/scripts/release/install-shellcheck.sh @@ -0,0 +1,107 @@ +#!/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/test-release-boundaries.sh b/scripts/release/test-release-boundaries.sh index a81fd9c..8321303 100755 --- a/scripts/release/test-release-boundaries.sh +++ b/scripts/release/test-release-boundaries.sh @@ -52,6 +52,16 @@ assert_contains() { 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 @@ -410,6 +420,8 @@ test_finalization_boundaries() { 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" @@ -432,7 +444,26 @@ test_release_workflow_boundaries() { 'FUZZ_TIME: 10s' "$build_job" assert_contains "tag build reruns the fuzz harness" \ 'scripts/release/fuzz-smoke.sh' "$build_job" - assert_contains "tag build reruns ShellCheck" \ + 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" @@ -448,6 +479,14 @@ test_release_workflow_boundaries() { '${{ 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" \ diff --git a/scripts/release/tool-versions.env b/scripts/release/tool-versions.env index f26752d..69de108 100644 --- a/scripts/release/tool-versions.env +++ b/scripts/release/tool-versions.env @@ -6,6 +6,8 @@ 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 From c32e0a389f7f0177e3567872f510ec7111c5c650 Mon Sep 17 00:00:00 2001 From: Alex Au Date: Wed, 5 Aug 2026 05:36:07 +0800 Subject: [PATCH 4/5] fix: enforce configured request validity windows Separate team protocol validity from generic envelopes, enforce signed-config TTLs across registration and submission paths, and grant published verifiers attestation read access. --- .github/workflows/release.yml | 2 + cmd/eventctl/identity.go | 8 ++- cmd/eventctl/submission.go | 12 ++-- cmd/eventctl/submission_public_verify.go | 2 +- cmd/eventctl/team.go | 15 ++-- internal/bundle/bundle_test.go | 44 ++++++++++++ internal/bundle/fuzz_test.go | 2 + internal/bundle/pack.go | 2 +- internal/bundle/types.go | 13 +++- internal/bundle/validate.go | 8 +-- internal/config/config.go | 3 +- internal/config/config_test.go | 18 +++++ internal/envelope/envelope.go | 49 ++++++++++--- internal/envelope/envelope_test.go | 53 ++++++++++++-- internal/envelope/submission.go | 14 ++-- internal/envelope/submission_test.go | 15 +++- internal/team/team.go | 64 ++++++++++------- internal/team/team_test.go | 84 ++++++++++++++++++++-- scripts/release/test-release-boundaries.sh | 8 +++ 19 files changed, 339 insertions(+), 77 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 570c9d1..3cb332b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -274,6 +274,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: + attestations: read contents: read steps: - name: Check out verification scripts @@ -328,6 +329,7 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 15 permissions: + attestations: read contents: read steps: - name: Check out verification scripts diff --git a/cmd/eventctl/identity.go b/cmd/eventctl/identity.go index f783a81..e643fa4 100644 --- a/cmd/eventctl/identity.go +++ b/cmd/eventctl/identity.go @@ -64,11 +64,12 @@ func identityRegister(args []string, stderr io.Writer) (any, error) { return nil, verificationError("decrypt participant key", err) } issued := time.Now().UTC().Truncate(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(time.Duration(event.Registration.RequestTTLSeconds) * time.Second)}, pair.Private) + 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}) + 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) } @@ -109,7 +110,8 @@ func identityVerify(args []string) (any, error) { if err != nil { return nil, ioError("read registration request", err) } - verified, err := envelope.VerifyRegistration(raw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: sourceTime}) + 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) } diff --git a/cmd/eventctl/submission.go b/cmd/eventctl/submission.go index 3417865..6bde7bf 100644 --- a/cmd/eventctl/submission.go +++ b/cmd/eventctl/submission.go @@ -249,12 +249,13 @@ func submissionPrepare(args []string, stderr io.Writer) (any, error) { 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(time.Duration(event.Submissions.EnvelopeTTLSeconds) * time.Second), Bundle: reference}, pair.Private) + 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}, registry) + 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) } @@ -299,7 +300,8 @@ func submissionVerifyRequest(args []string) (any, error) { if err != nil { return nil, ioError("read submission request", err) } - verified, err := envelope.VerifySubmission(requestRaw, envelope.Expected{EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *actorID, ConfigDigest: digest, Now: sourceTime}, registry) + 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: digest, Now: sourceTime}, envelopeTTL, registry) if err != nil { return nil, verificationError("verify submission request", err) } @@ -412,7 +414,7 @@ func submissionDecryptVerify(args []string, stderr io.Writer) (any, error) { verifiedRequest, err := envelope.VerifySubmission(requestRaw, envelope.Expected{ EventID: event.EventID, EventEpoch: event.EventEpoch, RepositoryID: event.BaseRepository.ID, ActorID: *expectActorID, ConfigDigest: digest, Now: time.Time{}, - }, registry) + }, time.Duration(event.Submissions.EnvelopeTTLSeconds)*time.Second, registry) if err != nil { return nil, verificationError("verify signed submission request", err) } @@ -522,7 +524,7 @@ func submissionDecryptVerify(args []string, stderr io.Writer) (any, error) { } 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} + 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 diff --git a/cmd/eventctl/submission_public_verify.go b/cmd/eventctl/submission_public_verify.go index bc0411c..0fb7199 100644 --- a/cmd/eventctl/submission_public_verify.go +++ b/cmd/eventctl/submission_public_verify.go @@ -77,7 +77,7 @@ func submissionVerifyPublic(args []string) (any, error) { 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.ValidateWindow(e.IssuedAt, e.ExpiresAt, time.Now().UTC()); err != 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} diff --git a/cmd/eventctl/team.go b/cmd/eventctl/team.go index 6aa4dce..33bb14a 100644 --- a/cmd/eventctl/team.go +++ b/cmd/eventctl/team.go @@ -80,11 +80,12 @@ func teamPropose(args []string, stderr io.Writer) (any, error) { return nil, invalidError("team size is outside configured bounds", nil) } issued := time.Now().UTC().Truncate(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(time.Duration(event.Teams.ProposalTTLSeconds) * time.Second)}, pair.Private) + 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}, registry) + 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) } @@ -137,11 +138,12 @@ func teamConsent(args []string, stderr io.Writer) (any, error) { return nil, ioError("read team proposal", err) } issued := time.Now().UTC().Truncate(time.Second) - raw, err := team.NewConsent(proposalRaw, team.ConsentParams{OperationID: *requestID, ActorID: *actorID, KeyEpoch: keyEpoch, IssuedAt: issued, ExpiresAt: issued.Add(time.Duration(event.Teams.ProposalTTLSeconds) * time.Second)}, pair.Private, registry) + 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}, registry) + 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) } @@ -192,10 +194,11 @@ func teamVerify(args []string) (any, error) { 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, registry) + verified, verifyErr := team.VerifyProposal(requestRaw, expected, proposalTTL, registry) if verifyErr != nil { return nil, verificationError("verify team proposal", verifyErr) } @@ -209,7 +212,7 @@ func teamVerify(args []string) (any, error) { if loadErr != nil { return nil, verificationError("load verified team proposal", loadErr) } - verified, verifyErr := team.VerifyConsent(proposalRaw, requestRaw, expected, registry) + verified, verifyErr := team.VerifyConsent(proposalRaw, requestRaw, expected, proposalTTL, registry) if verifyErr != nil { return nil, verificationError("verify team consent", verifyErr) } diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index ea8f8ac..369efe2 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -16,11 +16,21 @@ import ( "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() @@ -312,6 +322,40 @@ func TestAuthenticatePublicVerifiesOuterSignatureBeforeCiphertext(t *testing.T) } } +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() diff --git a/internal/bundle/fuzz_test.go b/internal/bundle/fuzz_test.go index cf20ba8..5f78ded 100644 --- a/internal/bundle/fuzz_test.go +++ b/internal/bundle/fuzz_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" "filippo.io/age" ) @@ -143,5 +144,6 @@ func fuzzLimits() Limits { MaxPlaintextBytes: 48 * 1024, MaxRecipients: 4, MaxTotalFileBytes: 48 * 1024, + MaxValidity: 24 * time.Hour, } } diff --git a/internal/bundle/pack.go b/internal/bundle/pack.go index 128d73a..eb7389b 100644 --- a/internal/bundle/pack.go +++ b/internal/bundle/pack.go @@ -61,7 +61,7 @@ func PackDirectory(ctx context.Context, options PackOptions) (Packed, error) { if options.Binding.KeyID != signerKeyID { return Packed{}, fmt.Errorf("signing key ID does not match binding key_id") } - if err := validateBinding(options.Binding); err != nil { + if err := validateBinding(options.Binding, limits.MaxValidity); err != nil { return Packed{}, err } if options.SourceDir == "" || options.OutputPath == "" { diff --git a/internal/bundle/types.go b/internal/bundle/types.go index 931ff96..0869e08 100644 --- a/internal/bundle/types.go +++ b/internal/bundle/types.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "math" + "time" protocolenvelope "github.com/pythonhk/eventctl/internal/envelope" ) @@ -66,6 +67,7 @@ type Limits struct { MaxPlaintextBytes uint64 MaxRecipients uint32 MaxTotalFileBytes uint64 + MaxValidity time.Duration } // DefaultLimits returns the v1 hard processing limits. @@ -79,6 +81,7 @@ func DefaultLimits() Limits { MaxPlaintextBytes: MaxPlaintextBytesV1, MaxRecipients: 32, MaxTotalFileBytes: MaxPlaintextBytesV1, + MaxValidity: protocolenvelope.MaxGenericValidity, } } @@ -86,26 +89,30 @@ 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.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) } - hard := DefaultLimits() 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.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 || diff --git a/internal/bundle/validate.go b/internal/bundle/validate.go index 240bfa4..7715e20 100644 --- a/internal/bundle/validate.go +++ b/internal/bundle/validate.go @@ -40,7 +40,7 @@ func unmarshalCanonical(data []byte, value any) error { return nil } -func validateBinding(binding Binding) error { +func validateBinding(binding Binding, maximumValidity time.Duration) error { if !protocolenvelope.IsEventID(binding.EventID) { return fmt.Errorf("%w: event_id is invalid", ErrInvalidFormat) } @@ -71,7 +71,7 @@ func validateBinding(binding Binding) error { if err := identity.ValidateDecimal(binding.RecipientEpoch, "recipient_epoch"); err != nil { return fmt.Errorf("%w: %v", ErrInvalidFormat, err) } - if err := protocolenvelope.ValidateWindow(binding.IssuedAt, binding.ExpiresAt, time.Time{}); err != nil { + 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 @@ -93,7 +93,7 @@ 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)); err != nil { + if err := validateBinding(bindingFromManifest(manifest), limits.MaxValidity); err != nil { return err } if len(manifest.Files) == 0 { @@ -136,7 +136,7 @@ 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)); err != nil { + if err := validateBinding(bindingFromEnvelope(envelope), limits.MaxValidity); err != nil { return err } if envelope.Encryption != EncryptionAlgorithm || envelope.SignatureAlgorithm != identity.Algorithm { diff --git a/internal/config/config.go b/internal/config/config.go index d9f530c..bb9416d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ import ( "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" ) @@ -456,7 +457,7 @@ func validateRegistration(v Registration) error { 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 < 300 || v.ProposalTTLSeconds > 1209600 || v.MembershipLockPhase != "submissions_open" { + 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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8ececc5..b2cadbc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "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 { @@ -158,6 +159,23 @@ func TestProposalAndSubmissionCapacityFieldBounds(t *testing.T) { } } +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 diff --git a/internal/envelope/envelope.go b/internal/envelope/envelope.go index e41eb1f..6d98911 100644 --- a/internal/envelope/envelope.go +++ b/internal/envelope/envelope.go @@ -24,6 +24,7 @@ const ( 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" @@ -140,7 +141,7 @@ type VerifiedRegistration struct { // 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{}); err != nil { + if err := validateRegistration(value, time.Time{}, MaxGenericValidity); err != nil { return err } if value.ParticipantKey.KeyID != value.KeyID { @@ -190,7 +191,7 @@ func NewRegistration(params RegistrationParams, private identity.Private) ([]byt TermsDigest: params.TermsDigest, IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), ParticipantKey: pair.Public, } - if err := validateRegistration(registration, time.Time{}); err != nil { + if err := validateRegistration(registration, time.Time{}, MaxGenericValidity); err != nil { return nil, err } registration.Signature, err = Sign(RegistrationDomain, registrationUnsignedFrom(registration), pair.Private) @@ -201,8 +202,9 @@ func NewRegistration(params RegistrationParams, private identity.Private) ([]byt } // VerifyRegistration verifies strict structure, trusted context, validity -// window, self-signature, and epoch-1 proof of possession. -func VerifyRegistration(raw []byte, expected Expected) (VerifiedRegistration, error) { +// 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") } @@ -210,7 +212,7 @@ func VerifyRegistration(raw []byte, expected Expected) (VerifiedRegistration, er if err := canonical.StrictUnmarshal(raw, ®istration); err != nil { return VerifiedRegistration{}, fmt.Errorf("decode registration: %w", err) } - if err := validateRegistration(registration, expected.Now); err != nil { + 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 { @@ -382,6 +384,25 @@ func ParseTimestamp(value string) (time.Time, error) { } 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 @@ -393,8 +414,8 @@ func ValidateWindow(issuedText, expiresText string, now time.Time) error { if !expires.After(issued) { return errors.New("expires_at must be after issued_at") } - if expires.Sub(issued) > 24*time.Hour { - return errors.New("validity window exceeds 24 hours") + if expires.Sub(issued) > maximumValidity { + return errors.New(tooLongMessage) } if !now.IsZero() { now = now.UTC() @@ -420,7 +441,7 @@ func formatTime(value time.Time) string { return value.UTC().Truncate(time.Second).Format("2006-01-02T15:04:05Z") } -func validateRegistration(value Registration, now time.Time) error { +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") } @@ -448,7 +469,17 @@ func validateRegistration(value Registration, now time.Time) error { if err := value.ParticipantKey.Validate(); err != nil { return err } - return ValidateWindow(value.IssuedAt, value.ExpiresAt, now) + 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 { diff --git a/internal/envelope/envelope_test.go b/internal/envelope/envelope_test.go index 99ec30e..93a21bc 100644 --- a/internal/envelope/envelope_test.go +++ b/internal/envelope/envelope_test.go @@ -38,7 +38,7 @@ func TestRegistrationRoundTripAndExpectedContext(t *testing.T) { 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) + verified, err := VerifyRegistration(raw, expected, 15*time.Minute) if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestRegistrationRoundTripAndExpectedContext(t *testing.T) { t.Fatal(err) } expected.ActorID = "43" - if _, err := VerifyRegistration(raw, expected); err == nil { + if _, err := VerifyRegistration(raw, expected, 15*time.Minute); err == nil { t.Fatal("accepted wrong trusted actor") } } @@ -62,7 +62,7 @@ func TestRegistrationMutationAndUnknownFieldFail(t *testing.T) { t.Fatal(err) } mutated := bytes.Replace(raw, []byte(`"actor_id":"42"`), []byte(`"actor_id":"43"`), 1) - if _, err := VerifyRegistration(mutated, Expected{}); err == nil { + if _, err := VerifyRegistration(mutated, Expected{}, 15*time.Minute); err == nil { t.Fatal("accepted mutated actor") } var object map[string]any @@ -71,7 +71,7 @@ func TestRegistrationMutationAndUnknownFieldFail(t *testing.T) { } object["unknown"] = true withUnknown, _ := json.Marshal(object) - if _, err := VerifyRegistration(withUnknown, Expected{}); err == nil { + if _, err := VerifyRegistration(withUnknown, Expected{}, 15*time.Minute); err == nil { t.Fatal("accepted unknown field") } } @@ -118,13 +118,52 @@ func TestRegistrationValidityUsesTrustedSourceTime(t *testing.T) { } // 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)}); err != nil { + 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)}); err == nil { + 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)}); err == nil { + 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/submission.go b/internal/envelope/submission.go index b88e1c3..728a862 100644 --- a/internal/envelope/submission.go +++ b/internal/envelope/submission.go @@ -80,7 +80,7 @@ type Submission struct { // 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{}); err != nil { + if err := validateSubmission(value, time.Time{}, MaxGenericValidity); err != nil { return err } if err := value.Signature.ValidateEncoding(); err != nil { @@ -168,7 +168,7 @@ func NewSubmission(params SubmissionParams, private identity.Private) ([]byte, e ConfigDigest: params.ConfigDigest, IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), DeliveryMode: SubmissionDeliveryMode, Bundle: params.Bundle, } - if err := validateSubmission(submission, time.Time{}); err != nil { + if err := validateSubmission(submission, time.Time{}, MaxGenericValidity); err != nil { return nil, err } submission.Signature, err = Sign(SubmissionDomain, unsignedSubmission(submission), pair.Private) @@ -181,7 +181,9 @@ func NewSubmission(params SubmissionParams, private identity.Private) ([]byte, e // 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. -func VerifySubmission(raw []byte, expected Expected, registry identity.Registry) (VerifiedSubmission, error) { +// 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") } @@ -189,7 +191,7 @@ func VerifySubmission(raw []byte, expected Expected, registry identity.Registry) if err := canonical.StrictUnmarshal(raw, &submission); err != nil { return VerifiedSubmission{}, fmt.Errorf("decode submission: %w", err) } - if err := validateSubmission(submission, expected.Now); err != nil { + 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 { @@ -242,7 +244,7 @@ func ParsePRMetadata(raw []byte) (PRMetadata, error) { return metadata, nil } -func validateSubmission(value Submission, now time.Time) error { +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") } @@ -273,7 +275,7 @@ func validateSubmission(value Submission, now time.Time) error { if err := validateBundleReference(value.Bundle); err != nil { return err } - return ValidateWindow(value.IssuedAt, value.ExpiresAt, now) + return validateRequestWindow(value.IssuedAt, value.ExpiresAt, now, envelopeTTL) } func validatePullRequest(value PullRequest) error { diff --git a/internal/envelope/submission_test.go b/internal/envelope/submission_test.go index f1b310e..d95706e 100644 --- a/internal/envelope/submission_test.go +++ b/internal/envelope/submission_test.go @@ -20,7 +20,8 @@ func TestSubmissionBindsActorPRHeadAndBundle(t *testing.T) { 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} - raw, err := NewSubmission(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}, pair.Private) + 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) } @@ -29,7 +30,7 @@ func TestSubmissionBindsActorPRHeadAndBundle(t *testing.T) { 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)}, registry) + 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) } @@ -37,9 +38,17 @@ func TestSubmissionBindsActorPRHeadAndBundle(t *testing.T) { t.Fatal("binding lost") } mutated := bytes.Replace(raw, []byte(metadata.PullRequest.HeadSHA), []byte(strings.Repeat("b", 40)), 1) - if _, err := VerifySubmission(mutated, Expected{}, registry); err == nil { + 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) { diff --git a/internal/team/team.go b/internal/team/team.go index 7c691d7..b13b3cb 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -14,10 +14,14 @@ import ( ) const ( - ProposalKind = "team_proposal" - ProposalDomain = "team_proposal" - ConsentKind = "team_consent" - ConsentDomain = "team_consent" + 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. @@ -83,7 +87,7 @@ type Consent struct { // 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{}); err != nil { + if err := validateProposal(value, time.Time{}, MaxProposalTTL); err != nil { return err } if err := value.Signature.ValidateEncoding(); err != nil { @@ -99,7 +103,7 @@ func (value Proposal) ValidateUntrustedStructure() error { // 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{}); err != nil { + if err := validateConsent(value, time.Time{}, MaxProposalTTL); err != nil { return err } if err := value.Signature.ValidateEncoding(); err != nil { @@ -202,7 +206,7 @@ func NewProposal(params ProposalParams, private identity.Private) ([]byte, error MemberActorIDs: members, BaseRepository: params.BaseRepository, ConfigDigest: params.ConfigDigest, IssuedAt: formatTime(params.IssuedAt), ExpiresAt: formatTime(params.ExpiresAt), } - if err := validateProposal(proposal, time.Time{}); err != nil { + if err := validateProposal(proposal, time.Time{}, MaxProposalTTL); err != nil { return nil, err } proposal.Signature, err = envelope.Sign(ProposalDomain, unsignedProposal(proposal), pair.Private) @@ -212,7 +216,9 @@ func NewProposal(params ProposalParams, private identity.Private) ([]byte, error return canonical.Marshal(proposal) } -func VerifyProposal(raw []byte, expected envelope.Expected, registry identity.Registry) (VerifiedProposal, error) { +// 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") } @@ -220,7 +226,7 @@ func VerifyProposal(raw []byte, expected envelope.Expected, registry identity.Re if err := canonical.StrictUnmarshal(raw, &proposal); err != nil { return VerifiedProposal{}, fmt.Errorf("decode team proposal: %w", err) } - if err := validateProposal(proposal, expected.Now); err != nil { + 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 { @@ -256,7 +262,7 @@ func NewConsent(proposalRaw []byte, params ConsentParams, private identity.Priva if err != nil { return nil, err } - proposal, err := VerifyProposal(proposalRaw, envelope.Expected{}, registry) + proposal, err := VerifyProposal(proposalRaw, envelope.Expected{}, MaxProposalTTL, registry) if err != nil { return nil, fmt.Errorf("verify proposal before consent: %w", err) } @@ -283,7 +289,7 @@ func NewConsent(proposalRaw []byte, params ConsentParams, private identity.Priva if !ok || trusted != pair.Public { return nil, errors.New("consent signing key does not match trusted registration") } - if err := validateConsent(consent, time.Time{}); err != nil { + if err := validateConsent(consent, time.Time{}, MaxProposalTTL); err != nil { return nil, err } consent.Signature, err = envelope.Sign(ConsentDomain, unsignedConsent(consent), pair.Private) @@ -293,8 +299,10 @@ func NewConsent(proposalRaw []byte, params ConsentParams, private identity.Priva return canonical.Marshal(consent) } -func VerifyUnanimous(proposalRaw []byte, consentDocuments [][]byte, expected envelope.Expected, registry identity.Registry) (ActivationCandidate, error) { - proposal, err := VerifyProposal(proposalRaw, expected, registry) +// 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 } @@ -304,7 +312,7 @@ func VerifyUnanimous(proposalRaw []byte, consentDocuments [][]byte, expected env 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, registry) + consent, err := verifyConsent(raw, proposal, expected, proposalTTL, registry) if err != nil { return ActivationCandidate{}, fmt.Errorf("consent %d: %w", index, err) } @@ -327,16 +335,17 @@ func VerifyUnanimous(proposalRaw []byte, consentDocuments [][]byte, expected env } // VerifyConsent verifies one consent against one exact proposal and trusted -// registry. Unanimity remains a protected-state aggregation concern. -func VerifyConsent(proposalRaw, consentRaw []byte, expected envelope.Expected, registry identity.Registry) (VerifiedConsent, error) { - proposal, err := VerifyProposal(proposalRaw, expected, registry) +// 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, registry) + return verifyConsent(consentRaw, proposal, expected, proposalTTL, registry) } -func verifyConsent(raw []byte, proposal VerifiedProposal, expected envelope.Expected, registry identity.Registry) (VerifiedConsent, error) { +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") } @@ -344,7 +353,7 @@ func verifyConsent(raw []byte, proposal VerifiedProposal, expected envelope.Expe if err := canonical.StrictUnmarshal(raw, &consent); err != nil { return VerifiedConsent{}, fmt.Errorf("decode team consent: %w", err) } - if err := validateConsent(consent, expected.Now); err != nil { + 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 { @@ -374,7 +383,7 @@ func verifyConsent(raw []byte, proposal VerifiedProposal, expected envelope.Expe return VerifiedConsent{Document: consent, Fingerprint: fingerprint}, nil } -func validateProposal(value Proposal, now time.Time) error { +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") } @@ -410,10 +419,10 @@ func validateProposal(value Proposal, now time.Time) error { if !contains(value.MemberActorIDs, value.ProposerActorID) { return errors.New("proposer must be a member") } - return envelope.ValidateWindow(value.IssuedAt, value.ExpiresAt, now) + return validateTeamWindow(value.IssuedAt, value.ExpiresAt, now, proposalTTL) } -func validateConsent(value Consent, now time.Time) error { +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") } @@ -435,7 +444,14 @@ func validateConsent(value Consent, now time.Time) error { if err := envelope.ValidateRepository(value.BaseRepository); err != nil { return err } - return envelope.ValidateWindow(value.IssuedAt, value.ExpiresAt, now) + 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 { diff --git a/internal/team/team_test.go b/internal/team/team_test.go index 8568bad..02c9fe7 100644 --- a/internal/team/team_test.go +++ b/internal/team/team_test.go @@ -38,7 +38,7 @@ func TestUnanimousTeamRequiresEveryMemberIncludingProposer(t *testing.T) { if err != nil { t.Fatal(err) } - verified, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, r) + verified, err := VerifyProposal(proposal, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r) if err != nil { t.Fatal(err) } @@ -53,10 +53,10 @@ func TestUnanimousTeamRequiresEveryMemberIncludingProposer(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := VerifyUnanimous(proposal, [][]byte{c10}, envelope.Expected{Now: issued.Add(time.Minute)}, r); err == nil { + 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)}, r) + candidate, err := VerifyUnanimous(proposal, [][]byte{c10, c2}, envelope.Expected{Now: issued.Add(time.Minute)}, 15*time.Minute, r) if err != nil { t.Fatal(err) } @@ -74,7 +74,83 @@ func TestProposalRejectsAttackerKeyForTrustedActor(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := VerifyProposal(proposal, envelope.Expected{}, r); err == nil { + 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/scripts/release/test-release-boundaries.sh b/scripts/release/test-release-boundaries.sh index 8321303..fd4699d 100755 --- a/scripts/release/test-release-boundaries.sh +++ b/scripts/release/test-release-boundaries.sh @@ -425,6 +425,7 @@ test_release_workflow_boundaries() { 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 } @@ -437,6 +438,9 @@ test_release_workflow_boundaries() { 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" @@ -493,6 +497,10 @@ test_release_workflow_boundaries() { '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. From abcf7bb4e40a98aea9c12a10d06982f5a8e38256 Mon Sep 17 00:00:00 2001 From: Alex Au Date: Wed, 5 Aug 2026 10:12:14 +0800 Subject: [PATCH 5/5] fix: bind submission authentication and cancellation receipts --- README.md | 9 +- cmd/eventctl/help.go | 1 + cmd/eventctl/scorer.go | 2 +- cmd/eventctl/submission.go | 76 ++++- cmd/eventctl/submission_authenticate_test.go | 306 +++++++++++++++++++ internal/receipt/receipt.go | 62 +++- internal/receipt/receipt_test.go | 91 +++++- internal/receipt/sign.go | 3 +- 8 files changed, 527 insertions(+), 23 deletions(-) create mode 100644 cmd/eventctl/submission_authenticate_test.go diff --git a/README.md b/README.md index daef8a3..afb0ba0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ eventctl key generate|show|backup eventctl recipient generate|show eventctl identity register|verify eventctl team propose|consent|verify -eventctl submission pack|inspect|verify|prepare|verify-request|decrypt-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 @@ -59,6 +59,13 @@ 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, diff --git a/cmd/eventctl/help.go b/cmd/eventctl/help.go index 16bbb38..eef871b 100644 --- a/cmd/eventctl/help.go +++ b/cmd/eventctl/help.go @@ -127,6 +127,7 @@ var helpGroups = map[string]helpGroup{ {"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]"}, }, diff --git a/cmd/eventctl/scorer.go b/cmd/eventctl/scorer.go index 186722e..4daafa4 100644 --- a/cmd/eventctl/scorer.go +++ b/cmd/eventctl/scorer.go @@ -225,7 +225,7 @@ func loadScorerTrust(configPath, authorityPath, statePath, acceptancePath string 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 != request.SubmissionEnvelopeDigest || !document.StateAfter.Equal(request.Reservation) || document.SourceCreatedAt != request.SourceCreatedAt || document.IssuedAt != request.AcceptedAt || document.ScorerResultDigest != nil || document.ReservationReceiptDigest != nil { + 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 { diff --git a/cmd/eventctl/submission.go b/cmd/eventctl/submission.go index 6bde7bf..37a06ad 100644 --- a/cmd/eventctl/submission.go +++ b/cmd/eventctl/submission.go @@ -24,7 +24,7 @@ import ( func runSubmission(args []string, stderr io.Writer) (any, error) { if len(args) == 0 { - return nil, usageError("usage: eventctl submission pack|inspect|verify|prepare|verify-request|decrypt-verify") + return nil, usageError("usage: eventctl submission pack|inspect|verify|prepare|authenticate-request|verify-request|decrypt-verify") } switch args[0] { case "pack": @@ -35,12 +35,14 @@ func runSubmission(args []string, stderr io.Writer) (any, error) { 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|verify-request|decrypt-verify") + return nil, usageError("usage: eventctl submission pack|inspect|verify|prepare|authenticate-request|verify-request|decrypt-verify") } } @@ -266,6 +268,53 @@ func submissionPrepare(args []string, stderr io.Writer) (any, error) { 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") @@ -300,8 +349,7 @@ func submissionVerifyRequest(args []string) (any, error) { if err != nil { return nil, ioError("read submission request", err) } - 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: digest, Now: sourceTime}, envelopeTTL, registry) + verified, docDigest, err := verifySubmissionAuthenticity(event, digest, *actorID, sourceTime, registry, requestRaw) if err != nil { return nil, verificationError("verify submission request", err) } @@ -330,7 +378,6 @@ func submissionVerifyRequest(args []string) (any, error) { if err := compareConfiguredRecipients(inspection.Envelope, event); err != nil { return nil, verificationError("bundle recipient policy mismatch", err) } - docDigest, _ := envelope.DocumentDigest(verified.Document) 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) @@ -338,6 +385,23 @@ func submissionVerifyRequest(args []string) (any, error) { 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) @@ -426,7 +490,7 @@ func submissionDecryptVerify(args []string, stderr io.Writer) (any, error) { 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 != requestDocumentDigest || acceptance.ActorID != verifiedRequest.Document.ActorID || acceptance.TeamID == nil || *acceptance.TeamID != verifiedRequest.Document.TeamID || acceptance.AttemptID == nil || *acceptance.AttemptID != verifiedRequest.Document.AttemptID { + 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 { diff --git a/cmd/eventctl/submission_authenticate_test.go b/cmd/eventctl/submission_authenticate_test.go new file mode 100644 index 0000000..8385cc8 --- /dev/null +++ b/cmd/eventctl/submission_authenticate_test.go @@ -0,0 +1,306 @@ +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/internal/receipt/receipt.go b/internal/receipt/receipt.go index 524fdb8..9945ad9 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -17,15 +17,23 @@ import ( ) const ( - Kind = "operation_receipt" - Domain = "operation_receipt" - MaxSignedBytes = 1_682 - MaxPaddedBase64Chars = 2_244 - MaxSignedBytesWithLF = MaxSignedBytes + 1 + 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"` @@ -40,7 +48,7 @@ type Receipt struct { RequestKind string `json:"request_kind"` ReplayKey string `json:"replay_key"` RequestDigest string `json:"request_digest"` - RequestDocumentDigest string `json:"request_document_digest"` + RequestDocumentDigest *string `json:"request_document_digest"` ActorID string `json:"actor_id"` TeamID *string `json:"team_id"` AttemptID *string `json:"attempt_id"` @@ -69,7 +77,7 @@ type unsignedReceipt struct { RequestKind string `json:"request_kind"` ReplayKey string `json:"replay_key"` RequestDigest string `json:"request_digest"` - RequestDocumentDigest string `json:"request_document_digest"` + RequestDocumentDigest *string `json:"request_document_digest"` ActorID string `json:"actor_id"` TeamID *string `json:"team_id"` AttemptID *string `json:"attempt_id"` @@ -179,10 +187,14 @@ func (document Receipt) Validate() error { "team_consent": true, envelope.SubmissionKind: true, "scorer_result": true, + CancellationKind: true, } - if !requestKinds[document.RequestKind] || !envelope.IsDigest(document.ReplayKey) || !envelope.IsDigest(document.RequestDigest) || !envelope.IsDigest(document.RequestDocumentDigest) { + 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 } @@ -216,14 +228,29 @@ func (document Receipt) Validate() error { if document.ReservationReceiptDigest != nil && !envelope.IsDigest(*document.ReservationReceiptDigest) { return errors.New("receipt reservation_receipt_digest is invalid") } - if document.RequestKind == "scorer_result" { - if document.TeamID == nil || document.AttemptID == nil || document.QuotaCharged || document.ScorerResultDigest == nil || *document.ScorerResultDigest != document.RequestDocumentDigest || document.ReservationReceiptDigest == nil { + 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") } - } else { + 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") } @@ -261,12 +288,25 @@ func (document Receipt) Validate() error { 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, diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go index cfff137..3e27189 100644 --- a/internal/receipt/receipt_test.go +++ b/internal/receipt/receipt_test.go @@ -28,7 +28,7 @@ func TestSignedReceiptExactTransportBoundary(t *testing.T) { claim.QuotaCharged = false claim.StateBefore.Sequence = statepointer.MaxSequenceV1 - 1 claim.StateAfter.Sequence = statepointer.MaxSequenceV1 - claim.ScorerResultDigest = &claim.RequestDocumentDigest + claim.ScorerResultDigest = claim.RequestDocumentDigest reservationDigest := strings.Repeat("9", 64) claim.ReservationReceiptDigest = &reservationDigest expected := SignExpected{ @@ -276,7 +276,7 @@ func TestCommittedReceiptOperationShapes(t *testing.T) { expected := receiptSignExpected(pair, base.StateAfter) teamID := "22222222-2222-4222-8222-222222222222" attemptID := "33333333-3333-4333-8333-333333333333" - resultDigest := base.RequestDocumentDigest + resultDigest := *base.RequestDocumentDigest reservationDigest := strings.Repeat("9", 64) reasonCode := "judge_timeout" @@ -319,13 +319,16 @@ func TestCommittedReceiptOperationShapes(t *testing.T) { 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}, @@ -333,6 +336,7 @@ func TestCommittedReceiptOperationShapes(t *testing.T) { {"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}, @@ -353,6 +357,86 @@ func TestCommittedReceiptOperationShapes(t *testing.T) { } } +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 @@ -370,6 +454,7 @@ func receiptTestPair(t *testing.T, fill byte) identity.KeyPair { 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", @@ -377,7 +462,7 @@ func receiptTestClaim() CommittedClaim { 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: strings.Repeat("d", 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)}, diff --git a/internal/receipt/sign.go b/internal/receipt/sign.go index b810f27..b7615c3 100644 --- a/internal/receipt/sign.go +++ b/internal/receipt/sign.go @@ -30,7 +30,7 @@ type CommittedClaim struct { RequestKind string `json:"request_kind"` ReplayKey string `json:"replay_key"` RequestDigest string `json:"request_digest"` - RequestDocumentDigest string `json:"request_document_digest"` + RequestDocumentDigest *string `json:"request_document_digest"` ActorID string `json:"actor_id"` TeamID *string `json:"team_id"` AttemptID *string `json:"attempt_id"` @@ -147,6 +147,7 @@ func (claim CommittedClaim) Validate() error { "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")