diff --git a/.github/licenses.tmpl b/.github/licenses.tmpl new file mode 100644 index 000000000..f9e800d3d --- /dev/null +++ b/.github/licenses.tmpl @@ -0,0 +1,13 @@ +# GitHub CLI dependencies + +The following open source dependencies are used to build the [cli/cli][] GitHub CLI. + +## Go Packages + +Some packages may only be included on certain architectures or operating systems. + +{{ range . }} +- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}})) +{{- end }} + +[cli/cli]: https://github.com/cli/cli diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 48e8539d1..942f43071 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,11 +5,15 @@ on: - "**.go" - go.mod - go.sum + - ".github/licenses.tmpl" + - "script/licenses*" pull_request: paths: - "**.go" - go.mod - go.sum + - ".github/licenses.tmpl" + - "script/licenses*" permissions: contents: read @@ -46,3 +50,12 @@ jobs: uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: version: v2.1.6 + + # actions/setup-go does not setup the installed toolchain to be preferred over the system install, + # which causes go-licenses to raise "Package ... does not have module info" errors. + # for more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633 + - name: Check licenses + run: | + export GOROOT=$(go env GOROOT) + export PATH=${GOROOT}/bin:$PATH + make licenses-check diff --git a/Makefile b/Makefile index 32a06df2f..f823f6e93 100644 --- a/Makefile +++ b/Makefile @@ -106,3 +106,11 @@ ifndef VERSION endif ./script/release --local "$(VERSION)" --platform macos ./script/pkgmacos $(VERSION) + +.PHONY: licenses +licenses: + ./script/licenses + +.PHONY: licenses-check +licenses-check: + ./script/licenses-check diff --git a/docs/license-compliance.md b/docs/license-compliance.md new file mode 100644 index 000000000..69099cdd2 --- /dev/null +++ b/docs/license-compliance.md @@ -0,0 +1,46 @@ +# License Compliance + +GitHub CLI complies with the software licenses of its dependencies. This document explains how license compliance is maintained. + +## Overview + +When a dependency is added or updated, the license information needs to be updated. We use the [`google/go-licenses`](https://github.com/google/go-licenses) tool to: + +1. Generate markdown documentation listing all Go dependencies and their licenses +2. Copy license files for dependencies that require redistribution + +## License Files + +The following files contain license information: + +- `third-party-licenses.darwin.md` - License information for macOS dependencies +- `third-party-licenses.linux.md` - License information for Linux dependencies +- `third-party-licenses.windows.md` - License information for Windows dependencies +- `third-party/` - Directory containing source code and license files that require redistribution + +## Updating License Information + +When dependencies change, you need to update the license information: + +1. Update license information for all platforms: + + ```shell + make licenses + ``` + +2. Commit the changes: + + ```shell + git add third-party-licenses.*.md third-party/ + git commit -m "Update third-party license information" + ``` + +## Checking License Compliance + +The CI workflow checks if license information is up to date. To check locally: + +```sh +make licenses-check +``` + +If the check fails, follow the instructions to update the license information. diff --git a/script/licenses b/script/licenses new file mode 100755 index 000000000..c38b12258 --- /dev/null +++ b/script/licenses @@ -0,0 +1,25 @@ +#!/bin/bash + +go install github.com/google/go-licenses@latest + +# Setup temporary directory to collect updated third-party source code +export TEMPDIR="$(mktemp -d)" +trap "rm -fr ${TEMPDIR}" EXIT + +# Clear third-party source code to avoid stale content +rm -rf third-party +mkdir -p third-party + +for goos in linux darwin windows ; do + # Note: we ignore warnings because we want the command to succeed, however the output should be checked + # for any new warnings, and potentially we may need to add license information. + # + # Normally these warnings are packages containing non go code, which may or may not require explicit attribution, + # depending on the license. + echo "Generating licenses for ${goos}..." + GOOS="${goos}" go-licenses save ./... --save_path="${TEMPDIR}/${goos}" --force || echo "Ignore warnings" + GOOS="${goos}" go-licenses report ./... --template .github/licenses.tmpl --ignore github.com/cli/cli > third-party-licenses.${goos}.md || echo "Ignore warnings" + cp -fR "${TEMPDIR}/${goos}"/* third-party/ +done + +echo "Licenses generated for all platforms." diff --git a/script/licenses-check b/script/licenses-check new file mode 100755 index 000000000..345a964ac --- /dev/null +++ b/script/licenses-check @@ -0,0 +1,23 @@ +#!/bin/bash + +go install github.com/google/go-licenses@latest + +# Setup temporary directory for generated license reports +export TEMPDIR="$(mktemp -d)" +trap "rm -fr ${TEMPDIR}" EXIT + +for goos in linux darwin windows ; do + # Note: we ignore warnings because we want the command to succeed, however the output should be checked + # for any new warnings, and potentially we may need to add license information. + # + # Normally these warnings are packages containing non go code, which may or may not require explicit attribution, + # depending on the license. + echo "Checking licenses for ${goos}..." + GOOS="${goos}" go-licenses report ./... --template .github/licenses.tmpl --ignore github.com/cli/cli > "${TEMPDIR}/third-party-licenses.${goos}.md" || echo "Ignore warnings" + if ! diff -s "${TEMPDIR}/third-party-licenses.${goos}.md" "third-party-licenses.${goos}.md"; then + echo "::error title=License check failed::Please update the license files by running \`make licenses\` and committing the output." + exit 1 + fi +done + +echo "License check passed for all platforms." diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md new file mode 100644 index 000000000..8a6271d03 --- /dev/null +++ b/third-party-licenses.darwin.md @@ -0,0 +1,184 @@ +# GitHub CLI dependencies + +The following open source dependencies are used to build the [cli/cli][] GitHub CLI. + +## Go Packages + +Some packages may only be included on certain architectures or operating systems. + + +- [dario.cat/mergo](https://pkg.go.dev/dario.cat/mergo) ([BSD-3-Clause](https://github.com/imdario/mergo/blob/v1.0.1/LICENSE)) +- [github.com/AlecAivazis/survey/v2](https://pkg.go.dev/github.com/AlecAivazis/survey/v2) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/LICENSE)) +- [github.com/AlecAivazis/survey/v2/terminal](https://pkg.go.dev/github.com/AlecAivazis/survey/v2/terminal) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/terminal/LICENSE.txt)) +- [github.com/MakeNowJust/heredoc](https://pkg.go.dev/github.com/MakeNowJust/heredoc) ([MIT](https://github.com/MakeNowJust/heredoc/blob/v1.0.0/LICENSE)) +- [github.com/Masterminds/goutils](https://pkg.go.dev/github.com/Masterminds/goutils) ([Apache-2.0](https://github.com/Masterminds/goutils/blob/v1.1.1/LICENSE.txt)) +- [github.com/Masterminds/semver/v3](https://pkg.go.dev/github.com/Masterminds/semver/v3) ([MIT](https://github.com/Masterminds/semver/blob/v3.3.0/LICENSE.txt)) +- [github.com/Masterminds/sprig/v3](https://pkg.go.dev/github.com/Masterminds/sprig/v3) ([MIT](https://github.com/Masterminds/sprig/blob/v3.3.0/LICENSE.txt)) +- [github.com/alecthomas/chroma/v2](https://pkg.go.dev/github.com/alecthomas/chroma/v2) ([MIT](https://github.com/alecthomas/chroma/blob/v2.14.0/COPYING)) +- [github.com/alessio/shellescape](https://pkg.go.dev/github.com/alessio/shellescape) ([MIT](https://github.com/alessio/shellescape/blob/v1.4.2/LICENSE)) +- [github.com/asaskevich/govalidator](https://pkg.go.dev/github.com/asaskevich/govalidator) ([MIT](https://github.com/asaskevich/govalidator/blob/a9d515a09cc2/LICENSE)) +- [github.com/atotto/clipboard](https://pkg.go.dev/github.com/atotto/clipboard) ([BSD-3-Clause](https://github.com/atotto/clipboard/blob/v0.1.4/LICENSE)) +- [github.com/aymanbagabas/go-osc52/v2](https://pkg.go.dev/github.com/aymanbagabas/go-osc52/v2) ([MIT](https://github.com/aymanbagabas/go-osc52/blob/v2.0.1/LICENSE)) +- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) +- [github.com/blang/semver](https://pkg.go.dev/github.com/blang/semver) ([MIT](https://github.com/blang/semver/blob/v3.5.1/LICENSE)) +- [github.com/briandowns/spinner](https://pkg.go.dev/github.com/briandowns/spinner) ([Apache-2.0](https://github.com/briandowns/spinner/blob/v1.18.1/LICENSE)) +- [github.com/catppuccin/go](https://pkg.go.dev/github.com/catppuccin/go) ([MIT](https://github.com/catppuccin/go/blob/v0.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v4](https://pkg.go.dev/github.com/cenkalti/backoff/v4) ([MIT](https://github.com/cenkalti/backoff/blob/v4.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v5](https://pkg.go.dev/github.com/cenkalti/backoff/v5) ([MIT](https://github.com/cenkalti/backoff/blob/v5.0.2/LICENSE)) +- [github.com/charmbracelet/bubbles](https://pkg.go.dev/github.com/charmbracelet/bubbles) ([MIT](https://github.com/charmbracelet/bubbles/blob/v0.21.0/LICENSE)) +- [github.com/charmbracelet/bubbletea](https://pkg.go.dev/github.com/charmbracelet/bubbletea) ([MIT](https://github.com/charmbracelet/bubbletea/blob/v1.3.4/LICENSE)) +- [github.com/charmbracelet/colorprofile](https://pkg.go.dev/github.com/charmbracelet/colorprofile) ([MIT](https://github.com/charmbracelet/colorprofile/blob/f60798e515dc/LICENSE)) +- [github.com/charmbracelet/glamour](https://pkg.go.dev/github.com/charmbracelet/glamour) ([MIT](https://github.com/charmbracelet/glamour/blob/549f544650e3/LICENSE)) +- [github.com/charmbracelet/huh](https://pkg.go.dev/github.com/charmbracelet/huh) ([MIT](https://github.com/charmbracelet/huh/blob/v0.7.0/LICENSE)) +- [github.com/charmbracelet/lipgloss](https://pkg.go.dev/github.com/charmbracelet/lipgloss) ([MIT](https://github.com/charmbracelet/lipgloss/blob/166f707985bc/LICENSE)) +- [github.com/charmbracelet/x/ansi](https://pkg.go.dev/github.com/charmbracelet/x/ansi) ([MIT](https://github.com/charmbracelet/x/blob/ansi/v0.8.0/ansi/LICENSE)) +- [github.com/charmbracelet/x/cellbuf](https://pkg.go.dev/github.com/charmbracelet/x/cellbuf) ([MIT](https://github.com/charmbracelet/x/blob/cellbuf/v0.0.13/cellbuf/LICENSE)) +- [github.com/charmbracelet/x/exp/strings](https://pkg.go.dev/github.com/charmbracelet/x/exp/strings) ([MIT](https://github.com/charmbracelet/x/blob/212f7b056ed0/exp/strings/LICENSE)) +- [github.com/charmbracelet/x/term](https://pkg.go.dev/github.com/charmbracelet/x/term) ([MIT](https://github.com/charmbracelet/x/blob/term/v0.2.1/term/LICENSE)) +- [github.com/cli/browser](https://pkg.go.dev/github.com/cli/browser) ([BSD-2-Clause](https://github.com/cli/browser/blob/v1.3.0/LICENSE)) +- [github.com/cli/go-gh/v2](https://pkg.go.dev/github.com/cli/go-gh/v2) ([MIT](https://github.com/cli/go-gh/blob/v2.12.1/LICENSE)) +- [github.com/cli/oauth](https://pkg.go.dev/github.com/cli/oauth) ([MIT](https://github.com/cli/oauth/blob/v1.1.1/LICENSE)) +- [github.com/cli/safeexec](https://pkg.go.dev/github.com/cli/safeexec) ([BSD-2-Clause](https://github.com/cli/safeexec/blob/v1.0.1/LICENSE)) +- [github.com/cli/shurcooL-graphql](https://pkg.go.dev/github.com/cli/shurcooL-graphql) ([MIT](https://github.com/cli/shurcooL-graphql/blob/v0.0.4/LICENSE)) +- [github.com/containerd/stargz-snapshotter/estargz](https://pkg.go.dev/github.com/containerd/stargz-snapshotter/estargz) ([Apache-2.0](https://github.com/containerd/stargz-snapshotter/blob/estargz/v0.16.3/estargz/LICENSE)) +- [github.com/cpuguy83/go-md2man/v2/md2man](https://pkg.go.dev/github.com/cpuguy83/go-md2man/v2/md2man) ([MIT](https://github.com/cpuguy83/go-md2man/blob/v2.0.7/LICENSE.md)) +- [github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer](https://pkg.go.dev/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer) ([Apache-2.0](https://github.com/cyberphone/json-canonicalization/blob/57a0ce2678a7/LICENSE)) +- [github.com/davecgh/go-spew/spew](https://pkg.go.dev/github.com/davecgh/go-spew/spew) ([ISC](https://github.com/davecgh/go-spew/blob/d8f796af33cc/LICENSE)) +- [github.com/digitorus/pkcs7](https://pkg.go.dev/github.com/digitorus/pkcs7) ([MIT](https://github.com/digitorus/pkcs7/blob/3a137a874352/LICENSE)) +- [github.com/digitorus/timestamp](https://pkg.go.dev/github.com/digitorus/timestamp) ([BSD-2-Clause](https://github.com/digitorus/timestamp/blob/220c5c2851b7/LICENSE)) +- [github.com/distribution/reference](https://pkg.go.dev/github.com/distribution/reference) ([Apache-2.0](https://github.com/distribution/reference/blob/v0.6.0/LICENSE)) +- [github.com/dlclark/regexp2](https://pkg.go.dev/github.com/dlclark/regexp2) ([MIT](https://github.com/dlclark/regexp2/blob/v1.11.0/LICENSE)) +- [github.com/docker/cli/cli/config](https://pkg.go.dev/github.com/docker/cli/cli/config) ([Apache-2.0](https://github.com/docker/cli/blob/v28.2.2/LICENSE)) +- [github.com/docker/distribution/registry/client/auth/challenge](https://pkg.go.dev/github.com/docker/distribution/registry/client/auth/challenge) ([Apache-2.0](https://github.com/docker/distribution/blob/v2.8.3/LICENSE)) +- [github.com/docker/docker-credential-helpers](https://pkg.go.dev/github.com/docker/docker-credential-helpers) ([MIT](https://github.com/docker/docker-credential-helpers/blob/v0.9.3/LICENSE)) +- [github.com/dustin/go-humanize](https://pkg.go.dev/github.com/dustin/go-humanize) ([MIT](https://github.com/dustin/go-humanize/blob/v1.0.1/LICENSE)) +- [github.com/fatih/color](https://pkg.go.dev/github.com/fatih/color) ([MIT](https://github.com/fatih/color/blob/v1.16.0/LICENSE.md)) +- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE)) +- [github.com/gabriel-vasile/mimetype](https://pkg.go.dev/github.com/gabriel-vasile/mimetype) ([MIT](https://github.com/gabriel-vasile/mimetype/blob/v1.4.9/LICENSE)) +- [github.com/gdamore/encoding](https://pkg.go.dev/github.com/gdamore/encoding) ([Apache-2.0](https://github.com/gdamore/encoding/blob/v1.0.0/LICENSE)) +- [github.com/gdamore/tcell/v2](https://pkg.go.dev/github.com/gdamore/tcell/v2) ([Apache-2.0](https://github.com/gdamore/tcell/blob/v2.5.4/LICENSE)) +- [github.com/go-chi/chi](https://pkg.go.dev/github.com/go-chi/chi) ([MIT](https://github.com/go-chi/chi/blob/v4.1.2/LICENSE)) +- [github.com/go-jose/go-jose/v4](https://pkg.go.dev/github.com/go-jose/go-jose/v4) ([Apache-2.0](https://github.com/go-jose/go-jose/blob/v4.0.5/LICENSE)) +- [github.com/go-jose/go-jose/v4/json](https://pkg.go.dev/github.com/go-jose/go-jose/v4/json) ([BSD-3-Clause](https://github.com/go-jose/go-jose/blob/v4.0.5/json/LICENSE)) +- [github.com/go-logr/logr](https://pkg.go.dev/github.com/go-logr/logr) ([Apache-2.0](https://github.com/go-logr/logr/blob/v1.4.3/LICENSE)) +- [github.com/go-logr/stdr](https://pkg.go.dev/github.com/go-logr/stdr) ([Apache-2.0](https://github.com/go-logr/stdr/blob/v1.2.2/LICENSE)) +- [github.com/go-openapi/analysis](https://pkg.go.dev/github.com/go-openapi/analysis) ([Apache-2.0](https://github.com/go-openapi/analysis/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/errors](https://pkg.go.dev/github.com/go-openapi/errors) ([Apache-2.0](https://github.com/go-openapi/errors/blob/v0.22.1/LICENSE)) +- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/jsonreference](https://pkg.go.dev/github.com/go-openapi/jsonreference) ([Apache-2.0](https://github.com/go-openapi/jsonreference/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/loads](https://pkg.go.dev/github.com/go-openapi/loads) ([Apache-2.0](https://github.com/go-openapi/loads/blob/v0.22.0/LICENSE)) +- [github.com/go-openapi/runtime](https://pkg.go.dev/github.com/go-openapi/runtime) ([Apache-2.0](https://github.com/go-openapi/runtime/blob/v0.28.0/LICENSE)) +- [github.com/go-openapi/runtime/middleware/denco](https://pkg.go.dev/github.com/go-openapi/runtime/middleware/denco) ([MIT](https://github.com/go-openapi/runtime/blob/v0.28.0/middleware/denco/LICENSE)) +- [github.com/go-openapi/spec](https://pkg.go.dev/github.com/go-openapi/spec) ([Apache-2.0](https://github.com/go-openapi/spec/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) +- [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE)) +- [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v0.0.4/LICENSE)) +- [github.com/google/certificate-transparency-go](https://pkg.go.dev/github.com/google/certificate-transparency-go) ([Apache-2.0](https://github.com/google/certificate-transparency-go/blob/v1.3.1/LICENSE)) +- [github.com/google/go-containerregistry](https://pkg.go.dev/github.com/google/go-containerregistry) ([Apache-2.0](https://github.com/google/go-containerregistry/blob/v0.20.6/LICENSE)) +- [github.com/google/shlex](https://pkg.go.dev/github.com/google/shlex) ([Apache-2.0](https://github.com/google/shlex/blob/e7afc7fbc510/COPYING)) +- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) +- [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) +- [github.com/gorilla/websocket](https://pkg.go.dev/github.com/gorilla/websocket) ([BSD-2-Clause](https://github.com/gorilla/websocket/blob/v1.5.3/LICENSE)) +- [github.com/hashicorp/errwrap](https://pkg.go.dev/github.com/hashicorp/errwrap) ([MPL-2.0](https://github.com/hashicorp/errwrap/blob/v1.1.0/LICENSE)) +- [github.com/hashicorp/go-multierror](https://pkg.go.dev/github.com/hashicorp/go-multierror) ([MPL-2.0](https://github.com/hashicorp/go-multierror/blob/v1.1.1/LICENSE)) +- [github.com/hashicorp/go-version](https://pkg.go.dev/github.com/hashicorp/go-version) ([MPL-2.0](https://github.com/hashicorp/go-version/blob/v1.3.0/LICENSE)) +- [github.com/henvic/httpretty](https://pkg.go.dev/github.com/henvic/httpretty) ([MIT](https://github.com/henvic/httpretty/blob/v0.1.4/LICENSE.md)) +- [github.com/huandu/xstrings](https://pkg.go.dev/github.com/huandu/xstrings) ([MIT](https://github.com/huandu/xstrings/blob/v1.5.0/LICENSE)) +- [github.com/in-toto/attestation/go/v1](https://pkg.go.dev/github.com/in-toto/attestation/go/v1) ([Apache-2.0](https://github.com/in-toto/attestation/blob/v1.1.2/LICENSE)) +- [github.com/in-toto/in-toto-golang/in_toto](https://pkg.go.dev/github.com/in-toto/in-toto-golang/in_toto) ([Apache-2.0](https://github.com/in-toto/in-toto-golang/blob/v0.9.0/LICENSE)) +- [github.com/itchyny/gojq](https://pkg.go.dev/github.com/itchyny/gojq) ([MIT](https://github.com/itchyny/gojq/blob/v0.12.15/LICENSE)) +- [github.com/itchyny/timefmt-go](https://pkg.go.dev/github.com/itchyny/timefmt-go) ([MIT](https://github.com/itchyny/timefmt-go/blob/v0.1.5/LICENSE)) +- [github.com/jedisct1/go-minisign](https://pkg.go.dev/github.com/jedisct1/go-minisign) ([MIT](https://github.com/jedisct1/go-minisign/blob/1c139d1cc84b/LICENSE)) +- [github.com/joho/godotenv](https://pkg.go.dev/github.com/joho/godotenv) ([MIT](https://github.com/joho/godotenv/blob/v1.5.1/LICENCE)) +- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) +- [github.com/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE)) +- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.18.0/LICENSE)) +- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.18.0/internal/snapref/LICENSE)) +- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.18.0/zstd/internal/xxhash/LICENSE.txt)) +- [github.com/letsencrypt/boulder](https://pkg.go.dev/github.com/letsencrypt/boulder) ([MPL-2.0](https://github.com/letsencrypt/boulder/blob/de9c06129bec/LICENSE.txt)) +- [github.com/lucasb-eyer/go-colorful](https://pkg.go.dev/github.com/lucasb-eyer/go-colorful) ([MIT](https://github.com/lucasb-eyer/go-colorful/blob/v1.2.0/LICENSE)) +- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.9.0/LICENSE)) +- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.14/LICENSE)) +- [github.com/mattn/go-isatty](https://pkg.go.dev/github.com/mattn/go-isatty) ([MIT](https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE)) +- [github.com/mattn/go-runewidth](https://pkg.go.dev/github.com/mattn/go-runewidth) ([MIT](https://github.com/mattn/go-runewidth/blob/v0.0.16/LICENSE)) +- [github.com/mgutz/ansi](https://pkg.go.dev/github.com/mgutz/ansi) ([MIT](https://github.com/mgutz/ansi/blob/d51e80ef957d/LICENSE)) +- [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) +- [github.com/microsoft/dev-tunnels/go/tunnels](https://pkg.go.dev/github.com/microsoft/dev-tunnels/go/tunnels) ([MIT](https://github.com/microsoft/dev-tunnels/blob/v0.0.25/LICENSE)) +- [github.com/mitchellh/copystructure](https://pkg.go.dev/github.com/mitchellh/copystructure) ([MIT](https://github.com/mitchellh/copystructure/blob/v1.2.0/LICENSE)) +- [github.com/mitchellh/go-homedir](https://pkg.go.dev/github.com/mitchellh/go-homedir) ([MIT](https://github.com/mitchellh/go-homedir/blob/v1.1.0/LICENSE)) +- [github.com/mitchellh/hashstructure/v2](https://pkg.go.dev/github.com/mitchellh/hashstructure/v2) ([MIT](https://github.com/mitchellh/hashstructure/blob/v2.0.2/LICENSE)) +- [github.com/mitchellh/mapstructure](https://pkg.go.dev/github.com/mitchellh/mapstructure) ([MIT](https://github.com/mitchellh/mapstructure/blob/v1.5.0/LICENSE)) +- [github.com/mitchellh/reflectwalk](https://pkg.go.dev/github.com/mitchellh/reflectwalk) ([MIT](https://github.com/mitchellh/reflectwalk/blob/v1.0.2/LICENSE)) +- [github.com/muesli/ansi](https://pkg.go.dev/github.com/muesli/ansi) ([MIT](https://github.com/muesli/ansi/blob/276c6243b2f6/LICENSE)) +- [github.com/muesli/cancelreader](https://pkg.go.dev/github.com/muesli/cancelreader) ([MIT](https://github.com/muesli/cancelreader/blob/v0.2.2/LICENSE)) +- [github.com/muesli/reflow](https://pkg.go.dev/github.com/muesli/reflow) ([MIT](https://github.com/muesli/reflow/blob/v0.3.0/LICENSE)) +- [github.com/muesli/termenv](https://pkg.go.dev/github.com/muesli/termenv) ([MIT](https://github.com/muesli/termenv/blob/v0.16.0/LICENSE)) +- [github.com/muhammadmuzzammil1998/jsonc](https://pkg.go.dev/github.com/muhammadmuzzammil1998/jsonc) ([MIT](https://github.com/muhammadmuzzammil1998/jsonc/blob/615b0916ca38/LICENSE)) +- [github.com/oklog/ulid](https://pkg.go.dev/github.com/oklog/ulid) ([Apache-2.0](https://github.com/oklog/ulid/blob/v1.3.1/LICENSE)) +- [github.com/opencontainers/go-digest](https://pkg.go.dev/github.com/opencontainers/go-digest) ([Apache-2.0](https://github.com/opencontainers/go-digest/blob/v1.0.0/LICENSE)) +- [github.com/opencontainers/image-spec/specs-go](https://pkg.go.dev/github.com/opencontainers/image-spec/specs-go) ([Apache-2.0](https://github.com/opencontainers/image-spec/blob/v1.1.1/LICENSE)) +- [github.com/opentracing/opentracing-go](https://pkg.go.dev/github.com/opentracing/opentracing-go) ([Apache-2.0](https://github.com/opentracing/opentracing-go/blob/v1.2.0/LICENSE)) +- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) +- [github.com/pkg/errors](https://pkg.go.dev/github.com/pkg/errors) ([BSD-2-Clause](https://github.com/pkg/errors/blob/v0.9.1/LICENSE)) +- [github.com/pmezard/go-difflib/difflib](https://pkg.go.dev/github.com/pmezard/go-difflib/difflib) ([BSD-3-Clause](https://github.com/pmezard/go-difflib/blob/5d4384ee4fb2/LICENSE)) +- [github.com/rivo/tview](https://pkg.go.dev/github.com/rivo/tview) ([MIT](https://github.com/rivo/tview/blob/c4a7e501810d/LICENSE.txt)) +- [github.com/rivo/uniseg](https://pkg.go.dev/github.com/rivo/uniseg) ([MIT](https://github.com/rivo/uniseg/blob/v0.4.7/LICENSE.txt)) +- [github.com/rodaine/table](https://pkg.go.dev/github.com/rodaine/table) ([MIT](https://github.com/rodaine/table/blob/v1.0.1/license)) +- [github.com/russross/blackfriday/v2](https://pkg.go.dev/github.com/russross/blackfriday/v2) ([BSD-2-Clause](https://github.com/russross/blackfriday/blob/v2.1.0/LICENSE.txt)) +- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.7.0/LICENSE)) +- [github.com/sassoftware/relic/lib](https://pkg.go.dev/github.com/sassoftware/relic/lib) ([Apache-2.0](https://github.com/sassoftware/relic/blob/v7.2.1/LICENSE)) +- [github.com/secure-systems-lab/go-securesystemslib](https://pkg.go.dev/github.com/secure-systems-lab/go-securesystemslib) ([MIT](https://github.com/secure-systems-lab/go-securesystemslib/blob/v0.9.0/LICENSE)) +- [github.com/shibumi/go-pathspec](https://pkg.go.dev/github.com/shibumi/go-pathspec) ([Apache-2.0](https://github.com/shibumi/go-pathspec/blob/v1.3.0/LICENSE)) +- [github.com/shopspring/decimal](https://pkg.go.dev/github.com/shopspring/decimal) ([MIT](https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE)) +- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/18a1ae0e79dc/LICENSE)) +- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) +- [github.com/sigstore/protobuf-specs/gen/pb-go](https://pkg.go.dev/github.com/sigstore/protobuf-specs/gen/pb-go) ([Apache-2.0](https://github.com/sigstore/protobuf-specs/blob/v0.4.3/LICENSE)) +- [github.com/sigstore/rekor/pkg](https://pkg.go.dev/github.com/sigstore/rekor/pkg) ([Apache-2.0](https://github.com/sigstore/rekor/blob/v1.3.10/LICENSE)) +- [github.com/sigstore/sigstore-go/pkg](https://pkg.go.dev/github.com/sigstore/sigstore-go/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore-go/blob/v1.0.0/LICENSE)) +- [github.com/sigstore/sigstore/pkg](https://pkg.go.dev/github.com/sigstore/sigstore/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore/blob/v1.9.4/LICENSE)) +- [github.com/sigstore/timestamp-authority/pkg/verification](https://pkg.go.dev/github.com/sigstore/timestamp-authority/pkg/verification) ([Apache-2.0](https://github.com/sigstore/timestamp-authority/blob/v1.2.7/LICENSE)) +- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) +- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) +- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.12.0/LICENSE.txt)) +- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) +- [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.9.1/LICENSE.txt)) +- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE)) +- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE)) +- [github.com/stretchr/objx](https://pkg.go.dev/github.com/stretchr/objx) ([MIT](https://github.com/stretchr/objx/blob/v0.5.2/LICENSE)) +- [github.com/stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify) ([MIT](https://github.com/stretchr/testify/blob/v1.10.0/LICENSE)) +- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) +- [github.com/theupdateframework/go-tuf](https://pkg.go.dev/github.com/theupdateframework/go-tuf) ([BSD-3-Clause](https://github.com/theupdateframework/go-tuf/blob/v0.7.0/LICENSE)) +- [github.com/theupdateframework/go-tuf/v2/metadata](https://pkg.go.dev/github.com/theupdateframework/go-tuf/v2/metadata) ([Apache-2.0](https://github.com/theupdateframework/go-tuf/blob/v2.1.1/LICENSE)) +- [github.com/thlib/go-timezone-local/tzlocal](https://pkg.go.dev/github.com/thlib/go-timezone-local/tzlocal) ([Unlicense](https://github.com/thlib/go-timezone-local/blob/ef149e42d28e/LICENSE)) +- [github.com/titanous/rocacheck](https://pkg.go.dev/github.com/titanous/rocacheck) ([MIT](https://github.com/titanous/rocacheck/blob/afe73141d399/LICENSE)) +- [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) +- [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) +- [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.12/LICENSE)) +- [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.5/LICENSE)) +- [github.com/zalando/go-keyring](https://pkg.go.dev/github.com/zalando/go-keyring) ([MIT](https://github.com/zalando/go-keyring/blob/v0.2.5/LICENSE)) +- [go.mongodb.org/mongo-driver](https://pkg.go.dev/go.mongodb.org/mongo-driver) ([Apache-2.0](https://github.com/mongodb/mongo-go-driver/blob/v1.14.0/LICENSE)) +- [go.opentelemetry.io/auto/sdk](https://pkg.go.dev/go.opentelemetry.io/auto/sdk) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.1.0/sdk/LICENSE)) +- [go.opentelemetry.io/otel](https://pkg.go.dev/go.opentelemetry.io/otel) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/v1.36.0/LICENSE)) +- [go.opentelemetry.io/otel/metric](https://pkg.go.dev/go.opentelemetry.io/otel/metric) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.36.0/metric/LICENSE)) +- [go.opentelemetry.io/otel/trace](https://pkg.go.dev/go.opentelemetry.io/otel/trace) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.36.0/trace/LICENSE)) +- [go.uber.org/multierr](https://pkg.go.dev/go.uber.org/multierr) ([MIT](https://github.com/uber-go/multierr/blob/v1.11.0/LICENSE.txt)) +- [go.uber.org/zap](https://pkg.go.dev/go.uber.org/zap) ([MIT](https://github.com/uber-go/zap/blob/v1.27.0/LICENSE)) +- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.39.0:LICENSE)) +- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fd00a4e0:LICENSE)) +- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.25.0:LICENSE)) +- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.41.0:LICENSE)) +- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.15.0:LICENSE)) +- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.33.0:LICENSE)) +- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.32.0:LICENSE)) +- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.26.0:LICENSE)) +- [google.golang.org/genproto/googleapis/api](https://pkg.go.dev/google.golang.org/genproto/googleapis/api) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/api/LICENSE)) +- [google.golang.org/genproto/googleapis/rpc/status](https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/status) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/rpc/LICENSE)) +- [google.golang.org/grpc](https://pkg.go.dev/google.golang.org/grpc) ([Apache-2.0](https://github.com/grpc/grpc-go/blob/v1.72.2/LICENSE)) +- [google.golang.org/protobuf](https://pkg.go.dev/google.golang.org/protobuf) ([BSD-3-Clause](https://github.com/protocolbuffers/protobuf-go/blob/v1.36.6/LICENSE)) +- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) +- [k8s.io/klog/v2](https://pkg.go.dev/k8s.io/klog/v2) ([Apache-2.0](https://github.com/kubernetes/klog/blob/v2.130.1/LICENSE)) + +[cli/cli]: https://github.com/cli/cli diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md new file mode 100644 index 000000000..c11e24573 --- /dev/null +++ b/third-party-licenses.linux.md @@ -0,0 +1,184 @@ +# GitHub CLI dependencies + +The following open source dependencies are used to build the [cli/cli][] GitHub CLI. + +## Go Packages + +Some packages may only be included on certain architectures or operating systems. + + +- [dario.cat/mergo](https://pkg.go.dev/dario.cat/mergo) ([BSD-3-Clause](https://github.com/imdario/mergo/blob/v1.0.1/LICENSE)) +- [github.com/AlecAivazis/survey/v2](https://pkg.go.dev/github.com/AlecAivazis/survey/v2) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/LICENSE)) +- [github.com/AlecAivazis/survey/v2/terminal](https://pkg.go.dev/github.com/AlecAivazis/survey/v2/terminal) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/terminal/LICENSE.txt)) +- [github.com/MakeNowJust/heredoc](https://pkg.go.dev/github.com/MakeNowJust/heredoc) ([MIT](https://github.com/MakeNowJust/heredoc/blob/v1.0.0/LICENSE)) +- [github.com/Masterminds/goutils](https://pkg.go.dev/github.com/Masterminds/goutils) ([Apache-2.0](https://github.com/Masterminds/goutils/blob/v1.1.1/LICENSE.txt)) +- [github.com/Masterminds/semver/v3](https://pkg.go.dev/github.com/Masterminds/semver/v3) ([MIT](https://github.com/Masterminds/semver/blob/v3.3.0/LICENSE.txt)) +- [github.com/Masterminds/sprig/v3](https://pkg.go.dev/github.com/Masterminds/sprig/v3) ([MIT](https://github.com/Masterminds/sprig/blob/v3.3.0/LICENSE.txt)) +- [github.com/alecthomas/chroma/v2](https://pkg.go.dev/github.com/alecthomas/chroma/v2) ([MIT](https://github.com/alecthomas/chroma/blob/v2.14.0/COPYING)) +- [github.com/asaskevich/govalidator](https://pkg.go.dev/github.com/asaskevich/govalidator) ([MIT](https://github.com/asaskevich/govalidator/blob/a9d515a09cc2/LICENSE)) +- [github.com/atotto/clipboard](https://pkg.go.dev/github.com/atotto/clipboard) ([BSD-3-Clause](https://github.com/atotto/clipboard/blob/v0.1.4/LICENSE)) +- [github.com/aymanbagabas/go-osc52/v2](https://pkg.go.dev/github.com/aymanbagabas/go-osc52/v2) ([MIT](https://github.com/aymanbagabas/go-osc52/blob/v2.0.1/LICENSE)) +- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) +- [github.com/blang/semver](https://pkg.go.dev/github.com/blang/semver) ([MIT](https://github.com/blang/semver/blob/v3.5.1/LICENSE)) +- [github.com/briandowns/spinner](https://pkg.go.dev/github.com/briandowns/spinner) ([Apache-2.0](https://github.com/briandowns/spinner/blob/v1.18.1/LICENSE)) +- [github.com/catppuccin/go](https://pkg.go.dev/github.com/catppuccin/go) ([MIT](https://github.com/catppuccin/go/blob/v0.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v4](https://pkg.go.dev/github.com/cenkalti/backoff/v4) ([MIT](https://github.com/cenkalti/backoff/blob/v4.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v5](https://pkg.go.dev/github.com/cenkalti/backoff/v5) ([MIT](https://github.com/cenkalti/backoff/blob/v5.0.2/LICENSE)) +- [github.com/charmbracelet/bubbles](https://pkg.go.dev/github.com/charmbracelet/bubbles) ([MIT](https://github.com/charmbracelet/bubbles/blob/v0.21.0/LICENSE)) +- [github.com/charmbracelet/bubbletea](https://pkg.go.dev/github.com/charmbracelet/bubbletea) ([MIT](https://github.com/charmbracelet/bubbletea/blob/v1.3.4/LICENSE)) +- [github.com/charmbracelet/colorprofile](https://pkg.go.dev/github.com/charmbracelet/colorprofile) ([MIT](https://github.com/charmbracelet/colorprofile/blob/f60798e515dc/LICENSE)) +- [github.com/charmbracelet/glamour](https://pkg.go.dev/github.com/charmbracelet/glamour) ([MIT](https://github.com/charmbracelet/glamour/blob/549f544650e3/LICENSE)) +- [github.com/charmbracelet/huh](https://pkg.go.dev/github.com/charmbracelet/huh) ([MIT](https://github.com/charmbracelet/huh/blob/v0.7.0/LICENSE)) +- [github.com/charmbracelet/lipgloss](https://pkg.go.dev/github.com/charmbracelet/lipgloss) ([MIT](https://github.com/charmbracelet/lipgloss/blob/166f707985bc/LICENSE)) +- [github.com/charmbracelet/x/ansi](https://pkg.go.dev/github.com/charmbracelet/x/ansi) ([MIT](https://github.com/charmbracelet/x/blob/ansi/v0.8.0/ansi/LICENSE)) +- [github.com/charmbracelet/x/cellbuf](https://pkg.go.dev/github.com/charmbracelet/x/cellbuf) ([MIT](https://github.com/charmbracelet/x/blob/cellbuf/v0.0.13/cellbuf/LICENSE)) +- [github.com/charmbracelet/x/exp/strings](https://pkg.go.dev/github.com/charmbracelet/x/exp/strings) ([MIT](https://github.com/charmbracelet/x/blob/212f7b056ed0/exp/strings/LICENSE)) +- [github.com/charmbracelet/x/term](https://pkg.go.dev/github.com/charmbracelet/x/term) ([MIT](https://github.com/charmbracelet/x/blob/term/v0.2.1/term/LICENSE)) +- [github.com/cli/browser](https://pkg.go.dev/github.com/cli/browser) ([BSD-2-Clause](https://github.com/cli/browser/blob/v1.3.0/LICENSE)) +- [github.com/cli/go-gh/v2](https://pkg.go.dev/github.com/cli/go-gh/v2) ([MIT](https://github.com/cli/go-gh/blob/v2.12.1/LICENSE)) +- [github.com/cli/oauth](https://pkg.go.dev/github.com/cli/oauth) ([MIT](https://github.com/cli/oauth/blob/v1.1.1/LICENSE)) +- [github.com/cli/safeexec](https://pkg.go.dev/github.com/cli/safeexec) ([BSD-2-Clause](https://github.com/cli/safeexec/blob/v1.0.1/LICENSE)) +- [github.com/cli/shurcooL-graphql](https://pkg.go.dev/github.com/cli/shurcooL-graphql) ([MIT](https://github.com/cli/shurcooL-graphql/blob/v0.0.4/LICENSE)) +- [github.com/containerd/stargz-snapshotter/estargz](https://pkg.go.dev/github.com/containerd/stargz-snapshotter/estargz) ([Apache-2.0](https://github.com/containerd/stargz-snapshotter/blob/estargz/v0.16.3/estargz/LICENSE)) +- [github.com/cpuguy83/go-md2man/v2/md2man](https://pkg.go.dev/github.com/cpuguy83/go-md2man/v2/md2man) ([MIT](https://github.com/cpuguy83/go-md2man/blob/v2.0.7/LICENSE.md)) +- [github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer](https://pkg.go.dev/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer) ([Apache-2.0](https://github.com/cyberphone/json-canonicalization/blob/57a0ce2678a7/LICENSE)) +- [github.com/davecgh/go-spew/spew](https://pkg.go.dev/github.com/davecgh/go-spew/spew) ([ISC](https://github.com/davecgh/go-spew/blob/d8f796af33cc/LICENSE)) +- [github.com/digitorus/pkcs7](https://pkg.go.dev/github.com/digitorus/pkcs7) ([MIT](https://github.com/digitorus/pkcs7/blob/3a137a874352/LICENSE)) +- [github.com/digitorus/timestamp](https://pkg.go.dev/github.com/digitorus/timestamp) ([BSD-2-Clause](https://github.com/digitorus/timestamp/blob/220c5c2851b7/LICENSE)) +- [github.com/distribution/reference](https://pkg.go.dev/github.com/distribution/reference) ([Apache-2.0](https://github.com/distribution/reference/blob/v0.6.0/LICENSE)) +- [github.com/dlclark/regexp2](https://pkg.go.dev/github.com/dlclark/regexp2) ([MIT](https://github.com/dlclark/regexp2/blob/v1.11.0/LICENSE)) +- [github.com/docker/cli/cli/config](https://pkg.go.dev/github.com/docker/cli/cli/config) ([Apache-2.0](https://github.com/docker/cli/blob/v28.2.2/LICENSE)) +- [github.com/docker/distribution/registry/client/auth/challenge](https://pkg.go.dev/github.com/docker/distribution/registry/client/auth/challenge) ([Apache-2.0](https://github.com/docker/distribution/blob/v2.8.3/LICENSE)) +- [github.com/docker/docker-credential-helpers](https://pkg.go.dev/github.com/docker/docker-credential-helpers) ([MIT](https://github.com/docker/docker-credential-helpers/blob/v0.9.3/LICENSE)) +- [github.com/dustin/go-humanize](https://pkg.go.dev/github.com/dustin/go-humanize) ([MIT](https://github.com/dustin/go-humanize/blob/v1.0.1/LICENSE)) +- [github.com/fatih/color](https://pkg.go.dev/github.com/fatih/color) ([MIT](https://github.com/fatih/color/blob/v1.16.0/LICENSE.md)) +- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE)) +- [github.com/gabriel-vasile/mimetype](https://pkg.go.dev/github.com/gabriel-vasile/mimetype) ([MIT](https://github.com/gabriel-vasile/mimetype/blob/v1.4.9/LICENSE)) +- [github.com/gdamore/encoding](https://pkg.go.dev/github.com/gdamore/encoding) ([Apache-2.0](https://github.com/gdamore/encoding/blob/v1.0.0/LICENSE)) +- [github.com/gdamore/tcell/v2](https://pkg.go.dev/github.com/gdamore/tcell/v2) ([Apache-2.0](https://github.com/gdamore/tcell/blob/v2.5.4/LICENSE)) +- [github.com/go-chi/chi](https://pkg.go.dev/github.com/go-chi/chi) ([MIT](https://github.com/go-chi/chi/blob/v4.1.2/LICENSE)) +- [github.com/go-jose/go-jose/v4](https://pkg.go.dev/github.com/go-jose/go-jose/v4) ([Apache-2.0](https://github.com/go-jose/go-jose/blob/v4.0.5/LICENSE)) +- [github.com/go-jose/go-jose/v4/json](https://pkg.go.dev/github.com/go-jose/go-jose/v4/json) ([BSD-3-Clause](https://github.com/go-jose/go-jose/blob/v4.0.5/json/LICENSE)) +- [github.com/go-logr/logr](https://pkg.go.dev/github.com/go-logr/logr) ([Apache-2.0](https://github.com/go-logr/logr/blob/v1.4.3/LICENSE)) +- [github.com/go-logr/stdr](https://pkg.go.dev/github.com/go-logr/stdr) ([Apache-2.0](https://github.com/go-logr/stdr/blob/v1.2.2/LICENSE)) +- [github.com/go-openapi/analysis](https://pkg.go.dev/github.com/go-openapi/analysis) ([Apache-2.0](https://github.com/go-openapi/analysis/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/errors](https://pkg.go.dev/github.com/go-openapi/errors) ([Apache-2.0](https://github.com/go-openapi/errors/blob/v0.22.1/LICENSE)) +- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/jsonreference](https://pkg.go.dev/github.com/go-openapi/jsonreference) ([Apache-2.0](https://github.com/go-openapi/jsonreference/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/loads](https://pkg.go.dev/github.com/go-openapi/loads) ([Apache-2.0](https://github.com/go-openapi/loads/blob/v0.22.0/LICENSE)) +- [github.com/go-openapi/runtime](https://pkg.go.dev/github.com/go-openapi/runtime) ([Apache-2.0](https://github.com/go-openapi/runtime/blob/v0.28.0/LICENSE)) +- [github.com/go-openapi/runtime/middleware/denco](https://pkg.go.dev/github.com/go-openapi/runtime/middleware/denco) ([MIT](https://github.com/go-openapi/runtime/blob/v0.28.0/middleware/denco/LICENSE)) +- [github.com/go-openapi/spec](https://pkg.go.dev/github.com/go-openapi/spec) ([Apache-2.0](https://github.com/go-openapi/spec/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) +- [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE)) +- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.0/LICENSE)) +- [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v0.0.4/LICENSE)) +- [github.com/google/certificate-transparency-go](https://pkg.go.dev/github.com/google/certificate-transparency-go) ([Apache-2.0](https://github.com/google/certificate-transparency-go/blob/v1.3.1/LICENSE)) +- [github.com/google/go-containerregistry](https://pkg.go.dev/github.com/google/go-containerregistry) ([Apache-2.0](https://github.com/google/go-containerregistry/blob/v0.20.6/LICENSE)) +- [github.com/google/shlex](https://pkg.go.dev/github.com/google/shlex) ([Apache-2.0](https://github.com/google/shlex/blob/e7afc7fbc510/COPYING)) +- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) +- [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) +- [github.com/gorilla/websocket](https://pkg.go.dev/github.com/gorilla/websocket) ([BSD-2-Clause](https://github.com/gorilla/websocket/blob/v1.5.3/LICENSE)) +- [github.com/hashicorp/errwrap](https://pkg.go.dev/github.com/hashicorp/errwrap) ([MPL-2.0](https://github.com/hashicorp/errwrap/blob/v1.1.0/LICENSE)) +- [github.com/hashicorp/go-multierror](https://pkg.go.dev/github.com/hashicorp/go-multierror) ([MPL-2.0](https://github.com/hashicorp/go-multierror/blob/v1.1.1/LICENSE)) +- [github.com/hashicorp/go-version](https://pkg.go.dev/github.com/hashicorp/go-version) ([MPL-2.0](https://github.com/hashicorp/go-version/blob/v1.3.0/LICENSE)) +- [github.com/henvic/httpretty](https://pkg.go.dev/github.com/henvic/httpretty) ([MIT](https://github.com/henvic/httpretty/blob/v0.1.4/LICENSE.md)) +- [github.com/huandu/xstrings](https://pkg.go.dev/github.com/huandu/xstrings) ([MIT](https://github.com/huandu/xstrings/blob/v1.5.0/LICENSE)) +- [github.com/in-toto/attestation/go/v1](https://pkg.go.dev/github.com/in-toto/attestation/go/v1) ([Apache-2.0](https://github.com/in-toto/attestation/blob/v1.1.2/LICENSE)) +- [github.com/in-toto/in-toto-golang/in_toto](https://pkg.go.dev/github.com/in-toto/in-toto-golang/in_toto) ([Apache-2.0](https://github.com/in-toto/in-toto-golang/blob/v0.9.0/LICENSE)) +- [github.com/itchyny/gojq](https://pkg.go.dev/github.com/itchyny/gojq) ([MIT](https://github.com/itchyny/gojq/blob/v0.12.15/LICENSE)) +- [github.com/itchyny/timefmt-go](https://pkg.go.dev/github.com/itchyny/timefmt-go) ([MIT](https://github.com/itchyny/timefmt-go/blob/v0.1.5/LICENSE)) +- [github.com/jedisct1/go-minisign](https://pkg.go.dev/github.com/jedisct1/go-minisign) ([MIT](https://github.com/jedisct1/go-minisign/blob/1c139d1cc84b/LICENSE)) +- [github.com/joho/godotenv](https://pkg.go.dev/github.com/joho/godotenv) ([MIT](https://github.com/joho/godotenv/blob/v1.5.1/LICENCE)) +- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) +- [github.com/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE)) +- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.18.0/LICENSE)) +- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.18.0/internal/snapref/LICENSE)) +- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.18.0/zstd/internal/xxhash/LICENSE.txt)) +- [github.com/letsencrypt/boulder](https://pkg.go.dev/github.com/letsencrypt/boulder) ([MPL-2.0](https://github.com/letsencrypt/boulder/blob/de9c06129bec/LICENSE.txt)) +- [github.com/lucasb-eyer/go-colorful](https://pkg.go.dev/github.com/lucasb-eyer/go-colorful) ([MIT](https://github.com/lucasb-eyer/go-colorful/blob/v1.2.0/LICENSE)) +- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.9.0/LICENSE)) +- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.14/LICENSE)) +- [github.com/mattn/go-isatty](https://pkg.go.dev/github.com/mattn/go-isatty) ([MIT](https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE)) +- [github.com/mattn/go-runewidth](https://pkg.go.dev/github.com/mattn/go-runewidth) ([MIT](https://github.com/mattn/go-runewidth/blob/v0.0.16/LICENSE)) +- [github.com/mgutz/ansi](https://pkg.go.dev/github.com/mgutz/ansi) ([MIT](https://github.com/mgutz/ansi/blob/d51e80ef957d/LICENSE)) +- [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) +- [github.com/microsoft/dev-tunnels/go/tunnels](https://pkg.go.dev/github.com/microsoft/dev-tunnels/go/tunnels) ([MIT](https://github.com/microsoft/dev-tunnels/blob/v0.0.25/LICENSE)) +- [github.com/mitchellh/copystructure](https://pkg.go.dev/github.com/mitchellh/copystructure) ([MIT](https://github.com/mitchellh/copystructure/blob/v1.2.0/LICENSE)) +- [github.com/mitchellh/go-homedir](https://pkg.go.dev/github.com/mitchellh/go-homedir) ([MIT](https://github.com/mitchellh/go-homedir/blob/v1.1.0/LICENSE)) +- [github.com/mitchellh/hashstructure/v2](https://pkg.go.dev/github.com/mitchellh/hashstructure/v2) ([MIT](https://github.com/mitchellh/hashstructure/blob/v2.0.2/LICENSE)) +- [github.com/mitchellh/mapstructure](https://pkg.go.dev/github.com/mitchellh/mapstructure) ([MIT](https://github.com/mitchellh/mapstructure/blob/v1.5.0/LICENSE)) +- [github.com/mitchellh/reflectwalk](https://pkg.go.dev/github.com/mitchellh/reflectwalk) ([MIT](https://github.com/mitchellh/reflectwalk/blob/v1.0.2/LICENSE)) +- [github.com/muesli/ansi](https://pkg.go.dev/github.com/muesli/ansi) ([MIT](https://github.com/muesli/ansi/blob/276c6243b2f6/LICENSE)) +- [github.com/muesli/cancelreader](https://pkg.go.dev/github.com/muesli/cancelreader) ([MIT](https://github.com/muesli/cancelreader/blob/v0.2.2/LICENSE)) +- [github.com/muesli/reflow](https://pkg.go.dev/github.com/muesli/reflow) ([MIT](https://github.com/muesli/reflow/blob/v0.3.0/LICENSE)) +- [github.com/muesli/termenv](https://pkg.go.dev/github.com/muesli/termenv) ([MIT](https://github.com/muesli/termenv/blob/v0.16.0/LICENSE)) +- [github.com/muhammadmuzzammil1998/jsonc](https://pkg.go.dev/github.com/muhammadmuzzammil1998/jsonc) ([MIT](https://github.com/muhammadmuzzammil1998/jsonc/blob/615b0916ca38/LICENSE)) +- [github.com/oklog/ulid](https://pkg.go.dev/github.com/oklog/ulid) ([Apache-2.0](https://github.com/oklog/ulid/blob/v1.3.1/LICENSE)) +- [github.com/opencontainers/go-digest](https://pkg.go.dev/github.com/opencontainers/go-digest) ([Apache-2.0](https://github.com/opencontainers/go-digest/blob/v1.0.0/LICENSE)) +- [github.com/opencontainers/image-spec/specs-go](https://pkg.go.dev/github.com/opencontainers/image-spec/specs-go) ([Apache-2.0](https://github.com/opencontainers/image-spec/blob/v1.1.1/LICENSE)) +- [github.com/opentracing/opentracing-go](https://pkg.go.dev/github.com/opentracing/opentracing-go) ([Apache-2.0](https://github.com/opentracing/opentracing-go/blob/v1.2.0/LICENSE)) +- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) +- [github.com/pkg/errors](https://pkg.go.dev/github.com/pkg/errors) ([BSD-2-Clause](https://github.com/pkg/errors/blob/v0.9.1/LICENSE)) +- [github.com/pmezard/go-difflib/difflib](https://pkg.go.dev/github.com/pmezard/go-difflib/difflib) ([BSD-3-Clause](https://github.com/pmezard/go-difflib/blob/5d4384ee4fb2/LICENSE)) +- [github.com/rivo/tview](https://pkg.go.dev/github.com/rivo/tview) ([MIT](https://github.com/rivo/tview/blob/c4a7e501810d/LICENSE.txt)) +- [github.com/rivo/uniseg](https://pkg.go.dev/github.com/rivo/uniseg) ([MIT](https://github.com/rivo/uniseg/blob/v0.4.7/LICENSE.txt)) +- [github.com/rodaine/table](https://pkg.go.dev/github.com/rodaine/table) ([MIT](https://github.com/rodaine/table/blob/v1.0.1/license)) +- [github.com/russross/blackfriday/v2](https://pkg.go.dev/github.com/russross/blackfriday/v2) ([BSD-2-Clause](https://github.com/russross/blackfriday/blob/v2.1.0/LICENSE.txt)) +- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.7.0/LICENSE)) +- [github.com/sassoftware/relic/lib](https://pkg.go.dev/github.com/sassoftware/relic/lib) ([Apache-2.0](https://github.com/sassoftware/relic/blob/v7.2.1/LICENSE)) +- [github.com/secure-systems-lab/go-securesystemslib](https://pkg.go.dev/github.com/secure-systems-lab/go-securesystemslib) ([MIT](https://github.com/secure-systems-lab/go-securesystemslib/blob/v0.9.0/LICENSE)) +- [github.com/shibumi/go-pathspec](https://pkg.go.dev/github.com/shibumi/go-pathspec) ([Apache-2.0](https://github.com/shibumi/go-pathspec/blob/v1.3.0/LICENSE)) +- [github.com/shopspring/decimal](https://pkg.go.dev/github.com/shopspring/decimal) ([MIT](https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE)) +- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/18a1ae0e79dc/LICENSE)) +- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) +- [github.com/sigstore/protobuf-specs/gen/pb-go](https://pkg.go.dev/github.com/sigstore/protobuf-specs/gen/pb-go) ([Apache-2.0](https://github.com/sigstore/protobuf-specs/blob/v0.4.3/LICENSE)) +- [github.com/sigstore/rekor/pkg](https://pkg.go.dev/github.com/sigstore/rekor/pkg) ([Apache-2.0](https://github.com/sigstore/rekor/blob/v1.3.10/LICENSE)) +- [github.com/sigstore/sigstore-go/pkg](https://pkg.go.dev/github.com/sigstore/sigstore-go/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore-go/blob/v1.0.0/LICENSE)) +- [github.com/sigstore/sigstore/pkg](https://pkg.go.dev/github.com/sigstore/sigstore/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore/blob/v1.9.4/LICENSE)) +- [github.com/sigstore/timestamp-authority/pkg/verification](https://pkg.go.dev/github.com/sigstore/timestamp-authority/pkg/verification) ([Apache-2.0](https://github.com/sigstore/timestamp-authority/blob/v1.2.7/LICENSE)) +- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) +- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) +- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.12.0/LICENSE.txt)) +- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) +- [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.9.1/LICENSE.txt)) +- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE)) +- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE)) +- [github.com/stretchr/objx](https://pkg.go.dev/github.com/stretchr/objx) ([MIT](https://github.com/stretchr/objx/blob/v0.5.2/LICENSE)) +- [github.com/stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify) ([MIT](https://github.com/stretchr/testify/blob/v1.10.0/LICENSE)) +- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) +- [github.com/theupdateframework/go-tuf](https://pkg.go.dev/github.com/theupdateframework/go-tuf) ([BSD-3-Clause](https://github.com/theupdateframework/go-tuf/blob/v0.7.0/LICENSE)) +- [github.com/theupdateframework/go-tuf/v2/metadata](https://pkg.go.dev/github.com/theupdateframework/go-tuf/v2/metadata) ([Apache-2.0](https://github.com/theupdateframework/go-tuf/blob/v2.1.1/LICENSE)) +- [github.com/thlib/go-timezone-local/tzlocal](https://pkg.go.dev/github.com/thlib/go-timezone-local/tzlocal) ([Unlicense](https://github.com/thlib/go-timezone-local/blob/ef149e42d28e/LICENSE)) +- [github.com/titanous/rocacheck](https://pkg.go.dev/github.com/titanous/rocacheck) ([MIT](https://github.com/titanous/rocacheck/blob/afe73141d399/LICENSE)) +- [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) +- [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) +- [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.12/LICENSE)) +- [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.5/LICENSE)) +- [github.com/zalando/go-keyring](https://pkg.go.dev/github.com/zalando/go-keyring) ([MIT](https://github.com/zalando/go-keyring/blob/v0.2.5/LICENSE)) +- [go.mongodb.org/mongo-driver](https://pkg.go.dev/go.mongodb.org/mongo-driver) ([Apache-2.0](https://github.com/mongodb/mongo-go-driver/blob/v1.14.0/LICENSE)) +- [go.opentelemetry.io/auto/sdk](https://pkg.go.dev/go.opentelemetry.io/auto/sdk) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.1.0/sdk/LICENSE)) +- [go.opentelemetry.io/otel](https://pkg.go.dev/go.opentelemetry.io/otel) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/v1.36.0/LICENSE)) +- [go.opentelemetry.io/otel/metric](https://pkg.go.dev/go.opentelemetry.io/otel/metric) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.36.0/metric/LICENSE)) +- [go.opentelemetry.io/otel/trace](https://pkg.go.dev/go.opentelemetry.io/otel/trace) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.36.0/trace/LICENSE)) +- [go.uber.org/multierr](https://pkg.go.dev/go.uber.org/multierr) ([MIT](https://github.com/uber-go/multierr/blob/v1.11.0/LICENSE.txt)) +- [go.uber.org/zap](https://pkg.go.dev/go.uber.org/zap) ([MIT](https://github.com/uber-go/zap/blob/v1.27.0/LICENSE)) +- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.39.0:LICENSE)) +- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fd00a4e0:LICENSE)) +- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.25.0:LICENSE)) +- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.41.0:LICENSE)) +- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.15.0:LICENSE)) +- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.33.0:LICENSE)) +- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.32.0:LICENSE)) +- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.26.0:LICENSE)) +- [google.golang.org/genproto/googleapis/api](https://pkg.go.dev/google.golang.org/genproto/googleapis/api) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/api/LICENSE)) +- [google.golang.org/genproto/googleapis/rpc/status](https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/status) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/rpc/LICENSE)) +- [google.golang.org/grpc](https://pkg.go.dev/google.golang.org/grpc) ([Apache-2.0](https://github.com/grpc/grpc-go/blob/v1.72.2/LICENSE)) +- [google.golang.org/protobuf](https://pkg.go.dev/google.golang.org/protobuf) ([BSD-3-Clause](https://github.com/protocolbuffers/protobuf-go/blob/v1.36.6/LICENSE)) +- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) +- [k8s.io/klog/v2](https://pkg.go.dev/k8s.io/klog/v2) ([Apache-2.0](https://github.com/kubernetes/klog/blob/v2.130.1/LICENSE)) + +[cli/cli]: https://github.com/cli/cli diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md new file mode 100644 index 000000000..f175e8641 --- /dev/null +++ b/third-party-licenses.windows.md @@ -0,0 +1,187 @@ +# GitHub CLI dependencies + +The following open source dependencies are used to build the [cli/cli][] GitHub CLI. + +## Go Packages + +Some packages may only be included on certain architectures or operating systems. + + +- [dario.cat/mergo](https://pkg.go.dev/dario.cat/mergo) ([BSD-3-Clause](https://github.com/imdario/mergo/blob/v1.0.1/LICENSE)) +- [github.com/AlecAivazis/survey/v2](https://pkg.go.dev/github.com/AlecAivazis/survey/v2) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/LICENSE)) +- [github.com/AlecAivazis/survey/v2/terminal](https://pkg.go.dev/github.com/AlecAivazis/survey/v2/terminal) ([MIT](https://github.com/AlecAivazis/survey/blob/v2.3.7/terminal/LICENSE.txt)) +- [github.com/MakeNowJust/heredoc](https://pkg.go.dev/github.com/MakeNowJust/heredoc) ([MIT](https://github.com/MakeNowJust/heredoc/blob/v1.0.0/LICENSE)) +- [github.com/Masterminds/goutils](https://pkg.go.dev/github.com/Masterminds/goutils) ([Apache-2.0](https://github.com/Masterminds/goutils/blob/v1.1.1/LICENSE.txt)) +- [github.com/Masterminds/semver/v3](https://pkg.go.dev/github.com/Masterminds/semver/v3) ([MIT](https://github.com/Masterminds/semver/blob/v3.3.0/LICENSE.txt)) +- [github.com/Masterminds/sprig/v3](https://pkg.go.dev/github.com/Masterminds/sprig/v3) ([MIT](https://github.com/Masterminds/sprig/blob/v3.3.0/LICENSE.txt)) +- [github.com/alecthomas/chroma/v2](https://pkg.go.dev/github.com/alecthomas/chroma/v2) ([MIT](https://github.com/alecthomas/chroma/blob/v2.14.0/COPYING)) +- [github.com/asaskevich/govalidator](https://pkg.go.dev/github.com/asaskevich/govalidator) ([MIT](https://github.com/asaskevich/govalidator/blob/a9d515a09cc2/LICENSE)) +- [github.com/atotto/clipboard](https://pkg.go.dev/github.com/atotto/clipboard) ([BSD-3-Clause](https://github.com/atotto/clipboard/blob/v0.1.4/LICENSE)) +- [github.com/aymanbagabas/go-osc52/v2](https://pkg.go.dev/github.com/aymanbagabas/go-osc52/v2) ([MIT](https://github.com/aymanbagabas/go-osc52/blob/v2.0.1/LICENSE)) +- [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) +- [github.com/blang/semver](https://pkg.go.dev/github.com/blang/semver) ([MIT](https://github.com/blang/semver/blob/v3.5.1/LICENSE)) +- [github.com/briandowns/spinner](https://pkg.go.dev/github.com/briandowns/spinner) ([Apache-2.0](https://github.com/briandowns/spinner/blob/v1.18.1/LICENSE)) +- [github.com/catppuccin/go](https://pkg.go.dev/github.com/catppuccin/go) ([MIT](https://github.com/catppuccin/go/blob/v0.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v4](https://pkg.go.dev/github.com/cenkalti/backoff/v4) ([MIT](https://github.com/cenkalti/backoff/blob/v4.3.0/LICENSE)) +- [github.com/cenkalti/backoff/v5](https://pkg.go.dev/github.com/cenkalti/backoff/v5) ([MIT](https://github.com/cenkalti/backoff/blob/v5.0.2/LICENSE)) +- [github.com/charmbracelet/bubbles](https://pkg.go.dev/github.com/charmbracelet/bubbles) ([MIT](https://github.com/charmbracelet/bubbles/blob/v0.21.0/LICENSE)) +- [github.com/charmbracelet/bubbletea](https://pkg.go.dev/github.com/charmbracelet/bubbletea) ([MIT](https://github.com/charmbracelet/bubbletea/blob/v1.3.4/LICENSE)) +- [github.com/charmbracelet/colorprofile](https://pkg.go.dev/github.com/charmbracelet/colorprofile) ([MIT](https://github.com/charmbracelet/colorprofile/blob/f60798e515dc/LICENSE)) +- [github.com/charmbracelet/glamour](https://pkg.go.dev/github.com/charmbracelet/glamour) ([MIT](https://github.com/charmbracelet/glamour/blob/549f544650e3/LICENSE)) +- [github.com/charmbracelet/huh](https://pkg.go.dev/github.com/charmbracelet/huh) ([MIT](https://github.com/charmbracelet/huh/blob/v0.7.0/LICENSE)) +- [github.com/charmbracelet/lipgloss](https://pkg.go.dev/github.com/charmbracelet/lipgloss) ([MIT](https://github.com/charmbracelet/lipgloss/blob/166f707985bc/LICENSE)) +- [github.com/charmbracelet/x/ansi](https://pkg.go.dev/github.com/charmbracelet/x/ansi) ([MIT](https://github.com/charmbracelet/x/blob/ansi/v0.8.0/ansi/LICENSE)) +- [github.com/charmbracelet/x/cellbuf](https://pkg.go.dev/github.com/charmbracelet/x/cellbuf) ([MIT](https://github.com/charmbracelet/x/blob/cellbuf/v0.0.13/cellbuf/LICENSE)) +- [github.com/charmbracelet/x/exp/strings](https://pkg.go.dev/github.com/charmbracelet/x/exp/strings) ([MIT](https://github.com/charmbracelet/x/blob/212f7b056ed0/exp/strings/LICENSE)) +- [github.com/charmbracelet/x/term](https://pkg.go.dev/github.com/charmbracelet/x/term) ([MIT](https://github.com/charmbracelet/x/blob/term/v0.2.1/term/LICENSE)) +- [github.com/cli/browser](https://pkg.go.dev/github.com/cli/browser) ([BSD-2-Clause](https://github.com/cli/browser/blob/v1.3.0/LICENSE)) +- [github.com/cli/go-gh/v2](https://pkg.go.dev/github.com/cli/go-gh/v2) ([MIT](https://github.com/cli/go-gh/blob/v2.12.1/LICENSE)) +- [github.com/cli/oauth](https://pkg.go.dev/github.com/cli/oauth) ([MIT](https://github.com/cli/oauth/blob/v1.1.1/LICENSE)) +- [github.com/cli/safeexec](https://pkg.go.dev/github.com/cli/safeexec) ([BSD-2-Clause](https://github.com/cli/safeexec/blob/v1.0.1/LICENSE)) +- [github.com/cli/shurcooL-graphql](https://pkg.go.dev/github.com/cli/shurcooL-graphql) ([MIT](https://github.com/cli/shurcooL-graphql/blob/v0.0.4/LICENSE)) +- [github.com/containerd/stargz-snapshotter/estargz](https://pkg.go.dev/github.com/containerd/stargz-snapshotter/estargz) ([Apache-2.0](https://github.com/containerd/stargz-snapshotter/blob/estargz/v0.16.3/estargz/LICENSE)) +- [github.com/cpuguy83/go-md2man/v2/md2man](https://pkg.go.dev/github.com/cpuguy83/go-md2man/v2/md2man) ([MIT](https://github.com/cpuguy83/go-md2man/blob/v2.0.7/LICENSE.md)) +- [github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer](https://pkg.go.dev/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer) ([Apache-2.0](https://github.com/cyberphone/json-canonicalization/blob/57a0ce2678a7/LICENSE)) +- [github.com/danieljoos/wincred](https://pkg.go.dev/github.com/danieljoos/wincred) ([MIT](https://github.com/danieljoos/wincred/blob/v1.2.2/LICENSE)) +- [github.com/davecgh/go-spew/spew](https://pkg.go.dev/github.com/davecgh/go-spew/spew) ([ISC](https://github.com/davecgh/go-spew/blob/d8f796af33cc/LICENSE)) +- [github.com/digitorus/pkcs7](https://pkg.go.dev/github.com/digitorus/pkcs7) ([MIT](https://github.com/digitorus/pkcs7/blob/3a137a874352/LICENSE)) +- [github.com/digitorus/timestamp](https://pkg.go.dev/github.com/digitorus/timestamp) ([BSD-2-Clause](https://github.com/digitorus/timestamp/blob/220c5c2851b7/LICENSE)) +- [github.com/distribution/reference](https://pkg.go.dev/github.com/distribution/reference) ([Apache-2.0](https://github.com/distribution/reference/blob/v0.6.0/LICENSE)) +- [github.com/dlclark/regexp2](https://pkg.go.dev/github.com/dlclark/regexp2) ([MIT](https://github.com/dlclark/regexp2/blob/v1.11.0/LICENSE)) +- [github.com/docker/cli/cli/config](https://pkg.go.dev/github.com/docker/cli/cli/config) ([Apache-2.0](https://github.com/docker/cli/blob/v28.2.2/LICENSE)) +- [github.com/docker/distribution/registry/client/auth/challenge](https://pkg.go.dev/github.com/docker/distribution/registry/client/auth/challenge) ([Apache-2.0](https://github.com/docker/distribution/blob/v2.8.3/LICENSE)) +- [github.com/docker/docker-credential-helpers](https://pkg.go.dev/github.com/docker/docker-credential-helpers) ([MIT](https://github.com/docker/docker-credential-helpers/blob/v0.9.3/LICENSE)) +- [github.com/dustin/go-humanize](https://pkg.go.dev/github.com/dustin/go-humanize) ([MIT](https://github.com/dustin/go-humanize/blob/v1.0.1/LICENSE)) +- [github.com/erikgeiser/coninput](https://pkg.go.dev/github.com/erikgeiser/coninput) ([MIT](https://github.com/erikgeiser/coninput/blob/1c3628e74d0f/LICENSE)) +- [github.com/fatih/color](https://pkg.go.dev/github.com/fatih/color) ([MIT](https://github.com/fatih/color/blob/v1.16.0/LICENSE.md)) +- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE)) +- [github.com/gabriel-vasile/mimetype](https://pkg.go.dev/github.com/gabriel-vasile/mimetype) ([MIT](https://github.com/gabriel-vasile/mimetype/blob/v1.4.9/LICENSE)) +- [github.com/gdamore/encoding](https://pkg.go.dev/github.com/gdamore/encoding) ([Apache-2.0](https://github.com/gdamore/encoding/blob/v1.0.0/LICENSE)) +- [github.com/gdamore/tcell/v2](https://pkg.go.dev/github.com/gdamore/tcell/v2) ([Apache-2.0](https://github.com/gdamore/tcell/blob/v2.5.4/LICENSE)) +- [github.com/go-chi/chi](https://pkg.go.dev/github.com/go-chi/chi) ([MIT](https://github.com/go-chi/chi/blob/v4.1.2/LICENSE)) +- [github.com/go-jose/go-jose/v4](https://pkg.go.dev/github.com/go-jose/go-jose/v4) ([Apache-2.0](https://github.com/go-jose/go-jose/blob/v4.0.5/LICENSE)) +- [github.com/go-jose/go-jose/v4/json](https://pkg.go.dev/github.com/go-jose/go-jose/v4/json) ([BSD-3-Clause](https://github.com/go-jose/go-jose/blob/v4.0.5/json/LICENSE)) +- [github.com/go-logr/logr](https://pkg.go.dev/github.com/go-logr/logr) ([Apache-2.0](https://github.com/go-logr/logr/blob/v1.4.3/LICENSE)) +- [github.com/go-logr/stdr](https://pkg.go.dev/github.com/go-logr/stdr) ([Apache-2.0](https://github.com/go-logr/stdr/blob/v1.2.2/LICENSE)) +- [github.com/go-openapi/analysis](https://pkg.go.dev/github.com/go-openapi/analysis) ([Apache-2.0](https://github.com/go-openapi/analysis/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/errors](https://pkg.go.dev/github.com/go-openapi/errors) ([Apache-2.0](https://github.com/go-openapi/errors/blob/v0.22.1/LICENSE)) +- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/jsonreference](https://pkg.go.dev/github.com/go-openapi/jsonreference) ([Apache-2.0](https://github.com/go-openapi/jsonreference/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/loads](https://pkg.go.dev/github.com/go-openapi/loads) ([Apache-2.0](https://github.com/go-openapi/loads/blob/v0.22.0/LICENSE)) +- [github.com/go-openapi/runtime](https://pkg.go.dev/github.com/go-openapi/runtime) ([Apache-2.0](https://github.com/go-openapi/runtime/blob/v0.28.0/LICENSE)) +- [github.com/go-openapi/runtime/middleware/denco](https://pkg.go.dev/github.com/go-openapi/runtime/middleware/denco) ([MIT](https://github.com/go-openapi/runtime/blob/v0.28.0/middleware/denco/LICENSE)) +- [github.com/go-openapi/spec](https://pkg.go.dev/github.com/go-openapi/spec) ([Apache-2.0](https://github.com/go-openapi/spec/blob/v0.21.0/LICENSE)) +- [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) +- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) +- [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE)) +- [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v0.0.4/LICENSE)) +- [github.com/google/certificate-transparency-go](https://pkg.go.dev/github.com/google/certificate-transparency-go) ([Apache-2.0](https://github.com/google/certificate-transparency-go/blob/v1.3.1/LICENSE)) +- [github.com/google/go-containerregistry](https://pkg.go.dev/github.com/google/go-containerregistry) ([Apache-2.0](https://github.com/google/go-containerregistry/blob/v0.20.6/LICENSE)) +- [github.com/google/shlex](https://pkg.go.dev/github.com/google/shlex) ([Apache-2.0](https://github.com/google/shlex/blob/e7afc7fbc510/COPYING)) +- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) +- [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) +- [github.com/gorilla/websocket](https://pkg.go.dev/github.com/gorilla/websocket) ([BSD-2-Clause](https://github.com/gorilla/websocket/blob/v1.5.3/LICENSE)) +- [github.com/hashicorp/errwrap](https://pkg.go.dev/github.com/hashicorp/errwrap) ([MPL-2.0](https://github.com/hashicorp/errwrap/blob/v1.1.0/LICENSE)) +- [github.com/hashicorp/go-multierror](https://pkg.go.dev/github.com/hashicorp/go-multierror) ([MPL-2.0](https://github.com/hashicorp/go-multierror/blob/v1.1.1/LICENSE)) +- [github.com/hashicorp/go-version](https://pkg.go.dev/github.com/hashicorp/go-version) ([MPL-2.0](https://github.com/hashicorp/go-version/blob/v1.3.0/LICENSE)) +- [github.com/henvic/httpretty](https://pkg.go.dev/github.com/henvic/httpretty) ([MIT](https://github.com/henvic/httpretty/blob/v0.1.4/LICENSE.md)) +- [github.com/huandu/xstrings](https://pkg.go.dev/github.com/huandu/xstrings) ([MIT](https://github.com/huandu/xstrings/blob/v1.5.0/LICENSE)) +- [github.com/in-toto/attestation/go/v1](https://pkg.go.dev/github.com/in-toto/attestation/go/v1) ([Apache-2.0](https://github.com/in-toto/attestation/blob/v1.1.2/LICENSE)) +- [github.com/in-toto/in-toto-golang/in_toto](https://pkg.go.dev/github.com/in-toto/in-toto-golang/in_toto) ([Apache-2.0](https://github.com/in-toto/in-toto-golang/blob/v0.9.0/LICENSE)) +- [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) +- [github.com/itchyny/gojq](https://pkg.go.dev/github.com/itchyny/gojq) ([MIT](https://github.com/itchyny/gojq/blob/v0.12.15/LICENSE)) +- [github.com/itchyny/timefmt-go](https://pkg.go.dev/github.com/itchyny/timefmt-go) ([MIT](https://github.com/itchyny/timefmt-go/blob/v0.1.5/LICENSE)) +- [github.com/jedisct1/go-minisign](https://pkg.go.dev/github.com/jedisct1/go-minisign) ([MIT](https://github.com/jedisct1/go-minisign/blob/1c139d1cc84b/LICENSE)) +- [github.com/joho/godotenv](https://pkg.go.dev/github.com/joho/godotenv) ([MIT](https://github.com/joho/godotenv/blob/v1.5.1/LICENCE)) +- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) +- [github.com/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE)) +- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.18.0/LICENSE)) +- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.18.0/internal/snapref/LICENSE)) +- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.18.0/zstd/internal/xxhash/LICENSE.txt)) +- [github.com/letsencrypt/boulder](https://pkg.go.dev/github.com/letsencrypt/boulder) ([MPL-2.0](https://github.com/letsencrypt/boulder/blob/de9c06129bec/LICENSE.txt)) +- [github.com/lucasb-eyer/go-colorful](https://pkg.go.dev/github.com/lucasb-eyer/go-colorful) ([MIT](https://github.com/lucasb-eyer/go-colorful/blob/v1.2.0/LICENSE)) +- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.9.0/LICENSE)) +- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.14/LICENSE)) +- [github.com/mattn/go-isatty](https://pkg.go.dev/github.com/mattn/go-isatty) ([MIT](https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE)) +- [github.com/mattn/go-localereader](https://pkg.go.dev/github.com/mattn/go-localereader) ([Unknown](Unknown)) +- [github.com/mattn/go-runewidth](https://pkg.go.dev/github.com/mattn/go-runewidth) ([MIT](https://github.com/mattn/go-runewidth/blob/v0.0.16/LICENSE)) +- [github.com/mgutz/ansi](https://pkg.go.dev/github.com/mgutz/ansi) ([MIT](https://github.com/mgutz/ansi/blob/d51e80ef957d/LICENSE)) +- [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) +- [github.com/microsoft/dev-tunnels/go/tunnels](https://pkg.go.dev/github.com/microsoft/dev-tunnels/go/tunnels) ([MIT](https://github.com/microsoft/dev-tunnels/blob/v0.0.25/LICENSE)) +- [github.com/mitchellh/copystructure](https://pkg.go.dev/github.com/mitchellh/copystructure) ([MIT](https://github.com/mitchellh/copystructure/blob/v1.2.0/LICENSE)) +- [github.com/mitchellh/go-homedir](https://pkg.go.dev/github.com/mitchellh/go-homedir) ([MIT](https://github.com/mitchellh/go-homedir/blob/v1.1.0/LICENSE)) +- [github.com/mitchellh/hashstructure/v2](https://pkg.go.dev/github.com/mitchellh/hashstructure/v2) ([MIT](https://github.com/mitchellh/hashstructure/blob/v2.0.2/LICENSE)) +- [github.com/mitchellh/mapstructure](https://pkg.go.dev/github.com/mitchellh/mapstructure) ([MIT](https://github.com/mitchellh/mapstructure/blob/v1.5.0/LICENSE)) +- [github.com/mitchellh/reflectwalk](https://pkg.go.dev/github.com/mitchellh/reflectwalk) ([MIT](https://github.com/mitchellh/reflectwalk/blob/v1.0.2/LICENSE)) +- [github.com/muesli/ansi](https://pkg.go.dev/github.com/muesli/ansi) ([MIT](https://github.com/muesli/ansi/blob/276c6243b2f6/LICENSE)) +- [github.com/muesli/cancelreader](https://pkg.go.dev/github.com/muesli/cancelreader) ([MIT](https://github.com/muesli/cancelreader/blob/v0.2.2/LICENSE)) +- [github.com/muesli/reflow](https://pkg.go.dev/github.com/muesli/reflow) ([MIT](https://github.com/muesli/reflow/blob/v0.3.0/LICENSE)) +- [github.com/muesli/termenv](https://pkg.go.dev/github.com/muesli/termenv) ([MIT](https://github.com/muesli/termenv/blob/v0.16.0/LICENSE)) +- [github.com/muhammadmuzzammil1998/jsonc](https://pkg.go.dev/github.com/muhammadmuzzammil1998/jsonc) ([MIT](https://github.com/muhammadmuzzammil1998/jsonc/blob/615b0916ca38/LICENSE)) +- [github.com/oklog/ulid](https://pkg.go.dev/github.com/oklog/ulid) ([Apache-2.0](https://github.com/oklog/ulid/blob/v1.3.1/LICENSE)) +- [github.com/opencontainers/go-digest](https://pkg.go.dev/github.com/opencontainers/go-digest) ([Apache-2.0](https://github.com/opencontainers/go-digest/blob/v1.0.0/LICENSE)) +- [github.com/opencontainers/image-spec/specs-go](https://pkg.go.dev/github.com/opencontainers/image-spec/specs-go) ([Apache-2.0](https://github.com/opencontainers/image-spec/blob/v1.1.1/LICENSE)) +- [github.com/opentracing/opentracing-go](https://pkg.go.dev/github.com/opentracing/opentracing-go) ([Apache-2.0](https://github.com/opentracing/opentracing-go/blob/v1.2.0/LICENSE)) +- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) +- [github.com/pkg/errors](https://pkg.go.dev/github.com/pkg/errors) ([BSD-2-Clause](https://github.com/pkg/errors/blob/v0.9.1/LICENSE)) +- [github.com/pmezard/go-difflib/difflib](https://pkg.go.dev/github.com/pmezard/go-difflib/difflib) ([BSD-3-Clause](https://github.com/pmezard/go-difflib/blob/5d4384ee4fb2/LICENSE)) +- [github.com/rivo/tview](https://pkg.go.dev/github.com/rivo/tview) ([MIT](https://github.com/rivo/tview/blob/c4a7e501810d/LICENSE.txt)) +- [github.com/rivo/uniseg](https://pkg.go.dev/github.com/rivo/uniseg) ([MIT](https://github.com/rivo/uniseg/blob/v0.4.7/LICENSE.txt)) +- [github.com/rodaine/table](https://pkg.go.dev/github.com/rodaine/table) ([MIT](https://github.com/rodaine/table/blob/v1.0.1/license)) +- [github.com/russross/blackfriday/v2](https://pkg.go.dev/github.com/russross/blackfriday/v2) ([BSD-2-Clause](https://github.com/russross/blackfriday/blob/v2.1.0/LICENSE.txt)) +- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.7.0/LICENSE)) +- [github.com/sassoftware/relic/lib](https://pkg.go.dev/github.com/sassoftware/relic/lib) ([Apache-2.0](https://github.com/sassoftware/relic/blob/v7.2.1/LICENSE)) +- [github.com/secure-systems-lab/go-securesystemslib](https://pkg.go.dev/github.com/secure-systems-lab/go-securesystemslib) ([MIT](https://github.com/secure-systems-lab/go-securesystemslib/blob/v0.9.0/LICENSE)) +- [github.com/shibumi/go-pathspec](https://pkg.go.dev/github.com/shibumi/go-pathspec) ([Apache-2.0](https://github.com/shibumi/go-pathspec/blob/v1.3.0/LICENSE)) +- [github.com/shopspring/decimal](https://pkg.go.dev/github.com/shopspring/decimal) ([MIT](https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE)) +- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/18a1ae0e79dc/LICENSE)) +- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) +- [github.com/sigstore/protobuf-specs/gen/pb-go](https://pkg.go.dev/github.com/sigstore/protobuf-specs/gen/pb-go) ([Apache-2.0](https://github.com/sigstore/protobuf-specs/blob/v0.4.3/LICENSE)) +- [github.com/sigstore/rekor/pkg](https://pkg.go.dev/github.com/sigstore/rekor/pkg) ([Apache-2.0](https://github.com/sigstore/rekor/blob/v1.3.10/LICENSE)) +- [github.com/sigstore/sigstore-go/pkg](https://pkg.go.dev/github.com/sigstore/sigstore-go/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore-go/blob/v1.0.0/LICENSE)) +- [github.com/sigstore/sigstore/pkg](https://pkg.go.dev/github.com/sigstore/sigstore/pkg) ([Apache-2.0](https://github.com/sigstore/sigstore/blob/v1.9.4/LICENSE)) +- [github.com/sigstore/timestamp-authority/pkg/verification](https://pkg.go.dev/github.com/sigstore/timestamp-authority/pkg/verification) ([Apache-2.0](https://github.com/sigstore/timestamp-authority/blob/v1.2.7/LICENSE)) +- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) +- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) +- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.12.0/LICENSE.txt)) +- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) +- [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.9.1/LICENSE.txt)) +- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE)) +- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE)) +- [github.com/stretchr/objx](https://pkg.go.dev/github.com/stretchr/objx) ([MIT](https://github.com/stretchr/objx/blob/v0.5.2/LICENSE)) +- [github.com/stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify) ([MIT](https://github.com/stretchr/testify/blob/v1.10.0/LICENSE)) +- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) +- [github.com/theupdateframework/go-tuf](https://pkg.go.dev/github.com/theupdateframework/go-tuf) ([BSD-3-Clause](https://github.com/theupdateframework/go-tuf/blob/v0.7.0/LICENSE)) +- [github.com/theupdateframework/go-tuf/v2/metadata](https://pkg.go.dev/github.com/theupdateframework/go-tuf/v2/metadata) ([Apache-2.0](https://github.com/theupdateframework/go-tuf/blob/v2.1.1/LICENSE)) +- [github.com/thlib/go-timezone-local/tzlocal](https://pkg.go.dev/github.com/thlib/go-timezone-local/tzlocal) ([Unlicense](https://github.com/thlib/go-timezone-local/blob/ef149e42d28e/LICENSE)) +- [github.com/titanous/rocacheck](https://pkg.go.dev/github.com/titanous/rocacheck) ([MIT](https://github.com/titanous/rocacheck/blob/afe73141d399/LICENSE)) +- [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) +- [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) +- [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.12/LICENSE)) +- [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.5/LICENSE)) +- [github.com/zalando/go-keyring](https://pkg.go.dev/github.com/zalando/go-keyring) ([MIT](https://github.com/zalando/go-keyring/blob/v0.2.5/LICENSE)) +- [go.mongodb.org/mongo-driver](https://pkg.go.dev/go.mongodb.org/mongo-driver) ([Apache-2.0](https://github.com/mongodb/mongo-go-driver/blob/v1.14.0/LICENSE)) +- [go.opentelemetry.io/auto/sdk](https://pkg.go.dev/go.opentelemetry.io/auto/sdk) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.1.0/sdk/LICENSE)) +- [go.opentelemetry.io/otel](https://pkg.go.dev/go.opentelemetry.io/otel) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/v1.36.0/LICENSE)) +- [go.opentelemetry.io/otel/metric](https://pkg.go.dev/go.opentelemetry.io/otel/metric) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.36.0/metric/LICENSE)) +- [go.opentelemetry.io/otel/trace](https://pkg.go.dev/go.opentelemetry.io/otel/trace) ([Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.36.0/trace/LICENSE)) +- [go.uber.org/multierr](https://pkg.go.dev/go.uber.org/multierr) ([MIT](https://github.com/uber-go/multierr/blob/v1.11.0/LICENSE.txt)) +- [go.uber.org/zap](https://pkg.go.dev/go.uber.org/zap) ([MIT](https://github.com/uber-go/zap/blob/v1.27.0/LICENSE)) +- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.39.0:LICENSE)) +- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fd00a4e0:LICENSE)) +- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.25.0:LICENSE)) +- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.41.0:LICENSE)) +- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.15.0:LICENSE)) +- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.33.0:LICENSE)) +- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.32.0:LICENSE)) +- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.26.0:LICENSE)) +- [google.golang.org/genproto/googleapis/api](https://pkg.go.dev/google.golang.org/genproto/googleapis/api) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/api/LICENSE)) +- [google.golang.org/genproto/googleapis/rpc/status](https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/status) ([Apache-2.0](https://github.com/googleapis/go-genproto/blob/207652e42e2e/googleapis/rpc/LICENSE)) +- [google.golang.org/grpc](https://pkg.go.dev/google.golang.org/grpc) ([Apache-2.0](https://github.com/grpc/grpc-go/blob/v1.72.2/LICENSE)) +- [google.golang.org/protobuf](https://pkg.go.dev/google.golang.org/protobuf) ([BSD-3-Clause](https://github.com/protocolbuffers/protobuf-go/blob/v1.36.6/LICENSE)) +- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) +- [k8s.io/klog/v2](https://pkg.go.dev/k8s.io/klog/v2) ([Apache-2.0](https://github.com/kubernetes/klog/blob/v2.130.1/LICENSE)) + +[cli/cli]: https://github.com/cli/cli diff --git a/third-party/dario.cat/mergo/LICENSE b/third-party/dario.cat/mergo/LICENSE new file mode 100644 index 000000000..686680298 --- /dev/null +++ b/third-party/dario.cat/mergo/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2013 Dario Castañé. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/AlecAivazis/survey/v2/LICENSE b/third-party/github.com/AlecAivazis/survey/v2/LICENSE new file mode 100644 index 000000000..07a709ae2 --- /dev/null +++ b/third-party/github.com/AlecAivazis/survey/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Alec Aivazis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/AlecAivazis/survey/v2/terminal/LICENSE.txt b/third-party/github.com/AlecAivazis/survey/v2/terminal/LICENSE.txt new file mode 100644 index 000000000..ade5fef6d --- /dev/null +++ b/third-party/github.com/AlecAivazis/survey/v2/terminal/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 Takashi Kokubun + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/MakeNowJust/heredoc/LICENSE b/third-party/github.com/MakeNowJust/heredoc/LICENSE new file mode 100644 index 000000000..6d0eb9d5d --- /dev/null +++ b/third-party/github.com/MakeNowJust/heredoc/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 TSUYUSATO Kitsune + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/Masterminds/goutils/LICENSE.txt b/third-party/github.com/Masterminds/goutils/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/Masterminds/goutils/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/Masterminds/semver/v3/LICENSE.txt b/third-party/github.com/Masterminds/semver/v3/LICENSE.txt new file mode 100644 index 000000000..9ff7da9c4 --- /dev/null +++ b/third-party/github.com/Masterminds/semver/v3/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (C) 2014-2019, Matt Butcher and Matt Farina + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/Masterminds/sprig/v3/LICENSE.txt b/third-party/github.com/Masterminds/sprig/v3/LICENSE.txt new file mode 100644 index 000000000..f311b1eaa --- /dev/null +++ b/third-party/github.com/Masterminds/sprig/v3/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (C) 2013-2020 Masterminds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/alecthomas/chroma/v2/COPYING b/third-party/github.com/alecthomas/chroma/v2/COPYING new file mode 100644 index 000000000..92dc39f70 --- /dev/null +++ b/third-party/github.com/alecthomas/chroma/v2/COPYING @@ -0,0 +1,19 @@ +Copyright (C) 2017 Alec Thomas + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/alessio/shellescape/LICENSE b/third-party/github.com/alessio/shellescape/LICENSE new file mode 100644 index 000000000..9f760679f --- /dev/null +++ b/third-party/github.com/alessio/shellescape/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Alessio Treglia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/asaskevich/govalidator/LICENSE b/third-party/github.com/asaskevich/govalidator/LICENSE new file mode 100644 index 000000000..cacba9102 --- /dev/null +++ b/third-party/github.com/asaskevich/govalidator/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2020 Alex Saskevich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third-party/github.com/atotto/clipboard/LICENSE b/third-party/github.com/atotto/clipboard/LICENSE new file mode 100644 index 000000000..dee3257b0 --- /dev/null +++ b/third-party/github.com/atotto/clipboard/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2013 Ato Araki. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of @atotto. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/aymanbagabas/go-osc52/v2/LICENSE b/third-party/github.com/aymanbagabas/go-osc52/v2/LICENSE new file mode 100644 index 000000000..25cec1ed4 --- /dev/null +++ b/third-party/github.com/aymanbagabas/go-osc52/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ayman Bagabas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/aymerick/douceur/LICENSE b/third-party/github.com/aymerick/douceur/LICENSE new file mode 100644 index 000000000..6ce87cd37 --- /dev/null +++ b/third-party/github.com/aymerick/douceur/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Aymerick JEHANNE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/third-party/github.com/blang/semver/LICENSE b/third-party/github.com/blang/semver/LICENSE new file mode 100644 index 000000000..5ba5c86fc --- /dev/null +++ b/third-party/github.com/blang/semver/LICENSE @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2014 Benedikt Lang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/third-party/github.com/briandowns/spinner/LICENSE b/third-party/github.com/briandowns/spinner/LICENSE new file mode 100644 index 000000000..dd5b3a58a --- /dev/null +++ b/third-party/github.com/briandowns/spinner/LICENSE @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/third-party/github.com/briandowns/spinner/NOTICE.txt b/third-party/github.com/briandowns/spinner/NOTICE.txt new file mode 100644 index 000000000..95e2a248b --- /dev/null +++ b/third-party/github.com/briandowns/spinner/NOTICE.txt @@ -0,0 +1,4 @@ +Spinner +Copyright (c) 2022 Brian J. Downs +This product is licensed to you under the Apache 2.0 license (the "License"). You may not use this product except in compliance with the Apache 2.0 License. +This product may include a number of subcomponents with separate copyright notices and license terms. Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, as noted in the LICENSE file. diff --git a/third-party/github.com/catppuccin/go/LICENSE b/third-party/github.com/catppuccin/go/LICENSE new file mode 100644 index 000000000..006383b86 --- /dev/null +++ b/third-party/github.com/catppuccin/go/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Catppuccin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cenkalti/backoff/v4/LICENSE b/third-party/github.com/cenkalti/backoff/v4/LICENSE new file mode 100644 index 000000000..89b817996 --- /dev/null +++ b/third-party/github.com/cenkalti/backoff/v4/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Cenk Altı + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/cenkalti/backoff/v5/LICENSE b/third-party/github.com/cenkalti/backoff/v5/LICENSE new file mode 100644 index 000000000..89b817996 --- /dev/null +++ b/third-party/github.com/cenkalti/backoff/v5/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Cenk Altı + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/charmbracelet/bubbles/LICENSE b/third-party/github.com/charmbracelet/bubbles/LICENSE new file mode 100644 index 000000000..31d76c1c6 --- /dev/null +++ b/third-party/github.com/charmbracelet/bubbles/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2023 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/bubbletea/LICENSE b/third-party/github.com/charmbracelet/bubbletea/LICENSE new file mode 100644 index 000000000..31d76c1c6 --- /dev/null +++ b/third-party/github.com/charmbracelet/bubbletea/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2023 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/colorprofile/LICENSE b/third-party/github.com/charmbracelet/colorprofile/LICENSE new file mode 100644 index 000000000..b7974b076 --- /dev/null +++ b/third-party/github.com/charmbracelet/colorprofile/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/glamour/LICENSE b/third-party/github.com/charmbracelet/glamour/LICENSE new file mode 100644 index 000000000..e5a291626 --- /dev/null +++ b/third-party/github.com/charmbracelet/glamour/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2023 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/huh/LICENSE b/third-party/github.com/charmbracelet/huh/LICENSE new file mode 100644 index 000000000..2a08f15d3 --- /dev/null +++ b/third-party/github.com/charmbracelet/huh/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/lipgloss/LICENSE b/third-party/github.com/charmbracelet/lipgloss/LICENSE new file mode 100644 index 000000000..6f5b1fa62 --- /dev/null +++ b/third-party/github.com/charmbracelet/lipgloss/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2023 Charmbracelet, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/x/ansi/LICENSE b/third-party/github.com/charmbracelet/x/ansi/LICENSE new file mode 100644 index 000000000..65a5654e2 --- /dev/null +++ b/third-party/github.com/charmbracelet/x/ansi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/x/cellbuf/LICENSE b/third-party/github.com/charmbracelet/x/cellbuf/LICENSE new file mode 100644 index 000000000..65a5654e2 --- /dev/null +++ b/third-party/github.com/charmbracelet/x/cellbuf/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/x/exp/strings/LICENSE b/third-party/github.com/charmbracelet/x/exp/strings/LICENSE new file mode 100644 index 000000000..65a5654e2 --- /dev/null +++ b/third-party/github.com/charmbracelet/x/exp/strings/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/charmbracelet/x/term/LICENSE b/third-party/github.com/charmbracelet/x/term/LICENSE new file mode 100644 index 000000000..65a5654e2 --- /dev/null +++ b/third-party/github.com/charmbracelet/x/term/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cli/browser/LICENSE b/third-party/github.com/cli/browser/LICENSE new file mode 100644 index 000000000..65f78fb62 --- /dev/null +++ b/third-party/github.com/cli/browser/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2014, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/cli/cli/v2/LICENSE b/third-party/github.com/cli/cli/v2/LICENSE new file mode 100644 index 000000000..b6a58a957 --- /dev/null +++ b/third-party/github.com/cli/cli/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cli/go-gh/v2/LICENSE b/third-party/github.com/cli/go-gh/v2/LICENSE new file mode 100644 index 000000000..af732f027 --- /dev/null +++ b/third-party/github.com/cli/go-gh/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cli/oauth/LICENSE b/third-party/github.com/cli/oauth/LICENSE new file mode 100644 index 000000000..284b811ef --- /dev/null +++ b/third-party/github.com/cli/oauth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 GitHub, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cli/safeexec/LICENSE b/third-party/github.com/cli/safeexec/LICENSE new file mode 100644 index 000000000..ca498575a --- /dev/null +++ b/third-party/github.com/cli/safeexec/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2020, GitHub Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/cli/shurcooL-graphql/LICENSE b/third-party/github.com/cli/shurcooL-graphql/LICENSE new file mode 100644 index 000000000..ca4c77642 --- /dev/null +++ b/third-party/github.com/cli/shurcooL-graphql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Dmitri Shuralyov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/containerd/stargz-snapshotter/estargz/LICENSE b/third-party/github.com/containerd/stargz-snapshotter/estargz/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/containerd/stargz-snapshotter/estargz/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/cpuguy83/go-md2man/v2/md2man/LICENSE.md b/third-party/github.com/cpuguy83/go-md2man/v2/md2man/LICENSE.md new file mode 100644 index 000000000..1cade6cef --- /dev/null +++ b/third-party/github.com/cpuguy83/go-md2man/v2/md2man/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Brian Goff + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer/LICENSE b/third-party/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer/LICENSE new file mode 100644 index 000000000..591211595 --- /dev/null +++ b/third-party/github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer/LICENSE @@ -0,0 +1,13 @@ + Copyright 2018 Anders Rundgren + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/danieljoos/wincred/LICENSE b/third-party/github.com/danieljoos/wincred/LICENSE new file mode 100644 index 000000000..2f436f1b3 --- /dev/null +++ b/third-party/github.com/danieljoos/wincred/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Daniel Joos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third-party/github.com/davecgh/go-spew/spew/LICENSE b/third-party/github.com/davecgh/go-spew/spew/LICENSE new file mode 100644 index 000000000..bc52e96f2 --- /dev/null +++ b/third-party/github.com/davecgh/go-spew/spew/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2012-2016 Dave Collins + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third-party/github.com/digitorus/pkcs7/LICENSE b/third-party/github.com/digitorus/pkcs7/LICENSE new file mode 100644 index 000000000..75f320908 --- /dev/null +++ b/third-party/github.com/digitorus/pkcs7/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Andrew Smith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/third-party/github.com/digitorus/timestamp/LICENSE b/third-party/github.com/digitorus/timestamp/LICENSE new file mode 100644 index 000000000..dac8634ce --- /dev/null +++ b/third-party/github.com/digitorus/timestamp/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2017, Digitorus B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/distribution/reference/LICENSE b/third-party/github.com/distribution/reference/LICENSE new file mode 100644 index 000000000..e06d20818 --- /dev/null +++ b/third-party/github.com/distribution/reference/LICENSE @@ -0,0 +1,202 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/third-party/github.com/dlclark/regexp2/LICENSE b/third-party/github.com/dlclark/regexp2/LICENSE new file mode 100644 index 000000000..fe83dfdc9 --- /dev/null +++ b/third-party/github.com/dlclark/regexp2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Doug Clark + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/docker/cli/cli/config/LICENSE b/third-party/github.com/docker/cli/cli/config/LICENSE new file mode 100644 index 000000000..9c8e20ab8 --- /dev/null +++ b/third-party/github.com/docker/cli/cli/config/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2013-2017 Docker, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/docker/cli/cli/config/NOTICE b/third-party/github.com/docker/cli/cli/config/NOTICE new file mode 100644 index 000000000..1c40faaec --- /dev/null +++ b/third-party/github.com/docker/cli/cli/config/NOTICE @@ -0,0 +1,19 @@ +Docker +Copyright 2012-2017 Docker, Inc. + +This product includes software developed at Docker, Inc. (https://www.docker.com). + +This product contains software (https://github.com/creack/pty) developed +by Keith Rarick, licensed under the MIT License. + +The following is courtesy of our legal counsel: + + +Use and transfer of Docker may be subject to certain restrictions by the +United States and other governments. +It is your responsibility to ensure that your use and/or transfer does not +violate applicable laws. + +For more information, see https://www.bis.doc.gov + +See also https://www.apache.org/dev/crypto.html and/or seek legal counsel. diff --git a/third-party/github.com/docker/distribution/registry/client/auth/challenge/LICENSE b/third-party/github.com/docker/distribution/registry/client/auth/challenge/LICENSE new file mode 100644 index 000000000..e06d20818 --- /dev/null +++ b/third-party/github.com/docker/distribution/registry/client/auth/challenge/LICENSE @@ -0,0 +1,202 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/third-party/github.com/docker/docker-credential-helpers/LICENSE b/third-party/github.com/docker/docker-credential-helpers/LICENSE new file mode 100644 index 000000000..1ea555e2a --- /dev/null +++ b/third-party/github.com/docker/docker-credential-helpers/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 David Calavera + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/dustin/go-humanize/LICENSE b/third-party/github.com/dustin/go-humanize/LICENSE new file mode 100644 index 000000000..8d9a94a90 --- /dev/null +++ b/third-party/github.com/dustin/go-humanize/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2005-2008 Dustin Sallings + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/third-party/github.com/erikgeiser/coninput/LICENSE b/third-party/github.com/erikgeiser/coninput/LICENSE new file mode 100644 index 000000000..83c244082 --- /dev/null +++ b/third-party/github.com/erikgeiser/coninput/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Erik G. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/fatih/color/LICENSE.md b/third-party/github.com/fatih/color/LICENSE.md new file mode 100644 index 000000000..25fdaf639 --- /dev/null +++ b/third-party/github.com/fatih/color/LICENSE.md @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 Fatih Arslan + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/fsnotify/fsnotify/LICENSE b/third-party/github.com/fsnotify/fsnotify/LICENSE new file mode 100644 index 000000000..fb03ade75 --- /dev/null +++ b/third-party/github.com/fsnotify/fsnotify/LICENSE @@ -0,0 +1,25 @@ +Copyright © 2012 The Go Authors. All rights reserved. +Copyright © fsnotify Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used + to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/gabriel-vasile/mimetype/LICENSE b/third-party/github.com/gabriel-vasile/mimetype/LICENSE new file mode 100644 index 000000000..13b61daa5 --- /dev/null +++ b/third-party/github.com/gabriel-vasile/mimetype/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Gabriel Vasile + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/gdamore/encoding/LICENSE b/third-party/github.com/gdamore/encoding/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/gdamore/encoding/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/gdamore/tcell/v2/LICENSE b/third-party/github.com/gdamore/tcell/v2/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/gdamore/tcell/v2/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-chi/chi/LICENSE b/third-party/github.com/go-chi/chi/LICENSE new file mode 100644 index 000000000..d99f02ffa --- /dev/null +++ b/third-party/github.com/go-chi/chi/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/go-jose/go-jose/v4/LICENSE b/third-party/github.com/go-jose/go-jose/v4/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-jose/go-jose/v4/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-jose/go-jose/v4/json/LICENSE b/third-party/github.com/go-jose/go-jose/v4/json/LICENSE new file mode 100644 index 000000000..744875676 --- /dev/null +++ b/third-party/github.com/go-jose/go-jose/v4/json/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/go-logr/logr/LICENSE b/third-party/github.com/go-logr/logr/LICENSE new file mode 100644 index 000000000..8dada3eda --- /dev/null +++ b/third-party/github.com/go-logr/logr/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-logr/stdr/LICENSE b/third-party/github.com/go-logr/stdr/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/github.com/go-logr/stdr/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/analysis/LICENSE b/third-party/github.com/go-openapi/analysis/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/analysis/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/errors/LICENSE b/third-party/github.com/go-openapi/errors/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/errors/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/jsonpointer/LICENSE b/third-party/github.com/go-openapi/jsonpointer/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/jsonpointer/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/jsonreference/LICENSE b/third-party/github.com/go-openapi/jsonreference/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/jsonreference/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/loads/LICENSE b/third-party/github.com/go-openapi/loads/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/loads/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/runtime/LICENSE b/third-party/github.com/go-openapi/runtime/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/runtime/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/runtime/middleware/denco/LICENSE b/third-party/github.com/go-openapi/runtime/middleware/denco/LICENSE new file mode 100644 index 000000000..e65039ad8 --- /dev/null +++ b/third-party/github.com/go-openapi/runtime/middleware/denco/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 Naoya Inada + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/go-openapi/spec/LICENSE b/third-party/github.com/go-openapi/spec/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/spec/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/strfmt/LICENSE b/third-party/github.com/go-openapi/strfmt/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/strfmt/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/swag/LICENSE b/third-party/github.com/go-openapi/swag/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/swag/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-openapi/validate/LICENSE b/third-party/github.com/go-openapi/validate/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/go-openapi/validate/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/go-viper/mapstructure/v2/LICENSE b/third-party/github.com/go-viper/mapstructure/v2/LICENSE new file mode 100644 index 000000000..f9c841a51 --- /dev/null +++ b/third-party/github.com/go-viper/mapstructure/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/godbus/dbus/v5/LICENSE b/third-party/github.com/godbus/dbus/v5/LICENSE new file mode 100644 index 000000000..670d88fca --- /dev/null +++ b/third-party/github.com/godbus/dbus/v5/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2013, Georg Reinke (), Google +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/golang/snappy/LICENSE b/third-party/github.com/golang/snappy/LICENSE new file mode 100644 index 000000000..6050c10f4 --- /dev/null +++ b/third-party/github.com/golang/snappy/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/google/certificate-transparency-go/LICENSE b/third-party/github.com/google/certificate-transparency-go/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/google/certificate-transparency-go/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/google/go-containerregistry/LICENSE b/third-party/github.com/google/go-containerregistry/LICENSE new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/third-party/github.com/google/go-containerregistry/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third-party/github.com/google/shlex/COPYING b/third-party/github.com/google/shlex/COPYING new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/google/shlex/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/google/uuid/LICENSE b/third-party/github.com/google/uuid/LICENSE new file mode 100644 index 000000000..5dc68268d --- /dev/null +++ b/third-party/github.com/google/uuid/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/gorilla/css/scanner/LICENSE b/third-party/github.com/gorilla/css/scanner/LICENSE new file mode 100644 index 000000000..ee0d53cef --- /dev/null +++ b/third-party/github.com/gorilla/css/scanner/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2023 The Gorilla Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/third-party/github.com/gorilla/websocket/LICENSE b/third-party/github.com/gorilla/websocket/LICENSE new file mode 100644 index 000000000..9171c9722 --- /dev/null +++ b/third-party/github.com/gorilla/websocket/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/hashicorp/errwrap/LICENSE b/third-party/github.com/hashicorp/errwrap/LICENSE new file mode 100644 index 000000000..c33dcc7c9 --- /dev/null +++ b/third-party/github.com/hashicorp/errwrap/LICENSE @@ -0,0 +1,354 @@ +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. “Contributor” + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. “Contributor Version” + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor’s Contribution. + +1.3. “Contribution” + + means Covered Software of a particular Contributor. + +1.4. “Covered Software” + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. “Incompatible With Secondary Licenses” + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of version + 1.1 or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. “Executable Form” + + means any form of the work other than Source Code Form. + +1.7. “Larger Work” + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + +1.8. “License” + + means this document. + +1.9. “Licensable” + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed by + this License. + +1.10. “Modifications” + + means any of the following: + + a. any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. “Patent Claims” of a Contributor + + means any patent claim(s), including without limitation, method, process, + and apparatus claims, in any patent Licensable by such Contributor that + would be infringed, but for the grant of the License, by the making, + using, selling, offering for sale, having made, import, or transfer of + either its Contributions or its Contributor Version. + +1.12. “Secondary License” + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. “Source Code Form” + + means the form of the work preferred for making modifications. + +1.14. “You” (or “Your”) + + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, “control” means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its Contributions + or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding Section + 2.1(b) above, no patent license is granted by a Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party’s + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions + are its original creation(s) or it has sufficient rights to grant the + rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this License, + or sublicense it under different terms, provided that the license for + the Executable Form does not attempt to limit or alter the recipients’ + rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations + of liability) contained within the Source Code Form of the Covered + Software, except that You may alter any license notices to the extent + required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, judicial + order, or regulation then You must: (a) comply with the terms of this License + to the maximum extent possible; and (b) describe the limitations and the code + they affect. Such description must be placed in a text file included with all + distributions of the Covered Software under this License. Except to the + extent prohibited by statute or regulation, such description must be + sufficiently detailed for a recipient of ordinary skill to be able to + understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing basis, + if such Contributor fails to notify You of the non-compliance by some + reasonable means prior to 60 days after You have come back into compliance. + Moreover, Your grants from a particular Contributor are reinstated on an + ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of + non-compliance with this License from such Contributor, and You become + compliant prior to 30 days after Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an “as is” basis, without + warranty of any kind, either expressed, implied, or statutory, including, + without limitation, warranties that the Covered Software is free of defects, + merchantable, fit for a particular purpose or non-infringing. The entire + risk as to the quality and performance of the Covered Software is with You. + Should any Covered Software prove defective in any respect, You (not any + Contributor) assume the cost of any necessary servicing, repair, or + correction. This disclaimer of warranty constitutes an essential part of this + License. No use of any Covered Software is authorized under this License + except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from such + party’s negligence to the extent applicable law prohibits such limitation. + Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts of + a jurisdiction where the defendant maintains its principal place of business + and such litigation shall be governed by laws of that jurisdiction, without + reference to its conflict-of-law provisions. Nothing in this Section shall + prevent a party’s ability to bring cross-claims or counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject matter + hereof. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable. Any law or regulation which provides that the language of a + contract shall be construed against the drafter shall not be used to construe + this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of + the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any + references to the name of the license steward (except to note that such + modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is “Incompatible + With Secondary Licenses”, as defined by + the Mozilla Public License, v. 2.0. + diff --git a/third-party/github.com/hashicorp/errwrap/README.md b/third-party/github.com/hashicorp/errwrap/README.md new file mode 100644 index 000000000..444df08f8 --- /dev/null +++ b/third-party/github.com/hashicorp/errwrap/README.md @@ -0,0 +1,89 @@ +# errwrap + +`errwrap` is a package for Go that formalizes the pattern of wrapping errors +and checking if an error contains another error. + +There is a common pattern in Go of taking a returned `error` value and +then wrapping it (such as with `fmt.Errorf`) before returning it. The problem +with this pattern is that you completely lose the original `error` structure. + +Arguably the _correct_ approach is that you should make a custom structure +implementing the `error` interface, and have the original error as a field +on that structure, such [as this example](http://golang.org/pkg/os/#PathError). +This is a good approach, but you have to know the entire chain of possible +rewrapping that happens, when you might just care about one. + +`errwrap` formalizes this pattern (it doesn't matter what approach you use +above) by giving a single interface for wrapping errors, checking if a specific +error is wrapped, and extracting that error. + +## Installation and Docs + +Install using `go get github.com/hashicorp/errwrap`. + +Full documentation is available at +http://godoc.org/github.com/hashicorp/errwrap + +## Usage + +#### Basic Usage + +Below is a very basic example of its usage: + +```go +// A function that always returns an error, but wraps it, like a real +// function might. +func tryOpen() error { + _, err := os.Open("/i/dont/exist") + if err != nil { + return errwrap.Wrapf("Doesn't exist: {{err}}", err) + } + + return nil +} + +func main() { + err := tryOpen() + + // We can use the Contains helpers to check if an error contains + // another error. It is safe to do this with a nil error, or with + // an error that doesn't even use the errwrap package. + if errwrap.Contains(err, "does not exist") { + // Do something + } + if errwrap.ContainsType(err, new(os.PathError)) { + // Do something + } + + // Or we can use the associated `Get` functions to just extract + // a specific error. This would return nil if that specific error doesn't + // exist. + perr := errwrap.GetType(err, new(os.PathError)) +} +``` + +#### Custom Types + +If you're already making custom types that properly wrap errors, then +you can get all the functionality of `errwraps.Contains` and such by +implementing the `Wrapper` interface with just one function. Example: + +```go +type AppError { + Code ErrorCode + Err error +} + +func (e *AppError) WrappedErrors() []error { + return []error{e.Err} +} +``` + +Now this works: + +```go +err := &AppError{Err: fmt.Errorf("an error")} +if errwrap.ContainsType(err, fmt.Errorf("")) { + // This will work! +} +``` diff --git a/third-party/github.com/hashicorp/errwrap/errwrap.go b/third-party/github.com/hashicorp/errwrap/errwrap.go new file mode 100644 index 000000000..44e368e56 --- /dev/null +++ b/third-party/github.com/hashicorp/errwrap/errwrap.go @@ -0,0 +1,178 @@ +// Package errwrap implements methods to formalize error wrapping in Go. +// +// All of the top-level functions that take an `error` are built to be able +// to take any error, not just wrapped errors. This allows you to use errwrap +// without having to type-check and type-cast everywhere. +package errwrap + +import ( + "errors" + "reflect" + "strings" +) + +// WalkFunc is the callback called for Walk. +type WalkFunc func(error) + +// Wrapper is an interface that can be implemented by custom types to +// have all the Contains, Get, etc. functions in errwrap work. +// +// When Walk reaches a Wrapper, it will call the callback for every +// wrapped error in addition to the wrapper itself. Since all the top-level +// functions in errwrap use Walk, this means that all those functions work +// with your custom type. +type Wrapper interface { + WrappedErrors() []error +} + +// Wrap defines that outer wraps inner, returning an error type that +// can be cleanly used with the other methods in this package, such as +// Contains, GetAll, etc. +// +// This function won't modify the error message at all (the outer message +// will be used). +func Wrap(outer, inner error) error { + return &wrappedError{ + Outer: outer, + Inner: inner, + } +} + +// Wrapf wraps an error with a formatting message. This is similar to using +// `fmt.Errorf` to wrap an error. If you're using `fmt.Errorf` to wrap +// errors, you should replace it with this. +// +// format is the format of the error message. The string '{{err}}' will +// be replaced with the original error message. +// +// Deprecated: Use fmt.Errorf() +func Wrapf(format string, err error) error { + outerMsg := "" + if err != nil { + outerMsg = err.Error() + } + + outer := errors.New(strings.Replace( + format, "{{err}}", outerMsg, -1)) + + return Wrap(outer, err) +} + +// Contains checks if the given error contains an error with the +// message msg. If err is not a wrapped error, this will always return +// false unless the error itself happens to match this msg. +func Contains(err error, msg string) bool { + return len(GetAll(err, msg)) > 0 +} + +// ContainsType checks if the given error contains an error with +// the same concrete type as v. If err is not a wrapped error, this will +// check the err itself. +func ContainsType(err error, v interface{}) bool { + return len(GetAllType(err, v)) > 0 +} + +// Get is the same as GetAll but returns the deepest matching error. +func Get(err error, msg string) error { + es := GetAll(err, msg) + if len(es) > 0 { + return es[len(es)-1] + } + + return nil +} + +// GetType is the same as GetAllType but returns the deepest matching error. +func GetType(err error, v interface{}) error { + es := GetAllType(err, v) + if len(es) > 0 { + return es[len(es)-1] + } + + return nil +} + +// GetAll gets all the errors that might be wrapped in err with the +// given message. The order of the errors is such that the outermost +// matching error (the most recent wrap) is index zero, and so on. +func GetAll(err error, msg string) []error { + var result []error + + Walk(err, func(err error) { + if err.Error() == msg { + result = append(result, err) + } + }) + + return result +} + +// GetAllType gets all the errors that are the same type as v. +// +// The order of the return value is the same as described in GetAll. +func GetAllType(err error, v interface{}) []error { + var result []error + + var search string + if v != nil { + search = reflect.TypeOf(v).String() + } + Walk(err, func(err error) { + var needle string + if err != nil { + needle = reflect.TypeOf(err).String() + } + + if needle == search { + result = append(result, err) + } + }) + + return result +} + +// Walk walks all the wrapped errors in err and calls the callback. If +// err isn't a wrapped error, this will be called once for err. If err +// is a wrapped error, the callback will be called for both the wrapper +// that implements error as well as the wrapped error itself. +func Walk(err error, cb WalkFunc) { + if err == nil { + return + } + + switch e := err.(type) { + case *wrappedError: + cb(e.Outer) + Walk(e.Inner, cb) + case Wrapper: + cb(err) + + for _, err := range e.WrappedErrors() { + Walk(err, cb) + } + case interface{ Unwrap() error }: + cb(err) + Walk(e.Unwrap(), cb) + default: + cb(err) + } +} + +// wrappedError is an implementation of error that has both the +// outer and inner errors. +type wrappedError struct { + Outer error + Inner error +} + +func (w *wrappedError) Error() string { + return w.Outer.Error() +} + +func (w *wrappedError) WrappedErrors() []error { + return []error{w.Outer, w.Inner} +} + +func (w *wrappedError) Unwrap() error { + return w.Inner +} diff --git a/third-party/github.com/hashicorp/errwrap/errwrap_test.go b/third-party/github.com/hashicorp/errwrap/errwrap_test.go new file mode 100644 index 000000000..8c16a56fb --- /dev/null +++ b/third-party/github.com/hashicorp/errwrap/errwrap_test.go @@ -0,0 +1,119 @@ +package errwrap + +import ( + "errors" + "fmt" + "testing" +) + +func TestWrappedError_impl(t *testing.T) { + var _ error = new(wrappedError) +} + +func TestGetAll(t *testing.T) { + cases := []struct { + Err error + Msg string + Len int + }{ + {}, + { + fmt.Errorf("foo"), + "foo", + 1, + }, + { + fmt.Errorf("bar"), + "foo", + 0, + }, + { + Wrapf("bar", fmt.Errorf("foo")), + "foo", + 1, + }, + { + Wrapf("{{err}}", fmt.Errorf("foo")), + "foo", + 2, + }, + { + Wrapf("bar", Wrapf("baz", fmt.Errorf("foo"))), + "foo", + 1, + }, + { + fmt.Errorf("foo: %w", fmt.Errorf("bar")), + "foo: bar", + 1, + }, + { + fmt.Errorf("foo: %w", fmt.Errorf("bar")), + "bar", + 1, + }, + } + + for i, tc := range cases { + actual := GetAll(tc.Err, tc.Msg) + if len(actual) != tc.Len { + t.Fatalf("%d: bad: %#v", i, actual) + } + for _, v := range actual { + if v.Error() != tc.Msg { + t.Fatalf("%d: bad: %#v", i, actual) + } + } + } +} + +func TestGetAllType(t *testing.T) { + cases := []struct { + Err error + Type interface{} + Len int + }{ + {}, + { + fmt.Errorf("foo"), + "foo", + 0, + }, + { + fmt.Errorf("bar"), + fmt.Errorf("foo"), + 1, + }, + { + Wrapf("bar", fmt.Errorf("foo")), + fmt.Errorf("baz"), + 2, + }, + { + Wrapf("bar", Wrapf("baz", fmt.Errorf("foo"))), + Wrapf("", nil), + 0, + }, + { + fmt.Errorf("one: %w", fmt.Errorf("two: %w", fmt.Errorf("three"))), + fmt.Errorf("%w", errors.New("")), + 2, + }, + } + + for i, tc := range cases { + actual := GetAllType(tc.Err, tc.Type) + if len(actual) != tc.Len { + t.Fatalf("%d: bad: %#v", i, actual) + } + } +} + +func TestWrappedError_IsCompatibleWithErrorsUnwrap(t *testing.T) { + inner := errors.New("inner error") + err := Wrap(errors.New("outer"), inner) + actual := errors.Unwrap(err) + if actual != inner { + t.Fatal("wrappedError did not unwrap to inner") + } +} diff --git a/third-party/github.com/hashicorp/errwrap/go.mod b/third-party/github.com/hashicorp/errwrap/go.mod new file mode 100644 index 000000000..c9b84022c --- /dev/null +++ b/third-party/github.com/hashicorp/errwrap/go.mod @@ -0,0 +1 @@ +module github.com/hashicorp/errwrap diff --git a/third-party/github.com/hashicorp/go-multierror/.circleci/config.yml b/third-party/github.com/hashicorp/go-multierror/.circleci/config.yml new file mode 100644 index 000000000..491849779 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/.circleci/config.yml @@ -0,0 +1,164 @@ +version: 2.1 + +orbs: + win: circleci/windows@2.2.0 + +references: + environment: &ENVIRONMENT + TEST_RESULTS_PATH: &TEST_RESULTS_PATH /tmp/test-results + WIN_TEST_RESULTS: &WIN_TEST_RESULTS c:\Users\circleci\AppData\Local\Temp\test-results + +commands: + run-gotests: + parameters: + cmd: + type: string + platform: + type: string + steps: + - run: + name: "Run go tests" + command: | + PACKAGE_NAMES=$(go list ./... | circleci tests split --split-by=timings --timings-type=classname) + echo "Running $(echo $PACKAGE_NAMES | wc -w) packages" + echo $PACKAGE_NAMES + << parameters.cmd >> --format=short-verbose --junitfile $TEST_RESULTS_PATH/go-multierror/gotestsum-report.xml -- -p 2 -cover -coverprofile=<< parameters.platform >>_cov_$CIRCLE_NODE_INDEX.part $PACKAGE_NAMES + +jobs: + linux-tests: + docker: + - image: docker.mirror.hashicorp.services/circleci/golang:<< parameters.go-version >> + parameters: + go-version: + type: string + environment: + <<: *ENVIRONMENT + parallelism: 4 + steps: + - run: go version + - checkout + - attach_workspace: + at: . + - run: mkdir -p $TEST_RESULTS_PATH/go-multierror + + # Restore go module cache if there is one + - restore_cache: + keys: + - linux-gomod-cache-v1-{{ checksum "go.mod" }} + + - run: go mod download + + # Save go module cache if the go.mod file has changed + - save_cache: + key: linux-gomod-cache-v1-{{ checksum "go.mod" }} + paths: + - "/go/pkg/mod" + + # Check go fmt output because it does not report non-zero when there are fmt changes + - run: + name: check go fmt + command: | + files=$(go fmt ./...) + if [ -n "$files" ]; then + echo "The following file(s) do not conform to go fmt:" + echo "$files" + exit 1 + fi + # Run go tests with gotestsum + - run-gotests: + cmd: "gotestsum" + platform: "linux" + + # Save coverage report parts + - persist_to_workspace: + root: . + paths: + - linux_cov_*.part + + - store_test_results: + path: *TEST_RESULTS_PATH + - store_artifacts: + path: *TEST_RESULTS_PATH + + windows-tests: + executor: + name: win/default + shell: bash --login -eo pipefail + environment: + <<: *ENVIRONMENT + working_directory: c:\gopath\src\github.com\hashicorp\go-multierror + parameters: + go-version: + type: string + gotestsum-version: + type: string + steps: + - run: git config --global core.autocrlf false + - checkout + - attach_workspace: + at: . + - run: + name: Setup (remove pre-installed go) + command: | + rm -rf "c:\Go" + mkdir -p $TEST_RESULTS_PATH/go-multierror + - restore_cache: + keys: + - win-golang-<< parameters.go-version >>-cache-v1 + - win-gomod-cache-{{ checksum "go.mod" }}-v1 + + - run: + name: Install go version << parameters.go-version >> + command: | + if [ ! -d "c:\go" ]; then + echo "Cache not found, installing new version of go" + curl --fail --location https://dl.google.com/go/go<< parameters.go-version >>.windows-amd64.zip --output go.zip + unzip go.zip -d "/c" + fi + - run: + command: go mod download + + - save_cache: + key: win-golang-<< parameters.go-version >>-cache-v1 + paths: + - /go + + - save_cache: + key: win-gomod-cache-{{ checksum "go.mod" }}-v1 + paths: + - c:\Windows\system32\config\systemprofile\go\pkg\mod + + - run: + name: Install gotestsum + command: | + curl --fail --location https://github.com/gotestyourself/gotestsum/releases/download/v<< parameters.gotestsum-version >>/gotestsum_<< parameters.gotestsum-version >>_windows_amd64.tar.gz --output gotestsum.tar.gz + tar -xvzf gotestsum.tar.gz + - run-gotests: + cmd: "./gotestsum.exe" + platform: "win" + + # Save coverage report parts + - persist_to_workspace: + root: . + paths: + - win_cov_*.part + + - store_test_results: + path: *WIN_TEST_RESULTS + - store_artifacts: + path: *WIN_TEST_RESULTS + +workflows: + go-multierror: + jobs: + - linux-tests: + matrix: + parameters: + go-version: ["1.13", "1.14", "1.15"] + name: linux-test-go-<< matrix.go-version >> + - windows-tests: + matrix: + parameters: + go-version: ["1.13", "1.14", "1.15"] + gotestsum-version: ["1.6.2"] + name: win-test-go-<< matrix.go-version >> diff --git a/third-party/github.com/hashicorp/go-multierror/LICENSE b/third-party/github.com/hashicorp/go-multierror/LICENSE new file mode 100644 index 000000000..82b4de97c --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/LICENSE @@ -0,0 +1,353 @@ +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. “Contributor” + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. “Contributor Version” + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor’s Contribution. + +1.3. “Contribution” + + means Covered Software of a particular Contributor. + +1.4. “Covered Software” + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. “Incompatible With Secondary Licenses” + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of version + 1.1 or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. “Executable Form” + + means any form of the work other than Source Code Form. + +1.7. “Larger Work” + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + +1.8. “License” + + means this document. + +1.9. “Licensable” + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed by + this License. + +1.10. “Modifications” + + means any of the following: + + a. any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. “Patent Claims” of a Contributor + + means any patent claim(s), including without limitation, method, process, + and apparatus claims, in any patent Licensable by such Contributor that + would be infringed, but for the grant of the License, by the making, + using, selling, offering for sale, having made, import, or transfer of + either its Contributions or its Contributor Version. + +1.12. “Secondary License” + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. “Source Code Form” + + means the form of the work preferred for making modifications. + +1.14. “You” (or “Your”) + + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, “control” means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its Contributions + or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding Section + 2.1(b) above, no patent license is granted by a Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party’s + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions + are its original creation(s) or it has sufficient rights to grant the + rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this License, + or sublicense it under different terms, provided that the license for + the Executable Form does not attempt to limit or alter the recipients’ + rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations + of liability) contained within the Source Code Form of the Covered + Software, except that You may alter any license notices to the extent + required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, judicial + order, or regulation then You must: (a) comply with the terms of this License + to the maximum extent possible; and (b) describe the limitations and the code + they affect. Such description must be placed in a text file included with all + distributions of the Covered Software under this License. Except to the + extent prohibited by statute or regulation, such description must be + sufficiently detailed for a recipient of ordinary skill to be able to + understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing basis, + if such Contributor fails to notify You of the non-compliance by some + reasonable means prior to 60 days after You have come back into compliance. + Moreover, Your grants from a particular Contributor are reinstated on an + ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of + non-compliance with this License from such Contributor, and You become + compliant prior to 30 days after Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an “as is” basis, without + warranty of any kind, either expressed, implied, or statutory, including, + without limitation, warranties that the Covered Software is free of defects, + merchantable, fit for a particular purpose or non-infringing. The entire + risk as to the quality and performance of the Covered Software is with You. + Should any Covered Software prove defective in any respect, You (not any + Contributor) assume the cost of any necessary servicing, repair, or + correction. This disclaimer of warranty constitutes an essential part of this + License. No use of any Covered Software is authorized under this License + except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from such + party’s negligence to the extent applicable law prohibits such limitation. + Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts of + a jurisdiction where the defendant maintains its principal place of business + and such litigation shall be governed by laws of that jurisdiction, without + reference to its conflict-of-law provisions. Nothing in this Section shall + prevent a party’s ability to bring cross-claims or counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject matter + hereof. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable. Any law or regulation which provides that the language of a + contract shall be construed against the drafter shall not be used to construe + this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of + the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any + references to the name of the license steward (except to note that such + modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is “Incompatible + With Secondary Licenses”, as defined by + the Mozilla Public License, v. 2.0. diff --git a/third-party/github.com/hashicorp/go-multierror/Makefile b/third-party/github.com/hashicorp/go-multierror/Makefile new file mode 100644 index 000000000..b97cd6ed0 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/Makefile @@ -0,0 +1,31 @@ +TEST?=./... + +default: test + +# test runs the test suite and vets the code. +test: generate + @echo "==> Running tests..." + @go list $(TEST) \ + | grep -v "/vendor/" \ + | xargs -n1 go test -timeout=60s -parallel=10 ${TESTARGS} + +# testrace runs the race checker +testrace: generate + @echo "==> Running tests (race)..." + @go list $(TEST) \ + | grep -v "/vendor/" \ + | xargs -n1 go test -timeout=60s -race ${TESTARGS} + +# updatedeps installs all the dependencies needed to run and build. +updatedeps: + @sh -c "'${CURDIR}/scripts/deps.sh' '${NAME}'" + +# generate runs `go generate` to build the dynamically generated source files. +generate: + @echo "==> Generating..." + @find . -type f -name '.DS_Store' -delete + @go list ./... \ + | grep -v "/vendor/" \ + | xargs -n1 go generate + +.PHONY: default test testrace updatedeps generate diff --git a/third-party/github.com/hashicorp/go-multierror/README.md b/third-party/github.com/hashicorp/go-multierror/README.md new file mode 100644 index 000000000..71dd308ed --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/README.md @@ -0,0 +1,150 @@ +# go-multierror + +[![CircleCI](https://img.shields.io/circleci/build/github/hashicorp/go-multierror/master)](https://circleci.com/gh/hashicorp/go-multierror) +[![Go Reference](https://pkg.go.dev/badge/github.com/hashicorp/go-multierror.svg)](https://pkg.go.dev/github.com/hashicorp/go-multierror) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/hashicorp/go-multierror) + +[circleci]: https://app.circleci.com/pipelines/github/hashicorp/go-multierror +[godocs]: https://pkg.go.dev/github.com/hashicorp/go-multierror + +`go-multierror` is a package for Go that provides a mechanism for +representing a list of `error` values as a single `error`. + +This allows a function in Go to return an `error` that might actually +be a list of errors. If the caller knows this, they can unwrap the +list and access the errors. If the caller doesn't know, the error +formats to a nice human-readable format. + +`go-multierror` is fully compatible with the Go standard library +[errors](https://golang.org/pkg/errors/) package, including the +functions `As`, `Is`, and `Unwrap`. This provides a standardized approach +for introspecting on error values. + +## Installation and Docs + +Install using `go get github.com/hashicorp/go-multierror`. + +Full documentation is available at +https://pkg.go.dev/github.com/hashicorp/go-multierror + +### Requires go version 1.13 or newer + +`go-multierror` requires go version 1.13 or newer. Go 1.13 introduced +[error wrapping](https://golang.org/doc/go1.13#error_wrapping), which +this library takes advantage of. + +If you need to use an earlier version of go, you can use the +[v1.0.0](https://github.com/hashicorp/go-multierror/tree/v1.0.0) +tag, which doesn't rely on features in go 1.13. + +If you see compile errors that look like the below, it's likely that +you're on an older version of go: + +``` +/go/src/github.com/hashicorp/go-multierror/multierror.go:112:9: undefined: errors.As +/go/src/github.com/hashicorp/go-multierror/multierror.go:117:9: undefined: errors.Is +``` + +## Usage + +go-multierror is easy to use and purposely built to be unobtrusive in +existing Go applications/libraries that may not be aware of it. + +**Building a list of errors** + +The `Append` function is used to create a list of errors. This function +behaves a lot like the Go built-in `append` function: it doesn't matter +if the first argument is nil, a `multierror.Error`, or any other `error`, +the function behaves as you would expect. + +```go +var result error + +if err := step1(); err != nil { + result = multierror.Append(result, err) +} +if err := step2(); err != nil { + result = multierror.Append(result, err) +} + +return result +``` + +**Customizing the formatting of the errors** + +By specifying a custom `ErrorFormat`, you can customize the format +of the `Error() string` function: + +```go +var result *multierror.Error + +// ... accumulate errors here, maybe using Append + +if result != nil { + result.ErrorFormat = func([]error) string { + return "errors!" + } +} +``` + +**Accessing the list of errors** + +`multierror.Error` implements `error` so if the caller doesn't know about +multierror, it will work just fine. But if you're aware a multierror might +be returned, you can use type switches to access the list of errors: + +```go +if err := something(); err != nil { + if merr, ok := err.(*multierror.Error); ok { + // Use merr.Errors + } +} +``` + +You can also use the standard [`errors.Unwrap`](https://golang.org/pkg/errors/#Unwrap) +function. This will continue to unwrap into subsequent errors until none exist. + +**Extracting an error** + +The standard library [`errors.As`](https://golang.org/pkg/errors/#As) +function can be used directly with a multierror to extract a specific error: + +```go +// Assume err is a multierror value +err := somefunc() + +// We want to know if "err" has a "RichErrorType" in it and extract it. +var errRich RichErrorType +if errors.As(err, &errRich) { + // It has it, and now errRich is populated. +} +``` + +**Checking for an exact error value** + +Some errors are returned as exact errors such as the [`ErrNotExist`](https://golang.org/pkg/os/#pkg-variables) +error in the `os` package. You can check if this error is present by using +the standard [`errors.Is`](https://golang.org/pkg/errors/#Is) function. + +```go +// Assume err is a multierror value +err := somefunc() +if errors.Is(err, os.ErrNotExist) { + // err contains os.ErrNotExist +} +``` + +**Returning a multierror only if there are errors** + +If you build a `multierror.Error`, you can use the `ErrorOrNil` function +to return an `error` implementation only if there are errors to return: + +```go +var result *multierror.Error + +// ... accumulate errors here + +// Return the `error` only if errors were added to the multierror, otherwise +// return nil since there are no errors. +return result.ErrorOrNil() +``` diff --git a/third-party/github.com/hashicorp/go-multierror/append.go b/third-party/github.com/hashicorp/go-multierror/append.go new file mode 100644 index 000000000..3e2589bfd --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/append.go @@ -0,0 +1,43 @@ +package multierror + +// Append is a helper function that will append more errors +// onto an Error in order to create a larger multi-error. +// +// If err is not a multierror.Error, then it will be turned into +// one. If any of the errs are multierr.Error, they will be flattened +// one level into err. +// Any nil errors within errs will be ignored. If err is nil, a new +// *Error will be returned. +func Append(err error, errs ...error) *Error { + switch err := err.(type) { + case *Error: + // Typed nils can reach here, so initialize if we are nil + if err == nil { + err = new(Error) + } + + // Go through each error and flatten + for _, e := range errs { + switch e := e.(type) { + case *Error: + if e != nil { + err.Errors = append(err.Errors, e.Errors...) + } + default: + if e != nil { + err.Errors = append(err.Errors, e) + } + } + } + + return err + default: + newErrs := make([]error, 0, len(errs)+1) + if err != nil { + newErrs = append(newErrs, err) + } + newErrs = append(newErrs, errs...) + + return Append(&Error{}, newErrs...) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/append_test.go b/third-party/github.com/hashicorp/go-multierror/append_test.go new file mode 100644 index 000000000..58ddafa8d --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/append_test.go @@ -0,0 +1,82 @@ +package multierror + +import ( + "errors" + "testing" +) + +func TestAppend_Error(t *testing.T) { + original := &Error{ + Errors: []error{errors.New("foo")}, + } + + result := Append(original, errors.New("bar")) + if len(result.Errors) != 2 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } + + original = &Error{} + result = Append(original, errors.New("bar")) + if len(result.Errors) != 1 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } + + // Test when a typed nil is passed + var e *Error + result = Append(e, errors.New("baz")) + if len(result.Errors) != 1 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } + + // Test flattening + original = &Error{ + Errors: []error{errors.New("foo")}, + } + + result = Append(original, Append(nil, errors.New("foo"), errors.New("bar"))) + if len(result.Errors) != 3 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} + +func TestAppend_NilError(t *testing.T) { + var err error + result := Append(err, errors.New("bar")) + if len(result.Errors) != 1 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} + +func TestAppend_NilErrorArg(t *testing.T) { + var err error + var nilErr *Error + result := Append(err, nilErr) + if len(result.Errors) != 0 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} + +func TestAppend_NilErrorIfaceArg(t *testing.T) { + var err error + var nilErr error + result := Append(err, nilErr) + if len(result.Errors) != 0 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} + +func TestAppend_NonError(t *testing.T) { + original := errors.New("foo") + result := Append(original, errors.New("bar")) + if len(result.Errors) != 2 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} + +func TestAppend_NonError_Error(t *testing.T) { + original := errors.New("foo") + result := Append(original, Append(nil, errors.New("bar"))) + if len(result.Errors) != 2 { + t.Fatalf("wrong len: %d", len(result.Errors)) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/flatten.go b/third-party/github.com/hashicorp/go-multierror/flatten.go new file mode 100644 index 000000000..aab8e9abe --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/flatten.go @@ -0,0 +1,26 @@ +package multierror + +// Flatten flattens the given error, merging any *Errors together into +// a single *Error. +func Flatten(err error) error { + // If it isn't an *Error, just return the error as-is + if _, ok := err.(*Error); !ok { + return err + } + + // Otherwise, make the result and flatten away! + flatErr := new(Error) + flatten(err, flatErr) + return flatErr +} + +func flatten(err error, flatErr *Error) { + switch err := err.(type) { + case *Error: + for _, e := range err.Errors { + flatten(e, flatErr) + } + default: + flatErr.Errors = append(flatErr.Errors, err) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/flatten_test.go b/third-party/github.com/hashicorp/go-multierror/flatten_test.go new file mode 100644 index 000000000..e99c4101b --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/flatten_test.go @@ -0,0 +1,46 @@ +package multierror + +import ( + "errors" + "fmt" + "reflect" + "testing" +) + +func TestFlatten(t *testing.T) { + original := &Error{ + Errors: []error{ + errors.New("one"), + &Error{ + Errors: []error{ + errors.New("two"), + &Error{ + Errors: []error{ + errors.New("three"), + }, + }, + }, + }, + }, + } + + expected := `3 errors occurred: + * one + * two + * three + +` + actual := fmt.Sprintf("%s", Flatten(original)) + + if expected != actual { + t.Fatalf("expected: %s, got: %s", expected, actual) + } +} + +func TestFlatten_nonError(t *testing.T) { + err := errors.New("foo") + actual := Flatten(err) + if !reflect.DeepEqual(actual, err) { + t.Fatalf("bad: %#v", actual) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/format.go b/third-party/github.com/hashicorp/go-multierror/format.go new file mode 100644 index 000000000..47f13c49a --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/format.go @@ -0,0 +1,27 @@ +package multierror + +import ( + "fmt" + "strings" +) + +// ErrorFormatFunc is a function callback that is called by Error to +// turn the list of errors into a string. +type ErrorFormatFunc func([]error) string + +// ListFormatFunc is a basic formatter that outputs the number of errors +// that occurred along with a bullet point list of the errors. +func ListFormatFunc(es []error) string { + if len(es) == 1 { + return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", es[0]) + } + + points := make([]string, len(es)) + for i, err := range es { + points[i] = fmt.Sprintf("* %s", err) + } + + return fmt.Sprintf( + "%d errors occurred:\n\t%s\n\n", + len(es), strings.Join(points, "\n\t")) +} diff --git a/third-party/github.com/hashicorp/go-multierror/format_test.go b/third-party/github.com/hashicorp/go-multierror/format_test.go new file mode 100644 index 000000000..2b6da1def --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/format_test.go @@ -0,0 +1,40 @@ +package multierror + +import ( + "errors" + "testing" +) + +func TestListFormatFuncSingle(t *testing.T) { + expected := `1 error occurred: + * foo + +` + + errors := []error{ + errors.New("foo"), + } + + actual := ListFormatFunc(errors) + if actual != expected { + t.Fatalf("bad: %#v", actual) + } +} + +func TestListFormatFuncMultiple(t *testing.T) { + expected := `2 errors occurred: + * foo + * bar + +` + + errors := []error{ + errors.New("foo"), + errors.New("bar"), + } + + actual := ListFormatFunc(errors) + if actual != expected { + t.Fatalf("bad: %#v", actual) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/go.mod b/third-party/github.com/hashicorp/go-multierror/go.mod new file mode 100644 index 000000000..141cc4ccb --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/go.mod @@ -0,0 +1,5 @@ +module github.com/hashicorp/go-multierror + +go 1.13 + +require github.com/hashicorp/errwrap v1.0.0 diff --git a/third-party/github.com/hashicorp/go-multierror/go.sum b/third-party/github.com/hashicorp/go-multierror/go.sum new file mode 100644 index 000000000..e8238e9ec --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/go.sum @@ -0,0 +1,2 @@ +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/third-party/github.com/hashicorp/go-multierror/group.go b/third-party/github.com/hashicorp/go-multierror/group.go new file mode 100644 index 000000000..9c29efb7f --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/group.go @@ -0,0 +1,38 @@ +package multierror + +import "sync" + +// Group is a collection of goroutines which return errors that need to be +// coalesced. +type Group struct { + mutex sync.Mutex + err *Error + wg sync.WaitGroup +} + +// Go calls the given function in a new goroutine. +// +// If the function returns an error it is added to the group multierror which +// is returned by Wait. +func (g *Group) Go(f func() error) { + g.wg.Add(1) + + go func() { + defer g.wg.Done() + + if err := f(); err != nil { + g.mutex.Lock() + g.err = Append(g.err, err) + g.mutex.Unlock() + } + }() +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the multierror. +func (g *Group) Wait() *Error { + g.wg.Wait() + g.mutex.Lock() + defer g.mutex.Unlock() + return g.err +} diff --git a/third-party/github.com/hashicorp/go-multierror/group_test.go b/third-party/github.com/hashicorp/go-multierror/group_test.go new file mode 100644 index 000000000..9d472fd66 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/group_test.go @@ -0,0 +1,44 @@ +package multierror + +import ( + "errors" + "strings" + "testing" +) + +func TestGroup(t *testing.T) { + err1 := errors.New("group_test: 1") + err2 := errors.New("group_test: 2") + + cases := []struct { + errs []error + nilResult bool + }{ + {errs: []error{}, nilResult: true}, + {errs: []error{nil}, nilResult: true}, + {errs: []error{err1}}, + {errs: []error{err1, nil}}, + {errs: []error{err1, nil, err2}}, + } + + for _, tc := range cases { + var g Group + + for _, err := range tc.errs { + err := err + g.Go(func() error { return err }) + + } + + gErr := g.Wait() + if gErr != nil { + for i := range tc.errs { + if tc.errs[i] != nil && !strings.Contains(gErr.Error(), tc.errs[i].Error()) { + t.Fatalf("expected error to contain %q, actual: %v", tc.errs[i].Error(), gErr) + } + } + } else if !tc.nilResult { + t.Fatalf("Group.Wait() should not have returned nil for errs: %v", tc.errs) + } + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/multierror.go b/third-party/github.com/hashicorp/go-multierror/multierror.go new file mode 100644 index 000000000..f54574326 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/multierror.go @@ -0,0 +1,121 @@ +package multierror + +import ( + "errors" + "fmt" +) + +// Error is an error type to track multiple errors. This is used to +// accumulate errors in cases and return them as a single "error". +type Error struct { + Errors []error + ErrorFormat ErrorFormatFunc +} + +func (e *Error) Error() string { + fn := e.ErrorFormat + if fn == nil { + fn = ListFormatFunc + } + + return fn(e.Errors) +} + +// ErrorOrNil returns an error interface if this Error represents +// a list of errors, or returns nil if the list of errors is empty. This +// function is useful at the end of accumulation to make sure that the value +// returned represents the existence of errors. +func (e *Error) ErrorOrNil() error { + if e == nil { + return nil + } + if len(e.Errors) == 0 { + return nil + } + + return e +} + +func (e *Error) GoString() string { + return fmt.Sprintf("*%#v", *e) +} + +// WrappedErrors returns the list of errors that this Error is wrapping. It is +// an implementation of the errwrap.Wrapper interface so that multierror.Error +// can be used with that library. +// +// This method is not safe to be called concurrently. Unlike accessing the +// Errors field directly, this function also checks if the multierror is nil to +// prevent a null-pointer panic. It satisfies the errwrap.Wrapper interface. +func (e *Error) WrappedErrors() []error { + if e == nil { + return nil + } + return e.Errors +} + +// Unwrap returns an error from Error (or nil if there are no errors). +// This error returned will further support Unwrap to get the next error, +// etc. The order will match the order of Errors in the multierror.Error +// at the time of calling. +// +// The resulting error supports errors.As/Is/Unwrap so you can continue +// to use the stdlib errors package to introspect further. +// +// This will perform a shallow copy of the errors slice. Any errors appended +// to this error after calling Unwrap will not be available until a new +// Unwrap is called on the multierror.Error. +func (e *Error) Unwrap() error { + // If we have no errors then we do nothing + if e == nil || len(e.Errors) == 0 { + return nil + } + + // If we have exactly one error, we can just return that directly. + if len(e.Errors) == 1 { + return e.Errors[0] + } + + // Shallow copy the slice + errs := make([]error, len(e.Errors)) + copy(errs, e.Errors) + return chain(errs) +} + +// chain implements the interfaces necessary for errors.Is/As/Unwrap to +// work in a deterministic way with multierror. A chain tracks a list of +// errors while accounting for the current represented error. This lets +// Is/As be meaningful. +// +// Unwrap returns the next error. In the cleanest form, Unwrap would return +// the wrapped error here but we can't do that if we want to properly +// get access to all the errors. Instead, users are recommended to use +// Is/As to get the correct error type out. +// +// Precondition: []error is non-empty (len > 0) +type chain []error + +// Error implements the error interface +func (e chain) Error() string { + return e[0].Error() +} + +// Unwrap implements errors.Unwrap by returning the next error in the +// chain or nil if there are no more errors. +func (e chain) Unwrap() error { + if len(e) == 1 { + return nil + } + + return e[1:] +} + +// As implements errors.As by attempting to map to the current value. +func (e chain) As(target interface{}) bool { + return errors.As(e[0], target) +} + +// Is implements errors.Is by comparing the current value directly. +func (e chain) Is(target error) bool { + return errors.Is(e[0], target) +} diff --git a/third-party/github.com/hashicorp/go-multierror/multierror_test.go b/third-party/github.com/hashicorp/go-multierror/multierror_test.go new file mode 100644 index 000000000..ed1f08c72 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/multierror_test.go @@ -0,0 +1,208 @@ +package multierror + +import ( + "errors" + "fmt" + "reflect" + "testing" +) + +func TestError_Impl(t *testing.T) { + var _ error = new(Error) +} + +func TestErrorError_custom(t *testing.T) { + errors := []error{ + errors.New("foo"), + errors.New("bar"), + } + + fn := func(es []error) string { + return "foo" + } + + multi := &Error{Errors: errors, ErrorFormat: fn} + if multi.Error() != "foo" { + t.Fatalf("bad: %s", multi.Error()) + } +} + +func TestErrorError_default(t *testing.T) { + expected := `2 errors occurred: + * foo + * bar + +` + + errors := []error{ + errors.New("foo"), + errors.New("bar"), + } + + multi := &Error{Errors: errors} + if multi.Error() != expected { + t.Fatalf("bad: %s", multi.Error()) + } +} + +func TestErrorErrorOrNil(t *testing.T) { + err := new(Error) + if err.ErrorOrNil() != nil { + t.Fatalf("bad: %#v", err.ErrorOrNil()) + } + + err.Errors = []error{errors.New("foo")} + if v := err.ErrorOrNil(); v == nil { + t.Fatal("should not be nil") + } else if !reflect.DeepEqual(v, err) { + t.Fatalf("bad: %#v", v) + } +} + +func TestErrorWrappedErrors(t *testing.T) { + errors := []error{ + errors.New("foo"), + errors.New("bar"), + } + + multi := &Error{Errors: errors} + if !reflect.DeepEqual(multi.Errors, multi.WrappedErrors()) { + t.Fatalf("bad: %s", multi.WrappedErrors()) + } + + multi = nil + if err := multi.WrappedErrors(); err != nil { + t.Fatalf("bad: %#v", multi) + } +} + +func TestErrorUnwrap(t *testing.T) { + t.Run("with errors", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + errors.New("bar"), + errors.New("baz"), + }} + + var current error = err + for i := 0; i < len(err.Errors); i++ { + current = errors.Unwrap(current) + if !errors.Is(current, err.Errors[i]) { + t.Fatal("should be next value") + } + } + + if errors.Unwrap(current) != nil { + t.Fatal("should be nil at the end") + } + }) + + t.Run("with no errors", func(t *testing.T) { + err := &Error{Errors: nil} + if errors.Unwrap(err) != nil { + t.Fatal("should be nil") + } + }) + + t.Run("with nil multierror", func(t *testing.T) { + var err *Error + if errors.Unwrap(err) != nil { + t.Fatal("should be nil") + } + }) +} + +func TestErrorIs(t *testing.T) { + errBar := errors.New("bar") + + t.Run("with errBar", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + errBar, + errors.New("baz"), + }} + + if !errors.Is(err, errBar) { + t.Fatal("should be true") + } + }) + + t.Run("with errBar wrapped by fmt.Errorf", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + fmt.Errorf("errorf: %w", errBar), + errors.New("baz"), + }} + + if !errors.Is(err, errBar) { + t.Fatal("should be true") + } + }) + + t.Run("without errBar", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + errors.New("baz"), + }} + + if errors.Is(err, errBar) { + t.Fatal("should be false") + } + }) +} + +func TestErrorAs(t *testing.T) { + match := &nestedError{} + + t.Run("with the value", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + match, + errors.New("baz"), + }} + + var target *nestedError + if !errors.As(err, &target) { + t.Fatal("should be true") + } + if target == nil { + t.Fatal("target should not be nil") + } + }) + + t.Run("with the value wrapped by fmt.Errorf", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + fmt.Errorf("errorf: %w", match), + errors.New("baz"), + }} + + var target *nestedError + if !errors.As(err, &target) { + t.Fatal("should be true") + } + if target == nil { + t.Fatal("target should not be nil") + } + }) + + t.Run("without the value", func(t *testing.T) { + err := &Error{Errors: []error{ + errors.New("foo"), + errors.New("baz"), + }} + + var target *nestedError + if errors.As(err, &target) { + t.Fatal("should be false") + } + if target != nil { + t.Fatal("target should be nil") + } + }) +} + +// nestedError implements error and is used for tests. +type nestedError struct{} + +func (*nestedError) Error() string { return "" } diff --git a/third-party/github.com/hashicorp/go-multierror/prefix.go b/third-party/github.com/hashicorp/go-multierror/prefix.go new file mode 100644 index 000000000..5c477abe4 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/prefix.go @@ -0,0 +1,37 @@ +package multierror + +import ( + "fmt" + + "github.com/hashicorp/errwrap" +) + +// Prefix is a helper function that will prefix some text +// to the given error. If the error is a multierror.Error, then +// it will be prefixed to each wrapped error. +// +// This is useful to use when appending multiple multierrors +// together in order to give better scoping. +func Prefix(err error, prefix string) error { + if err == nil { + return nil + } + + format := fmt.Sprintf("%s {{err}}", prefix) + switch err := err.(type) { + case *Error: + // Typed nils can reach here, so initialize if we are nil + if err == nil { + err = new(Error) + } + + // Wrap each of the errors + for i, e := range err.Errors { + err.Errors[i] = errwrap.Wrapf(format, e) + } + + return err + default: + return errwrap.Wrapf(format, err) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/prefix_test.go b/third-party/github.com/hashicorp/go-multierror/prefix_test.go new file mode 100644 index 000000000..849ec3aec --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/prefix_test.go @@ -0,0 +1,36 @@ +package multierror + +import ( + "errors" + "testing" +) + +func TestPrefix_Error(t *testing.T) { + original := &Error{ + Errors: []error{errors.New("foo")}, + } + + result := Prefix(original, "bar") + if result.(*Error).Errors[0].Error() != "bar foo" { + t.Fatalf("bad: %s", result) + } +} + +func TestPrefix_NilError(t *testing.T) { + var err error + result := Prefix(err, "bar") + if result != nil { + t.Fatalf("bad: %#v", result) + } +} + +func TestPrefix_NonError(t *testing.T) { + original := errors.New("foo") + result := Prefix(original, "bar") + if result == nil { + t.Fatal("error result was nil") + } + if result.Error() != "bar foo" { + t.Fatalf("bad: %s", result) + } +} diff --git a/third-party/github.com/hashicorp/go-multierror/sort.go b/third-party/github.com/hashicorp/go-multierror/sort.go new file mode 100644 index 000000000..fecb14e81 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/sort.go @@ -0,0 +1,16 @@ +package multierror + +// Len implements sort.Interface function for length +func (err Error) Len() int { + return len(err.Errors) +} + +// Swap implements sort.Interface function for swapping elements +func (err Error) Swap(i, j int) { + err.Errors[i], err.Errors[j] = err.Errors[j], err.Errors[i] +} + +// Less implements sort.Interface function for determining order +func (err Error) Less(i, j int) bool { + return err.Errors[i].Error() < err.Errors[j].Error() +} diff --git a/third-party/github.com/hashicorp/go-multierror/sort_test.go b/third-party/github.com/hashicorp/go-multierror/sort_test.go new file mode 100644 index 000000000..7fd04e8c5 --- /dev/null +++ b/third-party/github.com/hashicorp/go-multierror/sort_test.go @@ -0,0 +1,52 @@ +package multierror + +import ( + "errors" + "reflect" + "sort" + "testing" +) + +func TestSortSingle(t *testing.T) { + errFoo := errors.New("foo") + + expected := []error{ + errFoo, + } + + err := &Error{ + Errors: []error{ + errFoo, + }, + } + + sort.Sort(err) + if !reflect.DeepEqual(err.Errors, expected) { + t.Fatalf("bad: %#v", err) + } +} + +func TestSortMultiple(t *testing.T) { + errBar := errors.New("bar") + errBaz := errors.New("baz") + errFoo := errors.New("foo") + + expected := []error{ + errBar, + errBaz, + errFoo, + } + + err := &Error{ + Errors: []error{ + errFoo, + errBar, + errBaz, + }, + } + + sort.Sort(err) + if !reflect.DeepEqual(err.Errors, expected) { + t.Fatalf("bad: %#v", err) + } +} diff --git a/third-party/github.com/hashicorp/go-version/.circleci/config.yml b/third-party/github.com/hashicorp/go-version/.circleci/config.yml new file mode 100644 index 000000000..221951163 --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/.circleci/config.yml @@ -0,0 +1,60 @@ +version: 2.1 + +references: + images: + go: &GOLANG_IMAGE docker.mirror.hashicorp.services/circleci/golang:1.15.3 + environments: + tmp: &TEST_RESULTS_PATH /tmp/test-results # path to where test results are saved + +# reusable 'executor' object for jobs +executors: + go: + docker: + - image: *GOLANG_IMAGE + environment: + - TEST_RESULTS: *TEST_RESULTS_PATH + +jobs: + go-test: + executor: go + steps: + - checkout + - run: mkdir -p $TEST_RESULTS + + - restore_cache: # restore cache from dev-build job + keys: + - go-version-modcache-v1-{{ checksum "go.mod" }} + + - run: go mod download + + # Save go module cache if the go.mod file has changed + - save_cache: + key: go-version-modcache-v1-{{ checksum "go.mod" }} + paths: + - "/go/pkg/mod" + + # check go fmt output because it does not report non-zero when there are fmt changes + - run: + name: check go fmt + command: | + files=$(go fmt ./...) + if [ -n "$files" ]; then + echo "The following file(s) do not conform to go fmt:" + echo "$files" + exit 1 + fi + + # run go tests with gotestsum + - run: | + PACKAGE_NAMES=$(go list ./...) + gotestsum --format=short-verbose --junitfile $TEST_RESULTS/gotestsum-report.xml -- $PACKAGE_NAMES + - store_test_results: + path: *TEST_RESULTS_PATH + - store_artifacts: + path: *TEST_RESULTS_PATH + +workflows: + version: 2 + test-and-build: + jobs: + - go-test diff --git a/third-party/github.com/hashicorp/go-version/CHANGELOG.md b/third-party/github.com/hashicorp/go-version/CHANGELOG.md new file mode 100644 index 000000000..dbae7f7be --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/CHANGELOG.md @@ -0,0 +1,25 @@ +# 1.3.0 (March 31, 2021) + +Please note that CHANGELOG.md does not exist in the source code prior to this release. + +FEATURES: + - Add `Core` function to return a version without prerelease or metadata ([#85](https://github.com/hashicorp/go-version/pull/85)) + +# 1.2.1 (June 17, 2020) + +BUG FIXES: + - Prevent `Version.Equal` method from panicking on `nil` encounter ([#73](https://github.com/hashicorp/go-version/pull/73)) + +# 1.2.0 (April 23, 2019) + +FEATURES: + - Add `GreaterThanOrEqual` and `LessThanOrEqual` helper methods ([#53](https://github.com/hashicorp/go-version/pull/53)) + +# 1.1.0 (Jan 07, 2019) + +FEATURES: + - Add `NewSemver` constructor ([#45](https://github.com/hashicorp/go-version/pull/45)) + +# 1.0.0 (August 24, 2018) + +Initial release. diff --git a/third-party/github.com/hashicorp/go-version/LICENSE b/third-party/github.com/hashicorp/go-version/LICENSE new file mode 100644 index 000000000..c33dcc7c9 --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/LICENSE @@ -0,0 +1,354 @@ +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. “Contributor” + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. “Contributor Version” + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor’s Contribution. + +1.3. “Contribution” + + means Covered Software of a particular Contributor. + +1.4. “Covered Software” + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. “Incompatible With Secondary Licenses” + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of version + 1.1 or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. “Executable Form” + + means any form of the work other than Source Code Form. + +1.7. “Larger Work” + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + +1.8. “License” + + means this document. + +1.9. “Licensable” + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed by + this License. + +1.10. “Modifications” + + means any of the following: + + a. any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. “Patent Claims” of a Contributor + + means any patent claim(s), including without limitation, method, process, + and apparatus claims, in any patent Licensable by such Contributor that + would be infringed, but for the grant of the License, by the making, + using, selling, offering for sale, having made, import, or transfer of + either its Contributions or its Contributor Version. + +1.12. “Secondary License” + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. “Source Code Form” + + means the form of the work preferred for making modifications. + +1.14. “You” (or “Your”) + + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, “control” means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its Contributions + or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding Section + 2.1(b) above, no patent license is granted by a Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party’s + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions + are its original creation(s) or it has sufficient rights to grant the + rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this License, + or sublicense it under different terms, provided that the license for + the Executable Form does not attempt to limit or alter the recipients’ + rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations + of liability) contained within the Source Code Form of the Covered + Software, except that You may alter any license notices to the extent + required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, judicial + order, or regulation then You must: (a) comply with the terms of this License + to the maximum extent possible; and (b) describe the limitations and the code + they affect. Such description must be placed in a text file included with all + distributions of the Covered Software under this License. Except to the + extent prohibited by statute or regulation, such description must be + sufficiently detailed for a recipient of ordinary skill to be able to + understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing basis, + if such Contributor fails to notify You of the non-compliance by some + reasonable means prior to 60 days after You have come back into compliance. + Moreover, Your grants from a particular Contributor are reinstated on an + ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of + non-compliance with this License from such Contributor, and You become + compliant prior to 30 days after Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an “as is” basis, without + warranty of any kind, either expressed, implied, or statutory, including, + without limitation, warranties that the Covered Software is free of defects, + merchantable, fit for a particular purpose or non-infringing. The entire + risk as to the quality and performance of the Covered Software is with You. + Should any Covered Software prove defective in any respect, You (not any + Contributor) assume the cost of any necessary servicing, repair, or + correction. This disclaimer of warranty constitutes an essential part of this + License. No use of any Covered Software is authorized under this License + except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from such + party’s negligence to the extent applicable law prohibits such limitation. + Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts of + a jurisdiction where the defendant maintains its principal place of business + and such litigation shall be governed by laws of that jurisdiction, without + reference to its conflict-of-law provisions. Nothing in this Section shall + prevent a party’s ability to bring cross-claims or counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject matter + hereof. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable. Any law or regulation which provides that the language of a + contract shall be construed against the drafter shall not be used to construe + this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of + the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any + references to the name of the license steward (except to note that such + modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is “Incompatible + With Secondary Licenses”, as defined by + the Mozilla Public License, v. 2.0. + diff --git a/third-party/github.com/hashicorp/go-version/README.md b/third-party/github.com/hashicorp/go-version/README.md new file mode 100644 index 000000000..851a337be --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/README.md @@ -0,0 +1,66 @@ +# Versioning Library for Go +[![Build Status](https://circleci.com/gh/hashicorp/go-version/tree/master.svg?style=svg)](https://circleci.com/gh/hashicorp/go-version/tree/master) +[![GoDoc](https://godoc.org/github.com/hashicorp/go-version?status.svg)](https://godoc.org/github.com/hashicorp/go-version) + +go-version is a library for parsing versions and version constraints, +and verifying versions against a set of constraints. go-version +can sort a collection of versions properly, handles prerelease/beta +versions, can increment versions, etc. + +Versions used with go-version must follow [SemVer](http://semver.org/). + +## Installation and Usage + +Package documentation can be found on +[GoDoc](http://godoc.org/github.com/hashicorp/go-version). + +Installation can be done with a normal `go get`: + +``` +$ go get github.com/hashicorp/go-version +``` + +#### Version Parsing and Comparison + +```go +v1, err := version.NewVersion("1.2") +v2, err := version.NewVersion("1.5+metadata") + +// Comparison example. There is also GreaterThan, Equal, and just +// a simple Compare that returns an int allowing easy >=, <=, etc. +if v1.LessThan(v2) { + fmt.Printf("%s is less than %s", v1, v2) +} +``` + +#### Version Constraints + +```go +v1, err := version.NewVersion("1.2") + +// Constraints example. +constraints, err := version.NewConstraint(">= 1.0, < 1.4") +if constraints.Check(v1) { + fmt.Printf("%s satisfies constraints %s", v1, constraints) +} +``` + +#### Version Sorting + +```go +versionsRaw := []string{"1.1", "0.7.1", "1.4-beta", "1.4", "2"} +versions := make([]*version.Version, len(versionsRaw)) +for i, raw := range versionsRaw { + v, _ := version.NewVersion(raw) + versions[i] = v +} + +// After this, the versions are properly sorted +sort.Sort(version.Collection(versions)) +``` + +## Issues and Contributing + +If you find an issue with this library, please report an issue. If you'd +like, we welcome any contributions. Fork this library and submit a pull +request. diff --git a/third-party/github.com/hashicorp/go-version/constraint.go b/third-party/github.com/hashicorp/go-version/constraint.go new file mode 100644 index 000000000..d05575961 --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/constraint.go @@ -0,0 +1,204 @@ +package version + +import ( + "fmt" + "reflect" + "regexp" + "strings" +) + +// Constraint represents a single constraint for a version, such as +// ">= 1.0". +type Constraint struct { + f constraintFunc + check *Version + original string +} + +// Constraints is a slice of constraints. We make a custom type so that +// we can add methods to it. +type Constraints []*Constraint + +type constraintFunc func(v, c *Version) bool + +var constraintOperators map[string]constraintFunc + +var constraintRegexp *regexp.Regexp + +func init() { + constraintOperators = map[string]constraintFunc{ + "": constraintEqual, + "=": constraintEqual, + "!=": constraintNotEqual, + ">": constraintGreaterThan, + "<": constraintLessThan, + ">=": constraintGreaterThanEqual, + "<=": constraintLessThanEqual, + "~>": constraintPessimistic, + } + + ops := make([]string, 0, len(constraintOperators)) + for k := range constraintOperators { + ops = append(ops, regexp.QuoteMeta(k)) + } + + constraintRegexp = regexp.MustCompile(fmt.Sprintf( + `^\s*(%s)\s*(%s)\s*$`, + strings.Join(ops, "|"), + VersionRegexpRaw)) +} + +// NewConstraint will parse one or more constraints from the given +// constraint string. The string must be a comma-separated list of +// constraints. +func NewConstraint(v string) (Constraints, error) { + vs := strings.Split(v, ",") + result := make([]*Constraint, len(vs)) + for i, single := range vs { + c, err := parseSingle(single) + if err != nil { + return nil, err + } + + result[i] = c + } + + return Constraints(result), nil +} + +// Check tests if a version satisfies all the constraints. +func (cs Constraints) Check(v *Version) bool { + for _, c := range cs { + if !c.Check(v) { + return false + } + } + + return true +} + +// Returns the string format of the constraints +func (cs Constraints) String() string { + csStr := make([]string, len(cs)) + for i, c := range cs { + csStr[i] = c.String() + } + + return strings.Join(csStr, ",") +} + +// Check tests if a constraint is validated by the given version. +func (c *Constraint) Check(v *Version) bool { + return c.f(v, c.check) +} + +func (c *Constraint) String() string { + return c.original +} + +func parseSingle(v string) (*Constraint, error) { + matches := constraintRegexp.FindStringSubmatch(v) + if matches == nil { + return nil, fmt.Errorf("Malformed constraint: %s", v) + } + + check, err := NewVersion(matches[2]) + if err != nil { + return nil, err + } + + return &Constraint{ + f: constraintOperators[matches[1]], + check: check, + original: v, + }, nil +} + +func prereleaseCheck(v, c *Version) bool { + switch vPre, cPre := v.Prerelease() != "", c.Prerelease() != ""; { + case cPre && vPre: + // A constraint with a pre-release can only match a pre-release version + // with the same base segments. + return reflect.DeepEqual(c.Segments64(), v.Segments64()) + + case !cPre && vPre: + // A constraint without a pre-release can only match a version without a + // pre-release. + return false + + case cPre && !vPre: + // OK, except with the pessimistic operator + case !cPre && !vPre: + // OK + } + return true +} + +//------------------------------------------------------------------- +// Constraint functions +//------------------------------------------------------------------- + +func constraintEqual(v, c *Version) bool { + return v.Equal(c) +} + +func constraintNotEqual(v, c *Version) bool { + return !v.Equal(c) +} + +func constraintGreaterThan(v, c *Version) bool { + return prereleaseCheck(v, c) && v.Compare(c) == 1 +} + +func constraintLessThan(v, c *Version) bool { + return prereleaseCheck(v, c) && v.Compare(c) == -1 +} + +func constraintGreaterThanEqual(v, c *Version) bool { + return prereleaseCheck(v, c) && v.Compare(c) >= 0 +} + +func constraintLessThanEqual(v, c *Version) bool { + return prereleaseCheck(v, c) && v.Compare(c) <= 0 +} + +func constraintPessimistic(v, c *Version) bool { + // Using a pessimistic constraint with a pre-release, restricts versions to pre-releases + if !prereleaseCheck(v, c) || (c.Prerelease() != "" && v.Prerelease() == "") { + return false + } + + // If the version being checked is naturally less than the constraint, then there + // is no way for the version to be valid against the constraint + if v.LessThan(c) { + return false + } + // We'll use this more than once, so grab the length now so it's a little cleaner + // to write the later checks + cs := len(c.segments) + + // If the version being checked has less specificity than the constraint, then there + // is no way for the version to be valid against the constraint + if cs > len(v.segments) { + return false + } + + // Check the segments in the constraint against those in the version. If the version + // being checked, at any point, does not have the same values in each index of the + // constraints segments, then it cannot be valid against the constraint. + for i := 0; i < c.si-1; i++ { + if v.segments[i] != c.segments[i] { + return false + } + } + + // Check the last part of the segment in the constraint. If the version segment at + // this index is less than the constraints segment at this index, then it cannot + // be valid against the constraint + if c.segments[cs-1] > v.segments[cs-1] { + return false + } + + // If nothing has rejected the version by now, it's valid + return true +} diff --git a/third-party/github.com/hashicorp/go-version/constraint_test.go b/third-party/github.com/hashicorp/go-version/constraint_test.go new file mode 100644 index 000000000..9c5bee312 --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/constraint_test.go @@ -0,0 +1,126 @@ +package version + +import ( + "testing" +) + +func TestNewConstraint(t *testing.T) { + cases := []struct { + input string + count int + err bool + }{ + {">= 1.2", 1, false}, + {"1.0", 1, false}, + {">= 1.x", 0, true}, + {">= 1.2, < 1.0", 2, false}, + + // Out of bounds + {"11387778780781445675529500000000000000000", 0, true}, + } + + for _, tc := range cases { + v, err := NewConstraint(tc.input) + if tc.err && err == nil { + t.Fatalf("expected error for input: %s", tc.input) + } else if !tc.err && err != nil { + t.Fatalf("error for input %s: %s", tc.input, err) + } + + if len(v) != tc.count { + t.Fatalf("input: %s\nexpected len: %d\nactual: %d", + tc.input, tc.count, len(v)) + } + } +} + +func TestConstraintCheck(t *testing.T) { + cases := []struct { + constraint string + version string + check bool + }{ + {">= 1.0, < 1.2", "1.1.5", true}, + {"< 1.0, < 1.2", "1.1.5", false}, + {"= 1.0", "1.1.5", false}, + {"= 1.0", "1.0.0", true}, + {"1.0", "1.0.0", true}, + {"~> 1.0", "2.0", false}, + {"~> 1.0", "1.1", true}, + {"~> 1.0", "1.2.3", true}, + {"~> 1.0.0", "1.2.3", false}, + {"~> 1.0.0", "1.0.7", true}, + {"~> 1.0.0", "1.1.0", false}, + {"~> 1.0.7", "1.0.4", false}, + {"~> 1.0.7", "1.0.7", true}, + {"~> 1.0.7", "1.0.8", true}, + {"~> 1.0.7", "1.0.7.5", true}, + {"~> 1.0.7", "1.0.6.99", false}, + {"~> 1.0.7", "1.0.8.0", true}, + {"~> 1.0.9.5", "1.0.9.5", true}, + {"~> 1.0.9.5", "1.0.9.4", false}, + {"~> 1.0.9.5", "1.0.9.6", true}, + {"~> 1.0.9.5", "1.0.9.5.0", true}, + {"~> 1.0.9.5", "1.0.9.5.1", true}, + {"~> 2.0", "2.1.0-beta", false}, + {"~> 2.1.0-a", "2.2.0", false}, + {"~> 2.1.0-a", "2.1.0", false}, + {"~> 2.1.0-a", "2.1.0-beta", true}, + {"~> 2.1.0-a", "2.2.0-alpha", false}, + {"> 2.0", "2.1.0-beta", false}, + {">= 2.1.0-a", "2.1.0-beta", true}, + {">= 2.1.0-a", "2.1.1-beta", false}, + {">= 2.0.0", "2.1.0-beta", false}, + {">= 2.1.0-a", "2.1.1", true}, + {">= 2.1.0-a", "2.1.1-beta", false}, + {">= 2.1.0-a", "2.1.0", true}, + {"<= 2.1.0-a", "2.0.0", true}, + } + + for _, tc := range cases { + c, err := NewConstraint(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + v, err := NewVersion(tc.version) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := c.Check(v) + expected := tc.check + if actual != expected { + t.Fatalf("Version: %s\nConstraint: %s\nExpected: %#v", + tc.version, tc.constraint, expected) + } + } +} + +func TestConstraintsString(t *testing.T) { + cases := []struct { + constraint string + result string + }{ + {">= 1.0, < 1.2", ""}, + {"~> 1.0.7", ""}, + } + + for _, tc := range cases { + c, err := NewConstraint(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := c.String() + expected := tc.result + if expected == "" { + expected = tc.constraint + } + + if actual != expected { + t.Fatalf("Constraint: %s\nExpected: %#v\nActual: %s", + tc.constraint, expected, actual) + } + } +} diff --git a/third-party/github.com/hashicorp/go-version/go.mod b/third-party/github.com/hashicorp/go-version/go.mod new file mode 100644 index 000000000..f5285555f --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/go.mod @@ -0,0 +1 @@ +module github.com/hashicorp/go-version diff --git a/third-party/github.com/hashicorp/go-version/version.go b/third-party/github.com/hashicorp/go-version/version.go new file mode 100644 index 000000000..8068834ec --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/version.go @@ -0,0 +1,392 @@ +package version + +import ( + "bytes" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" +) + +// The compiled regular expression used to test the validity of a version. +var ( + versionRegexp *regexp.Regexp + semverRegexp *regexp.Regexp +) + +// The raw regular expression string used for testing the validity +// of a version. +const ( + VersionRegexpRaw string = `v?([0-9]+(\.[0-9]+)*?)` + + `(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-?([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?` + + `(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + + `?` + + // SemverRegexpRaw requires a separator between version and prerelease + SemverRegexpRaw string = `v?([0-9]+(\.[0-9]+)*?)` + + `(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?` + + `(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + + `?` +) + +// Version represents a single version. +type Version struct { + metadata string + pre string + segments []int64 + si int + original string +} + +func init() { + versionRegexp = regexp.MustCompile("^" + VersionRegexpRaw + "$") + semverRegexp = regexp.MustCompile("^" + SemverRegexpRaw + "$") +} + +// NewVersion parses the given version and returns a new +// Version. +func NewVersion(v string) (*Version, error) { + return newVersion(v, versionRegexp) +} + +// NewSemver parses the given version and returns a new +// Version that adheres strictly to SemVer specs +// https://semver.org/ +func NewSemver(v string) (*Version, error) { + return newVersion(v, semverRegexp) +} + +func newVersion(v string, pattern *regexp.Regexp) (*Version, error) { + matches := pattern.FindStringSubmatch(v) + if matches == nil { + return nil, fmt.Errorf("Malformed version: %s", v) + } + segmentsStr := strings.Split(matches[1], ".") + segments := make([]int64, len(segmentsStr)) + si := 0 + for i, str := range segmentsStr { + val, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return nil, fmt.Errorf( + "Error parsing version: %s", err) + } + + segments[i] = int64(val) + si++ + } + + // Even though we could support more than three segments, if we + // got less than three, pad it with 0s. This is to cover the basic + // default usecase of semver, which is MAJOR.MINOR.PATCH at the minimum + for i := len(segments); i < 3; i++ { + segments = append(segments, 0) + } + + pre := matches[7] + if pre == "" { + pre = matches[4] + } + + return &Version{ + metadata: matches[10], + pre: pre, + segments: segments, + si: si, + original: v, + }, nil +} + +// Must is a helper that wraps a call to a function returning (*Version, error) +// and panics if error is non-nil. +func Must(v *Version, err error) *Version { + if err != nil { + panic(err) + } + + return v +} + +// Compare compares this version to another version. This +// returns -1, 0, or 1 if this version is smaller, equal, +// or larger than the other version, respectively. +// +// If you want boolean results, use the LessThan, Equal, +// GreaterThan, GreaterThanOrEqual or LessThanOrEqual methods. +func (v *Version) Compare(other *Version) int { + // A quick, efficient equality check + if v.String() == other.String() { + return 0 + } + + segmentsSelf := v.Segments64() + segmentsOther := other.Segments64() + + // If the segments are the same, we must compare on prerelease info + if reflect.DeepEqual(segmentsSelf, segmentsOther) { + preSelf := v.Prerelease() + preOther := other.Prerelease() + if preSelf == "" && preOther == "" { + return 0 + } + if preSelf == "" { + return 1 + } + if preOther == "" { + return -1 + } + + return comparePrereleases(preSelf, preOther) + } + + // Get the highest specificity (hS), or if they're equal, just use segmentSelf length + lenSelf := len(segmentsSelf) + lenOther := len(segmentsOther) + hS := lenSelf + if lenSelf < lenOther { + hS = lenOther + } + // Compare the segments + // Because a constraint could have more/less specificity than the version it's + // checking, we need to account for a lopsided or jagged comparison + for i := 0; i < hS; i++ { + if i > lenSelf-1 { + // This means Self had the lower specificity + // Check to see if the remaining segments in Other are all zeros + if !allZero(segmentsOther[i:]) { + // if not, it means that Other has to be greater than Self + return -1 + } + break + } else if i > lenOther-1 { + // this means Other had the lower specificity + // Check to see if the remaining segments in Self are all zeros - + if !allZero(segmentsSelf[i:]) { + //if not, it means that Self has to be greater than Other + return 1 + } + break + } + lhs := segmentsSelf[i] + rhs := segmentsOther[i] + if lhs == rhs { + continue + } else if lhs < rhs { + return -1 + } + // Otherwis, rhs was > lhs, they're not equal + return 1 + } + + // if we got this far, they're equal + return 0 +} + +func allZero(segs []int64) bool { + for _, s := range segs { + if s != 0 { + return false + } + } + return true +} + +func comparePart(preSelf string, preOther string) int { + if preSelf == preOther { + return 0 + } + + var selfInt int64 + selfNumeric := true + selfInt, err := strconv.ParseInt(preSelf, 10, 64) + if err != nil { + selfNumeric = false + } + + var otherInt int64 + otherNumeric := true + otherInt, err = strconv.ParseInt(preOther, 10, 64) + if err != nil { + otherNumeric = false + } + + // if a part is empty, we use the other to decide + if preSelf == "" { + if otherNumeric { + return -1 + } + return 1 + } + + if preOther == "" { + if selfNumeric { + return 1 + } + return -1 + } + + if selfNumeric && !otherNumeric { + return -1 + } else if !selfNumeric && otherNumeric { + return 1 + } else if !selfNumeric && !otherNumeric && preSelf > preOther { + return 1 + } else if selfInt > otherInt { + return 1 + } + + return -1 +} + +func comparePrereleases(v string, other string) int { + // the same pre release! + if v == other { + return 0 + } + + // split both pre releases for analyse their parts + selfPreReleaseMeta := strings.Split(v, ".") + otherPreReleaseMeta := strings.Split(other, ".") + + selfPreReleaseLen := len(selfPreReleaseMeta) + otherPreReleaseLen := len(otherPreReleaseMeta) + + biggestLen := otherPreReleaseLen + if selfPreReleaseLen > otherPreReleaseLen { + biggestLen = selfPreReleaseLen + } + + // loop for parts to find the first difference + for i := 0; i < biggestLen; i = i + 1 { + partSelfPre := "" + if i < selfPreReleaseLen { + partSelfPre = selfPreReleaseMeta[i] + } + + partOtherPre := "" + if i < otherPreReleaseLen { + partOtherPre = otherPreReleaseMeta[i] + } + + compare := comparePart(partSelfPre, partOtherPre) + // if parts are equals, continue the loop + if compare != 0 { + return compare + } + } + + return 0 +} + +// Core returns a new version constructed from only the MAJOR.MINOR.PATCH +// segments of the version, without prerelease or metadata. +func (v *Version) Core() *Version { + segments := v.Segments64() + segmentsOnly := fmt.Sprintf("%d.%d.%d", segments[0], segments[1], segments[2]) + return Must(NewVersion(segmentsOnly)) +} + +// Equal tests if two versions are equal. +func (v *Version) Equal(o *Version) bool { + if v == nil || o == nil { + return v == o + } + + return v.Compare(o) == 0 +} + +// GreaterThan tests if this version is greater than another version. +func (v *Version) GreaterThan(o *Version) bool { + return v.Compare(o) > 0 +} + +// GreaterThanOrEqual tests if this version is greater than or equal to another version. +func (v *Version) GreaterThanOrEqual(o *Version) bool { + return v.Compare(o) >= 0 +} + +// LessThan tests if this version is less than another version. +func (v *Version) LessThan(o *Version) bool { + return v.Compare(o) < 0 +} + +// LessThanOrEqual tests if this version is less than or equal to another version. +func (v *Version) LessThanOrEqual(o *Version) bool { + return v.Compare(o) <= 0 +} + +// Metadata returns any metadata that was part of the version +// string. +// +// Metadata is anything that comes after the "+" in the version. +// For example, with "1.2.3+beta", the metadata is "beta". +func (v *Version) Metadata() string { + return v.metadata +} + +// Prerelease returns any prerelease data that is part of the version, +// or blank if there is no prerelease data. +// +// Prerelease information is anything that comes after the "-" in the +// version (but before any metadata). For example, with "1.2.3-beta", +// the prerelease information is "beta". +func (v *Version) Prerelease() string { + return v.pre +} + +// Segments returns the numeric segments of the version as a slice of ints. +// +// This excludes any metadata or pre-release information. For example, +// for a version "1.2.3-beta", segments will return a slice of +// 1, 2, 3. +func (v *Version) Segments() []int { + segmentSlice := make([]int, len(v.segments)) + for i, v := range v.segments { + segmentSlice[i] = int(v) + } + return segmentSlice +} + +// Segments64 returns the numeric segments of the version as a slice of int64s. +// +// This excludes any metadata or pre-release information. For example, +// for a version "1.2.3-beta", segments will return a slice of +// 1, 2, 3. +func (v *Version) Segments64() []int64 { + result := make([]int64, len(v.segments)) + copy(result, v.segments) + return result +} + +// String returns the full version string included pre-release +// and metadata information. +// +// This value is rebuilt according to the parsed segments and other +// information. Therefore, ambiguities in the version string such as +// prefixed zeroes (1.04.0 => 1.4.0), `v` prefix (v1.0.0 => 1.0.0), and +// missing parts (1.0 => 1.0.0) will be made into a canonicalized form +// as shown in the parenthesized examples. +func (v *Version) String() string { + var buf bytes.Buffer + fmtParts := make([]string, len(v.segments)) + for i, s := range v.segments { + // We can ignore err here since we've pre-parsed the values in segments + str := strconv.FormatInt(s, 10) + fmtParts[i] = str + } + fmt.Fprintf(&buf, strings.Join(fmtParts, ".")) + if v.pre != "" { + fmt.Fprintf(&buf, "-%s", v.pre) + } + if v.metadata != "" { + fmt.Fprintf(&buf, "+%s", v.metadata) + } + + return buf.String() +} + +// Original returns the original parsed version as-is, including any +// potential whitespace, `v` prefix, etc. +func (v *Version) Original() string { + return v.original +} diff --git a/third-party/github.com/hashicorp/go-version/version_collection.go b/third-party/github.com/hashicorp/go-version/version_collection.go new file mode 100644 index 000000000..cc888d43e --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/version_collection.go @@ -0,0 +1,17 @@ +package version + +// Collection is a type that implements the sort.Interface interface +// so that versions can be sorted. +type Collection []*Version + +func (v Collection) Len() int { + return len(v) +} + +func (v Collection) Less(i, j int) bool { + return v[i].LessThan(v[j]) +} + +func (v Collection) Swap(i, j int) { + v[i], v[j] = v[j], v[i] +} diff --git a/third-party/github.com/hashicorp/go-version/version_collection_test.go b/third-party/github.com/hashicorp/go-version/version_collection_test.go new file mode 100644 index 000000000..14783d7e7 --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/version_collection_test.go @@ -0,0 +1,46 @@ +package version + +import ( + "reflect" + "sort" + "testing" +) + +func TestCollection(t *testing.T) { + versionsRaw := []string{ + "1.1.1", + "1.0", + "1.2", + "2", + "0.7.1", + } + + versions := make([]*Version, len(versionsRaw)) + for i, raw := range versionsRaw { + v, err := NewVersion(raw) + if err != nil { + t.Fatalf("err: %s", err) + } + + versions[i] = v + } + + sort.Sort(Collection(versions)) + + actual := make([]string, len(versions)) + for i, v := range versions { + actual[i] = v.String() + } + + expected := []string{ + "0.7.1", + "1.0.0", + "1.1.1", + "1.2.0", + "2.0.0", + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: %#v", actual) + } +} diff --git a/third-party/github.com/hashicorp/go-version/version_test.go b/third-party/github.com/hashicorp/go-version/version_test.go new file mode 100644 index 000000000..9fa34f6bd --- /dev/null +++ b/third-party/github.com/hashicorp/go-version/version_test.go @@ -0,0 +1,656 @@ +package version + +import ( + "reflect" + "testing" +) + +func TestNewVersion(t *testing.T) { + cases := []struct { + version string + err bool + }{ + {"", true}, + {"1.2.3", false}, + {"1.0", false}, + {"1", false}, + {"1.2.beta", true}, + {"1.21.beta", true}, + {"foo", true}, + {"1.2-5", false}, + {"1.2-beta.5", false}, + {"\n1.2", true}, + {"1.2.0-x.Y.0+metadata", false}, + {"1.2.0-x.Y.0+metadata-width-hypen", false}, + {"1.2.3-rc1-with-hypen", false}, + {"1.2.3.4", false}, + {"1.2.0.4-x.Y.0+metadata", false}, + {"1.2.0.4-x.Y.0+metadata-width-hypen", false}, + {"1.2.0-X-1.2.0+metadata~dist", false}, + {"1.2.3.4-rc1-with-hypen", false}, + {"1.2.3.4", false}, + {"v1.2.3", false}, + {"foo1.2.3", true}, + {"1.7rc2", false}, + {"v1.7rc2", false}, + {"1.0-", false}, + } + + for _, tc := range cases { + _, err := NewVersion(tc.version) + if tc.err && err == nil { + t.Fatalf("expected error for version: %q", tc.version) + } else if !tc.err && err != nil { + t.Fatalf("error for version %q: %s", tc.version, err) + } + } +} + +func TestNewSemver(t *testing.T) { + cases := []struct { + version string + err bool + }{ + {"", true}, + {"1.2.3", false}, + {"1.0", false}, + {"1", false}, + {"1.2.beta", true}, + {"1.21.beta", true}, + {"foo", true}, + {"1.2-5", false}, + {"1.2-beta.5", false}, + {"\n1.2", true}, + {"1.2.0-x.Y.0+metadata", false}, + {"1.2.0-x.Y.0+metadata-width-hypen", false}, + {"1.2.3-rc1-with-hypen", false}, + {"1.2.3.4", false}, + {"1.2.0.4-x.Y.0+metadata", false}, + {"1.2.0.4-x.Y.0+metadata-width-hypen", false}, + {"1.2.0-X-1.2.0+metadata~dist", false}, + {"1.2.3.4-rc1-with-hypen", false}, + {"1.2.3.4", false}, + {"v1.2.3", false}, + {"foo1.2.3", true}, + {"1.7rc2", true}, + {"v1.7rc2", true}, + {"1.0-", true}, + } + + for _, tc := range cases { + _, err := NewSemver(tc.version) + if tc.err && err == nil { + t.Fatalf("expected error for version: %q", tc.version) + } else if !tc.err && err != nil { + t.Fatalf("error for version %q: %s", tc.version, err) + } + } +} + +func TestCore(t *testing.T) { + cases := []struct { + v1 string + v2 string + }{ + {"1.2.3", "1.2.3"}, + {"2.3.4-alpha1", "2.3.4"}, + {"3.4.5alpha1", "3.4.5"}, + {"1.2.3-2", "1.2.3"}, + {"4.5.6-beta1+meta", "4.5.6"}, + {"5.6.7.1.2.3", "5.6.7"}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("error for version %q: %s", tc.v1, err) + } + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("error for version %q: %s", tc.v2, err) + } + + actual := v1.Core() + expected := v2 + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected: %s\nactual: %s", expected, actual) + } + } +} + +func TestVersionCompare(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected int + }{ + {"1.2.3", "1.4.5", -1}, + {"1.2-beta", "1.2-beta", 0}, + {"1.2", "1.1.4", 1}, + {"1.2", "1.2-beta", 1}, + {"1.2+foo", "1.2+beta", 0}, + {"v1.2", "v1.2-beta", 1}, + {"v1.2+foo", "v1.2+beta", 0}, + {"v1.2.3.4", "v1.2.3.4", 0}, + {"v1.2.0.0", "v1.2", 0}, + {"v1.2.0.0.1", "v1.2", 1}, + {"v1.2", "v1.2.0.0", 0}, + {"v1.2", "v1.2.0.0.1", -1}, + {"v1.2.0.0", "v1.2.0.0.1", -1}, + {"v1.2.3.0", "v1.2.3.4", -1}, + {"1.7rc2", "1.7rc1", 1}, + {"1.7rc2", "1.7", -1}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", 1}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.Compare(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s <=> %s\nexpected: %d\nactual: %d", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestVersionCompare_versionAndSemver(t *testing.T) { + cases := []struct { + versionRaw string + semverRaw string + expected int + }{ + {"0.0.2", "0.0.2", 0}, + {"1.0.2alpha", "1.0.2-alpha", 0}, + {"v1.2+foo", "v1.2+beta", 0}, + {"v1.2", "v1.2+meta", 0}, + {"1.2", "1.2-beta", 1}, + {"v1.2", "v1.2-beta", 1}, + {"1.2.3", "1.4.5", -1}, + {"v1.2", "v1.2.0.0.1", -1}, + {"v1.0.3-", "v1.0.3", -1}, + } + + for _, tc := range cases { + ver, err := NewVersion(tc.versionRaw) + if err != nil { + t.Fatalf("err: %s", err) + } + + semver, err := NewSemver(tc.semverRaw) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := ver.Compare(semver) + if actual != tc.expected { + t.Fatalf( + "%s <=> %s\nexpected: %d\n actual: %d", + tc.versionRaw, tc.semverRaw, tc.expected, actual, + ) + } + } +} + +func TestVersionEqual_nil(t *testing.T) { + mustVersion := func(v string) *Version { + ver, err := NewVersion(v) + if err != nil { + t.Fatal(err) + } + return ver + } + cases := []struct { + leftVersion *Version + rightVersion *Version + expected bool + }{ + {mustVersion("1.0.0"), nil, false}, + {nil, mustVersion("1.0.0"), false}, + {nil, nil, true}, + } + + for _, tc := range cases { + given := tc.leftVersion.Equal(tc.rightVersion) + if given != tc.expected { + t.Fatalf("expected Equal to nil to be %t", tc.expected) + } + } +} + +func TestComparePreReleases(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected int + }{ + {"1.2-beta.2", "1.2-beta.2", 0}, + {"1.2-beta.1", "1.2-beta.2", -1}, + {"1.2-beta.2", "1.2-beta.11", -1}, + {"3.2-alpha.1", "3.2-alpha", 1}, + {"1.2-beta.2", "1.2-beta.1", 1}, + {"1.2-beta.11", "1.2-beta.2", 1}, + {"1.2-beta", "1.2-beta.3", -1}, + {"1.2-alpha", "1.2-beta.3", -1}, + {"1.2-beta", "1.2-alpha.3", 1}, + {"3.0-alpha.3", "3.0-rc.1", -1}, + {"3.0-alpha3", "3.0-rc1", -1}, + {"3.0-alpha.1", "3.0-alpha.beta", -1}, + {"5.4-alpha", "5.4-alpha.beta", 1}, + {"v1.2-beta.2", "v1.2-beta.2", 0}, + {"v1.2-beta.1", "v1.2-beta.2", -1}, + {"v3.2-alpha.1", "v3.2-alpha", 1}, + {"v3.2-rc.1-1-g123", "v3.2-rc.2", 1}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.Compare(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s <=> %s\nexpected: %d\nactual: %d", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestVersionMetadata(t *testing.T) { + cases := []struct { + version string + expected string + }{ + {"1.2.3", ""}, + {"1.2-beta", ""}, + {"1.2.0-x.Y.0", ""}, + {"1.2.0-x.Y.0+metadata", "metadata"}, + {"1.2.0-metadata-1.2.0+metadata~dist", "metadata~dist"}, + } + + for _, tc := range cases { + v, err := NewVersion(tc.version) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v.Metadata() + expected := tc.expected + if actual != expected { + t.Fatalf("expected: %s\nactual: %s", expected, actual) + } + } +} + +func TestVersionPrerelease(t *testing.T) { + cases := []struct { + version string + expected string + }{ + {"1.2.3", ""}, + {"1.2-beta", "beta"}, + {"1.2.0-x.Y.0", "x.Y.0"}, + {"1.2.0-7.Y.0", "7.Y.0"}, + {"1.2.0-x.Y.0+metadata", "x.Y.0"}, + {"1.2.0-metadata-1.2.0+metadata~dist", "metadata-1.2.0"}, + {"17.03.0-ce", "ce"}, // zero-padded fields + } + + for _, tc := range cases { + v, err := NewVersion(tc.version) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v.Prerelease() + expected := tc.expected + if actual != expected { + t.Fatalf("expected: %s\nactual: %s", expected, actual) + } + } +} + +func TestVersionSegments(t *testing.T) { + cases := []struct { + version string + expected []int + }{ + {"1.2.3", []int{1, 2, 3}}, + {"1.2-beta", []int{1, 2, 0}}, + {"1-x.Y.0", []int{1, 0, 0}}, + {"1.2.0-x.Y.0+metadata", []int{1, 2, 0}}, + {"1.2.0-metadata-1.2.0+metadata~dist", []int{1, 2, 0}}, + {"17.03.0-ce", []int{17, 3, 0}}, // zero-padded fields + } + + for _, tc := range cases { + v, err := NewVersion(tc.version) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v.Segments() + expected := tc.expected + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected: %#v\nactual: %#v", expected, actual) + } + } +} + +func TestVersionSegments64(t *testing.T) { + cases := []struct { + version string + expected []int64 + }{ + {"1.2.3", []int64{1, 2, 3}}, + {"1.2-beta", []int64{1, 2, 0}}, + {"1-x.Y.0", []int64{1, 0, 0}}, + {"1.2.0-x.Y.0+metadata", []int64{1, 2, 0}}, + {"1.4.9223372036854775807", []int64{1, 4, 9223372036854775807}}, + } + + for _, tc := range cases { + v, err := NewVersion(tc.version) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v.Segments64() + expected := tc.expected + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected: %#v\nactual: %#v", expected, actual) + } + + { + expected := actual[0] + actual[0]++ + actual = v.Segments64() + if actual[0] != expected { + t.Fatalf("Segments64 is mutable") + } + } + } +} + +func TestVersionString(t *testing.T) { + cases := [][]string{ + {"1.2.3", "1.2.3"}, + {"1.2-beta", "1.2.0-beta"}, + {"1.2.0-x.Y.0", "1.2.0-x.Y.0"}, + {"1.2.0-x.Y.0+metadata", "1.2.0-x.Y.0+metadata"}, + {"1.2.0-metadata-1.2.0+metadata~dist", "1.2.0-metadata-1.2.0+metadata~dist"}, + {"17.03.0-ce", "17.3.0-ce"}, // zero-padded fields + } + + for _, tc := range cases { + v, err := NewVersion(tc[0]) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v.String() + expected := tc[1] + if actual != expected { + t.Fatalf("expected: %s\nactual: %s", expected, actual) + } + if actual := v.Original(); actual != tc[0] { + t.Fatalf("expected original: %q\nactual: %q", tc[0], actual) + } + } +} + +func TestEqual(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected bool + }{ + {"1.2.3", "1.4.5", false}, + {"1.2-beta", "1.2-beta", true}, + {"1.2", "1.1.4", false}, + {"1.2", "1.2-beta", false}, + {"1.2+foo", "1.2+beta", true}, + {"v1.2", "v1.2-beta", false}, + {"v1.2+foo", "v1.2+beta", true}, + {"v1.2.3.4", "v1.2.3.4", true}, + {"v1.2.0.0", "v1.2", true}, + {"v1.2.0.0.1", "v1.2", false}, + {"v1.2", "v1.2.0.0", true}, + {"v1.2", "v1.2.0.0.1", false}, + {"v1.2.0.0", "v1.2.0.0.1", false}, + {"v1.2.3.0", "v1.2.3.4", false}, + {"1.7rc2", "1.7rc1", false}, + {"1.7rc2", "1.7", false}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", false}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.Equal(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s <=> %s\nexpected: %t\nactual: %t", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestGreaterThan(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected bool + }{ + {"1.2.3", "1.4.5", false}, + {"1.2-beta", "1.2-beta", false}, + {"1.2", "1.1.4", true}, + {"1.2", "1.2-beta", true}, + {"1.2+foo", "1.2+beta", false}, + {"v1.2", "v1.2-beta", true}, + {"v1.2+foo", "v1.2+beta", false}, + {"v1.2.3.4", "v1.2.3.4", false}, + {"v1.2.0.0", "v1.2", false}, + {"v1.2.0.0.1", "v1.2", true}, + {"v1.2", "v1.2.0.0", false}, + {"v1.2", "v1.2.0.0.1", false}, + {"v1.2.0.0", "v1.2.0.0.1", false}, + {"v1.2.3.0", "v1.2.3.4", false}, + {"1.7rc2", "1.7rc1", true}, + {"1.7rc2", "1.7", false}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", true}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.GreaterThan(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s > %s\nexpected: %t\nactual: %t", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestLessThan(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected bool + }{ + {"1.2.3", "1.4.5", true}, + {"1.2-beta", "1.2-beta", false}, + {"1.2", "1.1.4", false}, + {"1.2", "1.2-beta", false}, + {"1.2+foo", "1.2+beta", false}, + {"v1.2", "v1.2-beta", false}, + {"v1.2+foo", "v1.2+beta", false}, + {"v1.2.3.4", "v1.2.3.4", false}, + {"v1.2.0.0", "v1.2", false}, + {"v1.2.0.0.1", "v1.2", false}, + {"v1.2", "v1.2.0.0", false}, + {"v1.2", "v1.2.0.0.1", true}, + {"v1.2.0.0", "v1.2.0.0.1", true}, + {"v1.2.3.0", "v1.2.3.4", true}, + {"1.7rc2", "1.7rc1", false}, + {"1.7rc2", "1.7", true}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", false}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.LessThan(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s < %s\nexpected: %t\nactual: %t", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestGreaterThanOrEqual(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected bool + }{ + {"1.2.3", "1.4.5", false}, + {"1.2-beta", "1.2-beta", true}, + {"1.2", "1.1.4", true}, + {"1.2", "1.2-beta", true}, + {"1.2+foo", "1.2+beta", true}, + {"v1.2", "v1.2-beta", true}, + {"v1.2+foo", "v1.2+beta", true}, + {"v1.2.3.4", "v1.2.3.4", true}, + {"v1.2.0.0", "v1.2", true}, + {"v1.2.0.0.1", "v1.2", true}, + {"v1.2", "v1.2.0.0", true}, + {"v1.2", "v1.2.0.0.1", false}, + {"v1.2.0.0", "v1.2.0.0.1", false}, + {"v1.2.3.0", "v1.2.3.4", false}, + {"1.7rc2", "1.7rc1", true}, + {"1.7rc2", "1.7", false}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", true}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.GreaterThanOrEqual(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s >= %s\nexpected: %t\nactual: %t", + tc.v1, tc.v2, + expected, actual) + } + } +} + +func TestLessThanOrEqual(t *testing.T) { + cases := []struct { + v1 string + v2 string + expected bool + }{ + {"1.2.3", "1.4.5", true}, + {"1.2-beta", "1.2-beta", true}, + {"1.2", "1.1.4", false}, + {"1.2", "1.2-beta", false}, + {"1.2+foo", "1.2+beta", true}, + {"v1.2", "v1.2-beta", false}, + {"v1.2+foo", "v1.2+beta", true}, + {"v1.2.3.4", "v1.2.3.4", true}, + {"v1.2.0.0", "v1.2", true}, + {"v1.2.0.0.1", "v1.2", false}, + {"v1.2", "v1.2.0.0", true}, + {"v1.2", "v1.2.0.0.1", true}, + {"v1.2.0.0", "v1.2.0.0.1", true}, + {"v1.2.3.0", "v1.2.3.4", true}, + {"1.7rc2", "1.7rc1", false}, + {"1.7rc2", "1.7", true}, + {"1.2.0", "1.2.0-X-1.2.0+metadata~dist", false}, + } + + for _, tc := range cases { + v1, err := NewVersion(tc.v1) + if err != nil { + t.Fatalf("err: %s", err) + } + + v2, err := NewVersion(tc.v2) + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := v1.LessThanOrEqual(v2) + expected := tc.expected + if actual != expected { + t.Fatalf( + "%s <= %s\nexpected: %t\nactual: %t", + tc.v1, tc.v2, + expected, actual) + } + } +} diff --git a/third-party/github.com/henvic/httpretty/LICENSE.md b/third-party/github.com/henvic/httpretty/LICENSE.md new file mode 100644 index 000000000..426f2a874 --- /dev/null +++ b/third-party/github.com/henvic/httpretty/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Henrique Vicente + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/huandu/xstrings/LICENSE b/third-party/github.com/huandu/xstrings/LICENSE new file mode 100644 index 000000000..270177259 --- /dev/null +++ b/third-party/github.com/huandu/xstrings/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Huan Du + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/third-party/github.com/in-toto/attestation/go/v1/LICENSE b/third-party/github.com/in-toto/attestation/go/v1/LICENSE new file mode 100644 index 000000000..702a3365c --- /dev/null +++ b/third-party/github.com/in-toto/attestation/go/v1/LICENSE @@ -0,0 +1,13 @@ +Copyright 2021 in-toto Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/github.com/in-toto/in-toto-golang/in_toto/LICENSE b/third-party/github.com/in-toto/in-toto-golang/in_toto/LICENSE new file mode 100644 index 000000000..963ee949e --- /dev/null +++ b/third-party/github.com/in-toto/in-toto-golang/in_toto/LICENSE @@ -0,0 +1,13 @@ +Copyright 2018 New York University + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/github.com/inconshreveable/mousetrap/LICENSE b/third-party/github.com/inconshreveable/mousetrap/LICENSE new file mode 100644 index 000000000..5f920e973 --- /dev/null +++ b/third-party/github.com/inconshreveable/mousetrap/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Alan Shreve (@inconshreveable) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/itchyny/gojq/LICENSE b/third-party/github.com/itchyny/gojq/LICENSE new file mode 100644 index 000000000..fe5900407 --- /dev/null +++ b/third-party/github.com/itchyny/gojq/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-2024 itchyny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/itchyny/timefmt-go/LICENSE b/third-party/github.com/itchyny/timefmt-go/LICENSE new file mode 100644 index 000000000..84d6cb033 --- /dev/null +++ b/third-party/github.com/itchyny/timefmt-go/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2022 itchyny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/jedisct1/go-minisign/LICENSE b/third-party/github.com/jedisct1/go-minisign/LICENSE new file mode 100644 index 000000000..010ad6e7a --- /dev/null +++ b/third-party/github.com/jedisct1/go-minisign/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2021 Frank Denis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/joho/godotenv/LICENCE b/third-party/github.com/joho/godotenv/LICENCE new file mode 100644 index 000000000..e7ddd51be --- /dev/null +++ b/third-party/github.com/joho/godotenv/LICENCE @@ -0,0 +1,23 @@ +Copyright (c) 2013 John Barton + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/third-party/github.com/josharian/intern/license.md b/third-party/github.com/josharian/intern/license.md new file mode 100644 index 000000000..353d3055f --- /dev/null +++ b/third-party/github.com/josharian/intern/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Josh Bleecher Snyder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/kballard/go-shellquote/LICENSE b/third-party/github.com/kballard/go-shellquote/LICENSE new file mode 100644 index 000000000..a6d77312e --- /dev/null +++ b/third-party/github.com/kballard/go-shellquote/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2014 Kevin Ballard + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/klauspost/compress/LICENSE b/third-party/github.com/klauspost/compress/LICENSE new file mode 100644 index 000000000..87d557477 --- /dev/null +++ b/third-party/github.com/klauspost/compress/LICENSE @@ -0,0 +1,304 @@ +Copyright (c) 2012 The Go Authors. All rights reserved. +Copyright (c) 2019 Klaus Post. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------ + +Files: gzhttp/* + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017 The New York Times Company + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +------------------ + +Files: s2/cmd/internal/readahead/* + +The MIT License (MIT) + +Copyright (c) 2015 Klaus Post + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------- +Files: snappy/* +Files: internal/snapref/* + +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----------------- + +Files: s2/cmd/internal/filepathx/* + +Copyright 2016 The filepathx Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/klauspost/compress/internal/snapref/LICENSE b/third-party/github.com/klauspost/compress/internal/snapref/LICENSE new file mode 100644 index 000000000..6050c10f4 --- /dev/null +++ b/third-party/github.com/klauspost/compress/internal/snapref/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/klauspost/compress/zstd/internal/xxhash/LICENSE.txt b/third-party/github.com/klauspost/compress/zstd/internal/xxhash/LICENSE.txt new file mode 100644 index 000000000..24b53065f --- /dev/null +++ b/third-party/github.com/klauspost/compress/zstd/internal/xxhash/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/letsencrypt/boulder/.dockerignore b/third-party/github.com/letsencrypt/boulder/.dockerignore new file mode 100644 index 000000000..7fcd950a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.dockerignore @@ -0,0 +1,2 @@ +bin +tags diff --git a/third-party/github.com/letsencrypt/boulder/.github/FUNDING.yml b/third-party/github.com/letsencrypt/boulder/.github/FUNDING.yml new file mode 100644 index 000000000..22ce7e709 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://letsencrypt.org/donate/ diff --git a/third-party/github.com/letsencrypt/boulder/.github/dependabot.yml b/third-party/github.com/letsencrypt/boulder/.github/dependabot.yml new file mode 100644 index 000000000..f7caf901c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + groups: + aws: + patterns: + - "github.com/aws/*" + otel: + patterns: + - "go.opentelemetry.io/*" + open-pull-requests-limit: 1 + schedule: + interval: "weekly" + day: "wednesday" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 1 diff --git a/third-party/github.com/letsencrypt/boulder/.github/issue_template.md b/third-party/github.com/letsencrypt/boulder/.github/issue_template.md new file mode 100644 index 000000000..61510640d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/issue_template.md @@ -0,0 +1,21 @@ +--- +name: Default Template +about: File a bug report or feature request +title: '' +labels: '' +assignees: '' +--- + +**Summary:** + + +**Steps to reproduce:** + + +**Expected result:** + + +**Actual result:** + + +**Additional details:** diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/boulder-ci.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/boulder-ci.yml new file mode 100644 index 000000000..342b0c009 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/boulder-ci.yml @@ -0,0 +1,164 @@ +# Boulder CI test suite workflow + +name: Boulder CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: + - main + - release-branch-* + pull_request: + branches: + - '**' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +permissions: + contents: read + +jobs: + # Main test jobs. This looks like a single job, but the matrix + # items will multiply it. For example every entry in the + # BOULDER_TOOLS_TAG list will run with every test. If there were two + # tags and 5 tests there would be 10 jobs run. + b: + # The type of runner that the job will run on + runs-on: ubuntu-20.04 + + strategy: + # When set to true, GitHub cancels all in-progress jobs if any matrix job fails. Default: true + fail-fast: false + # Test matrix. + matrix: + # Add additional docker image tags here and all tests will be run with the additional image. + BOULDER_TOOLS_TAG: + - go1.22.3_2024-05-22 + # Tests command definitions. Use the entire "docker compose" command you want to run. + tests: + # Run ./test.sh --help for a description of each of the flags. + - "./t.sh --lints --generate" + - "./t.sh --integration" + # Testing Config Changes: + # Config changes that have landed in main but not yet been applied to + # production can be made in `test/config-next/.json`. + # + # Testing DB Schema Changes: + # Database migrations in `sa/_db-next/migrations` are only performed + # when `docker compose` is called using `-f docker-compose.yml -f + # docker-compose.next.yml`. + - "./tn.sh --integration" + - "./t.sh --unit --enable-race-detection" + - "./tn.sh --unit --enable-race-detection" + - "./t.sh --start-py" + + env: + # This sets the docker image tag for the boulder-tools repository to + # use in tests. It will be set appropriately for each tag in the list + # defined in the matrix. + BOULDER_TOOLS_TAG: ${{ matrix.BOULDER_TOOLS_TAG }} + + # Sequence of tasks that will be executed as part of the job. + steps: + # Checks out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Docker Login + # You may pin to the exact commit or the version. + # uses: docker/login-action@f3364599c6aa293cdc2b8391b1b56d0c30e45c8a + uses: docker/login-action@v3.2.0 + with: + # Username used to log against the Docker registry + username: ${{ secrets.DOCKER_USERNAME}} + # Password or personal access token used to log against the Docker registry + password: ${{ secrets.DOCKER_PASSWORD}} + # Log out from the Docker registry at the end of a job + logout: true + continue-on-error: true + + # Print the env variable being used to pull the docker image. For + # informational use. + - name: Print BOULDER_TOOLS_TAG + run: echo "Using BOULDER_TOOLS_TAG ${BOULDER_TOOLS_TAG}" + + # Pre-pull the docker containers before running the tests. + - name: docker compose pull + run: docker compose pull + + # Run the test matrix. This will run + - name: "Run Test: ${{ matrix.tests }}" + run: ${{ matrix.tests }} + + govulncheck: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + + steps: + # Checks out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@v5 + with: + # When Go produces a security release, we want govulncheck to run + # against the most recently released Go version. + check-latest: true + go-version: "stable" + + - name: Run govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + vendorcheck: + runs-on: ubuntu-20.04 + strategy: + # When set to true, GitHub cancels all in-progress jobs if any matrix job fails. Default: true + fail-fast: false + matrix: + go-version: [ '1.22.2' ] + + steps: + # Checks out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Verify vendor + shell: bash + run: | + go mod tidy + go mod vendor + git diff --exit-code + + + # This is a utility build job to detect if the status of any of the + # above jobs have failed and fail if so. It is needed so there can be + # one static job name that can be used to determine success of the job + # in GitHub branch protection. + # It does not block on the result of govulncheck so that a new vulnerability + # disclosure does not prevent any other PRs from being merged. + boulder_ci_test_matrix_status: + permissions: + contents: none + if: ${{ always() }} + runs-on: ubuntu-latest + name: Boulder CI Test Matrix + needs: + - b + - vendorcheck + steps: + - name: Check boulder ci test matrix status + if: ${{ needs.b.result != 'success' || needs.vendorcheck.result != 'success' }} + run: exit 1 diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/codeql.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/codeql.yml new file mode 100644 index 000000000..f0cd015c0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: "Code Scanning - Action" + +on: + pull_request: + branches: [ release-branch-*, main] + push: + branches: [ release-branch-*, main] + + +jobs: + CodeQL-Build: + # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest + runs-on: ubuntu-latest + + permissions: + # required for all workflows + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/cps-review.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/cps-review.yml new file mode 100644 index 000000000..dd854cc23 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/cps-review.yml @@ -0,0 +1,69 @@ +name: Check PR for changes that trigger CP/CPS review + +on: + pull_request: + types: [ready_for_review, review_requested] + paths: + - 'features/features.go' + +jobs: + check-features: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Checkout Upstream + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + - name: Get Current Flags + run: go run ./test/list-features/list-features.go | sort >| /tmp/currflags.txt + + - name: Checkout PR + uses: actions/checkout@v4 + - name: Get PR Flags + run: go run ./test/list-features/list-features.go | sort >| /tmp/prflags.txt + + - name: Identify New Flags + id: newflags + run: echo flagnames=$(comm -13 /tmp/currflags.txt /tmp/prflags.txt | paste -sd,) >> $GITHUB_OUTPUT + + - name: Comment PR + if: ${{ steps.newflags.outputs.flagnames != '' }} + uses: actions/github-script@v7 + with: + script: | + const { owner, repo, number: issue_number } = context.issue; + + // No need to comment if the PR description already has a CPS review. + const reviewRegexp = /^CPS Compliance Review:/; + if (reviewRegexp.test(context.payload.pull_request.body)) { + return; + } + + // No need to comment if this task has previously commented on this PR. + const commentMarker = ''; + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number + }); + if (comments.data.find(c => c.body.includes(commentMarker))) { + return; + } + + // No existing review or comment found, post the comment. + const prAuthor = context.payload.pull_request.user.login; + const flagNames = '${{ steps.newflags.outputs.flagnames }}'; + const commentBody = `${commentMarker}\n@${prAuthor}, this PR adds one or more new feature flags: ${flagNames}. As such, this PR must be accompanied by a review of the Let's Encrypt CP/CPS to ensure that our behavior both before and after this flag is flipped is compliant with that document.\n\nPlease conduct such a review, then add your findings to the PR description in a paragraph beginning with "CPS Compliance Review:".`; + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: commentBody + }); diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/issue-for-sre-handoff.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/issue-for-sre-handoff.yml new file mode 100644 index 000000000..19cdc8b09 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/issue-for-sre-handoff.yml @@ -0,0 +1,55 @@ +name: Check PR for configuration and SQL changes + +on: + pull_request: + types: [ready_for_review, review_requested] + paths: + - 'test/config-next/*.json' + - 'test/config-next/*.yaml' + - 'test/config-next/*.yml' + - 'sa/db-users/*.sql' + - 'sa/db-next/**/*.sql' + - 'sa/db/**/*.sql' + +jobs: + check-changes: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment PR + uses: actions/github-script@v7 + with: + script: | + const commentMarker = ''; + const prAuthor = context.payload.pull_request.user.login; + const commentBody = `${commentMarker}\n@${prAuthor}, this PR appears to contain configuration and/or SQL schema changes. Please ensure that a corresponding deployment ticket has been filed with the new values.\n`; + const { owner, repo, number: issue_number } = context.issue; + const issueRegexp = /IN-\d+/; + + // Get PR body and all issue comments. + const prBody = context.payload.pull_request.body; + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number + }); + + if (issueRegexp.test(prBody) || comments.data.some(c => issueRegexp.test(c.body))) { + // Issue number exists in PR body or comments. + return; + } + + if (comments.data.find(c => c.body.includes(commentMarker))) { + // Comment already exists. + return; + } + + // No issue number or comment were found, post the comment. + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: commentBody + }); + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/release.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/release.yml new file mode 100644 index 000000000..ea678fc5e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/release.yml @@ -0,0 +1,50 @@ +# Build the Boulder Debian package on every PR, push to main, and tag push. On +# tag pushes, additionally create a GitHub release and with the resulting Debian +# package. +# Keep in sync with try-release.yml, with the exception that try-release.yml +# can have multiple entries in its matrix but this should only have one. +name: Build release +on: + push: + tags: + - release-* + +jobs: + push-release: + strategy: + fail-fast: false + matrix: + GO_VERSION: + - "1.22.3" + runs-on: ubuntu-20.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build .deb + id: build + env: + GO_VERSION: ${{ matrix.GO_VERSION }} + run: ./tools/make-assets.sh + + - name: Compute checksums + id: checksums + # The files listed on this line must be identical to the files uploaded + # in the last step. + run: sha256sum boulder*.deb boulder*.tar.gz >| checksums.txt + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # https://cli.github.com/manual/gh_release_create + run: gh release create "${GITHUB_REF_NAME}" + continue-on-error: true + + - name: Upload release files + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # https://cli.github.com/manual/gh_release_upload + run: gh release upload "${GITHUB_REF_NAME}" boulder*.deb boulder*.tar.gz checksums.txt diff --git a/third-party/github.com/letsencrypt/boulder/.github/workflows/try-release.yml b/third-party/github.com/letsencrypt/boulder/.github/workflows/try-release.yml new file mode 100644 index 000000000..d93d696ab --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.github/workflows/try-release.yml @@ -0,0 +1,35 @@ +# Try building the Boulder Debian package on every PR and push to main. +# This is to make sure the actual release job will succeed when we tag a +# release. +# Keep in sync with release.yml +name: Try release +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + try-release: + strategy: + fail-fast: false + matrix: + GO_VERSION: + - "1.22.3" + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build .deb + id: build + env: + GO_VERSION: ${{ matrix.GO_VERSION }} + run: ./tools/make-assets.sh + + - name: Compute checksums + id: checksums + # The files listed on this line must be identical to the files uploaded + # in the last step of the real release action. + run: sha256sum boulder*.deb boulder*.tar.gz diff --git a/third-party/github.com/letsencrypt/boulder/.gitignore b/third-party/github.com/letsencrypt/boulder/.gitignore new file mode 100644 index 000000000..bb3f1cc4b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.gitignore @@ -0,0 +1,42 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so +*.pyc + +# Folders +_obj +_test +bin +.gocache + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +# Vim swap files +*.sw? + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.sw? +*.exe +*.test +*.prof +*.coverprofile + +tags + +# IDE support files +.idea + +.vscode/* + +# ProxySQL log files +test/proxysql/*.log* diff --git a/third-party/github.com/letsencrypt/boulder/.golangci.yml b/third-party/github.com/letsencrypt/boulder/.golangci.yml new file mode 100644 index 000000000..7e0aed488 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.golangci.yml @@ -0,0 +1,60 @@ +linters: + disable-all: true + enable: + - errcheck + - gofmt + - gosec + - gosimple + - govet + - ineffassign + - misspell + - typecheck + - unconvert + - unparam + - unused + # TODO(#6202): Re-enable 'wastedassign' linter +linters-settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + - (net.Conn).Write + - encoding/binary.Write + - io.Write + - net/http.Write + - os.Remove + - github.com/miekg/dns.WriteMsg + gosimple: + # S1029: Range over the string directly + checks: ["all", "-S1029"] + govet: + enable-all: true + disable: + - fieldalignment + - shadow + settings: + printf: + funcs: + - (github.com/letsencrypt/boulder/log.Logger).Errf + - (github.com/letsencrypt/boulder/log.Logger).Warningf + - (github.com/letsencrypt/boulder/log.Logger).Infof + - (github.com/letsencrypt/boulder/log.Logger).Debugf + - (github.com/letsencrypt/boulder/log.Logger).AuditInfof + - (github.com/letsencrypt/boulder/log.Logger).AuditErrf + - (github.com/letsencrypt/boulder/ocsp/responder).SampledError + - (github.com/letsencrypt/boulder/web.RequestEvent).AddError + gosec: + excludes: + # TODO: Identify, fix, and remove violations of most of these rules + - G101 # Potential hardcoded credentials + - G102 # Binds to all network interfaces + - G107 # Potential HTTP request made with variable url + - G201 # SQL string formatting + - G202 # SQL string concatenation + - G306 # Expect WriteFile permissions to be 0600 or less + - G401 # Use of weak cryptographic primitive + - G402 # TLS InsecureSkipVerify set true. + - G403 # RSA keys should be at least 2048 bits + - G404 # Use of weak random number generator (math/rand instead of crypto/rand) + - G501 # Blacklisted import `crypto/md5`: weak cryptographic primitive + - G505 # Blacklisted import `crypto/sha1`: weak cryptographic primitive + - G601 # Implicit memory aliasing in for loop (this is fixed by go1.22) diff --git a/third-party/github.com/letsencrypt/boulder/.typos.toml b/third-party/github.com/letsencrypt/boulder/.typos.toml new file mode 100644 index 000000000..3451ac76a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/.typos.toml @@ -0,0 +1,37 @@ +[files] +extend-exclude = [ + ".git/", + "go.mod", + "go.sum", + "vendor/", +] +ignore-hidden = false + +[default] +extend-ignore-re = [ + # Anything base64 or base64url longer than 36 chars is probably encoded. + '\b[0-9A-Za-z+/]{36,}\b', + '\b[0-9A-Za-z_-]{36,}\b', + "0002a4ba3cf408927759", + "65CuDAA", + '"sql_warnings", "TrUe"', + '"tx_read_only", "FalSe"', + "evenMOREcaps", + '"iSsUe"', +] + +[default.extend-words] +# Extended DNS Error +"ede" = "ede" +# Alternative spelling +"unmarshaling" = "unmarshaling" + +[default.extend-identifiers] +"caaFailer" = "caaFailer" +"challStrat" = "challStrat" +"ExpectedStratType" = "ExpectedStratType" +"otConf" = "otConf" +"serInt" = "serInt" +"StratName" = "StratName" +"UPDATEs" = "UPDATEs" +"vai" = "vai" diff --git a/third-party/github.com/letsencrypt/boulder/CODEOWNERS b/third-party/github.com/letsencrypt/boulder/CODEOWNERS new file mode 100644 index 000000000..0c4ed22ba --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/CODEOWNERS @@ -0,0 +1 @@ +* @letsencrypt/boulder-developers diff --git a/third-party/github.com/letsencrypt/boulder/LICENSE.txt b/third-party/github.com/letsencrypt/boulder/LICENSE.txt new file mode 100644 index 000000000..fa274d92d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright 2016 ISRG. All rights reserved. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third-party/github.com/letsencrypt/boulder/Makefile b/third-party/github.com/letsencrypt/boulder/Makefile new file mode 100644 index 000000000..dfe15599d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/Makefile @@ -0,0 +1,73 @@ +OBJDIR ?= $(shell pwd)/bin +DESTDIR ?= /usr/local/bin +ARCHIVEDIR ?= /tmp + +VERSION ?= 1.0.0 +EPOCH ?= 1 +MAINTAINER ?= "Community" + +CMDS = $(shell find ./cmd -maxdepth 1 -mindepth 1 -type d | grep -v testdata) +CMD_BASENAMES = $(shell echo $(CMDS) | xargs -n1 basename) +CMD_BINS = $(addprefix bin/, $(CMD_BASENAMES) ) +OBJECTS = $(CMD_BINS) + +# Build environment variables (referencing core/util.go) +COMMIT_ID = $(shell git rev-parse --short=8 HEAD) + +BUILD_ID = $(shell git symbolic-ref --short=8 HEAD 2>/dev/null) +$(COMMIT_ID) +BUILD_ID_VAR = github.com/letsencrypt/boulder/core.BuildID + +BUILD_HOST = $(shell whoami)@$(shell hostname) +BUILD_HOST_VAR = github.com/letsencrypt/boulder/core.BuildHost + +BUILD_TIME = $(shell date -u) +BUILD_TIME_VAR = github.com/letsencrypt/boulder/core.BuildTime + +GO_BUILD_FLAGS = -ldflags "-X \"$(BUILD_ID_VAR)=$(BUILD_ID)\" -X \"$(BUILD_TIME_VAR)=$(BUILD_TIME)\" -X \"$(BUILD_HOST_VAR)=$(BUILD_HOST)\"" + +.PHONY: all build build_cmds rpm deb tar +all: build + +build: $(OBJECTS) + +$(OBJDIR): + @mkdir -p $(OBJDIR) + +$(CMD_BINS): build_cmds + +build_cmds: | $(OBJDIR) + echo $(OBJECTS) + GOBIN=$(OBJDIR) GO111MODULE=on go install -mod=vendor $(GO_BUILD_FLAGS) ./... + ./link.sh + +# Building an RPM requires `fpm` from https://github.com/jordansissel/fpm +# which you can install with `gem install fpm`. +# It is recommended that maintainers use environment overrides to specify +# Version and Epoch, such as: +# +# VERSION=0.1.9 EPOCH=52 MAINTAINER="$(whoami)" ARCHIVEDIR=/tmp make build rpm +rpm: build + fpm -f -s dir -t rpm --rpm-digest sha256 --name "boulder" \ + --license "Mozilla Public License v2.0" --vendor "ISRG" \ + --url "https://github.com/letsencrypt/boulder" --prefix=/opt/boulder \ + --version "$(VERSION)" --iteration "$(COMMIT_ID)" --epoch "$(EPOCH)" \ + --package "$(ARCHIVEDIR)/boulder-$(VERSION)-$(COMMIT_ID).x86_64.rpm" \ + --description "Boulder is an ACME-compatible X.509 Certificate Authority" \ + --maintainer "$(MAINTAINER)" \ + test/config/ sa/db data/ $(OBJECTS) + +deb: build + fpm -f -s dir -t deb --name "boulder" \ + --license "Mozilla Public License v2.0" --vendor "ISRG" \ + --url "https://github.com/letsencrypt/boulder" --prefix=/opt/boulder \ + --version "$(VERSION)" --iteration "$(COMMIT_ID)" --epoch "$(EPOCH)" \ + --package "$(ARCHIVEDIR)/boulder-$(VERSION)-$(COMMIT_ID).x86_64.deb" \ + --description "Boulder is an ACME-compatible X.509 Certificate Authority" \ + --maintainer "$(MAINTAINER)" \ + test/config/ sa/db data/ $(OBJECTS) bin/ct-test-srv + +tar: build + fpm -f -s dir -t tar --name "boulder" --prefix=/opt/boulder \ + --package "$(ARCHIVEDIR)/boulder-$(VERSION)-$(COMMIT_ID).amd64.tar" \ + test/config/ sa/db data/ $(OBJECTS) bin/ct-test-srv + gzip -f "$(ARCHIVEDIR)/boulder-$(VERSION)-$(COMMIT_ID).amd64.tar" diff --git a/third-party/github.com/letsencrypt/boulder/README.md b/third-party/github.com/letsencrypt/boulder/README.md new file mode 100644 index 000000000..c12240a18 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/README.md @@ -0,0 +1,286 @@ +# Boulder - An ACME CA + +[![Build Status](https://github.com/letsencrypt/boulder/actions/workflows/boulder-ci.yml/badge.svg?branch=main)](https://github.com/letsencrypt/boulder/actions/workflows/boulder-ci.yml?query=branch%3Amain) + +This is an implementation of an ACME-based CA. The [ACME +protocol](https://github.com/ietf-wg-acme/acme/) allows the CA to +automatically verify that an applicant for a certificate actually controls an +identifier, and allows domain holders to issue and revoke certificates for +their domains. Boulder is the software that runs [Let's +Encrypt](https://letsencrypt.org). + +## Contents + +* [Overview](#overview) +* [Setting up Boulder](#setting-up-boulder) + * [Development](#development) + * [Working with Certbot](#working-with-certbot) + * [Working with another ACME Client](#working-with-another-acme-client) + * [Production](#production) +* [Contributing](#contributing) +* [License](#license) + +## Overview + +Boulder is divided into the following main components: + +1. Web Front Ends (one per API version) +2. Registration Authority +3. Validation Authority +4. Certificate Authority +5. Storage Authority +6. Publisher +7. OCSP Responder +8. CRL Updater + +This component model lets us separate the function of the CA by security +context. The Web Front End, Validation Authority, OCSP Responder and +Publisher need access to the Internet, which puts them at greater risk of +compromise. The Registration Authority can live without Internet +connectivity, but still needs to talk to the Web Front End and Validation +Authority. The Certificate Authority need only receive instructions from the +Registration Authority. All components talk to the SA for storage, so most +lines indicating SA RPCs are not shown here. + +```text + CA ---------> Publisher + ^ + | + Subscriber -> WFE --> RA --> SA --> MariaDB + | ^ +Subscriber server <- VA <----+ | + | + Browser -------------------> OCSP Responder +``` + +Internally, the logic of the system is based around five types of objects: +accounts, authorizations, challenges, orders and certificates, mapping directly +to the resources of the same name in ACME. Requests from ACME clients result in +new objects and changes to objects. The Storage Authority maintains persistent +copies of the current set of objects. + +Boulder uses gRPC for inter-component communication. For components that you +want to be remote, it is necessary to instantiate a "client" and "server" for +that component. The client implements the component's Go interface, while the +server has the actual logic for the component. A high level overview for this +communication model can be found in the [gRPC +documentation](https://www.grpc.io/docs/). + +The full details of how the various ACME operations happen in Boulder are +laid out in +[DESIGN.md](https://github.com/letsencrypt/boulder/blob/main/docs/DESIGN.md). + +## Setting up Boulder + +### Development + +Boulder has a Dockerfile and uses Docker Compose to make it easy to install +and set up all its dependencies. This is how the maintainers work on Boulder, +and is our main recommended way to run it for development/experimentation. It +is not suitable for use as a production environment. + +While we aim to make Boulder easy to setup ACME client developers may find +[Pebble](https://github.com/letsencrypt/pebble), a miniature version of +Boulder, to be better suited for continuous integration and quick +experimentation. + +We recommend setting git's [fsckObjects +setting](https://groups.google.com/forum/#!topic/binary-transparency/f-BI4o8HZW0/discussion) +before getting a copy of Boulder to have better integrity guarantees for +updates. + +Clone the boulder repository: + +```shell +git clone https://github.com/letsencrypt/boulder/ +cd boulder +``` + +Additionally, make sure you have Docker Engine 1.13.0+ and Docker Compose +1.10.0+ installed. If you do not, you can follow Docker's [installation +instructions](https://docs.docker.com/compose/install/). + +We recommend having **at least 2GB of RAM** available on your Docker host. In +practice using less RAM may result in the MariaDB container failing in +non-obvious ways. + +To start Boulder in a Docker container, run: + +```shell +docker compose up +``` + +To run our standard battery of tests (lints, unit, integration): + +```shell +docker compose run --use-aliases boulder ./test.sh +``` + +To run all unit tests: + +```shell +docker compose run --use-aliases boulder ./test.sh --unit +``` + +To run specific unit tests (example is of the ./va directory): + +```shell +docker compose run --use-aliases boulder ./test.sh --unit --filter=./va +``` + +To run all integration tests: + +```shell +docker compose run --use-aliases boulder ./test.sh --integration +``` + +To run specific integration tests (example runs TestAkamaiPurgerDrainQueueFails and TestWFECORS): + +```shell +docker compose run --use-aliases boulder ./test.sh --filter TestAkamaiPurgerDrainQueueFails/TestWFECORS +``` + +To get a list of available integration tests: + +```shell +docker compose run --use-aliases boulder ./test.sh --list-integration-tests +``` + +The configuration in docker-compose.yml mounts your boulder checkout at +/boulder so you can edit code on your host and it will be immediately +reflected inside the Docker containers run with `docker compose`. + +If you have problems with Docker, you may want to try [removing all +containers and +volumes](https://www.digitalocean.com/community/tutorials/how-to-remove-docker-images-containers-and-volumes). + +By default, Boulder uses a fake DNS resolver that resolves all hostnames to +127.0.0.1. This is suitable for running integration tests inside the Docker +container. If you want Boulder to be able to communicate with a client +running on your host instead, you should find your host's Docker IP with: + +```shell +ifconfig docker0 | grep "inet addr:" | cut -d: -f2 | awk '{ print $1}' +``` + +And edit docker-compose.yml to change the `FAKE_DNS` environment variable to +match. This will cause Boulder's stubbed-out DNS resolver (`sd-test-srv`) to +respond to all A queries with the address in `FAKE_DNS`. + +If you use a host-based firewall (e.g. `ufw` or `iptables`) make sure you allow +connections from the Docker instance to your host on the required validation +ports to your ACME client. + +Alternatively, you can override the docker-compose.yml default with an +environmental variable using -e (replace 172.17.0.1 with the host IPv4 +address found in the command above) + +```shell +docker compose run --use-aliases -e FAKE_DNS=172.17.0.1 --service-ports boulder ./start.py +``` + +Running tests without the `./test.sh` wrapper: + +Run all unit tests + +```shell +docker compose run --use-aliases boulder go test -p 1 ./... +``` + +Run unit tests for a specific directory: + +```shell +docker compose run --use-aliases boulder go test +``` + +Run integration tests (omit `--filter ` to run all): + +```shell +docker compose run --use-aliases boulder python3 test/integration-test.py --chisel --gotest --filter +``` + +### Working with Certbot + +Check out the Certbot client from https://github.com/certbot/certbot and +follow their setup instructions. Once you've got the client set up, you'll +probably want to run it against your local Boulder. There are a number of +command line flags that are necessary to run the client against a local +Boulder, and without root access. The simplest way to run the client locally +is to use a convenient alias for certbot (`certbot_test`) with a custom +`SERVER` environment variable: + +```shell +SERVER=http://localhost:4001/directory certbot_test certonly --standalone -d test.example.com +``` + +Your local Boulder instance uses a fake DNS resolver that returns 127.0.0.1 +for any query, so you can use any value for the -d flag. To return an answer +other than `127.0.0.1` change the Boulder `FAKE_DNS` environment variable to +another IP address. + +### Working with another ACME Client + +Once you have followed the Boulder development environment instructions and have +started the containers you will find the ACME endpoints exposed to your host at +the following URLs: + +* ACME v2, HTTP: `http://localhost:4001/directory` +* ACME v2, HTTPS: `https://localhost:4431/directory` + +To access the HTTPS versions of the endpoints you will need to configure your +ACME client software to use a CA truststore that contains the +`test/certs/ipki/minica.pem` CA certificate. See +[`test/certs/README.md`](https://github.com/letsencrypt/boulder/blob/main/test/certs/README.md) +for more information. + +Your local Boulder instance uses a fake DNS resolver that returns 127.0.0.1 +for any query, allowing you to issue certificates for any domain as if it +resolved to your localhost. To return an answer other than `127.0.0.1` change +the Boulder `FAKE_DNS` environment variable to another IP address. + +Most often you will want to configure `FAKE_DNS` to point to your host +machine where you run an ACME client. + +### Production + +Boulder is custom built for Let's Encrypt and is intended only to support the +Web PKI and the CA/Browser forum's baseline requirements. In our experience +often Boulder is not the right fit for organizations that are evaluating it for +production usage. In most cases a centrally managed PKI that doesn't require +domain-authorization with ACME is a better choice. For this environment we +recommend evaluating a project other than Boulder. + +We offer a brief [deployment and implementation +guide](https://github.com/letsencrypt/boulder/wiki/Deployment-&-Implementation-Guide) +that describes some of the required work and security considerations involved in +using Boulder in a production environment. As-is the docker based Boulder +development environment is **not suitable for +production usage**. It uses private key material that is publicly available, +exposes debug ports and is brittle to component failure. + +While we are supportive of other organization's deploying Boulder in +a production setting we prioritize support and development work that favors +Let's Encrypt's mission. This means we may not be able to provide timely support +or accept pull-requests that deviate significantly from our first line goals. If +you've thoroughly evaluated the alternatives and Boulder is definitely the best +fit we're happy to answer questions to the best of our ability. + +## Contributing + +Please take a look at +[CONTRIBUTING.md](https://github.com/letsencrypt/boulder/blob/main/docs/CONTRIBUTING.md) +for our guidelines on submitting patches, code review process, code of conduct, +and various other tips related to working on the codebase. + +## Code of Conduct + +The code of conduct for everyone participating in this community in any capacity +is available for reference +[on the community forum](https://community.letsencrypt.org/guidelines). + +## License + +This project is licensed under the Mozilla Public License 2.0, the full text +of which can be found in the +[LICENSE.txt](https://github.com/letsencrypt/boulder/blob/main/LICENSE.txt) +file. diff --git a/third-party/github.com/letsencrypt/boulder/akamai/cache-client.go b/third-party/github.com/letsencrypt/boulder/akamai/cache-client.go new file mode 100644 index 000000000..58b51ebd5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/akamai/cache-client.go @@ -0,0 +1,402 @@ +package akamai + +import ( + "bytes" + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" +) + +const ( + timestampFormat = "20060102T15:04:05-0700" + v3PurgePath = "/ccu/v3/delete/url/" + v3PurgeTagPath = "/ccu/v3/delete/tag/" +) + +var ( + // ErrAllRetriesFailed indicates that all purge submission attempts have + // failed. + ErrAllRetriesFailed = errors.New("all attempts to submit purge request failed") + + // errFatal is returned by the purge method of CachePurgeClient to indicate + // that it failed for a reason that cannot be remediated by retrying the + // request. + errFatal = errors.New("fatal error") +) + +type v3PurgeRequest struct { + Objects []string `json:"objects"` +} + +type purgeResponse struct { + HTTPStatus int `json:"httpStatus"` + Detail string `json:"detail"` + EstimatedSeconds int `json:"estimatedSeconds"` + PurgeID string `json:"purgeId"` +} + +// CachePurgeClient talks to the Akamai CCU REST API. It is safe to make +// concurrent requests using this client. +type CachePurgeClient struct { + client *http.Client + apiEndpoint string + apiHost string + apiScheme string + clientToken string + clientSecret string + accessToken string + v3Network string + retries int + retryBackoff time.Duration + log blog.Logger + purgeLatency prometheus.Histogram + purges *prometheus.CounterVec + clk clock.Clock +} + +// NewCachePurgeClient performs some basic validation of supplied configuration +// and returns a newly constructed CachePurgeClient. +func NewCachePurgeClient( + baseURL, + clientToken, + secret, + accessToken, + network string, + retries int, + retryBackoff time.Duration, + log blog.Logger, scope prometheus.Registerer, +) (*CachePurgeClient, error) { + if network != "production" && network != "staging" { + return nil, fmt.Errorf("'V3Network' must be \"staging\" or \"production\", got %q", network) + } + + endpoint, err := url.Parse(strings.TrimSuffix(baseURL, "/")) + if err != nil { + return nil, fmt.Errorf("failed to parse 'BaseURL' as a URL: %s", err) + } + + purgeLatency := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "ccu_purge_latency", + Help: "Histogram of latencies of CCU purges", + Buckets: metrics.InternetFacingBuckets, + }) + scope.MustRegister(purgeLatency) + + purges := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ccu_purges", + Help: "A counter of CCU purges labelled by the result", + }, []string{"type"}) + scope.MustRegister(purges) + + return &CachePurgeClient{ + client: new(http.Client), + apiEndpoint: endpoint.String(), + apiHost: endpoint.Host, + apiScheme: strings.ToLower(endpoint.Scheme), + clientToken: clientToken, + clientSecret: secret, + accessToken: accessToken, + v3Network: network, + retries: retries, + retryBackoff: retryBackoff, + log: log, + clk: clock.New(), + purgeLatency: purgeLatency, + purges: purges, + }, nil +} + +// makeAuthHeader constructs a special Akamai authorization header. This header +// is used to identify clients to Akamai's EdgeGrid APIs. For a more detailed +// description of the generation process see their docs: +// https://developer.akamai.com/introduction/Client_Auth.html +func (cpc *CachePurgeClient) makeAuthHeader(body []byte, apiPath string, nonce string) string { + // The akamai API is very time sensitive (recommending reliance on a stratum 2 + // or better time source). Additionally, timestamps MUST be in UTC. + timestamp := cpc.clk.Now().UTC().Format(timestampFormat) + header := fmt.Sprintf( + "EG1-HMAC-SHA256 client_token=%s;access_token=%s;timestamp=%s;nonce=%s;", + cpc.clientToken, + cpc.accessToken, + timestamp, + nonce, + ) + bodyHash := sha256.Sum256(body) + tbs := fmt.Sprintf( + "%s\t%s\t%s\t%s\t%s\t%s\t%s", + "POST", + cpc.apiScheme, + cpc.apiHost, + apiPath, + // Signed headers are not required for this request type. + "", + base64.StdEncoding.EncodeToString(bodyHash[:]), + header, + ) + cpc.log.Debugf("To-be-signed Akamai EdgeGrid authentication %q", tbs) + + h := hmac.New(sha256.New, signingKey(cpc.clientSecret, timestamp)) + h.Write([]byte(tbs)) + return fmt.Sprintf( + "%ssignature=%s", + header, + base64.StdEncoding.EncodeToString(h.Sum(nil)), + ) +} + +// signingKey makes a signing key by HMAC'ing the timestamp +// using a client secret as the key. +func signingKey(clientSecret string, timestamp string) []byte { + h := hmac.New(sha256.New, []byte(clientSecret)) + h.Write([]byte(timestamp)) + key := make([]byte, base64.StdEncoding.EncodedLen(32)) + base64.StdEncoding.Encode(key, h.Sum(nil)) + return key +} + +// PurgeTags constructs and dispatches a request to purge a batch of Tags. +func (cpc *CachePurgeClient) PurgeTags(tags []string) error { + purgeReq := v3PurgeRequest{ + Objects: tags, + } + endpoint := fmt.Sprintf("%s%s%s", cpc.apiEndpoint, v3PurgeTagPath, cpc.v3Network) + return cpc.authedRequest(endpoint, purgeReq) +} + +// purgeURLs constructs and dispatches a request to purge a batch of URLs. +func (cpc *CachePurgeClient) purgeURLs(urls []string) error { + purgeReq := v3PurgeRequest{ + Objects: urls, + } + endpoint := fmt.Sprintf("%s%s%s", cpc.apiEndpoint, v3PurgePath, cpc.v3Network) + return cpc.authedRequest(endpoint, purgeReq) +} + +// authedRequest POSTs the JSON marshaled purge request to the provided endpoint +// along with an Akamai authorization header. +func (cpc *CachePurgeClient) authedRequest(endpoint string, body v3PurgeRequest) error { + reqBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("%s: %w", err, errFatal) + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("%s: %w", err, errFatal) + } + + endpointURL, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("while parsing %q as URL: %s: %w", endpoint, err, errFatal) + } + + authorization := cpc.makeAuthHeader(reqBody, endpointURL.Path, core.RandomString(16)) + req.Header.Set("Authorization", authorization) + req.Header.Set("Content-Type", "application/json") + cpc.log.Debugf("POSTing to endpoint %q (header %q) (body %q)", endpoint, authorization, reqBody) + + start := cpc.clk.Now() + resp, err := cpc.client.Do(req) + cpc.purgeLatency.Observe(cpc.clk.Since(start).Seconds()) + if err != nil { + return fmt.Errorf("while POSTing to endpoint %q: %w", endpointURL, err) + } + defer resp.Body.Close() + + if resp.Body == nil { + return fmt.Errorf("response body was empty from URL %q", resp.Request.URL) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // Success for a request to purge a URL or Cache tag is 'HTTP 201'. + // https://techdocs.akamai.com/purge-cache/reference/delete-url + // https://techdocs.akamai.com/purge-cache/reference/delete-tag + if resp.StatusCode != http.StatusCreated { + switch resp.StatusCode { + // https://techdocs.akamai.com/purge-cache/reference/403 + case http.StatusForbidden: + return fmt.Errorf("client not authorized to make requests for URL %q: %w", resp.Request.URL, errFatal) + + // https://techdocs.akamai.com/purge-cache/reference/504 + case http.StatusGatewayTimeout: + return fmt.Errorf("server timed out, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL) + + // https://techdocs.akamai.com/purge-cache/reference/429 + case http.StatusTooManyRequests: + return fmt.Errorf("exceeded request count rate limit, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL) + + // https://techdocs.akamai.com/purge-cache/reference/413 + case http.StatusRequestEntityTooLarge: + return fmt.Errorf("exceeded request size rate limit, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL) + default: + return fmt.Errorf("received HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL) + } + } + + var purgeInfo purgeResponse + err = json.Unmarshal(respBody, &purgeInfo) + if err != nil { + return fmt.Errorf("while unmarshalling body %q from URL %q as JSON: %w", respBody, resp.Request.URL, err) + } + + // Ensure the unmarshaled body concurs with the status of the response + // received. + if purgeInfo.HTTPStatus != http.StatusCreated { + if purgeInfo.HTTPStatus == http.StatusForbidden { + return fmt.Errorf("client not authorized to make requests to URL %q: %w", resp.Request.URL, errFatal) + } + return fmt.Errorf("unmarshaled HTTP %d (body %q) from URL %q", purgeInfo.HTTPStatus, respBody, resp.Request.URL) + } + + cpc.log.AuditInfof("Purge request sent successfully (ID %s) (body %s). Purge expected in %ds", + purgeInfo.PurgeID, reqBody, purgeInfo.EstimatedSeconds) + return nil +} + +// Purge dispatches the provided URLs in a request to the Akamai Fast-Purge API. +// The request will be attempted cpc.retries number of times before giving up +// and returning ErrAllRetriesFailed. +func (cpc *CachePurgeClient) Purge(urls []string) error { + successful := false + for i := range cpc.retries + 1 { + cpc.clk.Sleep(core.RetryBackoff(i, cpc.retryBackoff, time.Minute, 1.3)) + + err := cpc.purgeURLs(urls) + if err != nil { + if errors.Is(err, errFatal) { + cpc.purges.WithLabelValues("fatal failure").Inc() + return err + } + cpc.log.AuditErrf("Akamai cache purge failed, retrying: %s", err) + cpc.purges.WithLabelValues("retryable failure").Inc() + continue + } + successful = true + break + } + + if !successful { + cpc.purges.WithLabelValues("fatal failure").Inc() + return ErrAllRetriesFailed + } + + cpc.purges.WithLabelValues("success").Inc() + return nil +} + +// CheckSignature is exported for use in tests and akamai-test-srv. +func CheckSignature(secret string, url string, r *http.Request, body []byte) error { + bodyHash := sha256.Sum256(body) + bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:]) + + authorization := r.Header.Get("Authorization") + authValues := make(map[string]string) + for _, v := range strings.Split(authorization, ";") { + splitValue := strings.Split(v, "=") + authValues[splitValue[0]] = splitValue[1] + } + headerTimestamp := authValues["timestamp"] + splitHeader := strings.Split(authorization, "signature=") + shortenedHeader, signature := splitHeader[0], splitHeader[1] + hostPort := strings.Split(url, "://")[1] + h := hmac.New(sha256.New, signingKey(secret, headerTimestamp)) + input := []byte(fmt.Sprintf("POST\thttp\t%s\t%s\t\t%s\t%s", + hostPort, + r.URL.Path, + bodyHashB64, + shortenedHeader, + )) + h.Write(input) + expectedSignature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + if signature != expectedSignature { + return fmt.Errorf("expected signature %q, got %q in %q", + signature, authorization, expectedSignature) + } + return nil +} + +func reverseBytes(b []byte) []byte { + for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } + return b +} + +// makeOCSPCacheURLs constructs the 3 URLs associated with each cached OCSP +// response. +func makeOCSPCacheURLs(req []byte, ocspServer string) []string { + hash := md5.Sum(req) + encReq := base64.StdEncoding.EncodeToString(req) + return []string{ + // POST Cache Key: the format of this entry is the URL that was POSTed + // to with a query string with the parameter 'body-md5' and the value of + // the first two uint32s in little endian order in hex of the MD5 hash + // of the OCSP request body. + // + // There is limited public documentation of this feature. However, this + // entry is what triggers the Akamai cache behavior that allows Akamai to + // identify POST based OCSP for purging. For more information, see: + // https://techdocs.akamai.com/property-mgr/reference/v2020-03-04-cachepost + // https://techdocs.akamai.com/property-mgr/docs/cache-post-responses + fmt.Sprintf("%s?body-md5=%x%x", ocspServer, reverseBytes(hash[0:4]), reverseBytes(hash[4:8])), + + // URL (un-encoded): RFC 2560 and RFC 5019 state OCSP GET URLs 'MUST + // properly url-encode the base64 encoded' request but a large enough + // portion of tools do not properly do this (~10% of GET requests we + // receive) such that we must purge both the encoded and un-encoded + // URLs. + // + // Due to Akamai proxy/cache behavior which collapses '//' -> '/' we also + // collapse double slashes in the un-encoded URL so that we properly purge + // what is stored in the cache. + fmt.Sprintf("%s%s", ocspServer, strings.Replace(encReq, "//", "/", -1)), + + // URL (encoded): this entry is the url-encoded GET URL used to request + // OCSP as specified in RFC 2560 and RFC 5019. + fmt.Sprintf("%s%s", ocspServer, url.QueryEscape(encReq)), + } +} + +// GeneratePurgeURLs generates akamai URLs that can be POSTed to in order to +// purge akamai's cache of the corresponding OCSP responses. The URLs encode +// the contents of the OCSP request, so this method constructs a full OCSP +// request. +func GeneratePurgeURLs(cert, issuer *x509.Certificate) ([]string, error) { + req, err := ocsp.CreateRequest(cert, issuer, nil) + if err != nil { + return nil, err + } + + // Create a GET and special Akamai POST style OCSP url for each endpoint in + // cert.OCSPServer. + urls := []string{} + for _, ocspServer := range cert.OCSPServer { + if !strings.HasSuffix(ocspServer, "/") { + ocspServer += "/" + } + urls = append(urls, makeOCSPCacheURLs(req, ocspServer)...) + } + return urls, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/akamai/cache-client_test.go b/third-party/github.com/letsencrypt/boulder/akamai/cache-client_test.go new file mode 100644 index 000000000..600b49111 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/akamai/cache-client_test.go @@ -0,0 +1,275 @@ +package akamai + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func TestMakeAuthHeader(t *testing.T) { + log := blog.NewMock() + stats := metrics.NoopRegisterer + cpc, err := NewCachePurgeClient( + "https://akaa-baseurl-xxxxxxxxxxx-xxxxxxxxxxxxx.luna.akamaiapis.net", + "akab-client-token-xxx-xxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=", + "akab-access-token-xxx-xxxxxxxxxxxxxxxx", + "production", + 2, + time.Second, + log, + stats, + ) + test.AssertNotError(t, err, "Failed to create cache purge client") + fc := clock.NewFake() + cpc.clk = fc + wantedTimestamp, err := time.Parse(timestampFormat, "20140321T19:34:21+0000") + test.AssertNotError(t, err, "Failed to parse timestamp") + fc.Set(wantedTimestamp) + + expectedHeader := "EG1-HMAC-SHA256 client_token=akab-client-token-xxx-xxxxxxxxxxxxxxxx;access_token=akab-access-token-xxx-xxxxxxxxxxxxxxxx;timestamp=20140321T19:34:21+0000;nonce=nonce-xx-xxxx-xxxx-xxxx-xxxxxxxxxxxx;signature=hXm4iCxtpN22m4cbZb4lVLW5rhX8Ca82vCFqXzSTPe4=" + authHeader := cpc.makeAuthHeader( + []byte("datadatadatadatadatadatadatadata"), + "/testapi/v1/t3", + "nonce-xx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ) + test.AssertEquals(t, authHeader, expectedHeader) +} + +type akamaiServer struct { + responseCode int + *httptest.Server +} + +func (as *akamaiServer) sendResponse(w http.ResponseWriter, resp purgeResponse) { + respBytes, err := json.Marshal(resp) + if err != nil { + fmt.Printf("Failed to marshal response body: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(as.responseCode) + w.Write(respBytes) +} + +func (as *akamaiServer) purgeHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Objects []string + } + body, err := io.ReadAll(r.Body) + if err != nil { + fmt.Printf("Failed to read request body: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = CheckSignature("secret", as.URL, r, body) + if err != nil { + fmt.Printf("Error checking signature: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = json.Unmarshal(body, &req) + if err != nil { + fmt.Printf("Failed to unmarshal request body: %s\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + resp := purgeResponse{ + HTTPStatus: as.responseCode, + Detail: "?", + EstimatedSeconds: 10, + PurgeID: "?", + } + + fmt.Println(r.URL.Path, v3PurgePath) + if strings.HasPrefix(r.URL.Path, v3PurgePath) { + for _, testURL := range req.Objects { + if !strings.HasPrefix(testURL, "http://") { + resp.HTTPStatus = http.StatusForbidden + break + } + } + } + as.sendResponse(w, resp) +} +func newAkamaiServer(code int) *akamaiServer { + m := http.NewServeMux() + as := akamaiServer{ + responseCode: code, + Server: httptest.NewServer(m), + } + m.HandleFunc(v3PurgePath, as.purgeHandler) + m.HandleFunc(v3PurgeTagPath, as.purgeHandler) + return &as +} + +// TestV3Purge tests the Akamai CCU v3 purge API +func TestV3Purge(t *testing.T) { + as := newAkamaiServer(http.StatusCreated) + defer as.Close() + + // Client is a purge client with a "production" v3Network parameter + client, err := NewCachePurgeClient( + as.URL, + "token", + "secret", + "accessToken", + "production", + 3, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Failed to create CachePurgeClient") + client.clk = clock.NewFake() + + err = client.Purge([]string{"http://test.com"}) + test.AssertNotError(t, err, "Purge failed; expected 201 response") + + started := client.clk.Now() + as.responseCode = http.StatusInternalServerError + err = client.Purge([]string{"http://test.com"}) + test.AssertError(t, err, "Purge succeeded; expected 500 response") + t.Log(client.clk.Since(started)) + // Given 3 retries, with a retry interval of 1 second, a growth factor of 1.3, + // and a jitter of 0.2, the minimum amount of elapsed time is: + // (1 * 0.8) + (1 * 1.3 * 0.8) + (1 * 1.3 * 1.3 * 0.8) = 3.192s + test.Assert(t, client.clk.Since(started) > (time.Second*3), "Retries should've taken at least 3.192 seconds") + + started = client.clk.Now() + as.responseCode = http.StatusCreated + err = client.Purge([]string{"http:/test.com"}) + test.AssertError(t, err, "Purge succeeded; expected a 403 response from malformed URL") + test.Assert(t, client.clk.Since(started) < time.Second, "Purge should've failed out immediately") +} + +func TestPurgeTags(t *testing.T) { + as := newAkamaiServer(http.StatusCreated) + defer as.Close() + + // Client is a purge client with a "production" v3Network parameter + client, err := NewCachePurgeClient( + as.URL, + "token", + "secret", + "accessToken", + "production", + 3, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Failed to create CachePurgeClient") + fc := clock.NewFake() + client.clk = fc + + err = client.PurgeTags([]string{"ff"}) + test.AssertNotError(t, err, "Purge failed; expected response 201") + + as.responseCode = http.StatusForbidden + err = client.PurgeTags([]string{"http://test.com"}) + test.AssertError(t, err, "Purge succeeded; expected Forbidden response") +} + +func TestNewCachePurgeClient(t *testing.T) { + // Creating a new cache purge client with an invalid "network" parameter should error + _, err := NewCachePurgeClient( + "http://127.0.0.1:9000/", + "token", + "secret", + "accessToken", + "fake", + 3, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + ) + test.AssertError(t, err, "NewCachePurgeClient with invalid network parameter didn't error") + + // Creating a new cache purge client with a valid "network" parameter shouldn't error + _, err = NewCachePurgeClient( + "http://127.0.0.1:9000/", + "token", + "secret", + "accessToken", + "staging", + 3, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "NewCachePurgeClient with valid network parameter errored") + + // Creating a new cache purge client with an invalid server URL parameter should error + _, err = NewCachePurgeClient( + "h&ttp://whatever", + "token", + "secret", + "accessToken", + "staging", + 3, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + ) + test.AssertError(t, err, "NewCachePurgeClient with invalid server url parameter didn't error") +} + +func TestBigBatchPurge(t *testing.T) { + log := blog.NewMock() + + as := newAkamaiServer(http.StatusCreated) + + client, err := NewCachePurgeClient( + as.URL, + "token", + "secret", + "accessToken", + "production", + 3, + time.Second, + log, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Failed to create CachePurgeClient") + + var urls []string + for i := range 250 { + urls = append(urls, fmt.Sprintf("http://test.com/%d", i)) + } + + err = client.Purge(urls) + test.AssertNotError(t, err, "Purge failed.") +} + +func TestReverseBytes(t *testing.T) { + a := []byte{0, 1, 2, 3} + test.AssertDeepEquals(t, reverseBytes(a), []byte{3, 2, 1, 0}) +} + +func TestGenerateOCSPCacheKeys(t *testing.T) { + der := []byte{105, 239, 255} + test.AssertDeepEquals( + t, + makeOCSPCacheURLs(der, "ocsp.invalid/"), + []string{ + "ocsp.invalid/?body-md5=d6101198a9d9f1f6", + "ocsp.invalid/ae/", + "ocsp.invalid/ae%2F%2F", + }, + ) +} diff --git a/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.pb.go b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.pb.go new file mode 100644 index 000000000..bdc56162f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.pb.go @@ -0,0 +1,154 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: akamai.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PurgeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Urls []string `protobuf:"bytes,1,rep,name=urls,proto3" json:"urls,omitempty"` +} + +func (x *PurgeRequest) Reset() { + *x = PurgeRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_akamai_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PurgeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PurgeRequest) ProtoMessage() {} + +func (x *PurgeRequest) ProtoReflect() protoreflect.Message { + mi := &file_akamai_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PurgeRequest.ProtoReflect.Descriptor instead. +func (*PurgeRequest) Descriptor() ([]byte, []int) { + return file_akamai_proto_rawDescGZIP(), []int{0} +} + +func (x *PurgeRequest) GetUrls() []string { + if x != nil { + return x.Urls + } + return nil +} + +var File_akamai_proto protoreflect.FileDescriptor + +var file_akamai_proto_rawDesc = []byte{ + 0x0a, 0x0c, 0x61, 0x6b, 0x61, 0x6d, 0x61, 0x69, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, + 0x61, 0x6b, 0x61, 0x6d, 0x61, 0x69, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0x22, 0x0a, 0x0c, 0x50, 0x75, 0x72, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x32, 0x47, 0x0a, 0x0c, 0x41, 0x6b, 0x61, 0x6d, 0x61, + 0x69, 0x50, 0x75, 0x72, 0x67, 0x65, 0x72, 0x12, 0x37, 0x0a, 0x05, 0x50, 0x75, 0x72, 0x67, 0x65, + 0x12, 0x14, 0x2e, 0x61, 0x6b, 0x61, 0x6d, 0x61, 0x69, 0x2e, 0x50, 0x75, 0x72, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, + 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, + 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, + 0x65, 0x72, 0x2f, 0x61, 0x6b, 0x61, 0x6d, 0x61, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_akamai_proto_rawDescOnce sync.Once + file_akamai_proto_rawDescData = file_akamai_proto_rawDesc +) + +func file_akamai_proto_rawDescGZIP() []byte { + file_akamai_proto_rawDescOnce.Do(func() { + file_akamai_proto_rawDescData = protoimpl.X.CompressGZIP(file_akamai_proto_rawDescData) + }) + return file_akamai_proto_rawDescData +} + +var file_akamai_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_akamai_proto_goTypes = []interface{}{ + (*PurgeRequest)(nil), // 0: akamai.PurgeRequest + (*emptypb.Empty)(nil), // 1: google.protobuf.Empty +} +var file_akamai_proto_depIdxs = []int32{ + 0, // 0: akamai.AkamaiPurger.Purge:input_type -> akamai.PurgeRequest + 1, // 1: akamai.AkamaiPurger.Purge:output_type -> google.protobuf.Empty + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_akamai_proto_init() } +func file_akamai_proto_init() { + if File_akamai_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_akamai_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PurgeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_akamai_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_akamai_proto_goTypes, + DependencyIndexes: file_akamai_proto_depIdxs, + MessageInfos: file_akamai_proto_msgTypes, + }.Build() + File_akamai_proto = out.File + file_akamai_proto_rawDesc = nil + file_akamai_proto_goTypes = nil + file_akamai_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.proto b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.proto new file mode 100644 index 000000000..7294ed1f1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package akamai; +option go_package = "github.com/letsencrypt/boulder/akamai/proto"; + +import "google/protobuf/empty.proto"; + +service AkamaiPurger { + rpc Purge(PurgeRequest) returns (google.protobuf.Empty) {} +} + +message PurgeRequest { + repeated string urls = 1; +} diff --git a/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai_grpc.pb.go new file mode 100644 index 000000000..6970a2c67 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/akamai/proto/akamai_grpc.pb.go @@ -0,0 +1,111 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: akamai.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AkamaiPurger_Purge_FullMethodName = "/akamai.AkamaiPurger/Purge" +) + +// AkamaiPurgerClient is the client API for AkamaiPurger service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AkamaiPurgerClient interface { + Purge(ctx context.Context, in *PurgeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type akamaiPurgerClient struct { + cc grpc.ClientConnInterface +} + +func NewAkamaiPurgerClient(cc grpc.ClientConnInterface) AkamaiPurgerClient { + return &akamaiPurgerClient{cc} +} + +func (c *akamaiPurgerClient) Purge(ctx context.Context, in *PurgeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, AkamaiPurger_Purge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AkamaiPurgerServer is the server API for AkamaiPurger service. +// All implementations must embed UnimplementedAkamaiPurgerServer +// for forward compatibility +type AkamaiPurgerServer interface { + Purge(context.Context, *PurgeRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedAkamaiPurgerServer() +} + +// UnimplementedAkamaiPurgerServer must be embedded to have forward compatible implementations. +type UnimplementedAkamaiPurgerServer struct { +} + +func (UnimplementedAkamaiPurgerServer) Purge(context.Context, *PurgeRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Purge not implemented") +} +func (UnimplementedAkamaiPurgerServer) mustEmbedUnimplementedAkamaiPurgerServer() {} + +// UnsafeAkamaiPurgerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AkamaiPurgerServer will +// result in compilation errors. +type UnsafeAkamaiPurgerServer interface { + mustEmbedUnimplementedAkamaiPurgerServer() +} + +func RegisterAkamaiPurgerServer(s grpc.ServiceRegistrar, srv AkamaiPurgerServer) { + s.RegisterService(&AkamaiPurger_ServiceDesc, srv) +} + +func _AkamaiPurger_Purge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PurgeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AkamaiPurgerServer).Purge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AkamaiPurger_Purge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AkamaiPurgerServer).Purge(ctx, req.(*PurgeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AkamaiPurger_ServiceDesc is the grpc.ServiceDesc for AkamaiPurger service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AkamaiPurger_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "akamai.AkamaiPurger", + HandlerType: (*AkamaiPurgerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Purge", + Handler: _AkamaiPurger_Purge_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "akamai.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/dns.go b/third-party/github.com/letsencrypt/boulder/bdns/dns.go new file mode 100644 index 000000000..775d99383 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/dns.go @@ -0,0 +1,731 @@ +package bdns + +import ( + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/jmhodges/clock" + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/features" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" +) + +func parseCidr(network string, comment string) net.IPNet { + _, net, err := net.ParseCIDR(network) + if err != nil { + panic(fmt.Sprintf("error parsing %s (%s): %s", network, comment, err)) + } + return *net +} + +var ( + // Private CIDRs to ignore + privateNetworks = []net.IPNet{ + // RFC1918 + // 10.0.0.0/8 + { + IP: []byte{10, 0, 0, 0}, + Mask: []byte{255, 0, 0, 0}, + }, + // 172.16.0.0/12 + { + IP: []byte{172, 16, 0, 0}, + Mask: []byte{255, 240, 0, 0}, + }, + // 192.168.0.0/16 + { + IP: []byte{192, 168, 0, 0}, + Mask: []byte{255, 255, 0, 0}, + }, + // RFC5735 + // 127.0.0.0/8 + { + IP: []byte{127, 0, 0, 0}, + Mask: []byte{255, 0, 0, 0}, + }, + // RFC1122 Section 3.2.1.3 + // 0.0.0.0/8 + { + IP: []byte{0, 0, 0, 0}, + Mask: []byte{255, 0, 0, 0}, + }, + // RFC3927 + // 169.254.0.0/16 + { + IP: []byte{169, 254, 0, 0}, + Mask: []byte{255, 255, 0, 0}, + }, + // RFC 5736 + // 192.0.0.0/24 + { + IP: []byte{192, 0, 0, 0}, + Mask: []byte{255, 255, 255, 0}, + }, + // RFC 5737 + // 192.0.2.0/24 + { + IP: []byte{192, 0, 2, 0}, + Mask: []byte{255, 255, 255, 0}, + }, + // 198.51.100.0/24 + { + IP: []byte{198, 51, 100, 0}, + Mask: []byte{255, 255, 255, 0}, + }, + // 203.0.113.0/24 + { + IP: []byte{203, 0, 113, 0}, + Mask: []byte{255, 255, 255, 0}, + }, + // RFC 3068 + // 192.88.99.0/24 + { + IP: []byte{192, 88, 99, 0}, + Mask: []byte{255, 255, 255, 0}, + }, + // RFC 2544, Errata 423 + // 198.18.0.0/15 + { + IP: []byte{198, 18, 0, 0}, + Mask: []byte{255, 254, 0, 0}, + }, + // RFC 3171 + // 224.0.0.0/4 + { + IP: []byte{224, 0, 0, 0}, + Mask: []byte{240, 0, 0, 0}, + }, + // RFC 1112 + // 240.0.0.0/4 + { + IP: []byte{240, 0, 0, 0}, + Mask: []byte{240, 0, 0, 0}, + }, + // RFC 919 Section 7 + // 255.255.255.255/32 + { + IP: []byte{255, 255, 255, 255}, + Mask: []byte{255, 255, 255, 255}, + }, + // RFC 6598 + // 100.64.0.0/10 + { + IP: []byte{100, 64, 0, 0}, + Mask: []byte{255, 192, 0, 0}, + }, + } + // Sourced from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml + // where Global, Source, or Destination is False + privateV6Networks = []net.IPNet{ + parseCidr("::/128", "RFC 4291: Unspecified Address"), + parseCidr("::1/128", "RFC 4291: Loopback Address"), + parseCidr("::ffff:0:0/96", "RFC 4291: IPv4-mapped Address"), + parseCidr("100::/64", "RFC 6666: Discard Address Block"), + parseCidr("2001::/23", "RFC 2928: IETF Protocol Assignments"), + parseCidr("2001:2::/48", "RFC 5180: Benchmarking"), + parseCidr("2001:db8::/32", "RFC 3849: Documentation"), + parseCidr("2001::/32", "RFC 4380: TEREDO"), + parseCidr("fc00::/7", "RFC 4193: Unique-Local"), + parseCidr("fe80::/10", "RFC 4291: Section 2.5.6 Link-Scoped Unicast"), + parseCidr("ff00::/8", "RFC 4291: Section 2.7"), + // We disable validations to IPs under the 6to4 anycase prefix because + // there's too much risk of a malicious actor advertising the prefix and + // answering validations for a 6to4 host they do not control. + // https://community.letsencrypt.org/t/problems-validating-ipv6-against-host-running-6to4/18312/9 + parseCidr("2002::/16", "RFC 7526: 6to4 anycast prefix deprecated"), + } +) + +// ResolverAddrs contains DNS resolver(s) that were chosen to perform a +// validation request or CAA recheck. A ResolverAddr will be in the form of +// host:port, A:host:port, or AAAA:host:port depending on which type of lookup +// was done. +type ResolverAddrs []string + +// Client queries for DNS records +type Client interface { + LookupTXT(context.Context, string) (txts []string, resolver ResolverAddrs, err error) + LookupHost(context.Context, string) ([]net.IP, ResolverAddrs, error) + LookupCAA(context.Context, string) ([]*dns.CAA, string, ResolverAddrs, error) +} + +// impl represents a client that talks to an external resolver +type impl struct { + dnsClient exchanger + servers ServerProvider + allowRestrictedAddresses bool + maxTries int + clk clock.Clock + log blog.Logger + + queryTime *prometheus.HistogramVec + totalLookupTime *prometheus.HistogramVec + timeoutCounter *prometheus.CounterVec + idMismatchCounter *prometheus.CounterVec +} + +var _ Client = &impl{} + +type exchanger interface { + Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration, error) +} + +// New constructs a new DNS resolver object that utilizes the +// provided list of DNS servers for resolution. +// +// `tlsConfig` is the configuration used for outbound DoH queries, +// if applicable. +func New( + readTimeout time.Duration, + servers ServerProvider, + stats prometheus.Registerer, + clk clock.Clock, + maxTries int, + log blog.Logger, + tlsConfig *tls.Config, +) Client { + var client exchanger + if features.Get().DOH { + // Clone the default transport because it comes with various settings + // that we like, which are different from the zero value of an + // `http.Transport`. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = tlsConfig + // The default transport already sets this field, but it isn't + // documented that it will always be set. Set it again to be sure, + // because Unbound will reject non-HTTP/2 DoH requests. + transport.ForceAttemptHTTP2 = true + client = &dohExchanger{ + clk: clk, + hc: http.Client{ + Timeout: readTimeout, + Transport: transport, + }, + } + } else { + client = &dns.Client{ + // Set timeout for underlying net.Conn + ReadTimeout: readTimeout, + Net: "udp", + } + } + + queryTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "dns_query_time", + Help: "Time taken to perform a DNS query", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"qtype", "result", "resolver"}, + ) + totalLookupTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "dns_total_lookup_time", + Help: "Time taken to perform a DNS lookup, including all retried queries", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"qtype", "result", "retries", "resolver"}, + ) + timeoutCounter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dns_timeout", + Help: "Counter of various types of DNS query timeouts", + }, + []string{"qtype", "type", "resolver", "isTLD"}, + ) + idMismatchCounter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dns_id_mismatch", + Help: "Counter of DNS ErrId errors sliced by query type and resolver", + }, + []string{"qtype", "resolver"}, + ) + stats.MustRegister(queryTime, totalLookupTime, timeoutCounter, idMismatchCounter) + return &impl{ + dnsClient: client, + servers: servers, + allowRestrictedAddresses: false, + maxTries: maxTries, + clk: clk, + queryTime: queryTime, + totalLookupTime: totalLookupTime, + timeoutCounter: timeoutCounter, + idMismatchCounter: idMismatchCounter, + log: log, + } +} + +// NewTest constructs a new DNS resolver object that utilizes the +// provided list of DNS servers for resolution and will allow loopback addresses. +// This constructor should *only* be called from tests (unit or integration). +func NewTest( + readTimeout time.Duration, + servers ServerProvider, + stats prometheus.Registerer, + clk clock.Clock, + maxTries int, + log blog.Logger, + tlsConfig *tls.Config, +) Client { + resolver := New(readTimeout, servers, stats, clk, maxTries, log, tlsConfig) + resolver.(*impl).allowRestrictedAddresses = true + return resolver +} + +// exchangeOne performs a single DNS exchange with a randomly chosen server +// out of the server list, returning the response, time, and error (if any). +// We assume that the upstream resolver requests and validates DNSSEC records +// itself. +func (dnsClient *impl) exchangeOne(ctx context.Context, hostname string, qtype uint16) (resp *dns.Msg, resolver string, err error) { + m := new(dns.Msg) + // Set question type + m.SetQuestion(dns.Fqdn(hostname), qtype) + // Set the AD bit in the query header so that the resolver knows that + // we are interested in this bit in the response header. If this isn't + // set the AD bit in the response is useless (RFC 6840 Section 5.7). + // This has no security implications, it simply allows us to gather + // metrics about the percentage of responses that are secured with + // DNSSEC. + m.AuthenticatedData = true + // Tell the resolver that we're willing to receive responses up to 4096 bytes. + // This happens sometimes when there are a very large number of CAA records + // present. + m.SetEdns0(4096, false) + + servers, err := dnsClient.servers.Addrs() + if err != nil { + return nil, "", fmt.Errorf("failed to list DNS servers: %w", err) + } + chosenServerIndex := 0 + chosenServer := servers[chosenServerIndex] + resolver = chosenServer + + // Strip off the IP address part of the server address because + // we talk to the same server on multiple ports, and don't want + // to blow up the cardinality. + chosenServerIP, _, err := net.SplitHostPort(chosenServer) + if err != nil { + return + } + + start := dnsClient.clk.Now() + client := dnsClient.dnsClient + qtypeStr := dns.TypeToString[qtype] + tries := 1 + defer func() { + result := "failed" + if resp != nil { + result = dns.RcodeToString[resp.Rcode] + } + dnsClient.totalLookupTime.With(prometheus.Labels{ + "qtype": qtypeStr, + "result": result, + "retries": strconv.Itoa(tries), + "resolver": chosenServerIP, + }).Observe(dnsClient.clk.Since(start).Seconds()) + }() + for { + ch := make(chan dnsResp, 1) + + // Strip off the IP address part of the server address because + // we talk to the same server on multiple ports, and don't want + // to blow up the cardinality. + // Note: validateServerAddress() has already checked net.SplitHostPort() + // and ensures that chosenServer can't be a bare port, e.g. ":1337" + chosenServerIP, _, err = net.SplitHostPort(chosenServer) + if err != nil { + return + } + + go func() { + rsp, rtt, err := client.Exchange(m, chosenServer) + result := "failed" + if rsp != nil { + result = dns.RcodeToString[rsp.Rcode] + } + if err != nil { + logDNSError(dnsClient.log, chosenServer, hostname, m, rsp, err) + if err == dns.ErrId { + dnsClient.idMismatchCounter.With(prometheus.Labels{ + "qtype": qtypeStr, + "resolver": chosenServerIP, + }).Inc() + } + } + dnsClient.queryTime.With(prometheus.Labels{ + "qtype": qtypeStr, + "result": result, + "resolver": chosenServerIP, + }).Observe(rtt.Seconds()) + ch <- dnsResp{m: rsp, err: err} + }() + select { + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + dnsClient.timeoutCounter.With(prometheus.Labels{ + "qtype": qtypeStr, + "type": "deadline exceeded", + "resolver": chosenServerIP, + "isTLD": isTLD(hostname), + }).Inc() + } else if ctx.Err() == context.Canceled { + dnsClient.timeoutCounter.With(prometheus.Labels{ + "qtype": qtypeStr, + "type": "canceled", + "resolver": chosenServerIP, + "isTLD": isTLD(hostname), + }).Inc() + } else { + dnsClient.timeoutCounter.With(prometheus.Labels{ + "qtype": qtypeStr, + "type": "unknown", + "resolver": chosenServerIP, + }).Inc() + } + err = ctx.Err() + return + case r := <-ch: + if r.err != nil { + var isRetryable bool + if features.Get().DOH { + // According to the http package documentation, retryable + // errors emitted by the http package are of type *url.Error. + var urlErr *url.Error + isRetryable = errors.As(r.err, &urlErr) && urlErr.Temporary() + } else { + // According to the net package documentation, retryable + // errors emitted by the net package are of type *net.OpError. + var opErr *net.OpError + isRetryable = errors.As(r.err, &opErr) && opErr.Temporary() + } + hasRetriesLeft := tries < dnsClient.maxTries + if isRetryable && hasRetriesLeft { + tries++ + // Chose a new server to retry the query with by incrementing the + // chosen server index modulo the number of servers. This ensures that + // if one dns server isn't available we retry with the next in the + // list. + chosenServerIndex = (chosenServerIndex + 1) % len(servers) + chosenServer = servers[chosenServerIndex] + resolver = chosenServer + continue + } else if isRetryable && !hasRetriesLeft { + dnsClient.timeoutCounter.With(prometheus.Labels{ + "qtype": qtypeStr, + "type": "out of retries", + "resolver": chosenServerIP, + "isTLD": isTLD(hostname), + }).Inc() + } + } + resp, err = r.m, r.err + return + } + } + +} + +// isTLD returns a simplified view of whether something is a TLD: does it have +// any dots in it? This returns true or false as a string, and is meant solely +// for Prometheus metrics. +func isTLD(hostname string) string { + if strings.Contains(hostname, ".") { + return "false" + } else { + return "true" + } +} + +type dnsResp struct { + m *dns.Msg + err error +} + +// LookupTXT sends a DNS query to find all TXT records associated with +// the provided hostname which it returns along with the returned +// DNS authority section. +func (dnsClient *impl) LookupTXT(ctx context.Context, hostname string) ([]string, ResolverAddrs, error) { + var txt []string + dnsType := dns.TypeTXT + r, resolver, err := dnsClient.exchangeOne(ctx, hostname, dnsType) + errWrap := wrapErr(dnsType, hostname, r, err) + if errWrap != nil { + return nil, ResolverAddrs{resolver}, errWrap + } + + for _, answer := range r.Answer { + if answer.Header().Rrtype == dnsType { + if txtRec, ok := answer.(*dns.TXT); ok { + txt = append(txt, strings.Join(txtRec.Txt, "")) + } + } + } + + return txt, ResolverAddrs{resolver}, err +} + +func isPrivateV4(ip net.IP) bool { + for _, net := range privateNetworks { + if net.Contains(ip) { + return true + } + } + return false +} + +func isPrivateV6(ip net.IP) bool { + for _, net := range privateV6Networks { + if net.Contains(ip) { + return true + } + } + return false +} + +func (dnsClient *impl) lookupIP(ctx context.Context, hostname string, ipType uint16) ([]dns.RR, string, error) { + resp, resolver, err := dnsClient.exchangeOne(ctx, hostname, ipType) + switch ipType { + case dns.TypeA: + if resolver != "" { + resolver = "A:" + resolver + } + case dns.TypeAAAA: + if resolver != "" { + resolver = "AAAA:" + resolver + } + } + errWrap := wrapErr(ipType, hostname, resp, err) + if errWrap != nil { + return nil, resolver, errWrap + } + return resp.Answer, resolver, nil +} + +// LookupHost sends a DNS query to find all A and AAAA records associated with +// the provided hostname. This method assumes that the external resolver will +// chase CNAME/DNAME aliases and return relevant records. It will retry +// requests in the case of temporary network errors. It returns an error if +// both the A and AAAA lookups fail or are empty, but succeeds otherwise. +func (dnsClient *impl) LookupHost(ctx context.Context, hostname string) ([]net.IP, ResolverAddrs, error) { + var recordsA, recordsAAAA []dns.RR + var errA, errAAAA error + var resolverA, resolverAAAA string + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + recordsA, resolverA, errA = dnsClient.lookupIP(ctx, hostname, dns.TypeA) + }() + wg.Add(1) + go func() { + defer wg.Done() + recordsAAAA, resolverAAAA, errAAAA = dnsClient.lookupIP(ctx, hostname, dns.TypeAAAA) + }() + wg.Wait() + + resolvers := ResolverAddrs{resolverA, resolverAAAA} + resolvers = slices.DeleteFunc(resolvers, func(a string) bool { + return a == "" + }) + + var addrsA []net.IP + if errA == nil { + for _, answer := range recordsA { + if answer.Header().Rrtype == dns.TypeA { + a, ok := answer.(*dns.A) + if ok && a.A.To4() != nil && (!isPrivateV4(a.A) || dnsClient.allowRestrictedAddresses) { + addrsA = append(addrsA, a.A) + } + } + } + if len(addrsA) == 0 { + errA = fmt.Errorf("no valid A records found for %s", hostname) + } + } + + var addrsAAAA []net.IP + if errAAAA == nil { + for _, answer := range recordsAAAA { + if answer.Header().Rrtype == dns.TypeAAAA { + aaaa, ok := answer.(*dns.AAAA) + if ok && aaaa.AAAA.To16() != nil && (!isPrivateV6(aaaa.AAAA) || dnsClient.allowRestrictedAddresses) { + addrsAAAA = append(addrsAAAA, aaaa.AAAA) + } + } + } + if len(addrsAAAA) == 0 { + errAAAA = fmt.Errorf("no valid AAAA records found for %s", hostname) + } + } + + if errA != nil && errAAAA != nil { + // Construct a new error from both underlying errors. We can only use %w for + // one of them, because the go error unwrapping protocol doesn't support + // branching. We don't use ProblemDetails and SubProblemDetails here, because + // this error will get wrapped in a DNSError and further munged by higher + // layers in the stack. + return nil, resolvers, fmt.Errorf("%w; %s", errA, errAAAA) + } + + return append(addrsA, addrsAAAA...), resolvers, nil +} + +// LookupCAA sends a DNS query to find all CAA records associated with +// the provided hostname and the complete dig-style RR `response`. This +// response is quite verbose, however it's only populated when the CAA +// response is non-empty. +func (dnsClient *impl) LookupCAA(ctx context.Context, hostname string) ([]*dns.CAA, string, ResolverAddrs, error) { + dnsType := dns.TypeCAA + r, resolver, err := dnsClient.exchangeOne(ctx, hostname, dnsType) + + // Special case: when checking CAA for non-TLD names, treat NXDOMAIN as a + // successful response containing an empty set of records. This can come up in + // situations where records were provisioned for validation (e.g. TXT records + // for DNS-01 challenge) and then removed after validation but before CAA + // rechecking. But allow NXDOMAIN for TLDs to fall through to the error code + // below, so we don't issue for gTLDs that have been removed by ICANN. + if err == nil && r.Rcode == dns.RcodeNameError && strings.Contains(hostname, ".") { + return nil, "", ResolverAddrs{resolver}, nil + } + + errWrap := wrapErr(dnsType, hostname, r, err) + if errWrap != nil { + return nil, "", ResolverAddrs{resolver}, errWrap + } + + var CAAs []*dns.CAA + for _, answer := range r.Answer { + if caaR, ok := answer.(*dns.CAA); ok { + CAAs = append(CAAs, caaR) + } + } + var response string + if len(CAAs) > 0 { + response = r.String() + } + return CAAs, response, ResolverAddrs{resolver}, nil +} + +// logDNSError logs the provided err result from making a query for hostname to +// the chosenServer. If the err is a `dns.ErrId` instance then the Base64 +// encoded bytes of the query (and if not-nil, the response) in wire format +// is logged as well. This function is called from exchangeOne only for the case +// where an error occurs querying a hostname that indicates a problem between +// the VA and the chosenServer. +func logDNSError( + logger blog.Logger, + chosenServer string, + hostname string, + msg, resp *dns.Msg, + underlying error) { + // We don't expect logDNSError to be called with a nil msg or err but + // if it happens return early. We allow resp to be nil. + if msg == nil || len(msg.Question) == 0 || underlying == nil { + return + } + queryType := dns.TypeToString[msg.Question[0].Qtype] + + // If the error indicates there was a query/response ID mismatch then we want + // to log more detail. + if underlying == dns.ErrId { + packedMsgBytes, err := msg.Pack() + if err != nil { + logger.Errf("logDNSError failed to pack msg: %v", err) + return + } + encodedMsg := base64.StdEncoding.EncodeToString(packedMsgBytes) + + var encodedResp string + var respQname string + if resp != nil { + packedRespBytes, err := resp.Pack() + if err != nil { + logger.Errf("logDNSError failed to pack resp: %v", err) + return + } + encodedResp = base64.StdEncoding.EncodeToString(packedRespBytes) + if len(resp.Answer) > 0 && resp.Answer[0].Header() != nil { + respQname = resp.Answer[0].Header().Name + } + } + + logger.Infof( + "logDNSError ID mismatch chosenServer=[%s] hostname=[%s] respHostname=[%s] queryType=[%s] msg=[%s] resp=[%s] err=[%s]", + chosenServer, + hostname, + respQname, + queryType, + encodedMsg, + encodedResp, + underlying) + } else { + // Otherwise log a general DNS error + logger.Infof("logDNSError chosenServer=[%s] hostname=[%s] queryType=[%s] err=[%s]", + chosenServer, + hostname, + queryType, + underlying) + } +} + +type dohExchanger struct { + clk clock.Clock + hc http.Client +} + +// Exchange sends a DoH query to the provided DoH server and returns the response. +func (d *dohExchanger) Exchange(query *dns.Msg, server string) (*dns.Msg, time.Duration, error) { + q, err := query.Pack() + if err != nil { + return nil, 0, err + } + + // The default Unbound URL template + url := fmt.Sprintf("https://%s/dns-query", server) + req, err := http.NewRequest("POST", url, strings.NewReader(string(q))) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/dns-message") + req.Header.Set("Accept", "application/dns-message") + + start := d.clk.Now() + resp, err := d.hc.Do(req) + if err != nil { + return nil, d.clk.Since(start), err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, d.clk.Since(start), fmt.Errorf("doh: http status %d", resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, d.clk.Since(start), fmt.Errorf("doh: reading response body: %w", err) + } + + response := new(dns.Msg) + err = response.Unpack(b) + if err != nil { + return nil, d.clk.Since(start), fmt.Errorf("doh: unpacking response: %w", err) + } + + return response, d.clk.Since(start), nil +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/dns_test.go b/third-party/github.com/letsencrypt/boulder/bdns/dns_test.go new file mode 100644 index 000000000..8014e4928 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/dns_test.go @@ -0,0 +1,893 @@ +package bdns + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/url" + "os" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/features" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +const dnsLoopbackAddr = "127.0.0.1:4053" + +func mockDNSQuery(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Compress = false + + appendAnswer := func(rr dns.RR) { + m.Answer = append(m.Answer, rr) + } + for _, q := range r.Question { + q.Name = strings.ToLower(q.Name) + if q.Name == "servfail.com." || q.Name == "servfailexception.example.com" { + m.Rcode = dns.RcodeServerFailure + break + } + switch q.Qtype { + case dns.TypeSOA: + record := new(dns.SOA) + record.Hdr = dns.RR_Header{Name: "letsencrypt.org.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 0} + record.Ns = "ns.letsencrypt.org." + record.Mbox = "master.letsencrypt.org." + record.Serial = 1 + record.Refresh = 1 + record.Retry = 1 + record.Expire = 1 + record.Minttl = 1 + appendAnswer(record) + case dns.TypeAAAA: + if q.Name == "v6.letsencrypt.org." { + record := new(dns.AAAA) + record.Hdr = dns.RR_Header{Name: "v6.letsencrypt.org.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0} + record.AAAA = net.ParseIP("::1") + appendAnswer(record) + } + if q.Name == "dualstack.letsencrypt.org." { + record := new(dns.AAAA) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0} + record.AAAA = net.ParseIP("::1") + appendAnswer(record) + } + if q.Name == "v4error.letsencrypt.org." { + record := new(dns.AAAA) + record.Hdr = dns.RR_Header{Name: "v4error.letsencrypt.org.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0} + record.AAAA = net.ParseIP("::1") + appendAnswer(record) + } + if q.Name == "v6error.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } + if q.Name == "nxdomain.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNameError) + } + if q.Name == "dualstackerror.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } + case dns.TypeA: + if q.Name == "cps.letsencrypt.org." { + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: "cps.letsencrypt.org.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + appendAnswer(record) + } + if q.Name == "dualstack.letsencrypt.org." { + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + appendAnswer(record) + } + if q.Name == "v6error.letsencrypt.org." { + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + appendAnswer(record) + } + if q.Name == "v4error.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } + if q.Name == "nxdomain.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNameError) + } + if q.Name == "dualstackerror.letsencrypt.org." { + m.SetRcode(r, dns.RcodeRefused) + } + case dns.TypeCNAME: + if q.Name == "cname.letsencrypt.org." { + record := new(dns.CNAME) + record.Hdr = dns.RR_Header{Name: "cname.letsencrypt.org.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 30} + record.Target = "cps.letsencrypt.org." + appendAnswer(record) + } + if q.Name == "cname.example.com." { + record := new(dns.CNAME) + record.Hdr = dns.RR_Header{Name: "cname.example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 30} + record.Target = "CAA.example.com." + appendAnswer(record) + } + case dns.TypeDNAME: + if q.Name == "dname.letsencrypt.org." { + record := new(dns.DNAME) + record.Hdr = dns.RR_Header{Name: "dname.letsencrypt.org.", Rrtype: dns.TypeDNAME, Class: dns.ClassINET, Ttl: 30} + record.Target = "cps.letsencrypt.org." + appendAnswer(record) + } + case dns.TypeCAA: + if q.Name == "bracewel.net." || q.Name == "caa.example.com." { + record := new(dns.CAA) + record.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeCAA, Class: dns.ClassINET, Ttl: 0} + record.Tag = "issue" + record.Value = "letsencrypt.org" + record.Flag = 1 + appendAnswer(record) + } + if q.Name == "cname.example.com." { + record := new(dns.CAA) + record.Hdr = dns.RR_Header{Name: "caa.example.com.", Rrtype: dns.TypeCAA, Class: dns.ClassINET, Ttl: 0} + record.Tag = "issue" + record.Value = "letsencrypt.org" + record.Flag = 1 + appendAnswer(record) + } + if q.Name == "gonetld." { + m.SetRcode(r, dns.RcodeNameError) + } + case dns.TypeTXT: + if q.Name == "split-txt.letsencrypt.org." { + record := new(dns.TXT) + record.Hdr = dns.RR_Header{Name: "split-txt.letsencrypt.org.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0} + record.Txt = []string{"a", "b", "c"} + appendAnswer(record) + } else { + auth := new(dns.SOA) + auth.Hdr = dns.RR_Header{Name: "letsencrypt.org.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 0} + auth.Ns = "ns.letsencrypt.org." + auth.Mbox = "master.letsencrypt.org." + auth.Serial = 1 + auth.Refresh = 1 + auth.Retry = 1 + auth.Expire = 1 + auth.Minttl = 1 + m.Ns = append(m.Ns, auth) + } + if q.Name == "nxdomain.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNameError) + } + } + } + + err := w.WriteMsg(m) + if err != nil { + panic(err) // running tests, so panic is OK + } +} + +func serveLoopResolver(stopChan chan bool) { + dns.HandleFunc(".", mockDNSQuery) + tcpServer := &dns.Server{ + Addr: dnsLoopbackAddr, + Net: "tcp", + ReadTimeout: time.Second, + WriteTimeout: time.Second, + } + udpServer := &dns.Server{ + Addr: dnsLoopbackAddr, + Net: "udp", + ReadTimeout: time.Second, + WriteTimeout: time.Second, + } + go func() { + err := tcpServer.ListenAndServe() + if err != nil { + fmt.Println(err) + } + }() + go func() { + err := udpServer.ListenAndServe() + if err != nil { + fmt.Println(err) + } + }() + go func() { + <-stopChan + err := tcpServer.Shutdown() + if err != nil { + log.Fatal(err) + } + err = udpServer.Shutdown() + if err != nil { + log.Fatal(err) + } + }() +} + +func pollServer() { + backoff := 200 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ticker := time.NewTicker(backoff) + + for { + select { + case <-ctx.Done(): + fmt.Fprintln(os.Stderr, "Timeout reached while testing for the dns server to come up") + os.Exit(1) + case <-ticker.C: + conn, _ := dns.DialTimeout("udp", dnsLoopbackAddr, backoff) + if conn != nil { + _ = conn.Close() + return + } + } + } +} + +func TestMain(m *testing.M) { + stop := make(chan bool, 1) + serveLoopResolver(stop) + pollServer() + ret := m.Run() + stop <- true + os.Exit(ret) +} + +func TestDNSNoServers(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Hour, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + _, resolvers, err := obj.LookupHost(context.Background(), "letsencrypt.org") + test.AssertEquals(t, len(resolvers), 0) + test.AssertError(t, err, "No servers") + + _, _, err = obj.LookupTXT(context.Background(), "letsencrypt.org") + test.AssertError(t, err, "No servers") + + _, _, _, err = obj.LookupCAA(context.Background(), "letsencrypt.org") + test.AssertError(t, err, "No servers") +} + +func TestDNSOneServer(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + _, resolvers, err := obj.LookupHost(context.Background(), "cps.letsencrypt.org") + test.AssertEquals(t, len(resolvers), 2) + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + test.AssertNotError(t, err, "No message") +} + +func TestDNSDuplicateServers(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr, dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + _, resolvers, err := obj.LookupHost(context.Background(), "cps.letsencrypt.org") + test.AssertEquals(t, len(resolvers), 2) + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + test.AssertNotError(t, err, "No message") +} + +func TestDNSServFail(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + bad := "servfail.com" + + _, _, err = obj.LookupTXT(context.Background(), bad) + test.AssertError(t, err, "LookupTXT didn't return an error") + + _, _, err = obj.LookupHost(context.Background(), bad) + test.AssertError(t, err, "LookupHost didn't return an error") + + emptyCaa, _, _, err := obj.LookupCAA(context.Background(), bad) + test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records") + test.AssertError(t, err, "LookupCAA should have returned an error") +} + +func TestDNSLookupTXT(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + a, _, err := obj.LookupTXT(context.Background(), "letsencrypt.org") + t.Logf("A: %v", a) + test.AssertNotError(t, err, "No message") + + a, _, err = obj.LookupTXT(context.Background(), "split-txt.letsencrypt.org") + t.Logf("A: %v ", a) + test.AssertNotError(t, err, "No message") + test.AssertEquals(t, len(a), 1) + test.AssertEquals(t, a[0], "abc") +} + +func TestDNSLookupHost(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + ip, resolvers, err := obj.LookupHost(context.Background(), "servfail.com") + t.Logf("servfail.com - IP: %s, Err: %s", ip, err) + test.AssertError(t, err, "Server failure") + test.Assert(t, len(ip) == 0, "Should not have IPs") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + ip, resolvers, err = obj.LookupHost(context.Background(), "nonexistent.letsencrypt.org") + t.Logf("nonexistent.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertError(t, err, "No valid A or AAAA records should error") + test.Assert(t, len(ip) == 0, "Should not have IPs") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // Single IPv4 address + ip, resolvers, err = obj.LookupHost(context.Background(), "cps.letsencrypt.org") + t.Logf("cps.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have IP") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + ip, resolvers, err = obj.LookupHost(context.Background(), "cps.letsencrypt.org") + t.Logf("cps.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have IP") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // Single IPv6 address + ip, resolvers, err = obj.LookupHost(context.Background(), "v6.letsencrypt.org") + t.Logf("v6.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should not have IPs") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // Both IPv6 and IPv4 address + ip, resolvers, err = obj.LookupHost(context.Background(), "dualstack.letsencrypt.org") + t.Logf("dualstack.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 2, "Should have 2 IPs") + expected := net.ParseIP("127.0.0.1") + test.Assert(t, ip[0].To4().Equal(expected), "wrong ipv4 address") + expected = net.ParseIP("::1") + test.Assert(t, ip[1].To16().Equal(expected), "wrong ipv6 address") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // IPv6 error, IPv4 success + ip, resolvers, err = obj.LookupHost(context.Background(), "v6error.letsencrypt.org") + t.Logf("v6error.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have 1 IP") + expected = net.ParseIP("127.0.0.1") + test.Assert(t, ip[0].To4().Equal(expected), "wrong ipv4 address") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // IPv6 success, IPv4 error + ip, resolvers, err = obj.LookupHost(context.Background(), "v4error.letsencrypt.org") + t.Logf("v4error.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have 1 IP") + expected = net.ParseIP("::1") + test.Assert(t, ip[0].To16().Equal(expected), "wrong ipv6 address") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) + + // IPv6 error, IPv4 error + // Should return both the IPv4 error (Refused) and the IPv6 error (NotImplemented) + hostname := "dualstackerror.letsencrypt.org" + ip, resolvers, err = obj.LookupHost(context.Background(), hostname) + t.Logf("%s - IP: %s, Err: %s", hostname, ip, err) + test.AssertError(t, err, "Should be an error") + test.AssertContains(t, err.Error(), "REFUSED looking up A for") + test.AssertContains(t, err.Error(), "NOTIMP looking up AAAA for") + slices.Sort(resolvers) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"A:127.0.0.1:4053", "AAAA:127.0.0.1:4053"}) +} + +func TestDNSNXDOMAIN(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + + hostname := "nxdomain.letsencrypt.org" + _, _, err = obj.LookupHost(context.Background(), hostname) + test.AssertContains(t, err.Error(), "NXDOMAIN looking up A for") + test.AssertContains(t, err.Error(), "NXDOMAIN looking up AAAA for") + + _, _, err = obj.LookupTXT(context.Background(), hostname) + expected := Error{dns.TypeTXT, hostname, nil, dns.RcodeNameError, nil} + test.AssertDeepEquals(t, err, expected) +} + +func TestDNSLookupCAA(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil) + removeIDExp := regexp.MustCompile(" id: [[:digit:]]+") + + caas, resp, resolvers, err := obj.LookupCAA(context.Background(), "bracewel.net") + test.AssertNotError(t, err, "CAA lookup failed") + test.Assert(t, len(caas) > 0, "Should have CAA records") + test.AssertEquals(t, len(resolvers), 1) + test.AssertDeepEquals(t, resolvers, ResolverAddrs{"127.0.0.1:4053"}) + expectedResp := `;; opcode: QUERY, status: NOERROR, id: XXXX +;; flags: qr rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;bracewel.net. IN CAA + +;; ANSWER SECTION: +bracewel.net. 0 IN CAA 1 issue "letsencrypt.org" +` + test.AssertEquals(t, removeIDExp.ReplaceAllString(resp, " id: XXXX"), expectedResp) + + caas, resp, resolvers, err = obj.LookupCAA(context.Background(), "nonexistent.letsencrypt.org") + test.AssertNotError(t, err, "CAA lookup failed") + test.Assert(t, len(caas) == 0, "Shouldn't have CAA records") + test.AssertEquals(t, resolvers[0], "127.0.0.1:4053") + expectedResp = "" + test.AssertEquals(t, resp, expectedResp) + + caas, resp, resolvers, err = obj.LookupCAA(context.Background(), "nxdomain.letsencrypt.org") + slices.Sort(resolvers) + test.AssertNotError(t, err, "CAA lookup failed") + test.Assert(t, len(caas) == 0, "Shouldn't have CAA records") + test.AssertEquals(t, resolvers[0], "127.0.0.1:4053") + expectedResp = "" + test.AssertEquals(t, resp, expectedResp) + + caas, resp, resolvers, err = obj.LookupCAA(context.Background(), "cname.example.com") + test.AssertNotError(t, err, "CAA lookup failed") + test.Assert(t, len(caas) > 0, "Should follow CNAME to find CAA") + test.AssertEquals(t, resolvers[0], "127.0.0.1:4053") + expectedResp = `;; opcode: QUERY, status: NOERROR, id: XXXX +;; flags: qr rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;cname.example.com. IN CAA + +;; ANSWER SECTION: +caa.example.com. 0 IN CAA 1 issue "letsencrypt.org" +` + test.AssertEquals(t, removeIDExp.ReplaceAllString(resp, " id: XXXX"), expectedResp) + + _, _, resolvers, err = obj.LookupCAA(context.Background(), "gonetld") + test.AssertError(t, err, "should fail for TLD NXDOMAIN") + test.AssertContains(t, err.Error(), "NXDOMAIN") + test.AssertEquals(t, resolvers[0], "127.0.0.1:4053") +} + +func TestIsPrivateIP(t *testing.T) { + test.Assert(t, isPrivateV4(net.ParseIP("127.0.0.1")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("192.168.254.254")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("10.255.0.3")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("172.16.255.255")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("172.31.255.255")), "should be private") + test.Assert(t, !isPrivateV4(net.ParseIP("128.0.0.1")), "should be private") + test.Assert(t, !isPrivateV4(net.ParseIP("192.169.255.255")), "should not be private") + test.Assert(t, !isPrivateV4(net.ParseIP("9.255.0.255")), "should not be private") + test.Assert(t, !isPrivateV4(net.ParseIP("172.32.255.255")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("::0")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("::1")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("::2")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("fe80::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("febf::1")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("fec0::1")), "should not be private") + test.Assert(t, !isPrivateV6(net.ParseIP("feff::1")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("ff00::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("ff10::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should be private") + + test.Assert(t, isPrivateV6(net.ParseIP("2002::")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("0100::")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("0100::0000:ffff:ffff:ffff:ffff")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("0100::0001:0000:0000:0000:0000")), "should be private") +} + +type testExchanger struct { + sync.Mutex + count int + errs []error +} + +var errTooManyRequests = errors.New("too many requests") + +func (te *testExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration, error) { + te.Lock() + defer te.Unlock() + msg := &dns.Msg{ + MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess}, + } + if len(te.errs) <= te.count { + return nil, 0, errTooManyRequests + } + err := te.errs[te.count] + te.count++ + + return msg, 2 * time.Millisecond, err +} + +func TestRetry(t *testing.T) { + isTempErr := &net.OpError{Op: "read", Err: tempError(true)} + nonTempErr := &net.OpError{Op: "read", Err: tempError(false)} + servFailError := errors.New("DNS problem: server failure at resolver looking up TXT for example.com") + netError := errors.New("DNS problem: networking error looking up TXT for example.com") + type testCase struct { + name string + maxTries int + te *testExchanger + expected error + expectedCount int + metricsAllRetries float64 + } + tests := []*testCase{ + // The success on first try case + { + name: "success", + maxTries: 3, + te: &testExchanger{ + errs: []error{nil}, + }, + expected: nil, + expectedCount: 1, + }, + // Immediate non-OpError, error returns immediately + { + name: "non-operror", + maxTries: 3, + te: &testExchanger{ + errs: []error{errors.New("nope")}, + }, + expected: servFailError, + expectedCount: 1, + }, + // Temporary err, then non-OpError stops at two tries + { + name: "err-then-non-operror", + maxTries: 3, + te: &testExchanger{ + errs: []error{isTempErr, errors.New("nope")}, + }, + expected: servFailError, + expectedCount: 2, + }, + // Temporary error given always + { + name: "persistent-temp-error", + maxTries: 3, + te: &testExchanger{ + errs: []error{ + isTempErr, + isTempErr, + isTempErr, + }, + }, + expected: netError, + expectedCount: 3, + metricsAllRetries: 1, + }, + // Even with maxTries at 0, we should still let a single request go + // through + { + name: "zero-maxtries", + maxTries: 0, + te: &testExchanger{ + errs: []error{nil}, + }, + expected: nil, + expectedCount: 1, + }, + // Temporary error given just once causes two tries + { + name: "single-temp-error", + maxTries: 3, + te: &testExchanger{ + errs: []error{ + isTempErr, + nil, + }, + }, + expected: nil, + expectedCount: 2, + }, + // Temporary error given twice causes three tries + { + name: "double-temp-error", + maxTries: 3, + te: &testExchanger{ + errs: []error{ + isTempErr, + isTempErr, + nil, + }, + }, + expected: nil, + expectedCount: 3, + }, + // Temporary error given thrice causes three tries and fails + { + name: "triple-temp-error", + maxTries: 3, + te: &testExchanger{ + errs: []error{ + isTempErr, + isTempErr, + isTempErr, + }, + }, + expected: netError, + expectedCount: 3, + metricsAllRetries: 1, + }, + // temporary then non-Temporary error causes two retries + { + name: "temp-nontemp-error", + maxTries: 3, + te: &testExchanger{ + errs: []error{ + isTempErr, + nonTempErr, + }, + }, + expected: netError, + expectedCount: 2, + }, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + testClient := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), tc.maxTries, blog.UseMock(), nil) + dr := testClient.(*impl) + dr.dnsClient = tc.te + _, _, err = dr.LookupTXT(context.Background(), "example.com") + if err == errTooManyRequests { + t.Errorf("#%d, sent more requests than the test case handles", i) + } + expectedErr := tc.expected + if (expectedErr == nil && err != nil) || + (expectedErr != nil && err == nil) || + (expectedErr != nil && expectedErr.Error() != err.Error()) { + t.Errorf("#%d, error, expected %v, got %v", i, expectedErr, err) + } + if tc.expectedCount != tc.te.count { + t.Errorf("#%d, error, expectedCount %v, got %v", i, tc.expectedCount, tc.te.count) + } + if tc.metricsAllRetries > 0 { + test.AssertMetricWithLabelsEquals( + t, dr.timeoutCounter, prometheus.Labels{ + "qtype": "TXT", + "type": "out of retries", + "resolver": "127.0.0.1", + "isTLD": "false", + }, tc.metricsAllRetries) + } + }) + } + + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + testClient := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, blog.UseMock(), nil) + dr := testClient.(*impl) + dr.dnsClient = &testExchanger{errs: []error{isTempErr, isTempErr, nil}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, err = dr.LookupTXT(ctx, "example.com") + if err == nil || + err.Error() != "DNS problem: query timed out (and was canceled) looking up TXT for example.com" { + t.Errorf("expected %s, got %s", context.Canceled, err) + } + + dr.dnsClient = &testExchanger{errs: []error{isTempErr, isTempErr, nil}} + ctx, cancel = context.WithTimeout(context.Background(), -10*time.Hour) + defer cancel() + _, _, err = dr.LookupTXT(ctx, "example.com") + if err == nil || + err.Error() != "DNS problem: query timed out looking up TXT for example.com" { + t.Errorf("expected %s, got %s", context.DeadlineExceeded, err) + } + + dr.dnsClient = &testExchanger{errs: []error{isTempErr, isTempErr, nil}} + ctx, deadlineCancel := context.WithTimeout(context.Background(), -10*time.Hour) + deadlineCancel() + _, _, err = dr.LookupTXT(ctx, "example.com") + if err == nil || + err.Error() != "DNS problem: query timed out looking up TXT for example.com" { + t.Errorf("expected %s, got %s", context.DeadlineExceeded, err) + } + + test.AssertMetricWithLabelsEquals( + t, dr.timeoutCounter, prometheus.Labels{ + "qtype": "TXT", + "type": "canceled", + "resolver": "127.0.0.1", + }, 1) + + test.AssertMetricWithLabelsEquals( + t, dr.timeoutCounter, prometheus.Labels{ + "qtype": "TXT", + "type": "deadline exceeded", + "resolver": "127.0.0.1", + }, 2) +} + +func TestIsTLD(t *testing.T) { + if isTLD("com") != "true" { + t.Errorf("expected 'com' to be a TLD, got %q", isTLD("com")) + } + if isTLD("example.com") != "false" { + t.Errorf("expected 'example.com' to not a TLD, got %q", isTLD("example.com")) + } +} + +type tempError bool + +func (t tempError) Temporary() bool { return bool(t) } +func (t tempError) Error() string { return fmt.Sprintf("Temporary: %t", t) } + +// rotateFailureExchanger is a dns.Exchange implementation that tracks a count +// of the number of calls to `Exchange` for a given address in the `lookups` +// map. For all addresses in the `brokenAddresses` map, a retryable error is +// returned from `Exchange`. This mock is used by `TestRotateServerOnErr`. +type rotateFailureExchanger struct { + sync.Mutex + lookups map[string]int + brokenAddresses map[string]bool +} + +// Exchange for rotateFailureExchanger tracks the `a` argument in `lookups` and +// if present in `brokenAddresses`, returns a temporary error. +func (e *rotateFailureExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration, error) { + e.Lock() + defer e.Unlock() + + // Track that exchange was called for the given server + e.lookups[a]++ + + // If its a broken server, return a retryable error + if e.brokenAddresses[a] { + isTempErr := &net.OpError{Op: "read", Err: tempError(true)} + return nil, 2 * time.Millisecond, isTempErr + } + + return m, 2 * time.Millisecond, nil +} + +// TestRotateServerOnErr ensures that a retryable error returned from a DNS +// server will result in the retry being performed against the next server in +// the list. +func TestRotateServerOnErr(t *testing.T) { + // Configure three DNS servers + dnsServers := []string{ + "a:53", "b:53", "[2606:4700:4700::1111]:53", + } + + // Set up a DNS client using these servers that will retry queries up to + // a maximum of 5 times. It's important to choose a maxTries value >= the + // number of dnsServers to ensure we always get around to trying the one + // working server + staticProvider, err := NewStaticProvider(dnsServers) + test.AssertNotError(t, err, "Got error creating StaticProvider") + fmt.Println(staticProvider.servers) + + maxTries := 5 + client := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), maxTries, blog.UseMock(), nil) + + // Configure a mock exchanger that will always return a retryable error for + // servers A and B. This will force server "[2606:4700:4700::1111]:53" to do + // all the work once retries reach it. + mock := &rotateFailureExchanger{ + brokenAddresses: map[string]bool{ + "a:53": true, + "b:53": true, + }, + lookups: make(map[string]int), + } + client.(*impl).dnsClient = mock + + // Perform a bunch of lookups. We choose the initial server randomly. Any time + // A or B is chosen there should be an error and a retry using the next server + // in the list. Since we configured maxTries to be larger than the number of + // servers *all* queries should eventually succeed by being retried against + // server "[2606:4700:4700::1111]:53". + for range maxTries * 2 { + _, resolvers, err := client.LookupTXT(context.Background(), "example.com") + test.AssertEquals(t, len(resolvers), 1) + test.AssertEquals(t, resolvers[0], "[2606:4700:4700::1111]:53") + // Any errors are unexpected - server "[2606:4700:4700::1111]:53" should + // have responded without error. + test.AssertNotError(t, err, "Expected no error from eventual retry with functional server") + } + + // We expect that the A and B servers had a non-zero number of lookups + // attempted. + test.Assert(t, mock.lookups["a:53"] > 0, "Expected A server to have non-zero lookup attempts") + test.Assert(t, mock.lookups["b:53"] > 0, "Expected B server to have non-zero lookup attempts") + + // We expect that the server "[2606:4700:4700::1111]:53" eventually served + // all of the lookups attempted. + test.AssertEquals(t, mock.lookups["[2606:4700:4700::1111]:53"], maxTries*2) + +} + +type mockTempURLError struct{} + +func (m *mockTempURLError) Error() string { return "whoops, oh gosh" } +func (m *mockTempURLError) Timeout() bool { return false } +func (m *mockTempURLError) Temporary() bool { return true } + +type dohAlwaysRetryExchanger struct { + sync.Mutex + err error +} + +func (dohE *dohAlwaysRetryExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration, error) { + dohE.Lock() + defer dohE.Unlock() + + tempURLerror := &url.Error{ + Op: "GET", + URL: "https://example.com", + Err: &mockTempURLError{}, + } + + return nil, time.Second, tempURLerror +} + +func TestDOHMetric(t *testing.T) { + features.Set(features.Config{DOH: true}) + defer features.Reset() + + staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr}) + test.AssertNotError(t, err, "Got error creating StaticProvider") + + testClient := NewTest(time.Second*11, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 0, blog.UseMock(), nil) + resolver := testClient.(*impl) + resolver.dnsClient = &dohAlwaysRetryExchanger{err: &url.Error{Op: "read", Err: tempError(true)}} + + // Starting out, we should count 0 "out of retries" errors. + test.AssertMetricWithLabelsEquals(t, resolver.timeoutCounter, prometheus.Labels{"qtype": "None", "type": "out of retries", "resolver": "127.0.0.1", "isTLD": "false"}, 0) + + // Trigger the error. + _, _, _ = resolver.exchangeOne(context.Background(), "example.com", 0) + + // Now, we should count 1 "out of retries" errors. + test.AssertMetricWithLabelsEquals(t, resolver.timeoutCounter, prometheus.Labels{"qtype": "None", "type": "out of retries", "resolver": "127.0.0.1", "isTLD": "false"}, 1) +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/mocks.go b/third-party/github.com/letsencrypt/boulder/bdns/mocks.go new file mode 100644 index 000000000..36bf2e88d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/mocks.go @@ -0,0 +1,124 @@ +package bdns + +import ( + "context" + "errors" + "fmt" + "net" + "os" + + "github.com/miekg/dns" + + blog "github.com/letsencrypt/boulder/log" +) + +// MockClient is a mock +type MockClient struct { + Log blog.Logger +} + +// LookupTXT is a mock +func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string, ResolverAddrs, error) { + if hostname == "_acme-challenge.servfail.com" { + return nil, ResolverAddrs{"MockClient"}, fmt.Errorf("SERVFAIL") + } + if hostname == "_acme-challenge.good-dns01.com" { + // base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" + // + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI")) + // expected token + test account jwk thumbprint + return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "_acme-challenge.wrong-dns01.com" { + return []string{"a"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "_acme-challenge.wrong-many-dns01.com" { + return []string{"a", "b", "c", "d", "e"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "_acme-challenge.long-dns01.com" { + return []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "_acme-challenge.no-authority-dns01.com" { + // base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" + // + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI")) + // expected token + test account jwk thumbprint + return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil + } + // empty-txts.com always returns zero TXT records + if hostname == "_acme-challenge.empty-txts.com" { + return []string{}, ResolverAddrs{"MockClient"}, nil + } + return []string{"hostname"}, ResolverAddrs{"MockClient"}, nil +} + +// makeTimeoutError returns a a net.OpError for which Timeout() returns true. +func makeTimeoutError() *net.OpError { + return &net.OpError{ + Err: os.NewSyscallError("ugh timeout", timeoutError{}), + } +} + +type timeoutError struct{} + +func (t timeoutError) Error() string { + return "so sloooow" +} +func (t timeoutError) Timeout() bool { + return true +} + +// LookupHost is a mock +func (mock *MockClient) LookupHost(_ context.Context, hostname string) ([]net.IP, ResolverAddrs, error) { + if hostname == "always.invalid" || + hostname == "invalid.invalid" { + return []net.IP{}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "always.timeout" { + return []net.IP{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, "always.timeout", makeTimeoutError(), -1, nil} + } + if hostname == "always.error" { + err := &net.OpError{ + Op: "read", + Net: "udp", + Err: errors.New("some net error"), + } + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(hostname), dns.TypeA) + m.AuthenticatedData = true + m.SetEdns0(4096, false) + logDNSError(mock.Log, "mock.server", hostname, m, nil, err) + return []net.IP{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, hostname, err, -1, nil} + } + if hostname == "id.mismatch" { + err := dns.ErrId + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(hostname), dns.TypeA) + m.AuthenticatedData = true + m.SetEdns0(4096, false) + r := new(dns.Msg) + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: dns.Fqdn(hostname), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + r.Answer = append(r.Answer, record) + logDNSError(mock.Log, "mock.server", hostname, m, r, err) + return []net.IP{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, hostname, err, -1, nil} + } + // dual-homed host with an IPv6 and an IPv4 address + if hostname == "ipv4.and.ipv6.localhost" { + return []net.IP{ + net.ParseIP("::1"), + net.ParseIP("127.0.0.1"), + }, ResolverAddrs{"MockClient"}, nil + } + if hostname == "ipv6.localhost" { + return []net.IP{ + net.ParseIP("::1"), + }, ResolverAddrs{"MockClient"}, nil + } + ip := net.ParseIP("127.0.0.1") + return []net.IP{ip}, ResolverAddrs{"MockClient"}, nil +} + +// LookupCAA returns mock records for use in tests. +func (mock *MockClient) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, ResolverAddrs, error) { + return nil, "", ResolverAddrs{"MockClient"}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/problem.go b/third-party/github.com/letsencrypt/boulder/bdns/problem.go new file mode 100644 index 000000000..7e22fbedf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/problem.go @@ -0,0 +1,150 @@ +package bdns + +import ( + "context" + "fmt" + "net" + + "github.com/miekg/dns" +) + +// Error wraps a DNS error with various relevant information +type Error struct { + recordType uint16 + hostname string + // Exactly one of rCode or underlying should be set. + underlying error + rCode int + + // Optional: If the resolver returned extended error information, it will be stored here. + // https://www.rfc-editor.org/rfc/rfc8914 + extended *dns.EDNS0_EDE +} + +// extendedDNSError returns non-nil if the input message contained an OPT RR +// with an EDE option. https://www.rfc-editor.org/rfc/rfc8914. +func extendedDNSError(msg *dns.Msg) *dns.EDNS0_EDE { + opt := msg.IsEdns0() + if opt != nil { + for _, opt := range opt.Option { + ede, ok := opt.(*dns.EDNS0_EDE) + if !ok { + continue + } + return ede + } + } + return nil +} + +// wrapErr returns a non-nil error if err is non-nil or if resp.Rcode is not dns.RcodeSuccess. +// The error includes appropriate details about the DNS query that failed. +func wrapErr(queryType uint16, hostname string, resp *dns.Msg, err error) error { + if err != nil { + return Error{ + recordType: queryType, + hostname: hostname, + underlying: err, + extended: nil, + } + } + if resp.Rcode != dns.RcodeSuccess { + return Error{ + recordType: queryType, + hostname: hostname, + rCode: resp.Rcode, + underlying: nil, + extended: extendedDNSError(resp), + } + } + return nil +} + +// A copy of miekg/dns's mapping of error codes to strings. We tweak it slightly so all DNSSEC-related +// errors say "DNSSEC" at the beginning. +// https://pkg.go.dev/github.com/miekg/dns#ExtendedErrorCodeToString +// Also note that not all of these codes can currently be emitted by Unbound. See Unbound's +// announcement post for EDE: https://blog.nlnetlabs.nl/extended-dns-error-support-for-unbound/ +var extendedErrorCodeToString = map[uint16]string{ + dns.ExtendedErrorCodeOther: "Other", + dns.ExtendedErrorCodeUnsupportedDNSKEYAlgorithm: "DNSSEC: Unsupported DNSKEY Algorithm", + dns.ExtendedErrorCodeUnsupportedDSDigestType: "DNSSEC: Unsupported DS Digest Type", + dns.ExtendedErrorCodeStaleAnswer: "Stale Answer", + dns.ExtendedErrorCodeForgedAnswer: "Forged Answer", + dns.ExtendedErrorCodeDNSSECIndeterminate: "DNSSEC: Indeterminate", + dns.ExtendedErrorCodeDNSBogus: "DNSSEC: Bogus", + dns.ExtendedErrorCodeSignatureExpired: "DNSSEC: Signature Expired", + dns.ExtendedErrorCodeSignatureNotYetValid: "DNSSEC: Signature Not Yet Valid", + dns.ExtendedErrorCodeDNSKEYMissing: "DNSSEC: DNSKEY Missing", + dns.ExtendedErrorCodeRRSIGsMissing: "DNSSEC: RRSIGs Missing", + dns.ExtendedErrorCodeNoZoneKeyBitSet: "DNSSEC: No Zone Key Bit Set", + dns.ExtendedErrorCodeNSECMissing: "DNSSEC: NSEC Missing", + dns.ExtendedErrorCodeCachedError: "Cached Error", + dns.ExtendedErrorCodeNotReady: "Not Ready", + dns.ExtendedErrorCodeBlocked: "Blocked", + dns.ExtendedErrorCodeCensored: "Censored", + dns.ExtendedErrorCodeFiltered: "Filtered", + dns.ExtendedErrorCodeProhibited: "Prohibited", + dns.ExtendedErrorCodeStaleNXDOMAINAnswer: "Stale NXDOMAIN Answer", + dns.ExtendedErrorCodeNotAuthoritative: "Not Authoritative", + dns.ExtendedErrorCodeNotSupported: "Not Supported", + dns.ExtendedErrorCodeNoReachableAuthority: "No Reachable Authority", + dns.ExtendedErrorCodeNetworkError: "Network Error between Resolver and Authority", + dns.ExtendedErrorCodeInvalidData: "Invalid Data", +} + +func (d Error) Error() string { + var detail, additional string + if d.underlying != nil { + if netErr, ok := d.underlying.(*net.OpError); ok { + if netErr.Timeout() { + detail = detailDNSTimeout + } else { + detail = detailDNSNetFailure + } + // Note: we check d.underlying here even though `Timeout()` does this because the call to `netErr.Timeout()` above only + // happens for `*net.OpError` underlying types! + } else if d.underlying == context.DeadlineExceeded { + detail = detailDNSTimeout + } else if d.underlying == context.Canceled { + detail = detailCanceled + } else { + detail = detailServerFailure + } + } else if d.rCode != dns.RcodeSuccess { + detail = dns.RcodeToString[d.rCode] + if explanation, ok := rcodeExplanations[d.rCode]; ok { + additional = " - " + explanation + } + } else { + detail = detailServerFailure + } + + if d.extended == nil { + return fmt.Sprintf("DNS problem: %s looking up %s for %s%s", detail, + dns.TypeToString[d.recordType], d.hostname, additional) + } + + summary := extendedErrorCodeToString[d.extended.InfoCode] + if summary == "" { + summary = fmt.Sprintf("Unknown Extended DNS Error code %d", d.extended.InfoCode) + } + result := fmt.Sprintf("DNS problem: looking up %s for %s: %s", + dns.TypeToString[d.recordType], d.hostname, summary) + if d.extended.ExtraText != "" { + result = result + ": " + d.extended.ExtraText + } + return result +} + +const detailDNSTimeout = "query timed out" +const detailCanceled = "query timed out (and was canceled)" +const detailDNSNetFailure = "networking error" +const detailServerFailure = "server failure at resolver" + +// rcodeExplanations provide additional friendly explanatory text to be included in DNS +// error messages, for select inscrutable RCODEs. +var rcodeExplanations = map[int]string{ + dns.RcodeNameError: "check that a DNS record exists for this domain", + dns.RcodeServerFailure: "the domain's nameservers may be malfunctioning", +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/problem_test.go b/third-party/github.com/letsencrypt/boulder/bdns/problem_test.go new file mode 100644 index 000000000..f20f5bdb3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/problem_test.go @@ -0,0 +1,78 @@ +package bdns + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/letsencrypt/boulder/test" + "github.com/miekg/dns" +) + +func TestError(t *testing.T) { + testCases := []struct { + err error + expected string + }{ + { + &Error{dns.TypeA, "hostname", makeTimeoutError(), -1, nil}, + "DNS problem: query timed out looking up A for hostname", + }, { + &Error{dns.TypeMX, "hostname", &net.OpError{Err: errors.New("some net error")}, -1, nil}, + "DNS problem: networking error looking up MX for hostname", + }, { + &Error{dns.TypeTXT, "hostname", nil, dns.RcodeNameError, nil}, + "DNS problem: NXDOMAIN looking up TXT for hostname - check that a DNS record exists for this domain", + }, { + &Error{dns.TypeTXT, "hostname", context.DeadlineExceeded, -1, nil}, + "DNS problem: query timed out looking up TXT for hostname", + }, { + &Error{dns.TypeTXT, "hostname", context.Canceled, -1, nil}, + "DNS problem: query timed out (and was canceled) looking up TXT for hostname", + }, { + &Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil}, + "DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning", + }, { + &Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1, ExtraText: "oh no"}}, + "DNS problem: looking up A for hostname: DNSSEC: Unsupported DNSKEY Algorithm: oh no", + }, { + &Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 6, ExtraText: ""}}, + "DNS problem: looking up A for hostname: DNSSEC: Bogus", + }, { + &Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1337, ExtraText: "mysterious"}}, + "DNS problem: looking up A for hostname: Unknown Extended DNS Error code 1337: mysterious", + }, { + &Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil}, + "DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning", + }, { + &Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil}, + "DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning", + }, { + &Error{dns.TypeA, "hostname", nil, dns.RcodeFormatError, nil}, + "DNS problem: FORMERR looking up A for hostname", + }, + } + for _, tc := range testCases { + if tc.err.Error() != tc.expected { + t.Errorf("got %q, expected %q", tc.err.Error(), tc.expected) + } + } +} + +func TestWrapErr(t *testing.T) { + err := wrapErr(dns.TypeA, "hostname", &dns.Msg{ + MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess}, + }, nil) + test.AssertNotError(t, err, "expected success") + + err = wrapErr(dns.TypeA, "hostname", &dns.Msg{ + MsgHdr: dns.MsgHdr{Rcode: dns.RcodeRefused}, + }, nil) + test.AssertError(t, err, "expected error") + + err = wrapErr(dns.TypeA, "hostname", &dns.Msg{ + MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess}, + }, errors.New("oh no")) + test.AssertError(t, err, "expected error") +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/servers.go b/third-party/github.com/letsencrypt/boulder/bdns/servers.go new file mode 100644 index 000000000..dd8edee98 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/servers.go @@ -0,0 +1,324 @@ +package bdns + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net" + "strconv" + "sync" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" +) + +// ServerProvider represents a type which can provide a list of addresses for +// the bdns to use as DNS resolvers. Different implementations may provide +// different strategies for providing addresses, and may provide different kinds +// of addresses (e.g. host:port combos vs IP addresses). +type ServerProvider interface { + Addrs() ([]string, error) + Stop() +} + +// staticProvider stores a list of host:port combos, and provides that whole +// list in randomized order when asked for addresses. This replicates the old +// behavior of the bdns.impl's servers field. +type staticProvider struct { + servers []string +} + +var _ ServerProvider = &staticProvider{} + +// validateServerAddress ensures that a given server address is formatted in +// such a way that it can be dialed. The provided server address must include a +// host/IP and port separated by colon. Additionally, if the host is a literal +// IPv6 address, it must be enclosed in square brackets. +// (https://golang.org/src/net/dial.go?s=9833:9881#L281) +func validateServerAddress(address string) error { + // Ensure the host and port portions of `address` can be split. + host, port, err := net.SplitHostPort(address) + if err != nil { + return err + } + + // Ensure `address` contains both a `host` and `port` portion. + if host == "" || port == "" { + return errors.New("port cannot be missing") + } + + // Ensure the `port` portion of `address` is a valid port. + portNum, err := strconv.Atoi(port) + if err != nil { + return fmt.Errorf("parsing port number: %s", err) + } + if portNum <= 0 || portNum > 65535 { + return errors.New("port must be an integer between 0 - 65535") + } + + // Ensure the `host` portion of `address` is a valid FQDN or IP address. + IPv6 := net.ParseIP(host).To16() + IPv4 := net.ParseIP(host).To4() + FQDN := dns.IsFqdn(dns.Fqdn(host)) + if IPv6 == nil && IPv4 == nil && !FQDN { + return errors.New("host is not an FQDN or IP address") + } + return nil +} + +func NewStaticProvider(servers []string) (*staticProvider, error) { + var serverAddrs []string + for _, server := range servers { + err := validateServerAddress(server) + if err != nil { + return nil, fmt.Errorf("server address %q invalid: %s", server, err) + } + serverAddrs = append(serverAddrs, server) + } + return &staticProvider{servers: serverAddrs}, nil +} + +func (sp *staticProvider) Addrs() ([]string, error) { + if len(sp.servers) == 0 { + return nil, fmt.Errorf("no servers configured") + } + r := make([]string, len(sp.servers)) + perm := rand.Perm(len(sp.servers)) + for i, v := range perm { + r[i] = sp.servers[v] + } + return r, nil +} + +func (sp *staticProvider) Stop() {} + +// dynamicProvider uses DNS to look up the set of IP addresses which correspond +// to its single host. It returns this list in random order when asked for +// addresses, and refreshes it regularly using a goroutine started by its +// constructor. +type dynamicProvider struct { + // dnsAuthority is the single : of the DNS + // server to be used for resolution of DNS backends. If the address contains + // a hostname it will be resolved via the system DNS. If the port is left + // unspecified it will default to '53'. If this field is left unspecified + // the system DNS will be used for resolution of DNS backends. + dnsAuthority string + // service is the service name to look up SRV records for within the domain. + // If this field is left unspecified 'dns' will be used as the service name. + service string + // proto is the IP protocol (tcp or udp) to look up SRV records for. + proto string + // domain is the name to look up SRV records within. + domain string + // A map of IP addresses (results of A record lookups for SRV Targets) to + // ports (Port fields in SRV records) associated with those addresses. + addrs map[string][]uint16 + // Other internal bookkeeping state. + cancel chan interface{} + mu sync.RWMutex + refresh time.Duration + updateCounter *prometheus.CounterVec +} + +// ParseTarget takes the user input target string and default port, returns +// formatted host and port info. If target doesn't specify a port, set the port +// to be the defaultPort. If target is in IPv6 format and host-name is enclosed +// in square brackets, brackets are stripped when setting the host. +// +// Examples: +// - target: "www.google.com" defaultPort: "443" returns host: "www.google.com", port: "443" +// - target: "ipv4-host:80" defaultPort: "443" returns host: "ipv4-host", port: "80" +// - target: "[ipv6-host]" defaultPort: "443" returns host: "ipv6-host", port: "443" +// - target: ":80" defaultPort: "443" returns host: "localhost", port: "80" +// +// This function is copied from: +// https://github.com/grpc/grpc-go/blob/master/internal/resolver/dns/dns_resolver.go +// It has been minimally modified to fit our code style. +func ParseTarget(target, defaultPort string) (host, port string, err error) { + if target == "" { + return "", "", errors.New("missing address") + } + ip := net.ParseIP(target) + if ip != nil { + // Target is an IPv4 or IPv6(without brackets) address. + return target, defaultPort, nil + } + host, port, err = net.SplitHostPort(target) + if err == nil { + if port == "" { + // If the port field is empty (target ends with colon), e.g. + // "[::1]:", this is an error. + return "", "", errors.New("missing port after port-separator colon") + } + // target has port, i.e ipv4-host:port, [ipv6-host]:port, host-name:port + if host == "" { + // Keep consistent with net.Dial(): If the host is empty, as in + // ":80", the local system is assumed. + host = "localhost" + } + return host, port, nil + } + host, port, err = net.SplitHostPort(target + ":" + defaultPort) + if err == nil { + // Target doesn't have port. + return host, port, nil + } + return "", "", fmt.Errorf("invalid target address %v, error info: %v", target, err) +} + +var _ ServerProvider = &dynamicProvider{} + +// StartDynamicProvider constructs a new dynamicProvider and starts its +// auto-update goroutine. The auto-update process queries DNS for SRV records +// at refresh intervals and uses the resulting IP/port combos to populate the +// list returned by Addrs. The update process ignores the Priority and Weight +// attributes of the SRV records. +// +// `proto` is the IP protocol (tcp or udp) to look up SRV records for. +func StartDynamicProvider(c *cmd.DNSProvider, refresh time.Duration, proto string) (*dynamicProvider, error) { + if c.SRVLookup.Domain == "" { + return nil, fmt.Errorf("'domain' cannot be empty") + } + + service := c.SRVLookup.Service + if service == "" { + // Default to "dns" if no service is specified. This is the default + // service name for DNS servers. + service = "dns" + } + + host, port, err := ParseTarget(c.DNSAuthority, "53") + if err != nil { + return nil, err + } + + dnsAuthority := net.JoinHostPort(host, port) + err = validateServerAddress(dnsAuthority) + if err != nil { + return nil, err + } + + dp := dynamicProvider{ + dnsAuthority: dnsAuthority, + service: service, + proto: proto, + domain: c.SRVLookup.Domain, + addrs: make(map[string][]uint16), + cancel: make(chan interface{}), + refresh: refresh, + updateCounter: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dns_update", + Help: "Counter of attempts to update a dynamic provider", + }, + []string{"success"}, + ), + } + + // Update once immediately, so we can know whether that was successful, then + // kick off the long-running update goroutine. + err = dp.update() + if err != nil { + return nil, fmt.Errorf("failed to start dynamic provider: %w", err) + } + go dp.run() + + return &dp, nil +} + +// run loops forever, calling dp.update() every dp.refresh interval. Does not +// halt until the dp.cancel channel is closed, so should be run in a goroutine. +func (dp *dynamicProvider) run() { + t := time.NewTicker(dp.refresh) + for { + select { + case <-t.C: + err := dp.update() + if err != nil { + dp.updateCounter.With(prometheus.Labels{ + "success": "false", + }).Inc() + continue + } + dp.updateCounter.With(prometheus.Labels{ + "success": "true", + }).Inc() + case <-dp.cancel: + return + } + } +} + +// update performs the SRV and A record queries necessary to map the given DNS +// domain name to a set of cacheable IP addresses and ports, and stores the +// results in dp.addrs. +func (dp *dynamicProvider) update() error { + ctx, cancel := context.WithTimeout(context.Background(), dp.refresh/2) + defer cancel() + + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := &net.Dialer{} + return d.DialContext(ctx, network, dp.dnsAuthority) + }, + } + + // RFC 2782 formatted SRV record being queried e.g. "_service._proto.name." + record := fmt.Sprintf("_%s._%s.%s.", dp.service, dp.proto, dp.domain) + + _, srvs, err := resolver.LookupSRV(ctx, dp.service, dp.proto, dp.domain) + if err != nil { + return fmt.Errorf("during SRV lookup of %q: %w", record, err) + } + if len(srvs) == 0 { + return fmt.Errorf("SRV lookup of %q returned 0 results", record) + } + + addrPorts := make(map[string][]uint16) + for _, srv := range srvs { + addrs, err := resolver.LookupHost(ctx, srv.Target) + if err != nil { + return fmt.Errorf("during A/AAAA lookup of target %q from SRV record %q: %w", srv.Target, record, err) + } + for _, addr := range addrs { + joinedHostPort := net.JoinHostPort(addr, fmt.Sprint(srv.Port)) + err := validateServerAddress(joinedHostPort) + if err != nil { + return fmt.Errorf("invalid addr %q from SRV record %q: %w", joinedHostPort, record, err) + } + addrPorts[addr] = append(addrPorts[addr], srv.Port) + } + } + + dp.mu.Lock() + dp.addrs = addrPorts + dp.mu.Unlock() + return nil +} + +// Addrs returns a shuffled list of IP/port pairs, with the guarantee that no +// two IP/port pairs will share the same IP. +func (dp *dynamicProvider) Addrs() ([]string, error) { + var r []string + dp.mu.RLock() + for ip, ports := range dp.addrs { + port := fmt.Sprint(ports[rand.Intn(len(ports))]) + addr := net.JoinHostPort(ip, port) + r = append(r, addr) + } + dp.mu.RUnlock() + rand.Shuffle(len(r), func(i, j int) { + r[i], r[j] = r[j], r[i] + }) + return r, nil +} + +// Stop tells the background update goroutine to cease. It does not wait for +// confirmation that it has done so. +func (dp *dynamicProvider) Stop() { + close(dp.cancel) +} diff --git a/third-party/github.com/letsencrypt/boulder/bdns/servers_test.go b/third-party/github.com/letsencrypt/boulder/bdns/servers_test.go new file mode 100644 index 000000000..5d17d8b07 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/bdns/servers_test.go @@ -0,0 +1,103 @@ +package bdns + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func Test_validateServerAddress(t *testing.T) { + type args struct { + server string + } + tests := []struct { + name string + args args + wantErr bool + }{ + // ipv4 cases + {"ipv4 with port", args{"1.1.1.1:53"}, false}, + // sad path + {"ipv4 without port", args{"1.1.1.1"}, true}, + {"ipv4 port num missing", args{"1.1.1.1:"}, true}, + {"ipv4 string for port", args{"1.1.1.1:foo"}, true}, + {"ipv4 port out of range high", args{"1.1.1.1:65536"}, true}, + {"ipv4 port out of range low", args{"1.1.1.1:0"}, true}, + + // ipv6 cases + {"ipv6 with port", args{"[2606:4700:4700::1111]:53"}, false}, + // sad path + {"ipv6 sans brackets", args{"2606:4700:4700::1111:53"}, true}, + {"ipv6 without port", args{"[2606:4700:4700::1111]"}, true}, + {"ipv6 port num missing", args{"[2606:4700:4700::1111]:"}, true}, + {"ipv6 string for port", args{"[2606:4700:4700::1111]:foo"}, true}, + {"ipv6 port out of range high", args{"[2606:4700:4700::1111]:65536"}, true}, + {"ipv6 port out of range low", args{"[2606:4700:4700::1111]:0"}, true}, + + // hostname cases + {"hostname with port", args{"foo:53"}, false}, + // sad path + {"hostname without port", args{"foo"}, true}, + {"hostname port num missing", args{"foo:"}, true}, + {"hostname string for port", args{"foo:bar"}, true}, + {"hostname port out of range high", args{"foo:65536"}, true}, + {"hostname port out of range low", args{"foo:0"}, true}, + + // fqdn cases + {"fqdn with port", args{"bar.foo.baz:53"}, false}, + // sad path + {"fqdn without port", args{"bar.foo.baz"}, true}, + {"fqdn port num missing", args{"bar.foo.baz:"}, true}, + {"fqdn string for port", args{"bar.foo.baz:bar"}, true}, + {"fqdn port out of range high", args{"bar.foo.baz:65536"}, true}, + {"fqdn port out of range low", args{"bar.foo.baz:0"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateServerAddress(tt.args.server) + if (err != nil) != tt.wantErr { + t.Errorf("formatServer() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func Test_resolveDNSAuthority(t *testing.T) { + type want struct { + host string + port string + } + tests := []struct { + name string + target string + want want + wantErr bool + }{ + {"IP4 with port", "10.10.10.10:53", want{"10.10.10.10", "53"}, false}, + {"IP4 without port", "10.10.10.10", want{"10.10.10.10", "53"}, false}, + {"IP6 with port and brackets", "[2606:4700:4700::1111]:53", want{"2606:4700:4700::1111", "53"}, false}, + {"IP6 without port", "2606:4700:4700::1111", want{"2606:4700:4700::1111", "53"}, false}, + {"IP6 with brackets without port", "[2606:4700:4700::1111]", want{"2606:4700:4700::1111", "53"}, false}, + {"hostname with port", "localhost:53", want{"localhost", "53"}, false}, + {"hostname without port", "localhost", want{"localhost", "53"}, false}, + {"only port", ":53", want{"localhost", "53"}, false}, + {"hostname with no port after colon", "localhost:", want{"", ""}, true}, + {"IP4 with no port after colon", "10.10.10.10:", want{"", ""}, true}, + {"IP6 with no port after colon", "[2606:4700:4700::1111]:", want{"", ""}, true}, + {"no hostname or port", "", want{"", ""}, true}, + {"invalid addr", "foo:bar:baz", want{"", ""}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotPort, gotErr := ParseTarget(tt.target, "53") + test.AssertEquals(t, gotHost, tt.want.host) + test.AssertEquals(t, gotPort, tt.want.port) + if tt.wantErr { + test.AssertError(t, gotErr, "expected error") + } else { + test.AssertNotError(t, gotErr, "unexpected error") + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ca.go b/third-party/github.com/letsencrypt/boulder/ca/ca.go new file mode 100644 index 000000000..239a5a4c3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ca.go @@ -0,0 +1,713 @@ +package ca + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/gob" + "encoding/hex" + "errors" + "fmt" + "math/big" + mrand "math/rand" + "strings" + "time" + + ct "github.com/google/certificate-transparency-go" + cttls "github.com/google/certificate-transparency-go/tls" + "github.com/jmhodges/clock" + "github.com/miekg/pkcs11" + "github.com/prometheus/client_golang/prometheus" + "github.com/zmap/zlint/v3/lint" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + "golang.org/x/crypto/ocsp" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + csrlib "github.com/letsencrypt/boulder/csr" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/linter" + blog "github.com/letsencrypt/boulder/log" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type certificateType string + +const ( + precertType = certificateType("precertificate") + certType = certificateType("certificate") +) + +// Two maps of keys to Issuers. Lookup by PublicKeyAlgorithm is useful for +// determining the set of issuers which can sign a given (pre)cert, based on its +// PublicKeyAlgorithm. Lookup by NameID is useful for looking up a specific +// issuer based on the issuer of a given (pre)certificate. +type issuerMaps struct { + byAlg map[x509.PublicKeyAlgorithm][]*issuance.Issuer + byNameID map[issuance.NameID]*issuance.Issuer +} + +type certProfileWithID struct { + // name is a human readable name used to refer to the certificate profile. + name string + // hash is SHA256 sum over every exported field of an issuance.ProfileConfig + // used to generate the embedded *issuance.Profile. + hash [32]byte + profile *issuance.Profile +} + +// certProfilesMaps allows looking up the human-readable name of a certificate +// profile to retrieve the actual profile. The default profile to be used is +// stored alongside the maps. +type certProfilesMaps struct { + // The name of the profile that will be selected if no explicit profile name + // is provided via gRPC. + defaultName string + + profileByHash map[[32]byte]*certProfileWithID + profileByName map[string]*certProfileWithID +} + +// caMetrics holds various metrics which are shared between caImpl, ocspImpl, +// and crlImpl. +type caMetrics struct { + signatureCount *prometheus.CounterVec + signErrorCount *prometheus.CounterVec + lintErrorCount prometheus.Counter +} + +func NewCAMetrics(stats prometheus.Registerer) *caMetrics { + signatureCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "signatures", + Help: "Number of signatures", + }, + []string{"purpose", "issuer"}) + stats.MustRegister(signatureCount) + + signErrorCount := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "signature_errors", + Help: "A counter of signature errors labelled by error type", + }, []string{"type"}) + stats.MustRegister(signErrorCount) + + lintErrorCount := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "lint_errors", + Help: "Number of issuances that were halted by linting errors", + }) + stats.MustRegister(lintErrorCount) + + return &caMetrics{signatureCount, signErrorCount, lintErrorCount} +} + +func (m *caMetrics) noteSignError(err error) { + var pkcs11Error pkcs11.Error + if errors.As(err, &pkcs11Error) { + m.signErrorCount.WithLabelValues("HSM").Inc() + } +} + +// certificateAuthorityImpl represents a CA that signs certificates. +// It can sign OCSP responses as well, but only via delegation to an ocspImpl. +type certificateAuthorityImpl struct { + capb.UnsafeCertificateAuthorityServer + sa sapb.StorageAuthorityCertificateClient + pa core.PolicyAuthority + issuers issuerMaps + certProfiles certProfilesMaps + + // This is temporary, and will be used for testing and slow roll-out + // of ECDSA issuance, but will then be removed. + ecdsaAllowList *ECDSAAllowList + prefix int // Prepended to the serial number + validityPeriod time.Duration + backdate time.Duration + maxNames int + keyPolicy goodkey.KeyPolicy + clk clock.Clock + log blog.Logger + metrics *caMetrics +} + +var _ capb.CertificateAuthorityServer = (*certificateAuthorityImpl)(nil) + +// makeIssuerMaps processes a list of issuers into a set of maps for easy +// lookup either by key algorithm (useful for picking an issuer for a precert) +// or by unique ID (useful for final certs, OCSP, and CRLs). If two issuers with +// the same unique ID are encountered, an error is returned. +func makeIssuerMaps(issuers []*issuance.Issuer) (issuerMaps, error) { + issuersByAlg := make(map[x509.PublicKeyAlgorithm][]*issuance.Issuer, 2) + issuersByNameID := make(map[issuance.NameID]*issuance.Issuer, len(issuers)) + for _, issuer := range issuers { + if _, found := issuersByNameID[issuer.NameID()]; found { + return issuerMaps{}, fmt.Errorf("two issuers with same NameID %d (%s) configured", issuer.NameID(), issuer.Name()) + } + issuersByNameID[issuer.NameID()] = issuer + if issuer.IsActive() { + issuersByAlg[issuer.KeyType()] = append(issuersByAlg[issuer.KeyType()], issuer) + } + } + if i, ok := issuersByAlg[x509.ECDSA]; !ok || len(i) == 0 { + return issuerMaps{}, errors.New("no ECDSA issuers configured") + } + if i, ok := issuersByAlg[x509.RSA]; !ok || len(i) == 0 { + return issuerMaps{}, errors.New("no RSA issuers configured") + } + return issuerMaps{issuersByAlg, issuersByNameID}, nil +} + +// makeCertificateProfilesMap processes a set of named certificate issuance +// profile configs into a two pre-computed maps: 1) a human-readable name to the +// profile and 2) a unique hash over contents of the profile to the profile +// itself. It returns the maps or an error if a duplicate name or hash is found. +// It also associates the given lint registry with each profile. +// +// The unique hash is used in the case of +// - RA instructs CA1 to issue a precertificate +// - CA1 returns the precertificate DER bytes and profile hash to the RA +// - RA instructs CA2 to issue a final certificate, but CA2 does not contain a +// profile corresponding to that hash and an issuance is prevented. +func makeCertificateProfilesMap(defaultName string, profiles map[string]issuance.ProfileConfig, lints lint.Registry) (certProfilesMaps, error) { + if len(profiles) <= 0 { + return certProfilesMaps{}, fmt.Errorf("must pass at least one certificate profile") + } + + // Check that a profile exists with the configured default profile name. + _, ok := profiles[defaultName] + if !ok { + return certProfilesMaps{}, fmt.Errorf("defaultCertificateProfileName:\"%s\" was configured, but a profile object was not found for that name", defaultName) + } + + profileByName := make(map[string]*certProfileWithID, len(profiles)) + profileByHash := make(map[[32]byte]*certProfileWithID, len(profiles)) + + for name, profileConfig := range profiles { + profile, err := issuance.NewProfile(profileConfig, lints) + if err != nil { + return certProfilesMaps{}, err + } + + // gob can only encode exported fields, of which an issuance.Profile has + // none. However, since we're already in a loop iteration having access + // to the issuance.ProfileConfig used to generate the issuance.Profile, + // we'll generate the hash from that. + var encodedProfile bytes.Buffer + enc := gob.NewEncoder(&encodedProfile) + err = enc.Encode(profileConfig) + if err != nil { + return certProfilesMaps{}, err + } + if len(encodedProfile.Bytes()) <= 0 { + return certProfilesMaps{}, fmt.Errorf("certificate profile encoding returned 0 bytes") + } + hash := sha256.Sum256(encodedProfile.Bytes()) + + _, ok := profileByName[name] + if !ok { + profileByName[name] = &certProfileWithID{ + name: name, + hash: hash, + profile: profile, + } + } else { + return certProfilesMaps{}, fmt.Errorf("duplicate certificate profile name %s", name) + } + + _, ok = profileByHash[hash] + if !ok { + profileByHash[hash] = &certProfileWithID{ + name: name, + hash: hash, + profile: profile, + } + } else { + return certProfilesMaps{}, fmt.Errorf("duplicate certificate profile hash %d", hash) + } + } + + return certProfilesMaps{defaultName, profileByHash, profileByName}, nil +} + +// NewCertificateAuthorityImpl creates a CA instance that can sign certificates +// from any number of issuance.Issuers according to their profiles, and can sign +// OCSP (via delegation to an ocspImpl and its issuers). +func NewCertificateAuthorityImpl( + sa sapb.StorageAuthorityCertificateClient, + pa core.PolicyAuthority, + boulderIssuers []*issuance.Issuer, + defaultCertProfileName string, + certificateProfiles map[string]issuance.ProfileConfig, + lints lint.Registry, + ecdsaAllowList *ECDSAAllowList, + certExpiry time.Duration, + certBackdate time.Duration, + serialPrefix int, + maxNames int, + keyPolicy goodkey.KeyPolicy, + logger blog.Logger, + metrics *caMetrics, + clk clock.Clock, +) (*certificateAuthorityImpl, error) { + var ca *certificateAuthorityImpl + var err error + + // TODO(briansmith): Make the backdate setting mandatory after the + // production ca.json has been updated to include it. Until then, manually + // default to 1h, which is the backdating duration we currently use. + if certBackdate == 0 { + certBackdate = time.Hour + } + + if serialPrefix < 1 || serialPrefix > 127 { + err = errors.New("serial prefix must be between 1 and 127") + return nil, err + } + + if len(boulderIssuers) == 0 { + return nil, errors.New("must have at least one issuer") + } + + certProfiles, err := makeCertificateProfilesMap(defaultCertProfileName, certificateProfiles, lints) + if err != nil { + return nil, err + } + + issuers, err := makeIssuerMaps(boulderIssuers) + if err != nil { + return nil, err + } + + ca = &certificateAuthorityImpl{ + sa: sa, + pa: pa, + issuers: issuers, + certProfiles: certProfiles, + validityPeriod: certExpiry, + backdate: certBackdate, + prefix: serialPrefix, + maxNames: maxNames, + keyPolicy: keyPolicy, + log: logger, + metrics: metrics, + clk: clk, + ecdsaAllowList: ecdsaAllowList, + } + + return ca, nil +} + +var ocspStatusToCode = map[string]int{ + "good": ocsp.Good, + "revoked": ocsp.Revoked, + "unknown": ocsp.Unknown, +} + +// IssuePrecertificate is the first step in the [issuance cycle]. It allocates and stores a serial number, +// selects a certificate profile, generates and stores a linting certificate, sets the serial's status to +// "wait", signs and stores a precertificate, updates the serial's status to "good", then returns the +// precertificate. +// +// Subsequent final issuance based on this precertificate must happen at most once, and must use the same +// certificate profile. The certificate profile is identified by a hash to ensure an exact match even if +// the configuration for a specific profile _name_ changes. +// +// [issuance cycle]: https://github.com/letsencrypt/boulder/blob/main/docs/ISSUANCE-CYCLE.md +func (ca *certificateAuthorityImpl) IssuePrecertificate(ctx context.Context, issueReq *capb.IssueCertificateRequest) (*capb.IssuePrecertificateResponse, error) { + // issueReq.orderID may be zero, for ACMEv1 requests. + // issueReq.CertProfileName may be empty and will be populated in + // issuePrecertificateInner if so. + if core.IsAnyNilOrZero(issueReq, issueReq.Csr, issueReq.RegistrationID) { + return nil, berrors.InternalServerError("Incomplete issue certificate request") + } + + serialBigInt, validity, err := ca.generateSerialNumberAndValidity() + if err != nil { + return nil, err + } + + serialHex := core.SerialToString(serialBigInt) + regID := issueReq.RegistrationID + _, err = ca.sa.AddSerial(ctx, &sapb.AddSerialRequest{ + Serial: serialHex, + RegID: regID, + Created: timestamppb.New(ca.clk.Now()), + Expires: timestamppb.New(validity.NotAfter), + }) + if err != nil { + return nil, err + } + + precertDER, cpwid, err := ca.issuePrecertificateInner(ctx, issueReq, serialBigInt, validity) + if err != nil { + return nil, err + } + + _, err = ca.sa.SetCertificateStatusReady(ctx, &sapb.Serial{Serial: serialHex}) + if err != nil { + return nil, err + } + + return &capb.IssuePrecertificateResponse{ + DER: precertDER, + CertProfileName: cpwid.name, + CertProfileHash: cpwid.hash[:], + }, nil +} + +// IssueCertificateForPrecertificate final step in the [issuance cycle]. +// +// Given a precertificate and a set of SCTs for that precertificate, it generates +// a linting final certificate, then signs a final certificate using a real issuer. +// The poison extension is removed from the precertificate and a +// SCT list extension is inserted in its place. Except for this and the +// signature the final certificate exactly matches the precertificate. +// +// It's critical not to sign two different final certificates for the same +// precertificate. This can happen, for instance, if the caller provides a +// different set of SCTs on subsequent calls to IssueCertificateForPrecertificate. +// We rely on the RA not to call IssueCertificateForPrecertificate twice for the +// same serial. This is accomplished by the fact that +// IssueCertificateForPrecertificate is only ever called in a straight-through +// RPC path without retries. If there is any error, including a networking +// error, the whole certificate issuance attempt fails and any subsequent +// issuance will use a different serial number. +// +// We also check that the provided serial number does not already exist as a +// final certificate, but this is just a belt-and-suspenders measure, since +// there could be race conditions where two goroutines are issuing for the same +// serial number at the same time. +// +// [issuance cycle]: https://github.com/letsencrypt/boulder/blob/main/docs/ISSUANCE-CYCLE.md +func (ca *certificateAuthorityImpl) IssueCertificateForPrecertificate(ctx context.Context, req *capb.IssueCertificateForPrecertificateRequest) (*corepb.Certificate, error) { + // issueReq.orderID may be zero, for ACMEv1 requests. + if core.IsAnyNilOrZero(req, req.DER, req.SCTs, req.RegistrationID, req.CertProfileHash) { + return nil, berrors.InternalServerError("Incomplete cert for precertificate request") + } + + // The certificate profile hash is checked here instead of the name because + // the hash is over the entire contents of a *ProfileConfig giving assurance + // that the certificate profile has remained unchanged during the roundtrip + // from a CA, to the RA, then back to a (potentially different) CA node. + certProfile, ok := ca.certProfiles.profileByHash[[32]byte(req.CertProfileHash)] + if !ok { + return nil, fmt.Errorf("the CA is incapable of using a profile with hash %d", req.CertProfileHash) + } + + precert, err := x509.ParseCertificate(req.DER) + if err != nil { + return nil, err + } + + serialHex := core.SerialToString(precert.SerialNumber) + if _, err = ca.sa.GetCertificate(ctx, &sapb.Serial{Serial: serialHex}); err == nil { + err = berrors.InternalServerError("issuance of duplicate final certificate requested: %s", serialHex) + ca.log.AuditErr(err.Error()) + return nil, err + } else if !errors.Is(err, berrors.NotFound) { + return nil, fmt.Errorf("error checking for duplicate issuance of %s: %s", serialHex, err) + } + var scts []ct.SignedCertificateTimestamp + for _, sctBytes := range req.SCTs { + var sct ct.SignedCertificateTimestamp + _, err = cttls.Unmarshal(sctBytes, &sct) + if err != nil { + return nil, err + } + scts = append(scts, sct) + } + + issuer, ok := ca.issuers.byNameID[issuance.IssuerNameID(precert)] + if !ok { + return nil, berrors.InternalServerError("no issuer found for Issuer Name %s", precert.Issuer) + } + + issuanceReq, err := issuance.RequestFromPrecert(precert, scts) + if err != nil { + return nil, err + } + + names := strings.Join(issuanceReq.DNSNames, ", ") + ca.log.AuditInfof("Signing cert: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certProfileName=[%s] certProfileHash=[%x] precert=[%s]", + issuer.Name(), serialHex, req.RegistrationID, names, certProfile.name, certProfile.hash, hex.EncodeToString(precert.Raw)) + + lintCertBytes, issuanceToken, err := issuer.Prepare(certProfile.profile, issuanceReq) + if err != nil { + ca.log.AuditErrf("Preparing cert failed: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certProfileName=[%s] certProfileHash=[%x] err=[%v]", + issuer.Name(), serialHex, req.RegistrationID, names, certProfile.name, certProfile.hash, err) + return nil, berrors.InternalServerError("failed to prepare certificate signing: %s", err) + } + + certDER, err := issuer.Issue(issuanceToken) + if err != nil { + ca.metrics.noteSignError(err) + ca.log.AuditErrf("Signing cert failed: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certProfileName=[%s] certProfileHash=[%x] err=[%v]", + issuer.Name(), serialHex, req.RegistrationID, names, certProfile.name, certProfile.hash, err) + return nil, berrors.InternalServerError("failed to sign certificate: %s", err) + } + + err = tbsCertIsDeterministic(lintCertBytes, certDER) + if err != nil { + return nil, err + } + + ca.metrics.signatureCount.With(prometheus.Labels{"purpose": string(certType), "issuer": issuer.Name()}).Inc() + ca.log.AuditInfof("Signing cert success: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certificate=[%s] certProfileName=[%s] certProfileHash=[%x]", + issuer.Name(), serialHex, req.RegistrationID, names, hex.EncodeToString(certDER), certProfile.name, certProfile.hash) + + _, err = ca.sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: certDER, + RegID: req.RegistrationID, + Issued: timestamppb.New(ca.clk.Now()), + }) + if err != nil { + ca.log.AuditErrf("Failed RPC to store at SA: issuer=[%s] serial=[%s] cert=[%s] regID=[%d] orderID=[%d] certProfileName=[%s] certProfileHash=[%x] err=[%v]", + issuer.Name(), serialHex, hex.EncodeToString(certDER), req.RegistrationID, req.OrderID, certProfile.name, certProfile.hash, err) + return nil, err + } + + return &corepb.Certificate{ + RegistrationID: req.RegistrationID, + Serial: core.SerialToString(precert.SerialNumber), + Der: certDER, + Digest: core.Fingerprint256(certDER), + Issued: timestamppb.New(precert.NotBefore), + Expires: timestamppb.New(precert.NotAfter), + }, nil +} + +type validity struct { + NotBefore time.Time + NotAfter time.Time +} + +func (ca *certificateAuthorityImpl) generateSerialNumberAndValidity() (*big.Int, validity, error) { + // We want 136 bits of random number, plus an 8-bit instance id prefix. + const randBits = 136 + serialBytes := make([]byte, randBits/8+1) + serialBytes[0] = byte(ca.prefix) + _, err := rand.Read(serialBytes[1:]) + if err != nil { + err = berrors.InternalServerError("failed to generate serial: %s", err) + ca.log.AuditErrf("Serial randomness failed, err=[%v]", err) + return nil, validity{}, err + } + serialBigInt := big.NewInt(0) + serialBigInt = serialBigInt.SetBytes(serialBytes) + + notBefore := ca.clk.Now().Add(-ca.backdate) + validity := validity{ + NotBefore: notBefore, + NotAfter: notBefore.Add(ca.validityPeriod - time.Second), + } + + return serialBigInt, validity, nil +} + +// generateSKID computes the Subject Key Identifier using one of the methods in +// RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: +// The keyIdentifier [may be] composed of the leftmost 160-bits of the +// SHA-256 hash of the value of the BIT STRING subjectPublicKey +// (excluding the tag, length, and number of unused bits). +func generateSKID(pk crypto.PublicKey) ([]byte, error) { + pkBytes, err := x509.MarshalPKIXPublicKey(pk) + if err != nil { + return nil, err + } + + var pkixPublicKey struct { + Algo pkix.AlgorithmIdentifier + BitString asn1.BitString + } + if _, err := asn1.Unmarshal(pkBytes, &pkixPublicKey); err != nil { + return nil, err + } + + skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) + return skid[0:20:20], nil +} + +func (ca *certificateAuthorityImpl) issuePrecertificateInner(ctx context.Context, issueReq *capb.IssueCertificateRequest, serialBigInt *big.Int, validity validity) ([]byte, *certProfileWithID, error) { + // The CA must check if it is capable of issuing for the given certificate + // profile name. The name is checked here instead of the hash because the RA + // is unaware of what certificate profiles exist. Pre-existing orders stored + // in the database may not have an associated certificate profile name and + // will take the default name stored alongside the map. + if issueReq.CertProfileName == "" { + issueReq.CertProfileName = ca.certProfiles.defaultName + } + certProfile, ok := ca.certProfiles.profileByName[issueReq.CertProfileName] + if !ok { + return nil, nil, fmt.Errorf("the CA is incapable of using a profile named %s", issueReq.CertProfileName) + } + + csr, err := x509.ParseCertificateRequest(issueReq.Csr) + if err != nil { + return nil, nil, err + } + + err = csrlib.VerifyCSR(ctx, csr, ca.maxNames, &ca.keyPolicy, ca.pa) + if err != nil { + ca.log.AuditErr(err.Error()) + // VerifyCSR returns berror instances that can be passed through as-is + // without wrapping. + return nil, nil, err + } + + // Select which pool of issuers to use, based on the to-be-issued cert's key + // type and whether we're using the ECDSA Allow List. + alg := csr.PublicKeyAlgorithm + if alg == x509.ECDSA && !features.Get().ECDSAForAll && ca.ecdsaAllowList != nil && !ca.ecdsaAllowList.permitted(issueReq.RegistrationID) { + alg = x509.RSA + } + + // Select a random issuer from among the active issuers of this key type. + issuerPool, ok := ca.issuers.byAlg[alg] + if !ok || len(issuerPool) == 0 { + return nil, nil, berrors.InternalServerError("no issuers found for public key algorithm %s", csr.PublicKeyAlgorithm) + } + issuer := issuerPool[mrand.Intn(len(issuerPool))] + + if issuer.Cert.NotAfter.Before(validity.NotAfter) { + err = berrors.InternalServerError("cannot issue a certificate that expires after the issuer certificate") + ca.log.AuditErr(err.Error()) + return nil, nil, err + } + + subjectKeyId, err := generateSKID(csr.PublicKey) + if err != nil { + return nil, nil, fmt.Errorf("computing subject key ID: %w", err) + } + + serialHex := core.SerialToString(serialBigInt) + + ca.log.AuditInfof("Signing precert: serial=[%s] regID=[%d] names=[%s] csr=[%s]", + serialHex, issueReq.RegistrationID, strings.Join(csr.DNSNames, ", "), hex.EncodeToString(csr.Raw)) + + names := csrlib.NamesFromCSR(csr) + req := &issuance.IssuanceRequest{ + PublicKey: csr.PublicKey, + SubjectKeyId: subjectKeyId, + Serial: serialBigInt.Bytes(), + DNSNames: names.SANs, + CommonName: names.CN, + IncludeCTPoison: true, + IncludeMustStaple: issuance.ContainsMustStaple(csr.Extensions), + NotBefore: validity.NotBefore, + NotAfter: validity.NotAfter, + } + + lintCertBytes, issuanceToken, err := issuer.Prepare(certProfile.profile, req) + if err != nil { + ca.log.AuditErrf("Preparing precert failed: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certProfileName=[%s] certProfileHash=[%x] err=[%v]", + issuer.Name(), serialHex, issueReq.RegistrationID, strings.Join(csr.DNSNames, ", "), certProfile.name, certProfile.hash, err) + if errors.Is(err, linter.ErrLinting) { + ca.metrics.lintErrorCount.Inc() + } + return nil, nil, berrors.InternalServerError("failed to prepare precertificate signing: %s", err) + } + + _, err = ca.sa.AddPrecertificate(context.Background(), &sapb.AddCertificateRequest{ + Der: lintCertBytes, + RegID: issueReq.RegistrationID, + Issued: timestamppb.New(ca.clk.Now()), + IssuerNameID: int64(issuer.NameID()), + OcspNotReady: true, + }) + if err != nil { + return nil, nil, err + } + + certDER, err := issuer.Issue(issuanceToken) + if err != nil { + ca.metrics.noteSignError(err) + ca.log.AuditErrf("Signing precert failed: issuer=[%s] serial=[%s] regID=[%d] names=[%s] certProfileName=[%s] certProfileHash=[%x] err=[%v]", + issuer.Name(), serialHex, issueReq.RegistrationID, strings.Join(csr.DNSNames, ", "), certProfile.name, certProfile.hash, err) + return nil, nil, berrors.InternalServerError("failed to sign precertificate: %s", err) + } + + err = tbsCertIsDeterministic(lintCertBytes, certDER) + if err != nil { + return nil, nil, err + } + + ca.metrics.signatureCount.With(prometheus.Labels{"purpose": string(precertType), "issuer": issuer.Name()}).Inc() + ca.log.AuditInfof("Signing precert success: issuer=[%s] serial=[%s] regID=[%d] names=[%s] precertificate=[%s] certProfileName=[%s] certProfileHash=[%x]", + issuer.Name(), serialHex, issueReq.RegistrationID, strings.Join(csr.DNSNames, ", "), hex.EncodeToString(certDER), certProfile.name, certProfile.hash) + + return certDER, &certProfileWithID{certProfile.name, certProfile.hash, nil}, nil +} + +// verifyTBSCertIsDeterministic verifies that x509.CreateCertificate signing +// operation is deterministic and produced identical DER bytes between the given +// lint certificate and leaf certificate. If the DER byte equality check fails +// it's mississuance, but it's better to know about the problem sooner than +// later. The caller is responsible for passing the appropriate valid +// certificate bytes in the correct position. +func tbsCertIsDeterministic(lintCertBytes []byte, leafCertBytes []byte) error { + if core.IsAnyNilOrZero(lintCertBytes, leafCertBytes) { + return fmt.Errorf("lintCertBytes of leafCertBytes were nil") + } + + // extractTBSCertBytes is a partial copy of //crypto/x509/parser.go to + // extract the RawTBSCertificate field from given DER bytes. It the + // RawTBSCertificate field bytes or an error if the given bytes cannot be + // parsed. This is far more performant than parsing the entire *Certificate + // structure with x509.ParseCertificate(). + // + // RFC 5280, Section 4.1 + // Certificate ::= SEQUENCE { + // tbsCertificate TBSCertificate, + // signatureAlgorithm AlgorithmIdentifier, + // signatureValue BIT STRING } + // + // TBSCertificate ::= SEQUENCE { + // .. + extractTBSCertBytes := func(inputDERBytes *[]byte) ([]byte, error) { + input := cryptobyte.String(*inputDERBytes) + + // Extract the Certificate bytes + if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) { + return nil, errors.New("malformed certificate") + } + + var tbs cryptobyte.String + // Extract the TBSCertificate bytes from the Certificate bytes + if !input.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return nil, errors.New("malformed tbs certificate") + } + + if tbs.Empty() { + return nil, errors.New("parsed RawTBSCertificate field was empty") + } + + return tbs, nil + } + + lintRawTBSCert, err := extractTBSCertBytes(&lintCertBytes) + if err != nil { + return fmt.Errorf("while extracting lint TBS cert: %w", err) + } + + leafRawTBSCert, err := extractTBSCertBytes(&leafCertBytes) + if err != nil { + return fmt.Errorf("while extracting leaf TBS cert: %w", err) + } + + if !bytes.Equal(lintRawTBSCert, leafRawTBSCert) { + return fmt.Errorf("mismatch between lintCert and leafCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintRawTBSCert, leafRawTBSCert) + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ca_test.go b/third-party/github.com/letsencrypt/boulder/ca/ca_test.go new file mode 100644 index 000000000..e016ff505 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ca_test.go @@ -0,0 +1,1385 @@ +package ca + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "os" + "strings" + "testing" + "time" + + ct "github.com/google/certificate-transparency-go" + cttls "github.com/google/certificate-transparency-go/tls" + ctx509 "github.com/google/certificate-transparency-go/x509" + "github.com/jmhodges/clock" + "github.com/miekg/pkcs11" + "github.com/prometheus/client_golang/prometheus" + "github.com/zmap/zlint/v3/lint" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/linter" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/must" + "github.com/letsencrypt/boulder/policy" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +var ( + // * Random public key + // * CN = not-example.com + // * DNSNames = not-example.com, www.not-example.com + CNandSANCSR = mustRead("./testdata/cn_and_san.der.csr") + + // CSR generated by Go: + // * Random public key + // * CN = not-example.com + // * Includes an extensionRequest attribute for a well-formed TLS Feature extension + MustStapleCSR = mustRead("./testdata/must_staple.der.csr") + + // CSR generated by Go: + // * Random public key + // * CN = not-example.com + // * Includes an extensionRequest attribute for an unknown extension with an + // empty value. That extension's OID, 2.25.123456789, is on the UUID arc. + // It isn't a real randomly-generated UUID because Go represents the + // components of the OID as 32-bit integers, which aren't large enough to + // hold a real 128-bit UUID; this doesn't matter as far as what we're + // testing here is concerned. + UnsupportedExtensionCSR = mustRead("./testdata/unsupported_extension.der.csr") + + // CSR generated by Go: + // * Random public key + // * CN = not-example.com + // * Includes an extensionRequest attribute for the CT poison extension + // with a valid NULL value. + CTPoisonExtensionCSR = mustRead("./testdata/ct_poison_extension.der.csr") + + // CSR generated by Go: + // * Random public key + // * CN = not-example.com + // * Includes an extensionRequest attribute for the CT poison extension + // with an invalid empty value. + CTPoisonExtensionEmptyCSR = mustRead("./testdata/ct_poison_extension_empty.der.csr") + + // CSR generated by Go: + // * Random ECDSA public key. + // * CN = [none] + // * DNSNames = example.com, example2.com + ECDSACSR = mustRead("./testdata/ecdsa.der.csr") + + // OIDExtensionCTPoison is defined in RFC 6962 s3.1. + OIDExtensionCTPoison = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} + + // OIDExtensionSCTList is defined in RFC 6962 s3.3. + OIDExtensionSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} +) + +const arbitraryRegID int64 = 1001 + +func mustRead(path string) []byte { + return must.Do(os.ReadFile(path)) +} + +type testCtx struct { + pa core.PolicyAuthority + ocsp *ocspImpl + crl *crlImpl + defaultCertProfileName string + lints lint.Registry + certProfiles map[string]issuance.ProfileConfig + certExpiry time.Duration + certBackdate time.Duration + serialPrefix int + maxNames int + boulderIssuers []*issuance.Issuer + keyPolicy goodkey.KeyPolicy + fc clock.FakeClock + metrics *caMetrics + logger *blog.Mock +} + +type mockSA struct { + certificate core.Certificate +} + +func (m *mockSA) AddCertificate(ctx context.Context, req *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + m.certificate.DER = req.Der + return nil, nil +} + +func (m *mockSA) AddPrecertificate(ctx context.Context, req *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *mockSA) AddSerial(ctx context.Context, req *sapb.AddSerialRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *mockSA) GetCertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, berrors.NotFoundError("cannot find the cert") +} + +func (m *mockSA) GetLintPrecertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, berrors.NotFoundError("cannot find the precert") +} + +func (m *mockSA) SetCertificateStatusReady(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +var ctx = context.Background() + +func setup(t *testing.T) *testCtx { + features.Reset() + fc := clock.NewFake() + fc.Add(1 * time.Hour) + + pa, err := policy.New(nil, blog.NewMock()) + test.AssertNotError(t, err, "Couldn't create PA") + err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml") + test.AssertNotError(t, err, "Couldn't set hostname policy") + + certProfiles := make(map[string]issuance.ProfileConfig, 0) + certProfiles["defaultBoulderCertificateProfile"] = issuance.ProfileConfig{ + AllowMustStaple: true, + AllowCTPoison: true, + AllowSCTList: true, + AllowCommonName: true, + Policies: []issuance.PolicyConfig{ + {OID: "2.23.140.1.2.1"}, + }, + MaxValidityPeriod: config.Duration{Duration: time.Hour * 8760}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } + certProfiles["longerLived"] = issuance.ProfileConfig{ + AllowMustStaple: true, + AllowCTPoison: true, + AllowSCTList: true, + AllowCommonName: true, + Policies: []issuance.PolicyConfig{ + {OID: "2.23.140.1.2.1"}, + }, + MaxValidityPeriod: config.Duration{Duration: time.Hour * 8761}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } + test.AssertEquals(t, len(certProfiles), 2) + + boulderIssuers := make([]*issuance.Issuer, 4) + for i, name := range []string{"int-r3", "int-r4", "int-e1", "int-e2"} { + boulderIssuers[i], err = issuance.LoadIssuer(issuance.IssuerConfig{ + Active: true, + IssuerURL: fmt.Sprintf("http://not-example.com/i/%s", name), + OCSPURL: "http://not-example.com/o", + CRLURLBase: fmt.Sprintf("http://not-example.com/c/%s/", name), + Location: issuance.IssuerLoc{ + File: fmt.Sprintf("../test/hierarchy/%s.key.pem", name), + CertFile: fmt.Sprintf("../test/hierarchy/%s.cert.pem", name), + }, + }, fc) + test.AssertNotError(t, err, "Couldn't load test issuer") + } + + keyPolicy, err := goodkey.NewPolicy(nil, nil) + test.AssertNotError(t, err, "Failed to create test keypolicy") + + signatureCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "signatures", + Help: "Number of signatures", + }, + []string{"purpose", "issuer"}) + signErrorCount := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "signature_errors", + Help: "A counter of signature errors labelled by error type", + }, []string{"type"}) + lintErrorCount := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "lint_errors", + Help: "Number of issuances that were halted by linting errors", + }) + cametrics := &caMetrics{signatureCount, signErrorCount, lintErrorCount} + + lints, err := linter.NewRegistry([]string{"w_subject_common_name_included"}) + test.AssertNotError(t, err, "Failed to create zlint registry") + + ocsp, err := NewOCSPImpl( + boulderIssuers, + 24*time.Hour, + 0, + time.Second, + blog.NewMock(), + metrics.NoopRegisterer, + cametrics, + fc, + ) + test.AssertNotError(t, err, "Failed to create ocsp impl") + + crl, err := NewCRLImpl( + boulderIssuers, + issuance.CRLProfileConfig{ + ValidityInterval: config.Duration{Duration: 216 * time.Hour}, + MaxBackdate: config.Duration{Duration: time.Hour}, + }, + 100, + blog.NewMock(), + cametrics, + ) + test.AssertNotError(t, err, "Failed to create crl impl") + + return &testCtx{ + pa: pa, + ocsp: ocsp, + crl: crl, + defaultCertProfileName: "defaultBoulderCertificateProfile", + lints: lints, + certProfiles: certProfiles, + certExpiry: 8760 * time.Hour, + certBackdate: time.Hour, + serialPrefix: 17, + maxNames: 2, + boulderIssuers: boulderIssuers, + keyPolicy: keyPolicy, + fc: fc, + metrics: cametrics, + logger: blog.NewMock(), + } +} + +func TestSerialPrefix(t *testing.T) { + t.Parallel() + testCtx := setup(t) + + _, err := NewCertificateAuthorityImpl( + nil, + nil, + nil, + "", + nil, + nil, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + 0, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + nil, + testCtx.fc) + test.AssertError(t, err, "CA should have failed with no SerialPrefix") + + _, err = NewCertificateAuthorityImpl( + nil, + nil, + nil, + "", + nil, + nil, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + 128, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + nil, + testCtx.fc) + test.AssertError(t, err, "CA should have failed with too-large SerialPrefix") +} + +func TestNoteSignError(t *testing.T) { + testCtx := setup(t) + metrics := testCtx.metrics + + err := fmt.Errorf("wrapped non-signing error: %w", errors.New("oops")) + metrics.noteSignError(err) + test.AssertMetricWithLabelsEquals(t, metrics.signErrorCount, prometheus.Labels{"type": "HSM"}, 0) + + err = fmt.Errorf("wrapped signing error: %w", pkcs11.Error(5)) + metrics.noteSignError(err) + test.AssertMetricWithLabelsEquals(t, metrics.signErrorCount, prometheus.Labels{"type": "HSM"}, 1) +} + +type TestCertificateIssuance struct { + ca *certificateAuthorityImpl + sa *mockSA + req *x509.CertificateRequest + certDER []byte + cert *x509.Certificate +} + +func TestIssuePrecertificate(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + csr []byte + subTest func(t *testing.T, i *TestCertificateIssuance) + }{ + {"IssuePrecertificate", CNandSANCSR, issueCertificateSubTestIssuePrecertificate}, + {"ValidityUsesCAClock", CNandSANCSR, issueCertificateSubTestValidityUsesCAClock}, + {"ProfileSelectionRSA", CNandSANCSR, issueCertificateSubTestProfileSelectionRSA}, + {"ProfileSelectionECDSA", ECDSACSR, issueCertificateSubTestProfileSelectionECDSA}, + {"MustStaple", MustStapleCSR, issueCertificateSubTestMustStaple}, + {"UnknownExtension", UnsupportedExtensionCSR, issueCertificateSubTestUnknownExtension}, + {"CTPoisonExtension", CTPoisonExtensionCSR, issueCertificateSubTestCTPoisonExtension}, + {"CTPoisonExtensionEmpty", CTPoisonExtensionEmptyCSR, issueCertificateSubTestCTPoisonExtension}, + } + + for _, testCase := range testCases { + // TODO(#7454) Remove this rebinding + testCase := testCase + + // The loop through the issuance modes must be inside the loop through + // |testCases| because the "certificate-for-precertificate" tests use + // the precertificates previously generated from the preceding + // "precertificate" test. + for _, mode := range []string{"precertificate", "certificate-for-precertificate"} { + ca, sa := issueCertificateSubTestSetup(t, nil) + t.Run(fmt.Sprintf("%s - %s", mode, testCase.name), func(t *testing.T) { + t.Parallel() + req, err := x509.ParseCertificateRequest(testCase.csr) + test.AssertNotError(t, err, "Certificate request failed to parse") + issueReq := &capb.IssueCertificateRequest{Csr: testCase.csr, RegistrationID: arbitraryRegID} + + var certDER []byte + response, err := ca.IssuePrecertificate(ctx, issueReq) + + test.AssertNotError(t, err, "Failed to issue precertificate") + certDER = response.DER + + cert, err := x509.ParseCertificate(certDER) + test.AssertNotError(t, err, "Certificate failed to parse") + poisonExtension := findExtension(cert.Extensions, OIDExtensionCTPoison) + test.AssertNotNil(t, poisonExtension, "Precert doesn't contain poison extension") + if poisonExtension != nil { + test.AssertEquals(t, poisonExtension.Critical, true) + test.AssertDeepEquals(t, poisonExtension.Value, []byte{0x05, 0x00}) // ASN.1 DER NULL + } + + i := TestCertificateIssuance{ + ca: ca, + sa: sa, + req: req, + certDER: certDER, + cert: cert, + } + + testCase.subTest(t, &i) + }) + } + } +} + +func issueCertificateSubTestSetup(t *testing.T, e *ECDSAAllowList) (*certificateAuthorityImpl, *mockSA) { + testCtx := setup(t) + ecdsaAllowList := &ECDSAAllowList{} + if e == nil { + e = ecdsaAllowList + } + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + e, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + return ca, sa +} + +func issueCertificateSubTestIssuePrecertificate(t *testing.T, i *TestCertificateIssuance) { + cert := i.cert + + test.AssertEquals(t, cert.Subject.CommonName, "not-example.com") + + if len(cert.DNSNames) == 1 { + if cert.DNSNames[0] != "not-example.com" { + t.Errorf("Improper list of domain names %v", cert.DNSNames) + } + t.Errorf("Improper list of domain names %v", cert.DNSNames) + } + + if len(cert.Subject.Country) > 0 { + t.Errorf("Subject contained unauthorized values: %v", cert.Subject) + } +} + +func issueCertificateSubTestValidityUsesCAClock(t *testing.T, i *TestCertificateIssuance) { + test.AssertEquals(t, i.cert.NotBefore, i.ca.clk.Now().Add(-1*i.ca.backdate)) + test.AssertEquals(t, i.cert.NotAfter.Add(time.Second).Sub(i.cert.NotBefore), i.ca.validityPeriod) +} + +// Test failure mode when no issuers are present. +func TestNoIssuers(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &mockSA{} + _, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + nil, // No issuers + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertError(t, err, "No issuers found during CA construction.") + test.AssertEquals(t, err.Error(), "must have at least one issuer") +} + +// Test issuing when multiple issuers are present. +func TestMultipleIssuers(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to remake CA") + + selectedProfile := ca.certProfiles.defaultName + _, ok := ca.certProfiles.profileByName[selectedProfile] + test.Assert(t, ok, "Certificate profile was expected to exist") + + // Test that an RSA CSR gets issuance from an RSA issuer. + issuedCert, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, CertProfileName: selectedProfile}) + test.AssertNotError(t, err, "Failed to issue certificate") + cert, err := x509.ParseCertificate(issuedCert.DER) + test.AssertNotError(t, err, "Certificate failed to parse") + validated := false + for _, issuer := range ca.issuers.byAlg[x509.RSA] { + err = cert.CheckSignatureFrom(issuer.Cert.Certificate) + if err == nil { + validated = true + break + } + } + test.Assert(t, validated, "Certificate failed signature validation") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 1) + + // Test that an ECDSA CSR gets issuance from an ECDSA issuer. + issuedCert, err = ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID, CertProfileName: selectedProfile}) + test.AssertNotError(t, err, "Failed to issue certificate") + cert, err = x509.ParseCertificate(issuedCert.DER) + test.AssertNotError(t, err, "Certificate failed to parse") + validated = false + for _, issuer := range ca.issuers.byAlg[x509.ECDSA] { + err = cert.CheckSignatureFrom(issuer.Cert.Certificate) + if err == nil { + validated = true + break + } + } + test.Assert(t, validated, "Certificate failed signature validation") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 2) +} + +func TestUnpredictableIssuance(t *testing.T) { + testCtx := setup(t) + sa := &mockSA{} + + // Load our own set of issuer configs, specifically with: + // - 3 issuers, + // - 2 of which are active + boulderIssuers := make([]*issuance.Issuer, 3) + var err error + for i, name := range []string{"int-e1", "int-e2", "int-r3"} { + boulderIssuers[i], err = issuance.LoadIssuer(issuance.IssuerConfig{ + Active: i != 0, // Make one of the ECDSA issuers inactive. + IssuerURL: fmt.Sprintf("http://not-example.com/i/%s", name), + OCSPURL: "http://not-example.com/o", + CRLURLBase: fmt.Sprintf("http://not-example.com/c/%s/", name), + Location: issuance.IssuerLoc{ + File: fmt.Sprintf("../test/hierarchy/%s.key.pem", name), + CertFile: fmt.Sprintf("../test/hierarchy/%s.cert.pem", name), + }, + }, testCtx.fc) + test.AssertNotError(t, err, "Couldn't load test issuer") + } + + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to remake CA") + + // Then, modify the resulting issuer maps so that the RSA issuer appears to + // be an ECDSA issuer. This would be easier if we had three ECDSA issuers to + // use here, but that doesn't exist in //test/hierarchy (yet). + ca.issuers.byAlg[x509.ECDSA] = append(ca.issuers.byAlg[x509.ECDSA], ca.issuers.byAlg[x509.RSA]...) + ca.issuers.byAlg[x509.RSA] = []*issuance.Issuer{} + + // Issue the same (ECDSA-keyed) certificate 20 times. None of the issuances + // should come from the inactive issuer (int-e1). At least one issuance should + // come from each of the two active issuers (int-e2 and int-r3). With 20 + // trials, the probability that all 20 issuances come from the same issuer is + // 0.5 ^ 20 = 9.5e-7 ~= 1e-6 = 1 in a million, so we do not consider this test + // to be flaky. + req := &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID} + seenE2 := false + seenR3 := false + for i := 0; i < 20; i++ { + result, err := ca.IssuePrecertificate(ctx, req) + test.AssertNotError(t, err, "Failed to issue test certificate") + cert, err := x509.ParseCertificate(result.DER) + test.AssertNotError(t, err, "Failed to parse test certificate") + if strings.Contains(cert.Issuer.CommonName, "E1") { + t.Fatal("Issued certificate from inactive issuer") + } else if strings.Contains(cert.Issuer.CommonName, "E2") { + seenE2 = true + } else if strings.Contains(cert.Issuer.CommonName, "R3") { + seenR3 = true + } + } + test.Assert(t, seenE2, "Expected at least one issuance from active issuer") + test.Assert(t, seenR3, "Expected at least one issuance from active issuer") +} + +func TestProfiles(t *testing.T) { + t.Parallel() + testCtx := setup(t) + test.AssertEquals(t, len(testCtx.certProfiles), 2) + + sa := &mockSA{} + + duplicateProfiles := make(map[string]issuance.ProfileConfig, 0) + // These profiles contain the same data which will produce an identical + // hash, even though the names are different. + duplicateProfiles["defaultBoulderCertificateProfile"] = issuance.ProfileConfig{ + AllowMustStaple: false, + AllowCTPoison: false, + AllowSCTList: false, + AllowCommonName: false, + Policies: []issuance.PolicyConfig{ + {OID: "2.23.140.1.2.1"}, + }, + MaxValidityPeriod: config.Duration{Duration: time.Hour * 8760}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } + duplicateProfiles["uhoh_ohno"] = issuance.ProfileConfig{ + AllowMustStaple: false, + AllowCTPoison: false, + AllowSCTList: false, + AllowCommonName: false, + Policies: []issuance.PolicyConfig{ + {OID: "2.23.140.1.2.1"}, + }, + MaxValidityPeriod: config.Duration{Duration: time.Hour * 8760}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } + test.AssertEquals(t, len(duplicateProfiles), 2) + + jackedProfiles := make(map[string]issuance.ProfileConfig, 0) + jackedProfiles["ruhroh"] = issuance.ProfileConfig{ + AllowMustStaple: false, + AllowCTPoison: false, + AllowSCTList: false, + AllowCommonName: false, + Policies: []issuance.PolicyConfig{ + {OID: "2.23.140.1.2.1"}, + }, + MaxValidityPeriod: config.Duration{Duration: time.Hour * 9000}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } + test.AssertEquals(t, len(jackedProfiles), 1) + + type nameToHash struct { + name string + hash [32]byte + } + + emptyMap := make(map[string]issuance.ProfileConfig, 0) + testCases := []struct { + name string + profileConfigs map[string]issuance.ProfileConfig + defaultName string + expectedErrSubstr string + expectedProfiles []nameToHash + }{ + { + name: "no profiles", + profileConfigs: emptyMap, + expectedErrSubstr: "at least one certificate profile", + }, + { + name: "nil profile map", + profileConfigs: nil, + expectedErrSubstr: "at least one certificate profile", + }, + { + name: "duplicate hash", + profileConfigs: duplicateProfiles, + expectedErrSubstr: "duplicate certificate profile hash", + }, + { + name: "default profiles from setup func", + profileConfigs: testCtx.certProfiles, + expectedProfiles: []nameToHash{ + { + name: testCtx.defaultCertProfileName, + hash: [32]byte{205, 182, 88, 236, 32, 18, 154, 120, 148, 194, 42, 215, 117, 140, 13, 169, 127, 196, 219, 67, 82, 36, 147, 67, 254, 117, 65, 112, 202, 60, 185, 9}, + }, + { + name: "longerLived", + hash: [32]byte{80, 228, 198, 83, 7, 184, 187, 236, 113, 17, 103, 213, 226, 245, 172, 212, 135, 241, 125, 92, 122, 200, 34, 159, 139, 72, 191, 41, 1, 244, 86, 62}, + }, + }, + }, + { + name: "no profile matching default name", + profileConfigs: jackedProfiles, + expectedErrSubstr: "profile object was not found for that name", + }, + { + name: "certificate profile hash changed mid-issuance", + profileConfigs: jackedProfiles, + defaultName: "ruhroh", + expectedProfiles: []nameToHash{ + { + // We'll change the mapped hash key under the hood during + // the test. + name: "ruhroh", + hash: [32]byte{84, 131, 8, 59, 3, 244, 7, 36, 151, 161, 118, 68, 117, 183, 197, 177, 179, 232, 215, 10, 188, 48, 159, 195, 195, 140, 19, 204, 201, 182, 239, 235}, + }, + }, + }, + } + + for _, tc := range testCases { + // TODO(#7454) Remove this rebinding + tc := tc + // This is handled by boulder-ca, not the CA package. + if tc.defaultName == "" { + tc.defaultName = testCtx.defaultCertProfileName + } + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tCA, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + tc.defaultName, + tc.profileConfigs, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc, + ) + + if tc.expectedErrSubstr != "" { + test.AssertContains(t, err.Error(), tc.expectedErrSubstr) + test.AssertError(t, err, "No profile found during CA construction.") + } else { + test.AssertNotError(t, err, "Profiles should exist, but were not found") + } + + if tc.expectedProfiles != nil { + test.AssertEquals(t, len(tc.expectedProfiles), len(tCA.certProfiles.profileByName)) + } + + for _, expected := range tc.expectedProfiles { + cpwid, ok := tCA.certProfiles.profileByName[expected.name] + test.Assert(t, ok, "Profile name was not found, but should have been") + test.AssertEquals(t, expected.hash, cpwid.hash) + + if tc.name == "certificate profile hash changed mid-issuance" { + // This is an attempt to simulate the hash changing, but the + // name remaining the same on a CA node in the duration + // between CA1 sending capb.IssuePrecerticateResponse and + // before the RA calls + // capb.IssueCertificateForPrecertificate. We expect the + // receiving CA2 to error that the hash we expect could not + // be found in the map. + originalHash := cpwid.hash + cpwid.hash = [32]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6} + test.AssertNotEquals(t, originalHash, cpwid.hash) + } + } + }) + } +} + +func TestECDSAAllowList(t *testing.T) { + t.Parallel() + req := &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID} + + // With allowlist containing arbitraryRegID, issuance should come from ECDSA issuer. + regIDMap := makeRegIDsMap([]int64{arbitraryRegID}) + ca, _ := issueCertificateSubTestSetup(t, &ECDSAAllowList{regIDMap}) + result, err := ca.IssuePrecertificate(ctx, req) + test.AssertNotError(t, err, "Failed to issue certificate") + cert, err := x509.ParseCertificate(result.DER) + test.AssertNotError(t, err, "Certificate failed to parse") + test.AssertEquals(t, cert.SignatureAlgorithm, x509.ECDSAWithSHA384) + + // With allowlist not containing arbitraryRegID, issuance should fall back to RSA issuer. + regIDMap = makeRegIDsMap([]int64{2002}) + ca, _ = issueCertificateSubTestSetup(t, &ECDSAAllowList{regIDMap}) + result, err = ca.IssuePrecertificate(ctx, req) + test.AssertNotError(t, err, "Failed to issue certificate") + cert, err = x509.ParseCertificate(result.DER) + test.AssertNotError(t, err, "Certificate failed to parse") + test.AssertEquals(t, cert.SignatureAlgorithm, x509.SHA256WithRSA) + + // With empty allowlist but ECDSAForAll enabled, issuance should come from ECDSA issuer. + ca, _ = issueCertificateSubTestSetup(t, nil) + features.Set(features.Config{ECDSAForAll: true}) + defer features.Reset() + result, err = ca.IssuePrecertificate(ctx, req) + test.AssertNotError(t, err, "Failed to issue certificate") + cert, err = x509.ParseCertificate(result.DER) + test.AssertNotError(t, err, "Certificate failed to parse") + test.AssertEquals(t, cert.SignatureAlgorithm, x509.ECDSAWithSHA384) +} + +func TestInvalidCSRs(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + csrPath string + check func(t *testing.T, ca *certificateAuthorityImpl, sa *mockSA) + errorMessage string + errorType berrors.ErrorType + }{ + // Test that the CA rejects CSRs that have no names. + // + // CSR generated by Go: + // * Random RSA public key. + // * CN = [none] + // * DNSNames = [none] + {"RejectNoHostnames", "./testdata/no_names.der.csr", nil, "Issued certificate with no names", berrors.BadCSR}, + + // Test that the CA rejects CSRs that have too many names. + // + // CSR generated by Go: + // * Random public key + // * CN = [none] + // * DNSNames = not-example.com, www.not-example.com, mail.example.com + {"RejectTooManyHostnames", "./testdata/too_many_names.der.csr", nil, "Issued certificate with too many names", berrors.BadCSR}, + + // Test that the CA rejects CSRs that have public keys that are too short. + // + // CSR generated by Go: + // * Random public key -- 512 bits long + // * CN = (none) + // * DNSNames = not-example.com, www.not-example.com, mail.not-example.com + {"RejectShortKey", "./testdata/short_key.der.csr", nil, "Issued a certificate with too short a key.", berrors.BadCSR}, + + // Test that the CA rejects CSRs that have bad signature algorithms. + // + // CSR generated by Go: + // * Random public key -- 2048 bits long + // * CN = (none) + // * DNSNames = not-example.com, www.not-example.com, mail.not-example.com + // * Signature Algorithm: sha1WithRSAEncryption + {"RejectBadAlgorithm", "./testdata/bad_algorithm.der.csr", nil, "Issued a certificate based on a CSR with a bad signature algorithm.", berrors.BadCSR}, + + // CSR generated by Go: + // * Random RSA public key. + // * CN = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com + // * DNSNames = [none] + {"RejectLongCommonName", "./testdata/long_cn.der.csr", nil, "Issued a certificate with a CN over 64 bytes.", berrors.BadCSR}, + + // CSR generated by OpenSSL: + // Edited signature to become invalid. + {"RejectWrongSignature", "./testdata/invalid_signature.der.csr", nil, "Issued a certificate based on a CSR with an invalid signature.", berrors.BadCSR}, + } + + for _, testCase := range testCases { + // TODO(#7454) Remove this rebinding + testCase := testCase + testCtx := setup(t) + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + serializedCSR := mustRead(testCase.csrPath) + issueReq := &capb.IssueCertificateRequest{Csr: serializedCSR, RegistrationID: arbitraryRegID} + _, err = ca.IssuePrecertificate(ctx, issueReq) + + test.AssertErrorIs(t, err, testCase.errorType) + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "cert"}, 0) + + test.AssertError(t, err, testCase.errorMessage) + if testCase.check != nil { + testCase.check(t, ca, sa) + } + }) + } +} + +func TestRejectValidityTooLong(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + future, err := time.Parse(time.RFC3339, "2025-02-10T00:30:00Z") + + test.AssertNotError(t, err, "Failed to parse time") + testCtx.fc.Set(future) + // Test that the CA rejects CSRs that would expire after the intermediate cert + _, err = ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID}) + test.AssertError(t, err, "Cannot issue a certificate that expires after the intermediate certificate") + test.AssertErrorIs(t, err, berrors.InternalServer) +} + +func issueCertificateSubTestProfileSelectionRSA(t *testing.T, i *TestCertificateIssuance) { + // Certificates for RSA keys should be marked as usable for signatures and encryption. + expectedKeyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + t.Logf("expected key usage %v, got %v", expectedKeyUsage, i.cert.KeyUsage) + test.AssertEquals(t, i.cert.KeyUsage, expectedKeyUsage) +} + +func issueCertificateSubTestProfileSelectionECDSA(t *testing.T, i *TestCertificateIssuance) { + // Certificates for ECDSA keys should be marked as usable for only signatures. + expectedKeyUsage := x509.KeyUsageDigitalSignature + t.Logf("expected key usage %v, got %v", expectedKeyUsage, i.cert.KeyUsage) + test.AssertEquals(t, i.cert.KeyUsage, expectedKeyUsage) +} + +func countMustStaple(t *testing.T, cert *x509.Certificate) (count int) { + oidTLSFeature := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} + mustStapleFeatureValue := []byte{0x30, 0x03, 0x02, 0x01, 0x05} + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidTLSFeature) { + test.Assert(t, !ext.Critical, "Extension was marked critical") + test.AssertByteEquals(t, ext.Value, mustStapleFeatureValue) + count++ + } + } + return count +} + +func issueCertificateSubTestMustStaple(t *testing.T, i *TestCertificateIssuance) { + test.AssertMetricWithLabelsEquals(t, i.ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate"}, 1) + test.AssertEquals(t, countMustStaple(t, i.cert), 1) +} + +func issueCertificateSubTestUnknownExtension(t *testing.T, i *TestCertificateIssuance) { + test.AssertMetricWithLabelsEquals(t, i.ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate"}, 1) + + // NOTE: The hard-coded value here will have to change over time as Boulder + // adds or removes (unrequested/default) extensions in certificates. + expectedExtensionCount := 9 + test.AssertEquals(t, len(i.cert.Extensions), expectedExtensionCount) +} + +func issueCertificateSubTestCTPoisonExtension(t *testing.T, i *TestCertificateIssuance) { + test.AssertMetricWithLabelsEquals(t, i.ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate"}, 1) +} + +func findExtension(extensions []pkix.Extension, id asn1.ObjectIdentifier) *pkix.Extension { + for _, ext := range extensions { + if ext.Id.Equal(id) { + return &ext + } + } + return nil +} + +func makeSCTs() ([][]byte, error) { + sct := ct.SignedCertificateTimestamp{ + SCTVersion: 0, + Timestamp: 2020, + Signature: ct.DigitallySigned{ + Signature: []byte{0}, + }, + } + sctBytes, err := cttls.Marshal(sct) + if err != nil { + return nil, err + } + return [][]byte{sctBytes}, err +} + +func TestIssueCertificateForPrecertificate(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + _, ok := ca.certProfiles.profileByName[ca.certProfiles.defaultName] + test.Assert(t, ok, "Certificate profile was expected to exist") + + issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, OrderID: 0} + precert, err := ca.IssuePrecertificate(ctx, &issueReq) + test.AssertNotError(t, err, "Failed to issue precert") + parsedPrecert, err := x509.ParseCertificate(precert.DER) + test.AssertNotError(t, err, "Failed to parse precert") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 1) + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 0) + + // Check for poison extension + poisonExtension := findExtension(parsedPrecert.Extensions, OIDExtensionCTPoison) + test.AssertNotNil(t, poisonExtension, "Couldn't find CTPoison extension") + test.AssertEquals(t, poisonExtension.Critical, true) + test.AssertDeepEquals(t, poisonExtension.Value, []byte{0x05, 0x00}) // ASN.1 DER NULL + + sctBytes, err := makeSCTs() + if err != nil { + t.Fatal(err) + } + + test.AssertNotError(t, err, "Failed to marshal SCT") + cert, err := ca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{ + DER: precert.DER, + SCTs: sctBytes, + RegistrationID: arbitraryRegID, + OrderID: 0, + CertProfileHash: precert.CertProfileHash, + }) + test.AssertNotError(t, err, "Failed to issue cert from precert") + parsedCert, err := x509.ParseCertificate(cert.Der) + test.AssertNotError(t, err, "Failed to parse cert") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 1) + + // Check for SCT list extension + sctListExtension := findExtension(parsedCert.Extensions, OIDExtensionSCTList) + test.AssertNotNil(t, sctListExtension, "Couldn't find SCTList extension") + test.AssertEquals(t, sctListExtension.Critical, false) + var rawValue []byte + _, err = asn1.Unmarshal(sctListExtension.Value, &rawValue) + test.AssertNotError(t, err, "Failed to unmarshal extension value") + sctList, err := deserializeSCTList(rawValue) + test.AssertNotError(t, err, "Failed to deserialize SCT list") + test.Assert(t, len(sctList) == 1, fmt.Sprintf("Wrong number of SCTs, wanted: 1, got: %d", len(sctList))) +} + +func TestIssueCertificateForPrecertificateWithSpecificCertificateProfile(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &mockSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + selectedProfile := "longerLived" + certProfile, ok := ca.certProfiles.profileByName[selectedProfile] + test.Assert(t, ok, "Certificate profile was expected to exist") + + issueReq := capb.IssueCertificateRequest{ + Csr: CNandSANCSR, + RegistrationID: arbitraryRegID, + OrderID: 0, + CertProfileName: selectedProfile, + } + precert, err := ca.IssuePrecertificate(ctx, &issueReq) + test.AssertNotError(t, err, "Failed to issue precert") + parsedPrecert, err := x509.ParseCertificate(precert.DER) + test.AssertNotError(t, err, "Failed to parse precert") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 1) + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 0) + + // Check for poison extension + poisonExtension := findExtension(parsedPrecert.Extensions, OIDExtensionCTPoison) + test.AssertNotNil(t, poisonExtension, "Couldn't find CTPoison extension") + test.AssertEquals(t, poisonExtension.Critical, true) + test.AssertDeepEquals(t, poisonExtension.Value, []byte{0x05, 0x00}) // ASN.1 DER NULL + + sctBytes, err := makeSCTs() + if err != nil { + t.Fatal(err) + } + + test.AssertNotError(t, err, "Failed to marshal SCT") + cert, err := ca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{ + DER: precert.DER, + SCTs: sctBytes, + RegistrationID: arbitraryRegID, + OrderID: 0, + CertProfileHash: certProfile.hash[:], + }) + test.AssertNotError(t, err, "Failed to issue cert from precert") + parsedCert, err := x509.ParseCertificate(cert.Der) + test.AssertNotError(t, err, "Failed to parse cert") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 1) + + // Check for SCT list extension + sctListExtension := findExtension(parsedCert.Extensions, OIDExtensionSCTList) + test.AssertNotNil(t, sctListExtension, "Couldn't find SCTList extension") + test.AssertEquals(t, sctListExtension.Critical, false) + var rawValue []byte + _, err = asn1.Unmarshal(sctListExtension.Value, &rawValue) + test.AssertNotError(t, err, "Failed to unmarshal extension value") + sctList, err := deserializeSCTList(rawValue) + test.AssertNotError(t, err, "Failed to deserialize SCT list") + test.Assert(t, len(sctList) == 1, fmt.Sprintf("Wrong number of SCTs, wanted: 1, got: %d", len(sctList))) +} + +// deserializeSCTList deserializes a list of SCTs. +// Forked from github.com/cloudflare/cfssl/helpers +func deserializeSCTList(serializedSCTList []byte) ([]ct.SignedCertificateTimestamp, error) { + var sctList ctx509.SignedCertificateTimestampList + rest, err := cttls.Unmarshal(serializedSCTList, &sctList) + if err != nil { + return nil, err + } + if len(rest) != 0 { + return nil, errors.New("serialized SCT list contained trailing garbage") + } + list := make([]ct.SignedCertificateTimestamp, len(sctList.SCTList)) + for i, serializedSCT := range sctList.SCTList { + var sct ct.SignedCertificateTimestamp + rest, err := cttls.Unmarshal(serializedSCT.Val, &sct) + if err != nil { + return nil, err + } + if len(rest) != 0 { + return nil, errors.New("serialized SCT contained trailing garbage") + } + list[i] = sct + } + return list, nil +} + +// dupeSA returns a non-error to GetCertificate in order to simulate a request +// to issue a final certificate with a duplicate serial. +type dupeSA struct { + mockSA +} + +func (m *dupeSA) GetCertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, nil +} + +// getCertErrorSA always returns an error for GetCertificate +type getCertErrorSA struct { + mockSA +} + +func (m *getCertErrorSA) GetCertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, fmt.Errorf("i don't like it") +} + +func TestIssueCertificateForPrecertificateDuplicateSerial(t *testing.T) { + t.Parallel() + testCtx := setup(t) + sa := &dupeSA{} + ca, err := NewCertificateAuthorityImpl( + sa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + sctBytes, err := makeSCTs() + if err != nil { + t.Fatal(err) + } + + selectedProfile := ca.certProfiles.defaultName + certProfile, ok := ca.certProfiles.profileByName[selectedProfile] + test.Assert(t, ok, "Certificate profile was expected to exist") + + issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, OrderID: 0} + precert, err := ca.IssuePrecertificate(ctx, &issueReq) + test.AssertNotError(t, err, "Failed to issue precert") + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 1) + _, err = ca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{ + DER: precert.DER, + SCTs: sctBytes, + RegistrationID: arbitraryRegID, + OrderID: 0, + CertProfileHash: certProfile.hash[:], + }) + if err == nil { + t.Error("Expected error issuing duplicate serial but got none.") + } + if !strings.Contains(err.Error(), "issuance of duplicate final certificate requested") { + t.Errorf("Wrong type of error issuing duplicate serial. Expected 'issuance of duplicate', got '%s'", err) + } + // The success metric doesn't increase when a duplicate certificate issuance + // is attempted. + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 0) + + // Now check what happens if there is an error (e.g. timeout) while checking + // for the duplicate. + errorsa := &getCertErrorSA{} + errorca, err := NewCertificateAuthorityImpl( + errorsa, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + + _, err = errorca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{ + DER: precert.DER, + SCTs: sctBytes, + RegistrationID: arbitraryRegID, + OrderID: 0, + CertProfileHash: certProfile.hash[:], + }) + if err == nil { + t.Fatal("Expected error issuing duplicate serial but got none.") + } + if !strings.Contains(err.Error(), "error checking for duplicate") { + t.Fatalf("Wrong type of error issuing duplicate serial. Expected 'error checking for duplicate', got '%s'", err) + } + // The success metric doesn't increase when a duplicate certificate issuance + // is attempted. + test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "certificate", "status": "success"}, 0) +} + +func TestGenerateSKID(t *testing.T) { + t.Parallel() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + sha256skid, err := generateSKID(key.Public()) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, len(sha256skid), 20) + test.AssertEquals(t, cap(sha256skid), 20) + features.Reset() +} + +func TestVerifyTBSCertIsDeterministic(t *testing.T) { + t.Parallel() + + // Create first keypair and cert + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "unable to generate ECDSA private key") + template := &x509.Certificate{ + NotAfter: time.Now().Add(1 * time.Hour), + DNSNames: []string{"example.com"}, + SerialNumber: big.NewInt(1), + } + certDer1, err := x509.CreateCertificate(rand.Reader, template, template, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "unable to create certificate") + + // Create second keypair and cert + testKey2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "unable to generate ECDSA private key") + template2 := &x509.Certificate{ + NotAfter: time.Now().Add(2 * time.Hour), + DNSNames: []string{"example.net"}, + SerialNumber: big.NewInt(2), + } + certDer2, err := x509.CreateCertificate(rand.Reader, template2, template2, &testKey2.PublicKey, testKey2) + test.AssertNotError(t, err, "unable to create certificate") + + testCases := []struct { + name string + lintCertBytes []byte + leafCertBytes []byte + errorSubstr string + }{ + { + name: "Both nil", + lintCertBytes: nil, + leafCertBytes: nil, + errorSubstr: "were nil", + }, + { + name: "Missing a value, invalid input", + lintCertBytes: nil, + leafCertBytes: []byte{0x6, 0x6, 0x6}, + errorSubstr: "were nil", + }, + { + name: "Missing a value, valid input", + lintCertBytes: nil, + leafCertBytes: certDer1, + errorSubstr: "were nil", + }, + { + name: "Mismatched bytes, invalid input", + lintCertBytes: []byte{0x6, 0x6, 0x6}, + leafCertBytes: []byte{0x1, 0x2, 0x3}, + errorSubstr: "malformed certificate", + }, + { + name: "Mismatched bytes, invalider input", + lintCertBytes: certDer1, + leafCertBytes: []byte{0x1, 0x2, 0x3}, + errorSubstr: "malformed certificate", + }, + { + // This case is an example of when a linting cert's DER bytes are + // mismatched compared to then precert or final cert created from + // that linting cert's DER bytes. + name: "Mismatched bytes, valid input", + lintCertBytes: certDer1, + leafCertBytes: certDer2, + errorSubstr: "mismatch between", + }, + { + // Take this with a grain of salt since this test is not actually + // creating a linting certificate and performing two + // x509.CreateCertificate() calls like + // ca.IssueCertificateForPrecertificate and + // ca.issuePrecertificateInner do. However, we're still going to + // verify the equality. + name: "Valid", + lintCertBytes: certDer1, + leafCertBytes: certDer1, + }, + } + + for _, testCase := range testCases { + // TODO(#7454) Remove this rebinding + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + err := tbsCertIsDeterministic(testCase.lintCertBytes, testCase.leafCertBytes) + if testCase.errorSubstr != "" { + test.AssertError(t, err, "your lack of errors is disturbing") + test.AssertContains(t, err.Error(), testCase.errorSubstr) + } else { + test.AssertNotError(t, err, "unexpected error") + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/crl.go b/third-party/github.com/letsencrypt/boulder/ca/crl.go new file mode 100644 index 000000000..5937046fe --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/crl.go @@ -0,0 +1,203 @@ +package ca + +import ( + "crypto/sha256" + "crypto/x509" + "errors" + "fmt" + "io" + "strings" + + "google.golang.org/grpc" + + "github.com/prometheus/client_golang/prometheus" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + bcrl "github.com/letsencrypt/boulder/crl" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" +) + +type crlImpl struct { + capb.UnsafeCRLGeneratorServer + issuers map[issuance.NameID]*issuance.Issuer + profile *issuance.CRLProfile + maxLogLen int + log blog.Logger + metrics *caMetrics +} + +var _ capb.CRLGeneratorServer = (*crlImpl)(nil) + +// NewCRLImpl returns a new object which fulfils the ca.proto CRLGenerator +// interface. It uses the list of issuers to determine what issuers it can +// issue CRLs from. lifetime sets the validity period (inclusive) of the +// resulting CRLs. +func NewCRLImpl( + issuers []*issuance.Issuer, + profileConfig issuance.CRLProfileConfig, + maxLogLen int, + logger blog.Logger, + metrics *caMetrics, +) (*crlImpl, error) { + issuersByNameID := make(map[issuance.NameID]*issuance.Issuer, len(issuers)) + for _, issuer := range issuers { + issuersByNameID[issuer.NameID()] = issuer + } + + profile, err := issuance.NewCRLProfile(profileConfig) + if err != nil { + return nil, fmt.Errorf("loading CRL profile: %w", err) + } + + return &crlImpl{ + issuers: issuersByNameID, + profile: profile, + maxLogLen: maxLogLen, + log: logger, + metrics: metrics, + }, nil +} + +func (ci *crlImpl) GenerateCRL(stream grpc.BidiStreamingServer[capb.GenerateCRLRequest, capb.GenerateCRLResponse]) error { + var issuer *issuance.Issuer + var req *issuance.CRLRequest + rcs := make([]x509.RevocationListEntry, 0) + + for { + in, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return err + } + + switch payload := in.Payload.(type) { + case *capb.GenerateCRLRequest_Metadata: + if req != nil { + return errors.New("got more than one metadata message") + } + + req, err = ci.metadataToRequest(payload.Metadata) + if err != nil { + return err + } + + var ok bool + issuer, ok = ci.issuers[issuance.NameID(payload.Metadata.IssuerNameID)] + if !ok { + return fmt.Errorf("got unrecognized IssuerNameID: %d", payload.Metadata.IssuerNameID) + } + + case *capb.GenerateCRLRequest_Entry: + rc, err := ci.entryToRevokedCertificate(payload.Entry) + if err != nil { + return err + } + + rcs = append(rcs, *rc) + + default: + return errors.New("got empty or malformed message in input stream") + } + } + + if req == nil { + return errors.New("no crl metadata received") + } + + // Compute a unique ID for this issuer-number-shard combo, to tie together all + // the audit log lines related to its issuance. + logID := blog.LogLineChecksum(fmt.Sprintf("%d", issuer.NameID()) + req.Number.String() + fmt.Sprintf("%d", req.Shard)) + ci.log.AuditInfof( + "Signing CRL: logID=[%s] issuer=[%s] number=[%s] shard=[%d] thisUpdate=[%s] numEntries=[%d]", + logID, issuer.Cert.Subject.CommonName, req.Number.String(), req.Shard, req.ThisUpdate, len(rcs), + ) + + if len(rcs) > 0 { + builder := strings.Builder{} + for i := range len(rcs) { + if builder.Len() == 0 { + fmt.Fprintf(&builder, "Signing CRL: logID=[%s] entries=[", logID) + } + + fmt.Fprintf(&builder, "%x:%d,", rcs[i].SerialNumber.Bytes(), rcs[i].ReasonCode) + + if builder.Len() >= ci.maxLogLen { + fmt.Fprint(&builder, "]") + ci.log.AuditInfo(builder.String()) + builder = strings.Builder{} + } + } + fmt.Fprint(&builder, "]") + ci.log.AuditInfo(builder.String()) + } + + req.Entries = rcs + + crlBytes, err := issuer.IssueCRL(ci.profile, req) + if err != nil { + ci.metrics.noteSignError(err) + return fmt.Errorf("signing crl: %w", err) + } + ci.metrics.signatureCount.With(prometheus.Labels{"purpose": "crl", "issuer": issuer.Name()}).Inc() + + hash := sha256.Sum256(crlBytes) + ci.log.AuditInfof( + "Signing CRL success: logID=[%s] size=[%d] hash=[%x]", + logID, len(crlBytes), hash, + ) + + for i := 0; i < len(crlBytes); i += 1000 { + j := i + 1000 + if j > len(crlBytes) { + j = len(crlBytes) + } + err = stream.Send(&capb.GenerateCRLResponse{ + Chunk: crlBytes[i:j], + }) + if err != nil { + return err + } + if i%1000 == 0 { + ci.log.Debugf("Wrote %d bytes to output stream", i*1000) + } + } + + return nil +} + +func (ci *crlImpl) metadataToRequest(meta *capb.CRLMetadata) (*issuance.CRLRequest, error) { + if core.IsAnyNilOrZero(meta.IssuerNameID, meta.ThisUpdate, meta.ShardIdx) { + return nil, errors.New("got incomplete metadata message") + } + thisUpdate := meta.ThisUpdate.AsTime() + number := bcrl.Number(thisUpdate) + + return &issuance.CRLRequest{ + Number: number, + Shard: meta.ShardIdx, + ThisUpdate: thisUpdate, + }, nil +} + +func (ci *crlImpl) entryToRevokedCertificate(entry *corepb.CRLEntry) (*x509.RevocationListEntry, error) { + serial, err := core.StringToSerial(entry.Serial) + if err != nil { + return nil, err + } + + if core.IsAnyNilOrZero(entry.RevokedAt) { + return nil, errors.New("got empty or zero revocation timestamp") + } + revokedAt := entry.RevokedAt.AsTime() + + return &x509.RevocationListEntry{ + SerialNumber: serial, + RevocationTime: revokedAt, + ReasonCode: int(entry.Reason), + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/crl_test.go b/third-party/github.com/letsencrypt/boulder/ca/crl_test.go new file mode 100644 index 000000000..d4a36f90c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/crl_test.go @@ -0,0 +1,271 @@ +package ca + +import ( + "crypto/x509" + "fmt" + "io" + "testing" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/test" +) + +type mockGenerateCRLBidiStream struct { + grpc.ServerStream + input <-chan *capb.GenerateCRLRequest + output chan<- *capb.GenerateCRLResponse +} + +func (s mockGenerateCRLBidiStream) Recv() (*capb.GenerateCRLRequest, error) { + next, ok := <-s.input + if !ok { + return nil, io.EOF + } + return next, nil +} + +func (s mockGenerateCRLBidiStream) Send(entry *capb.GenerateCRLResponse) error { + s.output <- entry + return nil +} + +func TestGenerateCRL(t *testing.T) { + t.Parallel() + testCtx := setup(t) + crli := testCtx.crl + errs := make(chan error, 1) + + // Test that we get an error when no metadata is sent. + ins := make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + close(ins) + err := <-errs + test.AssertError(t, err, "can't generate CRL with no metadata") + test.AssertContains(t, err.Error(), "no crl metadata received") + + // Test that we get an error when incomplete metadata is sent. + ins = make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{}, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't generate CRL with incomplete metadata") + test.AssertContains(t, err.Error(), "got incomplete metadata message") + + // Test that we get an error when unrecognized metadata is sent. + ins = make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + now := testCtx.fc.Now() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: 1, + ThisUpdate: timestamppb.New(now), + ShardIdx: 1, + }, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't generate CRL with bad metadata") + test.AssertContains(t, err.Error(), "got unrecognized IssuerNameID") + + // Test that we get an error when two metadata are sent. + ins = make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()), + ThisUpdate: timestamppb.New(now), + ShardIdx: 1, + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()), + ThisUpdate: timestamppb.New(now), + ShardIdx: 1, + }, + }, + } + close(ins) + err = <-errs + fmt.Println("done waiting for error") + test.AssertError(t, err, "can't generate CRL with duplicate metadata") + test.AssertContains(t, err.Error(), "got more than one metadata message") + + // Test that we get an error when an entry has a bad serial. + ins = make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "123", + Reason: 1, + RevokedAt: timestamppb.New(now), + }, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't generate CRL with bad serials") + test.AssertContains(t, err.Error(), "invalid serial number") + + // Test that we get an error when an entry has a bad revocation time. + ins = make(chan *capb.GenerateCRLRequest) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil}) + }() + + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "deadbeefdeadbeefdeadbeefdeadbeefdead", + Reason: 1, + RevokedAt: nil, + }, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't generate CRL with bad serials") + test.AssertContains(t, err.Error(), "got empty or zero revocation timestamp") + + // Test that generating an empty CRL works. + ins = make(chan *capb.GenerateCRLRequest) + outs := make(chan *capb.GenerateCRLResponse) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: outs}) + close(outs) + }() + crlBytes := make([]byte, 0) + done := make(chan struct{}) + go func() { + for resp := range outs { + crlBytes = append(crlBytes, resp.Chunk...) + } + close(done) + }() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()), + ThisUpdate: timestamppb.New(now), + ShardIdx: 1, + }, + }, + } + close(ins) + err = <-errs + <-done + test.AssertNotError(t, err, "generating empty CRL should work") + test.Assert(t, len(crlBytes) > 0, "should have gotten some CRL bytes") + crl, err := x509.ParseRevocationList(crlBytes) + test.AssertNotError(t, err, "should be able to parse empty CRL") + test.AssertEquals(t, len(crl.RevokedCertificateEntries), 0) + err = crl.CheckSignatureFrom(testCtx.boulderIssuers[0].Cert.Certificate) + test.AssertEquals(t, crl.ThisUpdate, now) + test.AssertEquals(t, crl.ThisUpdate, timestamppb.New(now).AsTime()) + test.AssertNotError(t, err, "CRL signature should validate") + + // Test that generating a CRL with some entries works. + ins = make(chan *capb.GenerateCRLRequest) + outs = make(chan *capb.GenerateCRLResponse) + go func() { + errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: outs}) + close(outs) + }() + crlBytes = make([]byte, 0) + done = make(chan struct{}) + go func() { + for resp := range outs { + crlBytes = append(crlBytes, resp.Chunk...) + } + close(done) + }() + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()), + ThisUpdate: timestamppb.New(now), + ShardIdx: 1, + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "000000000000000000000000000000000000", + RevokedAt: timestamppb.New(now), + // Reason 0, Unspecified, is omitted. + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "111111111111111111111111111111111111", + Reason: 1, // keyCompromise + RevokedAt: timestamppb.New(now), + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "444444444444444444444444444444444444", + Reason: 4, // superseded + RevokedAt: timestamppb.New(now), + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "555555555555555555555555555555555555", + Reason: 5, // cessationOfOperation + RevokedAt: timestamppb.New(now), + }, + }, + } + ins <- &capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: &corepb.CRLEntry{ + Serial: "999999999999999999999999999999999999", + Reason: 9, // privilegeWithdrawn + RevokedAt: timestamppb.New(now), + }, + }, + } + close(ins) + err = <-errs + <-done + test.AssertNotError(t, err, "generating empty CRL should work") + test.Assert(t, len(crlBytes) > 0, "should have gotten some CRL bytes") + crl, err = x509.ParseRevocationList(crlBytes) + test.AssertNotError(t, err, "should be able to parse empty CRL") + test.AssertEquals(t, len(crl.RevokedCertificateEntries), 5) + err = crl.CheckSignatureFrom(testCtx.boulderIssuers[0].Cert.Certificate) + test.AssertNotError(t, err, "CRL signature should validate") +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list.go b/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list.go new file mode 100644 index 000000000..d0007ca6e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list.go @@ -0,0 +1,45 @@ +package ca + +import ( + "os" + + "github.com/letsencrypt/boulder/strictyaml" +) + +// ECDSAAllowList acts as a container for a map of Registration IDs. +type ECDSAAllowList struct { + regIDsMap map[int64]bool +} + +// permitted checks if ECDSA issuance is permitted for the specified +// Registration ID. +func (e *ECDSAAllowList) permitted(regID int64) bool { + return e.regIDsMap[regID] +} + +func makeRegIDsMap(regIDs []int64) map[int64]bool { + regIDsMap := make(map[int64]bool) + for _, regID := range regIDs { + regIDsMap[regID] = true + } + return regIDsMap +} + +// NewECDSAAllowListFromFile is exported to allow `boulder-ca` to construct a +// new `ECDSAAllowList` object. It returns the ECDSAAllowList, the size of allow +// list after attempting to load it (for CA logging purposes so inner fields don't need to be exported), or an error. +func NewECDSAAllowListFromFile(filename string) (*ECDSAAllowList, int, error) { + configBytes, err := os.ReadFile(filename) + if err != nil { + return nil, 0, err + } + + var regIDs []int64 + err = strictyaml.Unmarshal(configBytes, ®IDs) + if err != nil { + return nil, 0, err + } + + allowList := &ECDSAAllowList{regIDsMap: makeRegIDsMap(regIDs)} + return allowList, len(allowList.regIDsMap), nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list_test.go b/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list_test.go new file mode 100644 index 000000000..78aed0348 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ecdsa_allow_list_test.go @@ -0,0 +1,70 @@ +package ca + +import ( + "testing" +) + +func TestNewECDSAAllowListFromFile(t *testing.T) { + t.Parallel() + type args struct { + filename string + } + tests := []struct { + name string + args args + want1337Permitted bool + wantEntries int + wantErrBool bool + }{ + { + name: "one entry", + args: args{"testdata/ecdsa_allow_list.yml"}, + want1337Permitted: true, + wantEntries: 1, + wantErrBool: false, + }, + { + name: "one entry but it's not 1337", + args: args{"testdata/ecdsa_allow_list2.yml"}, + want1337Permitted: false, + wantEntries: 1, + wantErrBool: false, + }, + { + name: "should error due to no file", + args: args{"testdata/ecdsa_allow_list_no_exist.yml"}, + want1337Permitted: false, + wantEntries: 0, + wantErrBool: true, + }, + { + name: "should error due to malformed YAML", + args: args{"testdata/ecdsa_allow_list_malformed.yml"}, + want1337Permitted: false, + wantEntries: 0, + wantErrBool: true, + }, + } + + for _, tt := range tests { + // TODO(Remove this >= go1.22.3) This shouldn't be necessary due to + // go1.22 changing loopvars. + // https://github.com/golang/go/issues/65612#issuecomment-1943342030 + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + allowList, gotEntries, err := NewECDSAAllowListFromFile(tt.args.filename) + if (err != nil) != tt.wantErrBool { + t.Errorf("NewECDSAAllowListFromFile() error = %v, wantErr %v", err, tt.wantErrBool) + t.Error(allowList, gotEntries, err) + return + } + if allowList != nil && allowList.permitted(1337) != tt.want1337Permitted { + t.Errorf("NewECDSAAllowListFromFile() allowList = %v, want %v", allowList, tt.want1337Permitted) + } + if gotEntries != tt.wantEntries { + t.Errorf("NewECDSAAllowListFromFile() gotEntries = %v, want %v", gotEntries, tt.wantEntries) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ocsp.go b/third-party/github.com/letsencrypt/boulder/ca/ocsp.go new file mode 100644 index 000000000..2556182ef --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ocsp.go @@ -0,0 +1,253 @@ +package ca + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" +) + +// ocspImpl provides a backing implementation for the OCSP gRPC service. +type ocspImpl struct { + capb.UnsafeOCSPGeneratorServer + issuers map[issuance.NameID]*issuance.Issuer + ocspLifetime time.Duration + ocspLogQueue *ocspLogQueue + log blog.Logger + metrics *caMetrics + clk clock.Clock +} + +var _ capb.OCSPGeneratorServer = (*ocspImpl)(nil) + +func NewOCSPImpl( + issuers []*issuance.Issuer, + ocspLifetime time.Duration, + ocspLogMaxLength int, + ocspLogPeriod time.Duration, + logger blog.Logger, + stats prometheus.Registerer, + metrics *caMetrics, + clk clock.Clock, +) (*ocspImpl, error) { + issuersByNameID := make(map[issuance.NameID]*issuance.Issuer, len(issuers)) + for _, issuer := range issuers { + issuersByNameID[issuer.NameID()] = issuer + } + + if ocspLifetime < 8*time.Hour || ocspLifetime > 7*24*time.Hour { + return nil, fmt.Errorf("invalid OCSP lifetime %q", ocspLifetime) + } + + var ocspLogQueue *ocspLogQueue + if ocspLogMaxLength > 0 { + ocspLogQueue = newOCSPLogQueue(ocspLogMaxLength, ocspLogPeriod, stats, logger) + } + + oi := &ocspImpl{ + issuers: issuersByNameID, + ocspLifetime: ocspLifetime, + ocspLogQueue: ocspLogQueue, + log: logger, + metrics: metrics, + clk: clk, + } + return oi, nil +} + +// LogOCSPLoop collects OCSP generation log events into bundles, and logs +// them periodically. +func (oi *ocspImpl) LogOCSPLoop() { + if oi.ocspLogQueue != nil { + oi.ocspLogQueue.loop() + } +} + +// Stop asks this ocspImpl to shut down. It must be called after the +// corresponding RPC service is shut down and there are no longer any inflight +// RPCs. It will attempt to drain any logging queues (which may block), and will +// return only when done. +func (oi *ocspImpl) Stop() { + if oi.ocspLogQueue != nil { + oi.ocspLogQueue.stop() + } +} + +// GenerateOCSP produces a new OCSP response and returns it +func (oi *ocspImpl) GenerateOCSP(ctx context.Context, req *capb.GenerateOCSPRequest) (*capb.OCSPResponse, error) { + // req.Status, req.Reason, and req.RevokedAt are often 0, for non-revoked certs. + if core.IsAnyNilOrZero(req, req.Serial, req.IssuerID) { + return nil, berrors.InternalServerError("Incomplete generate OCSP request") + } + + serialInt, err := core.StringToSerial(req.Serial) + if err != nil { + return nil, err + } + serial := serialInt + + issuer, ok := oi.issuers[issuance.NameID(req.IssuerID)] + if !ok { + return nil, fmt.Errorf("unrecognized issuer ID %d", req.IssuerID) + } + + now := oi.clk.Now().Truncate(time.Minute) + tbsResponse := ocsp.Response{ + Status: ocspStatusToCode[req.Status], + SerialNumber: serial, + ThisUpdate: now, + NextUpdate: now.Add(oi.ocspLifetime - time.Second), + } + if tbsResponse.Status == ocsp.Revoked { + tbsResponse.RevokedAt = req.RevokedAt.AsTime() + tbsResponse.RevocationReason = int(req.Reason) + } + + if oi.ocspLogQueue != nil { + oi.ocspLogQueue.enqueue(serial.Bytes(), now, tbsResponse.Status, tbsResponse.RevocationReason) + } + + ocspResponse, err := ocsp.CreateResponse(issuer.Cert.Certificate, issuer.Cert.Certificate, tbsResponse, issuer.Signer) + if err == nil { + oi.metrics.signatureCount.With(prometheus.Labels{"purpose": "ocsp", "issuer": issuer.Name()}).Inc() + } else { + oi.metrics.noteSignError(err) + } + return &capb.OCSPResponse{Response: ocspResponse}, err +} + +// ocspLogQueue accumulates OCSP logging events and writes several of them +// in a single log line. This reduces the number of log lines and bytes, +// which would otherwise be quite high. As of Jan 2021 we do approximately +// 550 rps of OCSP generation events. We can turn that into about 5.5 rps +// of log lines if we accumulate 100 entries per line, which amounts to about +// 3900 bytes per log line. +// Summary of log line usage: +// serial in hex: 36 bytes, separator characters: 2 bytes, status: 1 byte +// If maxLogLen is less than the length of a single log item, generate +// one log line for every item. +type ocspLogQueue struct { + // Maximum length, in bytes, of a single log line. + maxLogLen int + // Maximum amount of time between OCSP logging events. + period time.Duration + queue chan ocspLog + // This allows the stop() function to block until we've drained the queue. + wg sync.WaitGroup + depth prometheus.Gauge + logger blog.Logger + clk clock.Clock +} + +type ocspLog struct { + serial []byte + time time.Time + status int + reason int +} + +func newOCSPLogQueue( + maxLogLen int, + period time.Duration, + stats prometheus.Registerer, + logger blog.Logger, +) *ocspLogQueue { + depth := prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "ocsp_log_queue_depth", + Help: "Number of OCSP generation log entries waiting to be written", + }) + stats.MustRegister(depth) + olq := ocspLogQueue{ + maxLogLen: maxLogLen, + period: period, + queue: make(chan ocspLog), + wg: sync.WaitGroup{}, + depth: depth, + logger: logger, + clk: clock.New(), + } + olq.wg.Add(1) + return &olq +} + +func (olq *ocspLogQueue) enqueue(serial []byte, time time.Time, status, reason int) { + olq.queue <- ocspLog{ + serial: append([]byte{}, serial...), + time: time, + status: status, + reason: reason, + } +} + +// To ensure we don't go over the max log line length, use a safety margin +// equal to the expected length of an entry. +const ocspSingleLogEntryLen = 39 + +// loop consumes events from the queue channel, batches them up, and +// logs them in batches of maxLogLen / 39, or every `period`, +// whichever comes first. +func (olq *ocspLogQueue) loop() { + defer olq.wg.Done() + done := false + for !done { + var builder strings.Builder + deadline := olq.clk.After(olq.period) + inner: + for { + olq.depth.Set(float64(len(olq.queue))) + select { + case ol, ok := <-olq.queue: + if !ok { + // Channel was closed, finish. + done = true + break inner + } + reasonStr := "_" + if ol.status == ocsp.Revoked { + reasonStr = fmt.Sprintf("%d", ol.reason) + } + fmt.Fprintf(&builder, "%x:%s,", ol.serial, reasonStr) + case <-deadline: + break inner + } + if builder.Len()+ocspSingleLogEntryLen >= olq.maxLogLen { + break + } + } + if builder.Len() > 0 { + olq.logger.AuditInfof("OCSP signed: %s", builder.String()) + } + } +} + +// stop the loop, and wait for it to finish. This must be called only after +// it's guaranteed that nothing will call enqueue again (for instance, after +// the OCSPGenerator and CertificateAuthority services are shut down with +// no RPCs in flight). Otherwise, enqueue will panic. +// If this is called without previously starting a goroutine running `.loop()`, +// it will block forever. +func (olq *ocspLogQueue) stop() { + close(olq.queue) + olq.wg.Wait() +} + +// OCSPGenerator is an interface which exposes both the auto-generated gRPC +// methods and our special-purpose log queue start and stop methods, so that +// they can be called from main without exporting the ocspImpl type. +type OCSPGenerator interface { + capb.OCSPGeneratorServer + LogOCSPLoop() + Stop() +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/ocsp_test.go b/third-party/github.com/letsencrypt/boulder/ca/ocsp_test.go new file mode 100644 index 000000000..9cea07656 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/ocsp_test.go @@ -0,0 +1,237 @@ +package ca + +import ( + "context" + "crypto/x509" + "encoding/hex" + "testing" + "time" + + "golang.org/x/crypto/ocsp" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func serial(t *testing.T) []byte { + serial, err := hex.DecodeString("aabbccddeeffaabbccddeeff000102030405") + if err != nil { + t.Fatal(err) + } + return serial + +} + +func TestOCSP(t *testing.T) { + t.Parallel() + testCtx := setup(t) + ca, err := NewCertificateAuthorityImpl( + &mockSA{}, + testCtx.pa, + testCtx.boulderIssuers, + testCtx.defaultCertProfileName, + testCtx.certProfiles, + testCtx.lints, + nil, + testCtx.certExpiry, + testCtx.certBackdate, + testCtx.serialPrefix, + testCtx.maxNames, + testCtx.keyPolicy, + testCtx.logger, + testCtx.metrics, + testCtx.fc) + test.AssertNotError(t, err, "Failed to create CA") + ocspi := testCtx.ocsp + + // Issue a certificate from an RSA issuer, request OCSP from the same issuer, + // and make sure it works. + rsaCertPB, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID}) + test.AssertNotError(t, err, "Failed to issue certificate") + rsaCert, err := x509.ParseCertificate(rsaCertPB.DER) + test.AssertNotError(t, err, "Failed to parse rsaCert") + rsaIssuerID := issuance.IssuerNameID(rsaCert) + rsaOCSPPB, err := ocspi.GenerateOCSP(ctx, &capb.GenerateOCSPRequest{ + Serial: core.SerialToString(rsaCert.SerialNumber), + IssuerID: int64(rsaIssuerID), + Status: string(core.OCSPStatusGood), + }) + test.AssertNotError(t, err, "Failed to generate OCSP") + rsaOCSP, err := ocsp.ParseResponse(rsaOCSPPB.Response, ca.issuers.byNameID[rsaIssuerID].Cert.Certificate) + test.AssertNotError(t, err, "Failed to parse / validate OCSP for rsaCert") + test.AssertEquals(t, rsaOCSP.Status, 0) + test.AssertEquals(t, rsaOCSP.RevocationReason, 0) + test.AssertEquals(t, rsaOCSP.SerialNumber.Cmp(rsaCert.SerialNumber), 0) + + // Check that a different issuer cannot validate the OCSP response + _, err = ocsp.ParseResponse(rsaOCSPPB.Response, ca.issuers.byAlg[x509.ECDSA][0].Cert.Certificate) + test.AssertError(t, err, "Parsed / validated OCSP for rsaCert, but should not have") + + // Issue a certificate from an ECDSA issuer, request OCSP from the same issuer, + // and make sure it works. + ecdsaCertPB, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID}) + test.AssertNotError(t, err, "Failed to issue certificate") + ecdsaCert, err := x509.ParseCertificate(ecdsaCertPB.DER) + test.AssertNotError(t, err, "Failed to parse ecdsaCert") + ecdsaIssuerID := issuance.IssuerNameID(ecdsaCert) + ecdsaOCSPPB, err := ocspi.GenerateOCSP(ctx, &capb.GenerateOCSPRequest{ + Serial: core.SerialToString(ecdsaCert.SerialNumber), + IssuerID: int64(ecdsaIssuerID), + Status: string(core.OCSPStatusGood), + }) + test.AssertNotError(t, err, "Failed to generate OCSP") + ecdsaOCSP, err := ocsp.ParseResponse(ecdsaOCSPPB.Response, ca.issuers.byNameID[ecdsaIssuerID].Cert.Certificate) + test.AssertNotError(t, err, "Failed to parse / validate OCSP for ecdsaCert") + test.AssertEquals(t, ecdsaOCSP.Status, 0) + test.AssertEquals(t, ecdsaOCSP.RevocationReason, 0) + test.AssertEquals(t, ecdsaOCSP.SerialNumber.Cmp(ecdsaCert.SerialNumber), 0) + + // GenerateOCSP with a bad IssuerID should fail. + _, err = ocspi.GenerateOCSP(context.Background(), &capb.GenerateOCSPRequest{ + Serial: core.SerialToString(rsaCert.SerialNumber), + IssuerID: int64(666), + Status: string(core.OCSPStatusGood), + }) + test.AssertError(t, err, "GenerateOCSP didn't fail with invalid IssuerID") + + // GenerateOCSP with a bad Serial should fail. + _, err = ocspi.GenerateOCSP(context.Background(), &capb.GenerateOCSPRequest{ + Serial: "BADDECAF", + IssuerID: int64(rsaIssuerID), + Status: string(core.OCSPStatusGood), + }) + test.AssertError(t, err, "GenerateOCSP didn't fail with invalid Serial") + + // GenerateOCSP with a valid-but-nonexistent Serial should *not* fail. + _, err = ocspi.GenerateOCSP(context.Background(), &capb.GenerateOCSPRequest{ + Serial: "03DEADBEEFBADDECAFFADEFACECAFE30", + IssuerID: int64(rsaIssuerID), + Status: string(core.OCSPStatusGood), + }) + test.AssertNotError(t, err, "GenerateOCSP failed with fake-but-valid Serial") +} + +// Set up an ocspLogQueue with a very long period and a large maxLen, +// to ensure any buffered entries get flushed on `.stop()`. +func TestOcspLogFlushOnExit(t *testing.T) { + t.Parallel() + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(4000, 10000*time.Millisecond, stats, log) + go queue.loop() + queue.enqueue(serial(t), time.Now(), ocsp.Good, ocsp.Unspecified) + queue.stop() + + expected := []string{ + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_,", + } + test.AssertDeepEquals(t, log.GetAll(), expected) +} + +// Ensure log lines are sent when they exceed maxLen. +func TestOcspFlushOnLength(t *testing.T) { + t.Parallel() + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(100, 100*time.Millisecond, stats, log) + go queue.loop() + for range 5 { + queue.enqueue(serial(t), time.Now(), ocsp.Good, ocsp.Unspecified) + } + queue.stop() + + expected := []string{ + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_,aabbccddeeffaabbccddeeff000102030405:_,", + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_,aabbccddeeffaabbccddeeff000102030405:_,", + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_,", + } + test.AssertDeepEquals(t, log.GetAll(), expected) +} + +// Ensure log lines are sent after a timeout. +func TestOcspFlushOnTimeout(t *testing.T) { + t.Parallel() + log := blog.NewWaitingMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(90000, 10*time.Millisecond, stats, log) + + go queue.loop() + queue.enqueue(serial(t), time.Now(), ocsp.Good, ocsp.Unspecified) + + expected := "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_," + logLines, err := log.WaitForMatch("OCSP signed", 50*time.Millisecond) + test.AssertNotError(t, err, "error in mock log") + test.AssertDeepEquals(t, logLines, expected) + queue.stop() +} + +// If the deadline passes and nothing has been logged, we should not log a blank line. +func TestOcspNoEmptyLines(t *testing.T) { + t.Parallel() + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(90000, 10*time.Millisecond, stats, log) + + go queue.loop() + time.Sleep(50 * time.Millisecond) + queue.stop() + + test.AssertDeepEquals(t, log.GetAll(), []string{}) +} + +// If the maxLogLen is shorter than one entry, log everything immediately. +func TestOcspLogWhenMaxLogLenIsShort(t *testing.T) { + t.Parallel() + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(3, 10000*time.Millisecond, stats, log) + go queue.loop() + queue.enqueue(serial(t), time.Now(), ocsp.Good, ocsp.Unspecified) + queue.stop() + + expected := []string{ + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:_,", + } + test.AssertDeepEquals(t, log.GetAll(), expected) +} + +// Enqueueing entries after stop causes panic. +func TestOcspLogPanicsOnEnqueueAfterStop(t *testing.T) { + t.Parallel() + + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(4000, 10000*time.Millisecond, stats, log) + go queue.loop() + queue.stop() + + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + queue.enqueue(serial(t), time.Now(), ocsp.Good, ocsp.Unspecified) +} + +// Ensure revoke reason gets set. +func TestOcspRevokeReasonIsSet(t *testing.T) { + t.Parallel() + log := blog.NewMock() + stats := metrics.NoopRegisterer + queue := newOCSPLogQueue(100, 100*time.Millisecond, stats, log) + go queue.loop() + + queue.enqueue(serial(t), time.Now(), ocsp.Revoked, ocsp.KeyCompromise) + queue.enqueue(serial(t), time.Now(), ocsp.Revoked, ocsp.CACompromise) + queue.stop() + + expected := []string{ + "INFO: [AUDIT] OCSP signed: aabbccddeeffaabbccddeeff000102030405:1,aabbccddeeffaabbccddeeff000102030405:2,", + } + test.AssertDeepEquals(t, log.GetAll(), expected) +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/proto/ca.pb.go b/third-party/github.com/letsencrypt/boulder/ca/proto/ca.pb.go new file mode 100644 index 000000000..fec630087 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/proto/ca.pb.go @@ -0,0 +1,846 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: ca.proto + +package proto + +import ( + proto "github.com/letsencrypt/boulder/core/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type IssueCertificateRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 6 + Csr []byte `protobuf:"bytes,1,opt,name=csr,proto3" json:"csr,omitempty"` + RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + OrderID int64 `protobuf:"varint,3,opt,name=orderID,proto3" json:"orderID,omitempty"` + // certProfileName is a human readable name provided by the RA and used to + // determine if the CA can issue for that profile. A default name will be + // assigned inside the CA during *Profile construction if no name is provided. + // The value of this field should not be relied upon inside the RA. + CertProfileName string `protobuf:"bytes,5,opt,name=certProfileName,proto3" json:"certProfileName,omitempty"` +} + +func (x *IssueCertificateRequest) Reset() { + *x = IssueCertificateRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IssueCertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueCertificateRequest) ProtoMessage() {} + +func (x *IssueCertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueCertificateRequest.ProtoReflect.Descriptor instead. +func (*IssueCertificateRequest) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{0} +} + +func (x *IssueCertificateRequest) GetCsr() []byte { + if x != nil { + return x.Csr + } + return nil +} + +func (x *IssueCertificateRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *IssueCertificateRequest) GetOrderID() int64 { + if x != nil { + return x.OrderID + } + return 0 +} + +func (x *IssueCertificateRequest) GetCertProfileName() string { + if x != nil { + return x.CertProfileName + } + return "" +} + +type IssuePrecertificateResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 4 + DER []byte `protobuf:"bytes,1,opt,name=DER,proto3" json:"DER,omitempty"` + // certProfileHash is a hash over the exported fields of a certificate profile + // to ensure that the profile remains unchanged after multiple roundtrips + // through the RA and CA. + CertProfileHash []byte `protobuf:"bytes,2,opt,name=certProfileHash,proto3" json:"certProfileHash,omitempty"` + // certProfileName is a human readable name returned back to the RA for later + // use. If IssueCertificateRequest.certProfileName was an empty string, the + // CAs default profile name will be assigned. + CertProfileName string `protobuf:"bytes,3,opt,name=certProfileName,proto3" json:"certProfileName,omitempty"` +} + +func (x *IssuePrecertificateResponse) Reset() { + *x = IssuePrecertificateResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IssuePrecertificateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssuePrecertificateResponse) ProtoMessage() {} + +func (x *IssuePrecertificateResponse) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssuePrecertificateResponse.ProtoReflect.Descriptor instead. +func (*IssuePrecertificateResponse) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{1} +} + +func (x *IssuePrecertificateResponse) GetDER() []byte { + if x != nil { + return x.DER + } + return nil +} + +func (x *IssuePrecertificateResponse) GetCertProfileHash() []byte { + if x != nil { + return x.CertProfileHash + } + return nil +} + +func (x *IssuePrecertificateResponse) GetCertProfileName() string { + if x != nil { + return x.CertProfileName + } + return "" +} + +type IssueCertificateForPrecertificateRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 6 + DER []byte `protobuf:"bytes,1,opt,name=DER,proto3" json:"DER,omitempty"` + SCTs [][]byte `protobuf:"bytes,2,rep,name=SCTs,proto3" json:"SCTs,omitempty"` + RegistrationID int64 `protobuf:"varint,3,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + OrderID int64 `protobuf:"varint,4,opt,name=orderID,proto3" json:"orderID,omitempty"` + // certProfileHash is a hash over the exported fields of a certificate profile + // to ensure that the profile remains unchanged after multiple roundtrips + // through the RA and CA. + CertProfileHash []byte `protobuf:"bytes,5,opt,name=certProfileHash,proto3" json:"certProfileHash,omitempty"` +} + +func (x *IssueCertificateForPrecertificateRequest) Reset() { + *x = IssueCertificateForPrecertificateRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IssueCertificateForPrecertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueCertificateForPrecertificateRequest) ProtoMessage() {} + +func (x *IssueCertificateForPrecertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueCertificateForPrecertificateRequest.ProtoReflect.Descriptor instead. +func (*IssueCertificateForPrecertificateRequest) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{2} +} + +func (x *IssueCertificateForPrecertificateRequest) GetDER() []byte { + if x != nil { + return x.DER + } + return nil +} + +func (x *IssueCertificateForPrecertificateRequest) GetSCTs() [][]byte { + if x != nil { + return x.SCTs + } + return nil +} + +func (x *IssueCertificateForPrecertificateRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *IssueCertificateForPrecertificateRequest) GetOrderID() int64 { + if x != nil { + return x.OrderID + } + return 0 +} + +func (x *IssueCertificateForPrecertificateRequest) GetCertProfileHash() []byte { + if x != nil { + return x.CertProfileHash + } + return nil +} + +// Exactly one of certDER or [serial and issuerID] must be set. +type GenerateOCSPRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 8 + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Reason int32 `protobuf:"varint,3,opt,name=reason,proto3" json:"reason,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=revokedAt,proto3" json:"revokedAt,omitempty"` + Serial string `protobuf:"bytes,5,opt,name=serial,proto3" json:"serial,omitempty"` + IssuerID int64 `protobuf:"varint,6,opt,name=issuerID,proto3" json:"issuerID,omitempty"` +} + +func (x *GenerateOCSPRequest) Reset() { + *x = GenerateOCSPRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GenerateOCSPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateOCSPRequest) ProtoMessage() {} + +func (x *GenerateOCSPRequest) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateOCSPRequest.ProtoReflect.Descriptor instead. +func (*GenerateOCSPRequest) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{3} +} + +func (x *GenerateOCSPRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *GenerateOCSPRequest) GetReason() int32 { + if x != nil { + return x.Reason + } + return 0 +} + +func (x *GenerateOCSPRequest) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +func (x *GenerateOCSPRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *GenerateOCSPRequest) GetIssuerID() int64 { + if x != nil { + return x.IssuerID + } + return 0 +} + +type OCSPResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Response []byte `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"` +} + +func (x *OCSPResponse) Reset() { + *x = OCSPResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OCSPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OCSPResponse) ProtoMessage() {} + +func (x *OCSPResponse) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OCSPResponse.ProtoReflect.Descriptor instead. +func (*OCSPResponse) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{4} +} + +func (x *OCSPResponse) GetResponse() []byte { + if x != nil { + return x.Response + } + return nil +} + +type GenerateCRLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *GenerateCRLRequest_Metadata + // *GenerateCRLRequest_Entry + Payload isGenerateCRLRequest_Payload `protobuf_oneof:"payload"` +} + +func (x *GenerateCRLRequest) Reset() { + *x = GenerateCRLRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GenerateCRLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateCRLRequest) ProtoMessage() {} + +func (x *GenerateCRLRequest) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateCRLRequest.ProtoReflect.Descriptor instead. +func (*GenerateCRLRequest) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{5} +} + +func (m *GenerateCRLRequest) GetPayload() isGenerateCRLRequest_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *GenerateCRLRequest) GetMetadata() *CRLMetadata { + if x, ok := x.GetPayload().(*GenerateCRLRequest_Metadata); ok { + return x.Metadata + } + return nil +} + +func (x *GenerateCRLRequest) GetEntry() *proto.CRLEntry { + if x, ok := x.GetPayload().(*GenerateCRLRequest_Entry); ok { + return x.Entry + } + return nil +} + +type isGenerateCRLRequest_Payload interface { + isGenerateCRLRequest_Payload() +} + +type GenerateCRLRequest_Metadata struct { + Metadata *CRLMetadata `protobuf:"bytes,1,opt,name=metadata,proto3,oneof"` +} + +type GenerateCRLRequest_Entry struct { + Entry *proto.CRLEntry `protobuf:"bytes,2,opt,name=entry,proto3,oneof"` +} + +func (*GenerateCRLRequest_Metadata) isGenerateCRLRequest_Payload() {} + +func (*GenerateCRLRequest_Entry) isGenerateCRLRequest_Payload() {} + +type CRLMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 5 + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + ThisUpdate *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=thisUpdate,proto3" json:"thisUpdate,omitempty"` + ShardIdx int64 `protobuf:"varint,3,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` +} + +func (x *CRLMetadata) Reset() { + *x = CRLMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CRLMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CRLMetadata) ProtoMessage() {} + +func (x *CRLMetadata) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CRLMetadata.ProtoReflect.Descriptor instead. +func (*CRLMetadata) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{6} +} + +func (x *CRLMetadata) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *CRLMetadata) GetThisUpdate() *timestamppb.Timestamp { + if x != nil { + return x.ThisUpdate + } + return nil +} + +func (x *CRLMetadata) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +type GenerateCRLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Chunk []byte `protobuf:"bytes,1,opt,name=chunk,proto3" json:"chunk,omitempty"` +} + +func (x *GenerateCRLResponse) Reset() { + *x = GenerateCRLResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_ca_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GenerateCRLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateCRLResponse) ProtoMessage() {} + +func (x *GenerateCRLResponse) ProtoReflect() protoreflect.Message { + mi := &file_ca_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateCRLResponse.ProtoReflect.Descriptor instead. +func (*GenerateCRLResponse) Descriptor() ([]byte, []int) { + return file_ca_proto_rawDescGZIP(), []int{7} +} + +func (x *GenerateCRLResponse) GetChunk() []byte { + if x != nil { + return x.Chunk + } + return nil +} + +var File_ca_proto protoreflect.FileDescriptor + +var file_ca_proto_rawDesc = []byte{ + 0x0a, 0x08, 0x63, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x63, 0x61, 0x1a, 0x15, + 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x01, 0x0a, 0x17, 0x49, 0x73, 0x73, 0x75, 0x65, + 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x73, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x03, 0x63, 0x73, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, + 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x44, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6f, + 0x72, 0x64, 0x65, 0x72, 0x49, 0x44, 0x12, 0x28, 0x0a, 0x0f, 0x63, 0x65, 0x72, 0x74, 0x50, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0f, 0x63, 0x65, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, + 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x83, 0x01, 0x0a, 0x1b, 0x49, 0x73, 0x73, 0x75, 0x65, + 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x44, 0x45, 0x52, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x03, 0x44, 0x45, 0x52, 0x12, 0x28, 0x0a, 0x0f, 0x63, 0x65, 0x72, 0x74, + 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0f, 0x63, 0x65, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x61, + 0x73, 0x68, 0x12, 0x28, 0x0a, 0x0f, 0x63, 0x65, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, + 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x63, 0x65, 0x72, + 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xbc, 0x01, 0x0a, + 0x28, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x46, 0x6f, 0x72, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x44, 0x45, 0x52, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x44, 0x45, 0x52, 0x12, 0x12, 0x0a, 0x04, 0x53, + 0x43, 0x54, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x04, 0x53, 0x43, 0x54, 0x73, 0x12, + 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x44, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, + 0x44, 0x12, 0x28, 0x0a, 0x0f, 0x63, 0x65, 0x72, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, + 0x48, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x63, 0x65, 0x72, 0x74, + 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x61, 0x73, 0x68, 0x22, 0xb9, 0x01, 0x0a, 0x13, + 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, + 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x41, 0x74, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x49, + 0x44, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x49, + 0x44, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x2a, 0x0a, 0x0c, 0x4f, 0x43, 0x53, 0x50, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x76, 0x0a, 0x12, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x43, + 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2d, 0x0a, 0x08, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x61, + 0x2e, 0x43, 0x52, 0x4c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x48, 0x00, 0x52, 0x08, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x26, 0x0a, 0x05, 0x65, 0x6e, 0x74, 0x72, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, + 0x52, 0x4c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x48, 0x00, 0x52, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, + 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x8f, 0x01, 0x0a, 0x0b, + 0x43, 0x52, 0x4c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x22, 0x0a, 0x0c, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, + 0x3a, 0x0a, 0x0a, 0x74, 0x68, 0x69, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x0a, 0x74, 0x68, 0x69, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, + 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, + 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0x2b, 0x0a, + 0x13, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x43, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x32, 0xd5, 0x01, 0x0a, 0x14, 0x43, + 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x74, 0x79, 0x12, 0x55, 0x0a, 0x13, 0x49, 0x73, 0x73, 0x75, 0x65, 0x50, 0x72, 0x65, 0x63, + 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x63, 0x61, 0x2e, + 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x63, 0x61, 0x2e, 0x49, 0x73, 0x73, + 0x75, 0x65, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x66, 0x0a, 0x21, 0x49, 0x73, + 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x46, 0x6f, + 0x72, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, + 0x2c, 0x2e, 0x63, 0x61, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x46, 0x6f, 0x72, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, + 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x11, 0x2e, + 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x22, 0x00, 0x32, 0x4c, 0x0a, 0x0d, 0x4f, 0x43, 0x53, 0x50, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, + 0x74, 0x6f, 0x72, 0x12, 0x3b, 0x0a, 0x0c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, + 0x43, 0x53, 0x50, 0x12, 0x17, 0x2e, 0x63, 0x61, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, + 0x65, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x63, + 0x61, 0x2e, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x32, 0x54, 0x0a, 0x0c, 0x43, 0x52, 0x4c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, + 0x12, 0x44, 0x0a, 0x0b, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x43, 0x52, 0x4c, 0x12, + 0x16, 0x2e, 0x63, 0x61, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x43, 0x52, 0x4c, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x61, 0x2e, 0x47, 0x65, 0x6e, + 0x65, 0x72, 0x61, 0x74, 0x65, 0x43, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_ca_proto_rawDescOnce sync.Once + file_ca_proto_rawDescData = file_ca_proto_rawDesc +) + +func file_ca_proto_rawDescGZIP() []byte { + file_ca_proto_rawDescOnce.Do(func() { + file_ca_proto_rawDescData = protoimpl.X.CompressGZIP(file_ca_proto_rawDescData) + }) + return file_ca_proto_rawDescData +} + +var file_ca_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_ca_proto_goTypes = []interface{}{ + (*IssueCertificateRequest)(nil), // 0: ca.IssueCertificateRequest + (*IssuePrecertificateResponse)(nil), // 1: ca.IssuePrecertificateResponse + (*IssueCertificateForPrecertificateRequest)(nil), // 2: ca.IssueCertificateForPrecertificateRequest + (*GenerateOCSPRequest)(nil), // 3: ca.GenerateOCSPRequest + (*OCSPResponse)(nil), // 4: ca.OCSPResponse + (*GenerateCRLRequest)(nil), // 5: ca.GenerateCRLRequest + (*CRLMetadata)(nil), // 6: ca.CRLMetadata + (*GenerateCRLResponse)(nil), // 7: ca.GenerateCRLResponse + (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp + (*proto.CRLEntry)(nil), // 9: core.CRLEntry + (*proto.Certificate)(nil), // 10: core.Certificate +} +var file_ca_proto_depIdxs = []int32{ + 8, // 0: ca.GenerateOCSPRequest.revokedAt:type_name -> google.protobuf.Timestamp + 6, // 1: ca.GenerateCRLRequest.metadata:type_name -> ca.CRLMetadata + 9, // 2: ca.GenerateCRLRequest.entry:type_name -> core.CRLEntry + 8, // 3: ca.CRLMetadata.thisUpdate:type_name -> google.protobuf.Timestamp + 0, // 4: ca.CertificateAuthority.IssuePrecertificate:input_type -> ca.IssueCertificateRequest + 2, // 5: ca.CertificateAuthority.IssueCertificateForPrecertificate:input_type -> ca.IssueCertificateForPrecertificateRequest + 3, // 6: ca.OCSPGenerator.GenerateOCSP:input_type -> ca.GenerateOCSPRequest + 5, // 7: ca.CRLGenerator.GenerateCRL:input_type -> ca.GenerateCRLRequest + 1, // 8: ca.CertificateAuthority.IssuePrecertificate:output_type -> ca.IssuePrecertificateResponse + 10, // 9: ca.CertificateAuthority.IssueCertificateForPrecertificate:output_type -> core.Certificate + 4, // 10: ca.OCSPGenerator.GenerateOCSP:output_type -> ca.OCSPResponse + 7, // 11: ca.CRLGenerator.GenerateCRL:output_type -> ca.GenerateCRLResponse + 8, // [8:12] is the sub-list for method output_type + 4, // [4:8] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_ca_proto_init() } +func file_ca_proto_init() { + if File_ca_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_ca_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IssueCertificateRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IssuePrecertificateResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IssueCertificateForPrecertificateRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GenerateOCSPRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OCSPResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GenerateCRLRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CRLMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ca_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GenerateCRLResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_ca_proto_msgTypes[5].OneofWrappers = []interface{}{ + (*GenerateCRLRequest_Metadata)(nil), + (*GenerateCRLRequest_Entry)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_ca_proto_rawDesc, + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 3, + }, + GoTypes: file_ca_proto_goTypes, + DependencyIndexes: file_ca_proto_depIdxs, + MessageInfos: file_ca_proto_msgTypes, + }.Build() + File_ca_proto = out.File + file_ca_proto_rawDesc = nil + file_ca_proto_goTypes = nil + file_ca_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/proto/ca.proto b/third-party/github.com/letsencrypt/boulder/ca/proto/ca.proto new file mode 100644 index 000000000..bb470e26d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/proto/ca.proto @@ -0,0 +1,102 @@ +syntax = "proto3"; + +package ca; +option go_package = "github.com/letsencrypt/boulder/ca/proto"; + +import "core/proto/core.proto"; +import "google/protobuf/timestamp.proto"; + +// CertificateAuthority issues certificates. +service CertificateAuthority { + rpc IssuePrecertificate(IssueCertificateRequest) returns (IssuePrecertificateResponse) {} + rpc IssueCertificateForPrecertificate(IssueCertificateForPrecertificateRequest) returns (core.Certificate) {} +} + +message IssueCertificateRequest { + // Next unused field number: 6 + bytes csr = 1; + int64 registrationID = 2; + int64 orderID = 3; + reserved 4; // Previously issuerNameID + + // certProfileName is a human readable name provided by the RA and used to + // determine if the CA can issue for that profile. A default name will be + // assigned inside the CA during *Profile construction if no name is provided. + // The value of this field should not be relied upon inside the RA. + string certProfileName = 5; +} + +message IssuePrecertificateResponse { + // Next unused field number: 4 + bytes DER = 1; + + // certProfileHash is a hash over the exported fields of a certificate profile + // to ensure that the profile remains unchanged after multiple roundtrips + // through the RA and CA. + bytes certProfileHash = 2; + + // certProfileName is a human readable name returned back to the RA for later + // use. If IssueCertificateRequest.certProfileName was an empty string, the + // CAs default profile name will be assigned. + string certProfileName = 3; +} + +message IssueCertificateForPrecertificateRequest { + // Next unused field number: 6 + bytes DER = 1; + repeated bytes SCTs = 2; + int64 registrationID = 3; + int64 orderID = 4; + + // certProfileHash is a hash over the exported fields of a certificate profile + // to ensure that the profile remains unchanged after multiple roundtrips + // through the RA and CA. + bytes certProfileHash = 5; +} + +// OCSPGenerator generates OCSP. We separate this out from +// CertificateAuthority so that we can restrict access to a different subset of +// hosts, so the hosts that need to request OCSP generation don't need to be +// able to request certificate issuance. +service OCSPGenerator { + rpc GenerateOCSP(GenerateOCSPRequest) returns (OCSPResponse) {} +} + +// Exactly one of certDER or [serial and issuerID] must be set. +message GenerateOCSPRequest { + // Next unused field number: 8 + string status = 2; + int32 reason = 3; + reserved 4; // Previously revokedAtNS + google.protobuf.Timestamp revokedAt = 7; + string serial = 5; + int64 issuerID = 6; +} + +message OCSPResponse { + bytes response = 1; +} + +// CRLGenerator signs CRLs. It is separated for the same reason as OCSPGenerator. +service CRLGenerator { + rpc GenerateCRL(stream GenerateCRLRequest) returns (stream GenerateCRLResponse) {} +} + +message GenerateCRLRequest { + oneof payload { + CRLMetadata metadata = 1; + core.CRLEntry entry = 2; + } +} + +message CRLMetadata { + // Next unused field number: 5 + int64 issuerNameID = 1; + reserved 2; // Previously thisUpdateNS + google.protobuf.Timestamp thisUpdate = 4; + int64 shardIdx = 3; +} + +message GenerateCRLResponse { + bytes chunk = 1; +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/proto/ca_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/ca/proto/ca_grpc.pb.go new file mode 100644 index 000000000..c2d87bc0c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/proto/ca_grpc.pb.go @@ -0,0 +1,325 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: ca.proto + +package proto + +import ( + context "context" + proto "github.com/letsencrypt/boulder/core/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CertificateAuthority_IssuePrecertificate_FullMethodName = "/ca.CertificateAuthority/IssuePrecertificate" + CertificateAuthority_IssueCertificateForPrecertificate_FullMethodName = "/ca.CertificateAuthority/IssueCertificateForPrecertificate" +) + +// CertificateAuthorityClient is the client API for CertificateAuthority service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type CertificateAuthorityClient interface { + IssuePrecertificate(ctx context.Context, in *IssueCertificateRequest, opts ...grpc.CallOption) (*IssuePrecertificateResponse, error) + IssueCertificateForPrecertificate(ctx context.Context, in *IssueCertificateForPrecertificateRequest, opts ...grpc.CallOption) (*proto.Certificate, error) +} + +type certificateAuthorityClient struct { + cc grpc.ClientConnInterface +} + +func NewCertificateAuthorityClient(cc grpc.ClientConnInterface) CertificateAuthorityClient { + return &certificateAuthorityClient{cc} +} + +func (c *certificateAuthorityClient) IssuePrecertificate(ctx context.Context, in *IssueCertificateRequest, opts ...grpc.CallOption) (*IssuePrecertificateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IssuePrecertificateResponse) + err := c.cc.Invoke(ctx, CertificateAuthority_IssuePrecertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *certificateAuthorityClient) IssueCertificateForPrecertificate(ctx context.Context, in *IssueCertificateForPrecertificateRequest, opts ...grpc.CallOption) (*proto.Certificate, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Certificate) + err := c.cc.Invoke(ctx, CertificateAuthority_IssueCertificateForPrecertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CertificateAuthorityServer is the server API for CertificateAuthority service. +// All implementations must embed UnimplementedCertificateAuthorityServer +// for forward compatibility +type CertificateAuthorityServer interface { + IssuePrecertificate(context.Context, *IssueCertificateRequest) (*IssuePrecertificateResponse, error) + IssueCertificateForPrecertificate(context.Context, *IssueCertificateForPrecertificateRequest) (*proto.Certificate, error) + mustEmbedUnimplementedCertificateAuthorityServer() +} + +// UnimplementedCertificateAuthorityServer must be embedded to have forward compatible implementations. +type UnimplementedCertificateAuthorityServer struct { +} + +func (UnimplementedCertificateAuthorityServer) IssuePrecertificate(context.Context, *IssueCertificateRequest) (*IssuePrecertificateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method IssuePrecertificate not implemented") +} +func (UnimplementedCertificateAuthorityServer) IssueCertificateForPrecertificate(context.Context, *IssueCertificateForPrecertificateRequest) (*proto.Certificate, error) { + return nil, status.Errorf(codes.Unimplemented, "method IssueCertificateForPrecertificate not implemented") +} +func (UnimplementedCertificateAuthorityServer) mustEmbedUnimplementedCertificateAuthorityServer() {} + +// UnsafeCertificateAuthorityServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CertificateAuthorityServer will +// result in compilation errors. +type UnsafeCertificateAuthorityServer interface { + mustEmbedUnimplementedCertificateAuthorityServer() +} + +func RegisterCertificateAuthorityServer(s grpc.ServiceRegistrar, srv CertificateAuthorityServer) { + s.RegisterService(&CertificateAuthority_ServiceDesc, srv) +} + +func _CertificateAuthority_IssuePrecertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IssueCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CertificateAuthorityServer).IssuePrecertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CertificateAuthority_IssuePrecertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CertificateAuthorityServer).IssuePrecertificate(ctx, req.(*IssueCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CertificateAuthority_IssueCertificateForPrecertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IssueCertificateForPrecertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CertificateAuthorityServer).IssueCertificateForPrecertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CertificateAuthority_IssueCertificateForPrecertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CertificateAuthorityServer).IssueCertificateForPrecertificate(ctx, req.(*IssueCertificateForPrecertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CertificateAuthority_ServiceDesc is the grpc.ServiceDesc for CertificateAuthority service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CertificateAuthority_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ca.CertificateAuthority", + HandlerType: (*CertificateAuthorityServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "IssuePrecertificate", + Handler: _CertificateAuthority_IssuePrecertificate_Handler, + }, + { + MethodName: "IssueCertificateForPrecertificate", + Handler: _CertificateAuthority_IssueCertificateForPrecertificate_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ca.proto", +} + +const ( + OCSPGenerator_GenerateOCSP_FullMethodName = "/ca.OCSPGenerator/GenerateOCSP" +) + +// OCSPGeneratorClient is the client API for OCSPGenerator service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type OCSPGeneratorClient interface { + GenerateOCSP(ctx context.Context, in *GenerateOCSPRequest, opts ...grpc.CallOption) (*OCSPResponse, error) +} + +type oCSPGeneratorClient struct { + cc grpc.ClientConnInterface +} + +func NewOCSPGeneratorClient(cc grpc.ClientConnInterface) OCSPGeneratorClient { + return &oCSPGeneratorClient{cc} +} + +func (c *oCSPGeneratorClient) GenerateOCSP(ctx context.Context, in *GenerateOCSPRequest, opts ...grpc.CallOption) (*OCSPResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OCSPResponse) + err := c.cc.Invoke(ctx, OCSPGenerator_GenerateOCSP_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// OCSPGeneratorServer is the server API for OCSPGenerator service. +// All implementations must embed UnimplementedOCSPGeneratorServer +// for forward compatibility +type OCSPGeneratorServer interface { + GenerateOCSP(context.Context, *GenerateOCSPRequest) (*OCSPResponse, error) + mustEmbedUnimplementedOCSPGeneratorServer() +} + +// UnimplementedOCSPGeneratorServer must be embedded to have forward compatible implementations. +type UnimplementedOCSPGeneratorServer struct { +} + +func (UnimplementedOCSPGeneratorServer) GenerateOCSP(context.Context, *GenerateOCSPRequest) (*OCSPResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GenerateOCSP not implemented") +} +func (UnimplementedOCSPGeneratorServer) mustEmbedUnimplementedOCSPGeneratorServer() {} + +// UnsafeOCSPGeneratorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to OCSPGeneratorServer will +// result in compilation errors. +type UnsafeOCSPGeneratorServer interface { + mustEmbedUnimplementedOCSPGeneratorServer() +} + +func RegisterOCSPGeneratorServer(s grpc.ServiceRegistrar, srv OCSPGeneratorServer) { + s.RegisterService(&OCSPGenerator_ServiceDesc, srv) +} + +func _OCSPGenerator_GenerateOCSP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GenerateOCSPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OCSPGeneratorServer).GenerateOCSP(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OCSPGenerator_GenerateOCSP_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OCSPGeneratorServer).GenerateOCSP(ctx, req.(*GenerateOCSPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// OCSPGenerator_ServiceDesc is the grpc.ServiceDesc for OCSPGenerator service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var OCSPGenerator_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ca.OCSPGenerator", + HandlerType: (*OCSPGeneratorServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GenerateOCSP", + Handler: _OCSPGenerator_GenerateOCSP_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ca.proto", +} + +const ( + CRLGenerator_GenerateCRL_FullMethodName = "/ca.CRLGenerator/GenerateCRL" +) + +// CRLGeneratorClient is the client API for CRLGenerator service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type CRLGeneratorClient interface { + GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[GenerateCRLRequest, GenerateCRLResponse], error) +} + +type cRLGeneratorClient struct { + cc grpc.ClientConnInterface +} + +func NewCRLGeneratorClient(cc grpc.ClientConnInterface) CRLGeneratorClient { + return &cRLGeneratorClient{cc} +} + +func (c *cRLGeneratorClient) GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[GenerateCRLRequest, GenerateCRLResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &CRLGenerator_ServiceDesc.Streams[0], CRLGenerator_GenerateCRL_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[GenerateCRLRequest, GenerateCRLResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type CRLGenerator_GenerateCRLClient = grpc.BidiStreamingClient[GenerateCRLRequest, GenerateCRLResponse] + +// CRLGeneratorServer is the server API for CRLGenerator service. +// All implementations must embed UnimplementedCRLGeneratorServer +// for forward compatibility +type CRLGeneratorServer interface { + GenerateCRL(grpc.BidiStreamingServer[GenerateCRLRequest, GenerateCRLResponse]) error + mustEmbedUnimplementedCRLGeneratorServer() +} + +// UnimplementedCRLGeneratorServer must be embedded to have forward compatible implementations. +type UnimplementedCRLGeneratorServer struct { +} + +func (UnimplementedCRLGeneratorServer) GenerateCRL(grpc.BidiStreamingServer[GenerateCRLRequest, GenerateCRLResponse]) error { + return status.Errorf(codes.Unimplemented, "method GenerateCRL not implemented") +} +func (UnimplementedCRLGeneratorServer) mustEmbedUnimplementedCRLGeneratorServer() {} + +// UnsafeCRLGeneratorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CRLGeneratorServer will +// result in compilation errors. +type UnsafeCRLGeneratorServer interface { + mustEmbedUnimplementedCRLGeneratorServer() +} + +func RegisterCRLGeneratorServer(s grpc.ServiceRegistrar, srv CRLGeneratorServer) { + s.RegisterService(&CRLGenerator_ServiceDesc, srv) +} + +func _CRLGenerator_GenerateCRL_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(CRLGeneratorServer).GenerateCRL(&grpc.GenericServerStream[GenerateCRLRequest, GenerateCRLResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type CRLGenerator_GenerateCRLServer = grpc.BidiStreamingServer[GenerateCRLRequest, GenerateCRLResponse] + +// CRLGenerator_ServiceDesc is the grpc.ServiceDesc for CRLGenerator service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CRLGenerator_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ca.CRLGenerator", + HandlerType: (*CRLGeneratorServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "GenerateCRL", + Handler: _CRLGenerator_GenerateCRL_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "ca.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/bad_algorithm.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/bad_algorithm.der.csr new file mode 100644 index 000000000..5768399d5 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/bad_algorithm.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/cn_and_san.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/cn_and_san.der.csr new file mode 100644 index 000000000..b25cf5f4d Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/cn_and_san.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension.der.csr new file mode 100644 index 000000000..0a6ef3174 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension_empty.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension_empty.der.csr new file mode 100644 index 000000000..2b7df0bfb Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/ct_poison_extension_empty.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa.der.csr new file mode 100644 index 000000000..741f9e8b8 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list.yml b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list.yml new file mode 100644 index 000000000..a648abda3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list.yml @@ -0,0 +1,2 @@ +--- +- 1337 diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list2.yml b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list2.yml new file mode 100644 index 000000000..3365f2b9c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list2.yml @@ -0,0 +1,2 @@ +--- +- 1338 diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list_malformed.yml b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list_malformed.yml new file mode 100644 index 000000000..286888a0a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/testdata/ecdsa_allow_list_malformed.yml @@ -0,0 +1 @@ +not yaml diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/invalid_signature.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/invalid_signature.der.csr new file mode 100644 index 000000000..dc76844ae Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/invalid_signature.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/long_cn.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/long_cn.der.csr new file mode 100644 index 000000000..442eea8a9 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/long_cn.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/must_staple.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/must_staple.der.csr new file mode 100644 index 000000000..c256d35c9 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/must_staple.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/no_names.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/no_names.der.csr new file mode 100644 index 000000000..2e45dfd73 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/no_names.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/short_key.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/short_key.der.csr new file mode 100644 index 000000000..7864f44f8 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/short_key.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/testcsr.go b/third-party/github.com/letsencrypt/boulder/ca/testdata/testcsr.go new file mode 100644 index 000000000..cd22487cd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ca/testdata/testcsr.go @@ -0,0 +1,40 @@ +// Hack up the x509.CertificateRequest in here, run `go run testcsr.go`, and a +// DER-encoded CertificateRequest will be printed to stdout. +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "log" + "os" +) + +func main() { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf("Failed to parse private key: %s", err) + } + + req := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: "CapiTalizedLetters.com", + }, + DNSNames: []string{ + "moreCAPs.com", + "morecaps.com", + "evenMOREcaps.com", + "Capitalizedletters.COM", + }, + } + csr, err := x509.CreateCertificateRequest(rand.Reader, req, priv) + if err != nil { + log.Fatalf("unable to create CSR: %s", err) + } + _, err = os.Stdout.Write(csr) + if err != nil { + log.Fatalf("unable to write to stdout: %s", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/too_many_names.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/too_many_names.der.csr new file mode 100644 index 000000000..71771782f Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/too_many_names.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/ca/testdata/unsupported_extension.der.csr b/third-party/github.com/letsencrypt/boulder/ca/testdata/unsupported_extension.der.csr new file mode 100644 index 000000000..fff3cbab2 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ca/testdata/unsupported_extension.der.csr differ diff --git a/third-party/github.com/letsencrypt/boulder/canceled/canceled.go b/third-party/github.com/letsencrypt/boulder/canceled/canceled.go new file mode 100644 index 000000000..405cacd3e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/canceled/canceled.go @@ -0,0 +1,16 @@ +package canceled + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Is returns true if err is non-nil and is either context.Canceled, or has a +// grpc code of Canceled. This is useful because cancellations propagate through +// gRPC boundaries, and if we choose to treat in-process cancellations a certain +// way, we usually want to treat cross-process cancellations the same way. +func Is(err error) bool { + return err == context.Canceled || status.Code(err) == codes.Canceled +} diff --git a/third-party/github.com/letsencrypt/boulder/canceled/canceled_test.go b/third-party/github.com/letsencrypt/boulder/canceled/canceled_test.go new file mode 100644 index 000000000..251072d8e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/canceled/canceled_test.go @@ -0,0 +1,22 @@ +package canceled + +import ( + "context" + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestCanceled(t *testing.T) { + if !Is(context.Canceled) { + t.Errorf("Expected context.Canceled to be canceled, but wasn't.") + } + if !Is(status.Errorf(codes.Canceled, "hi")) { + t.Errorf("Expected gRPC cancellation to be cancelled, but wasn't.") + } + if Is(errors.New("hi")) { + t.Errorf("Expected random error to not be cancelled, but was.") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin-revoker/main.go b/third-party/github.com/letsencrypt/boulder/cmd/admin-revoker/main.go new file mode 100644 index 000000000..7d18bc749 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin-revoker/main.go @@ -0,0 +1,70 @@ +package notmain + +import ( + "fmt" + "os" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/features" +) + +type Config struct { + Revoker struct { + DB cmd.DBConfig + // Similarly, the Revoker needs a TLSConfig to set up its GRPC client + // certs, but doesn't get the TLS field from ServiceConfig, so declares + // its own. + TLS cmd.TLSConfig + + RAService *cmd.GRPCClientConfig + SAService *cmd.GRPCClientConfig + + Features features.Config + } + + Syslog cmd.SyslogConfig +} + +func main() { + if len(os.Args) == 1 { + fmt.Println("use `admin -h` to learn how to use the new admin tool") + os.Exit(1) + } + + command := os.Args[1] + switch { + case command == "serial-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -serial deadbeef -reason X` instead") + + case command == "batched-serial-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -serials-file path -reason X` instead") + + case command == "reg-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -reg-id Y -reason X` instead") + + case command == "malformed-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -serial deadbeef -reason X -malformed` instead") + + case command == "list-reasons": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -h` instead") + + case command == "private-key-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -private-key path -reason X` instead") + + case command == "private-key-block": + fmt.Println("use `admin -config path/to/cfg.json block-key -private-key path -comment foo` instead") + + case command == "incident-table-revoke": + fmt.Println("use `admin -config path/to/cfg.json revoke-cert -incident-table tablename -reason X` instead") + + case command == "clear-email": + fmt.Println("use `admin -config path/to/cfg.json update-email -address foo@bar.org -clear` instead") + + default: + fmt.Println("use `admin -h` to see a list of flags and subcommands for the new admin tool") + } +} + +func init() { + cmd.RegisterCommand("admin-revoker", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/admin.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/admin.go new file mode 100644 index 000000000..d8d3d2ba8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/admin.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "fmt" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// admin holds all of the external connections necessary to perform admin +// actions on a boulder deployment. +type admin struct { + rac rapb.RegistrationAuthorityClient + sac sapb.StorageAuthorityClient + saroc sapb.StorageAuthorityReadOnlyClient + // TODO: Remove this and only use sac and saroc to interact with the db. + // We cannot have true dry-run safety as long as we have a direct dbMap. + dbMap *db.WrappedMap + + // TODO: Remove this when the dbMap is removed and the dryRunSAC and dryRunRAC + // handle all dry-run safety. + dryRun bool + + clk clock.Clock + log blog.Logger +} + +// newAdmin constructs a new admin object on the heap and returns a pointer to +// it. +func newAdmin(configFile string, dryRun bool) (*admin, error) { + // Unlike most boulder service constructors, this does all of its own config + // parsing and dependency setup. If this is broken out into its own package + // (outside the //cmd/ directory) those pieces of setup should stay behind + // in //cmd/admin/main.go, to match other boulder services. + var c Config + err := cmd.ReadConfigFile(configFile, &c) + if err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.Admin.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + clk := cmd.Clock() + features.Set(c.Admin.Features) + + tlsConfig, err := c.Admin.TLS.Load(scope) + if err != nil { + return nil, fmt.Errorf("loading TLS config: %w", err) + } + + var rac rapb.RegistrationAuthorityClient = dryRunRAC{log: logger} + if !dryRun { + raConn, err := bgrpc.ClientSetup(c.Admin.RAService, tlsConfig, scope, clk) + if err != nil { + return nil, fmt.Errorf("creating RA gRPC client: %w", err) + } + rac = rapb.NewRegistrationAuthorityClient(raConn) + } + + saConn, err := bgrpc.ClientSetup(c.Admin.SAService, tlsConfig, scope, clk) + if err != nil { + return nil, fmt.Errorf("creating SA gRPC client: %w", err) + } + saroc := sapb.NewStorageAuthorityReadOnlyClient(saConn) + + var sac sapb.StorageAuthorityClient = dryRunSAC{log: logger} + if !dryRun { + sac = sapb.NewStorageAuthorityClient(saConn) + } + + dbMap, err := sa.InitWrappedDb(c.Admin.DB, nil, logger) + if err != nil { + return nil, fmt.Errorf("creating database connection: %w", err) + } + + return &admin{ + rac: rac, + sac: sac, + saroc: saroc, + dbMap: dbMap, + dryRun: dryRun, + clk: clk, + log: logger, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/cert.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/cert.go new file mode 100644 index 000000000..dc9c48884 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/cert.go @@ -0,0 +1,324 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/user" + "strings" + "sync" + "sync/atomic" + "unicode" + + "golang.org/x/crypto/ocsp" + "golang.org/x/exp/maps" + + core "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// subcommandRevokeCert encapsulates the "admin revoke-cert" command. It accepts +// many flags specifying different ways a to-be-revoked certificate can be +// identified. It then gathers the serial numbers of all identified certs, spins +// up a worker pool, and revokes all of those serials individually. +// +// Note that some batch methods (such as -incident-table and -serials-file) can +// result in high memory usage, as this subcommand will gather every serial in +// memory before beginning to revoke any of them. This trades local memory usage +// for shorter database and gRPC query times, so that we don't need massive +// timeouts when collecting serials to revoke. +type subcommandRevokeCert struct { + parallelism uint + reasonStr string + skipBlock bool + malformed bool + serial string + incidentTable string + serialsFile string + privKey string + regID uint + certFile string +} + +var _ subcommand = (*subcommandRevokeCert)(nil) + +func (s *subcommandRevokeCert) Desc() string { + return "Revoke one or more certificates" +} + +func (s *subcommandRevokeCert) Flags(flag *flag.FlagSet) { + // General flags relevant to all certificate input methods. + flag.UintVar(&s.parallelism, "parallelism", 10, "Number of concurrent workers to use while revoking certs") + flag.StringVar(&s.reasonStr, "reason", "unspecified", "Revocation reason (unspecified, keyCompromise, superseded, cessationOfOperation, or privilegeWithdrawn)") + flag.BoolVar(&s.skipBlock, "skip-block-key", false, "Skip blocking the key, if revoked for keyCompromise - use with extreme caution") + flag.BoolVar(&s.malformed, "malformed", false, "Indicates that the cert cannot be parsed - use with caution") + + // Flags specifying the input method for the certificates to be revoked. + flag.StringVar(&s.serial, "serial", "", "Revoke the certificate with this hex serial") + flag.StringVar(&s.incidentTable, "incident-table", "", "Revoke all certificates whose serials are in this table") + flag.StringVar(&s.serialsFile, "serials-file", "", "Revoke all certificates whose hex serials are in this file") + flag.StringVar(&s.privKey, "private-key", "", "Revoke all certificates whose pubkey matches this private key") + flag.UintVar(&s.regID, "reg-id", 0, "Revoke all certificates issued to this account") + flag.StringVar(&s.certFile, "cert-file", "", "Revoke the single PEM-formatted certificate in this file") +} + +func (s *subcommandRevokeCert) Run(ctx context.Context, a *admin) error { + if s.parallelism == 0 { + // Why did they override it to 0, instead of just leaving it the default? + return fmt.Errorf("got unacceptable parallelism %d", s.parallelism) + } + + reasonCode := revocation.Reason(-1) + for code := range revocation.AdminAllowedReasons { + if s.reasonStr == revocation.ReasonToString[code] { + reasonCode = code + break + } + } + if reasonCode == revocation.Reason(-1) { + return fmt.Errorf("got unacceptable revocation reason %q", s.reasonStr) + } + + if s.skipBlock && reasonCode == ocsp.KeyCompromise { + // We would only add the SPKI hash of the pubkey to the blockedKeys table if + // the revocation reason is keyCompromise. + return errors.New("-skip-block-key only makes sense with -reason=1") + } + + if s.malformed && reasonCode == ocsp.KeyCompromise { + // This is because we can't extract and block the pubkey if we can't + // parse the certificate. + return errors.New("cannot revoke malformed certs for reason keyCompromise") + } + + // This is a map of all input-selection flags to whether or not they were set + // to a non-default value. We use this to ensure that exactly one input + // selection flag was given on the command line. + setInputs := map[string]bool{ + "-serial": s.serial != "", + "-incident-table": s.incidentTable != "", + "-serials-file": s.serialsFile != "", + "-private-key": s.privKey != "", + "-reg-id": s.regID != 0, + "-cert-file": s.certFile != "", + } + maps.DeleteFunc(setInputs, func(_ string, v bool) bool { return !v }) + if len(setInputs) == 0 { + return errors.New("at least one input method flag must be specified") + } else if len(setInputs) > 1 { + return fmt.Errorf("more than one input method flag specified: %v", maps.Keys(setInputs)) + } + + var serials []string + var err error + switch maps.Keys(setInputs)[0] { + case "-serial": + serials, err = []string{s.serial}, nil + case "-incident-table": + serials, err = a.serialsFromIncidentTable(ctx, s.incidentTable) + case "-serials-file": + serials, err = a.serialsFromFile(ctx, s.serialsFile) + case "-private-key": + serials, err = a.serialsFromPrivateKey(ctx, s.privKey) + case "-reg-id": + serials, err = a.serialsFromRegID(ctx, int64(s.regID)) + case "-cert-file": + serials, err = a.serialsFromCertPEM(ctx, s.certFile) + default: + return errors.New("no recognized input method flag set (this shouldn't happen)") + } + if err != nil { + return fmt.Errorf("collecting serials to revoke: %w", err) + } + + if len(serials) == 0 { + return errors.New("no serials to revoke found") + } + a.log.Infof("Found %d certificates to revoke", len(serials)) + + err = a.revokeSerials(ctx, serials, reasonCode, s.malformed, s.skipBlock, s.parallelism) + if err != nil { + return fmt.Errorf("revoking serials: %w", err) + } + + return nil +} + +func (a *admin) serialsFromIncidentTable(ctx context.Context, tableName string) ([]string, error) { + stream, err := a.saroc.SerialsForIncident(ctx, &sapb.SerialsForIncidentRequest{IncidentTable: tableName}) + if err != nil { + return nil, fmt.Errorf("setting up stream of serials from incident table %q: %s", tableName, err) + } + + var serials []string + for { + is, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("streaming serials from incident table %q: %s", tableName, err) + } + serials = append(serials, is.Serial) + } + + return serials, nil +} + +func (a *admin) serialsFromFile(_ context.Context, filePath string) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("opening serials file: %w", err) + } + + var serials []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + serial := scanner.Text() + if serial == "" { + continue + } + serials = append(serials, serial) + } + + return serials, nil +} + +func (a *admin) serialsFromPrivateKey(ctx context.Context, privkeyFile string) ([]string, error) { + spkiHash, err := a.spkiHashFromPrivateKey(privkeyFile) + if err != nil { + return nil, err + } + + stream, err := a.saroc.GetSerialsByKey(ctx, &sapb.SPKIHash{KeyHash: spkiHash}) + if err != nil { + return nil, fmt.Errorf("setting up stream of serials from SA: %s", err) + } + + var serials []string + for { + serial, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("streaming serials from SA: %s", err) + } + serials = append(serials, serial.Serial) + } + + return serials, nil +} + +func (a *admin) serialsFromRegID(ctx context.Context, regID int64) ([]string, error) { + _, err := a.saroc.GetRegistration(ctx, &sapb.RegistrationID{Id: regID}) + if err != nil { + return nil, fmt.Errorf("couldn't confirm regID exists: %w", err) + } + + stream, err := a.saroc.GetSerialsByAccount(ctx, &sapb.RegistrationID{Id: regID}) + if err != nil { + return nil, fmt.Errorf("setting up stream of serials from SA: %s", err) + } + + var serials []string + for { + serial, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("streaming serials from SA: %s", err) + } + serials = append(serials, serial.Serial) + } + + return serials, nil +} + +func (a *admin) serialsFromCertPEM(_ context.Context, filename string) ([]string, error) { + cert, err := core.LoadCert(filename) + if err != nil { + return nil, fmt.Errorf("loading certificate pem: %w", err) + } + + return []string{core.SerialToString(cert.SerialNumber)}, nil +} + +func cleanSerial(serial string) (string, error) { + serialStrip := func(r rune) rune { + switch { + case unicode.IsLetter(r): + return r + case unicode.IsDigit(r): + return r + } + return rune(-1) + } + strippedSerial := strings.Map(serialStrip, serial) + if !core.ValidSerial(strippedSerial) { + return "", fmt.Errorf("cleaned serial %q is not valid", strippedSerial) + } + return strippedSerial, nil +} + +func (a *admin) revokeSerials(ctx context.Context, serials []string, reason revocation.Reason, malformed bool, skipBlockKey bool, parallelism uint) error { + u, err := user.Current() + if err != nil { + return fmt.Errorf("getting admin username: %w", err) + } + + var errCount atomic.Uint64 + wg := new(sync.WaitGroup) + work := make(chan string, parallelism) + for i := uint(0); i < parallelism; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for serial := range work { + cleanedSerial, err := cleanSerial(serial) + if err != nil { + a.log.Errf("skipping serial %q: %s", serial, err) + continue + } + _, err = a.rac.AdministrativelyRevokeCertificate( + ctx, + &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: cleanedSerial, + Code: int64(reason), + AdminName: u.Username, + SkipBlockKey: skipBlockKey, + Malformed: malformed, + }, + ) + if err != nil { + errCount.Add(1) + if errors.Is(err, berrors.AlreadyRevoked) { + a.log.Errf("not revoking %q: already revoked", serial) + } else { + a.log.Errf("failed to revoke %q: %s", serial, err) + } + } + } + }() + } + + for _, serial := range serials { + work <- serial + } + close(work) + wg.Wait() + + if errCount.Load() > 0 { + return fmt.Errorf("encountered %d errors while revoking certs; see logs above for details", errCount.Load()) + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/cert_test.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/cert_test.go new file mode 100644 index 000000000..185d49701 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/cert_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "os" + "path" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mocks" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +// mockSAWithIncident is a mock which only implements the SerialsForIncident +// gRPC method. It can be initialized with a set of serials for that method +// to return. +type mockSAWithIncident struct { + sapb.StorageAuthorityReadOnlyClient + incidentSerials []string +} + +// SerialsForIncident returns a fake gRPC stream client object which itself +// will return the mockSAWithIncident's serials in order. +func (msa *mockSAWithIncident) SerialsForIncident(_ context.Context, _ *sapb.SerialsForIncidentRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[sapb.IncidentSerial], error) { + fakeResults := make([]*sapb.IncidentSerial, len(msa.incidentSerials)) + for i, serial := range msa.incidentSerials { + fakeResults[i] = &sapb.IncidentSerial{Serial: serial} + } + return &mocks.ServerStreamClient[sapb.IncidentSerial]{Results: fakeResults}, nil +} + +func TestSerialsFromIncidentTable(t *testing.T) { + t.Parallel() + serials := []string{"foo", "bar", "baz"} + + a := admin{ + saroc: &mockSAWithIncident{incidentSerials: serials}, + } + + res, err := a.serialsFromIncidentTable(context.Background(), "tablename") + test.AssertNotError(t, err, "getting serials from mock SA") + test.AssertDeepEquals(t, res, serials) +} + +func TestSerialsFromFile(t *testing.T) { + t.Parallel() + serials := []string{"foo", "bar", "baz"} + + serialsFile := path.Join(t.TempDir(), "serials.txt") + err := os.WriteFile(serialsFile, []byte(strings.Join(serials, "\n")), os.ModeAppend) + test.AssertNotError(t, err, "writing temp serials file") + + a := admin{} + + res, err := a.serialsFromFile(context.Background(), serialsFile) + test.AssertNotError(t, err, "getting serials from file") + test.AssertDeepEquals(t, res, serials) +} + +// mockSAWithKey is a mock which only implements the GetSerialsByKey +// gRPC method. It can be initialized with a set of serials for that method +// to return. +type mockSAWithKey struct { + sapb.StorageAuthorityReadOnlyClient + keyHash []byte + serials []string +} + +// GetSerialsByKey returns a fake gRPC stream client object which itself +// will return the mockSAWithKey's serials in order. +func (msa *mockSAWithKey) GetSerialsByKey(_ context.Context, req *sapb.SPKIHash, _ ...grpc.CallOption) (grpc.ServerStreamingClient[sapb.Serial], error) { + if !slices.Equal(req.KeyHash, msa.keyHash) { + return &mocks.ServerStreamClient[sapb.Serial]{}, nil + } + fakeResults := make([]*sapb.Serial, len(msa.serials)) + for i, serial := range msa.serials { + fakeResults[i] = &sapb.Serial{Serial: serial} + } + return &mocks.ServerStreamClient[sapb.Serial]{Results: fakeResults}, nil +} + +func TestSerialsFromPrivateKey(t *testing.T) { + serials := []string{"foo", "bar", "baz"} + fc := clock.NewFake() + fc.Set(time.Now()) + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating test private key") + keyBytes, err := x509.MarshalPKCS8PrivateKey(privKey) + test.AssertNotError(t, err, "marshalling test private key bytes") + + keyFile := path.Join(t.TempDir(), "key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}) + err = os.WriteFile(keyFile, keyPEM, os.ModeAppend) + test.AssertNotError(t, err, "writing test private key file") + + keyHash, err := core.KeyDigest(privKey.Public()) + test.AssertNotError(t, err, "computing test SPKI hash") + + a := admin{saroc: &mockSAWithKey{keyHash: keyHash[:], serials: serials}} + + res, err := a.serialsFromPrivateKey(context.Background(), keyFile) + test.AssertNotError(t, err, "getting serials from keyHashToSerial table") + test.AssertDeepEquals(t, res, serials) +} + +// mockSAWithAccount is a mock which only implements the GetSerialsByAccount +// gRPC method. It can be initialized with a set of serials for that method +// to return. +type mockSAWithAccount struct { + sapb.StorageAuthorityReadOnlyClient + regID int64 + serials []string +} + +func (msa *mockSAWithAccount) GetRegistration(_ context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { + if req.Id != msa.regID { + return nil, errors.New("no such reg") + } + return &corepb.Registration{}, nil +} + +// GetSerialsByAccount returns a fake gRPC stream client object which itself +// will return the mockSAWithAccount's serials in order. +func (msa *mockSAWithAccount) GetSerialsByAccount(_ context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (grpc.ServerStreamingClient[sapb.Serial], error) { + if req.Id != msa.regID { + return &mocks.ServerStreamClient[sapb.Serial]{}, nil + } + fakeResults := make([]*sapb.Serial, len(msa.serials)) + for i, serial := range msa.serials { + fakeResults[i] = &sapb.Serial{Serial: serial} + } + return &mocks.ServerStreamClient[sapb.Serial]{Results: fakeResults}, nil +} + +func TestSerialsFromRegID(t *testing.T) { + serials := []string{"foo", "bar", "baz"} + a := admin{saroc: &mockSAWithAccount{regID: 123, serials: serials}} + + res, err := a.serialsFromRegID(context.Background(), 123) + test.AssertNotError(t, err, "getting serials from serials table") + test.AssertDeepEquals(t, res, serials) +} + +// mockRARecordingRevocations is a mock which only implements the +// AdministrativelyRevokeCertificate gRPC method. It can be initialized with +// serials to recognize as already revoked, or to fail. +type mockRARecordingRevocations struct { + rapb.RegistrationAuthorityClient + doomedToFail []string + alreadyRevoked []string + revocationRequests []*rapb.AdministrativelyRevokeCertificateRequest + sync.Mutex +} + +// AdministrativelyRevokeCertificate records the request it received on the mock +// RA struct, and succeeds if it doesn't recognize the serial as one it should +// fail for. +func (mra *mockRARecordingRevocations) AdministrativelyRevokeCertificate(_ context.Context, req *rapb.AdministrativelyRevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + mra.Lock() + defer mra.Unlock() + mra.revocationRequests = append(mra.revocationRequests, req) + if slices.Contains(mra.doomedToFail, req.Serial) { + return nil, errors.New("oops") + } + if slices.Contains(mra.alreadyRevoked, req.Serial) { + return nil, berrors.AlreadyRevokedError("too slow") + } + return &emptypb.Empty{}, nil +} + +func (mra *mockRARecordingRevocations) reset() { + mra.doomedToFail = nil + mra.alreadyRevoked = nil + mra.revocationRequests = nil +} + +func TestRevokeSerials(t *testing.T) { + t.Parallel() + serials := []string{ + "2a:18:59:2b:7f:4b:f5:96:fb:1a:1d:f1:35:56:7a:cd:82:5a", + "03:8c:3f:63:88:af:b7:69:5d:d4:d6:bb:e3:d2:64:f1:e4:e2", + "048c3f6388afb7695dd4d6bbe3d264f1e5e5!", + } + mra := mockRARecordingRevocations{} + log := blog.NewMock() + a := admin{rac: &mra, log: log} + + assertRequestsContain := func(reqs []*rapb.AdministrativelyRevokeCertificateRequest, code revocation.Reason, skipBlockKey bool, malformed bool) { + for _, req := range reqs { + test.AssertEquals(t, len(req.Cert), 0) + test.AssertEquals(t, req.Code, int64(code)) + test.AssertEquals(t, req.SkipBlockKey, skipBlockKey) + test.AssertEquals(t, req.Malformed, malformed) + } + } + + // Revoking should result in 3 gRPC requests and quiet execution. + mra.reset() + log.Clear() + a.dryRun = false + err := a.revokeSerials(context.Background(), serials, 0, false, false, 1) + test.AssertEquals(t, len(log.GetAllMatching("invalid serial format")), 0) + test.AssertNotError(t, err, "") + test.AssertEquals(t, len(log.GetAll()), 0) + test.AssertEquals(t, len(mra.revocationRequests), 3) + assertRequestsContain(mra.revocationRequests, 0, false, false) + + // Revoking an already-revoked serial should result in one log line. + mra.reset() + log.Clear() + mra.alreadyRevoked = []string{"048c3f6388afb7695dd4d6bbe3d264f1e5e5"} + err = a.revokeSerials(context.Background(), serials, 0, false, false, 1) + test.AssertError(t, err, "already-revoked should result in error") + test.AssertEquals(t, len(log.GetAllMatching("not revoking")), 1) + test.AssertEquals(t, len(mra.revocationRequests), 3) + assertRequestsContain(mra.revocationRequests, 0, false, false) + + // Revoking a doomed-to-fail serial should also result in one log line. + mra.reset() + log.Clear() + mra.doomedToFail = []string{"048c3f6388afb7695dd4d6bbe3d264f1e5e5"} + err = a.revokeSerials(context.Background(), serials, 0, false, false, 1) + test.AssertError(t, err, "gRPC error should result in error") + test.AssertEquals(t, len(log.GetAllMatching("failed to revoke")), 1) + test.AssertEquals(t, len(mra.revocationRequests), 3) + assertRequestsContain(mra.revocationRequests, 0, false, false) + + // Revoking with other parameters should get carried through. + mra.reset() + log.Clear() + err = a.revokeSerials(context.Background(), serials, 1, true, true, 3) + test.AssertNotError(t, err, "") + test.AssertEquals(t, len(mra.revocationRequests), 3) + assertRequestsContain(mra.revocationRequests, 1, true, true) + + // Revoking in dry-run mode should result in no gRPC requests and three logs. + mra.reset() + log.Clear() + a.dryRun = true + a.rac = dryRunRAC{log: log} + err = a.revokeSerials(context.Background(), serials, 0, false, false, 1) + test.AssertNotError(t, err, "") + test.AssertEquals(t, len(log.GetAllMatching("dry-run:")), 3) + test.AssertEquals(t, len(mra.revocationRequests), 0) + assertRequestsContain(mra.revocationRequests, 0, false, false) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/dryrun.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/dryrun.go new file mode 100644 index 000000000..77a7b1614 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/dryrun.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/prototext" + "google.golang.org/protobuf/types/known/emptypb" + + blog "github.com/letsencrypt/boulder/log" + rapb "github.com/letsencrypt/boulder/ra/proto" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type dryRunRAC struct { + rapb.RegistrationAuthorityClient + log blog.Logger +} + +func (d dryRunRAC) AdministrativelyRevokeCertificate(_ context.Context, req *rapb.AdministrativelyRevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + b, err := prototext.Marshal(req) + if err != nil { + return nil, err + } + d.log.Infof("dry-run: %#v", string(b)) + return &emptypb.Empty{}, nil +} + +type dryRunSAC struct { + sapb.StorageAuthorityClient + log blog.Logger +} + +func (d dryRunSAC) AddBlockedKey(_ context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + b, err := prototext.Marshal(req) + if err != nil { + return nil, err + } + d.log.Infof("dry-run: %#v", string(b)) + return &emptypb.Empty{}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/email.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/email.go new file mode 100644 index 000000000..c9b85e0c5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/email.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + + "github.com/letsencrypt/boulder/sa" +) + +// subcommandUpdateEmail encapsulates the "admin update-email" command. +// +// Note that this command may be very slow, as the initial query to find the set +// of accounts which have a matching contact email address does not use a +// database index. Therefore, when updating the found accounts, it does not exit +// on failure, preferring to continue and make as much progress as possible. +type subcommandUpdateEmail struct { + address string + clear bool +} + +var _ subcommand = (*subcommandUpdateEmail)(nil) + +func (s *subcommandUpdateEmail) Desc() string { + return "Change or remove an email address across all accounts" +} + +func (s *subcommandUpdateEmail) Flags(flag *flag.FlagSet) { + flag.StringVar(&s.address, "address", "", "Email address to update") + flag.BoolVar(&s.clear, "clear", false, "If set, remove the address") +} + +func (s *subcommandUpdateEmail) Run(ctx context.Context, a *admin) error { + if s.address == "" { + return errors.New("the -address flag is required") + } + + if s.clear { + return a.clearEmail(ctx, s.address) + } + + return errors.New("no action to perform on the given email was specified") +} + +func (a *admin) clearEmail(ctx context.Context, address string) error { + a.log.AuditInfof("Scanning database for accounts with email addresses matching %q in order to clear the email addresses.", address) + + // We use SQL `CONCAT` rather than interpolating with `+` or `%s` because we want to + // use a `?` placeholder for the email, which prevents SQL injection. + // Since this uses a substring match, it is important + // to subsequently parse the JSON list of addresses and look for exact matches. + // Because this does not use an index, it is very slow. + var regIDs []int64 + _, err := a.dbMap.Select(ctx, ®IDs, "SELECT id FROM registrations WHERE contact LIKE CONCAT('%\"mailto:', ?, '\"%')", address) + if err != nil { + return fmt.Errorf("identifying matching accounts: %w", err) + } + + a.log.Infof("Found %d registration IDs matching email %q.", len(regIDs), address) + + failures := 0 + for _, regID := range regIDs { + if a.dryRun { + a.log.Infof("dry-run: remove %q from account %d", address, regID) + continue + } + + err := sa.ClearEmail(ctx, a.dbMap, regID, address) + if err != nil { + // Log, but don't fail, because it took a long time to find the relevant registration IDs + // and we don't want to have to redo that work. + a.log.AuditErrf("failed to clear email %q for registration ID %d: %s", address, regID, err) + failures++ + } else { + a.log.AuditInfof("cleared email %q for registration ID %d", address, regID) + } + } + if failures > 0 { + return fmt.Errorf("failed to clear email for %d out of %d registration IDs", failures, len(regIDs)) + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/key.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/key.go new file mode 100644 index 000000000..66da63ebe --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/key.go @@ -0,0 +1,229 @@ +package main + +import ( + "bufio" + "context" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "os" + "os/user" + "sync" + "sync/atomic" + + "golang.org/x/exp/maps" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/privatekey" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// subcommandBlockKey encapsulates the "admin block-key" command. +type subcommandBlockKey struct { + parallelism uint + comment string + privKey string + spkiFile string + certFile string +} + +var _ subcommand = (*subcommandBlockKey)(nil) + +func (s *subcommandBlockKey) Desc() string { + return "Block a keypair from any future issuance" +} + +func (s *subcommandBlockKey) Flags(flag *flag.FlagSet) { + // General flags relevant to all key input methods. + flag.UintVar(&s.parallelism, "parallelism", 10, "Number of concurrent workers to use while blocking keys") + flag.StringVar(&s.comment, "comment", "", "Additional context to add to database comment column") + + // Flags specifying the input method for the keys to be blocked. + flag.StringVar(&s.privKey, "private-key", "", "Block issuance for the pubkey corresponding to this private key") + flag.StringVar(&s.spkiFile, "spki-file", "", "Block issuance for all keys listed in this file as SHA256 hashes of SPKI, hex encoded, one per line") + flag.StringVar(&s.certFile, "cert-file", "", "Block issuance for the public key of the single PEM-formatted certificate in this file") +} + +func (s *subcommandBlockKey) Run(ctx context.Context, a *admin) error { + // This is a map of all input-selection flags to whether or not they were set + // to a non-default value. We use this to ensure that exactly one input + // selection flag was given on the command line. + setInputs := map[string]bool{ + "-private-key": s.privKey != "", + "-spki-file": s.spkiFile != "", + "-cert-file": s.certFile != "", + } + maps.DeleteFunc(setInputs, func(_ string, v bool) bool { return !v }) + if len(setInputs) == 0 { + return errors.New("at least one input method flag must be specified") + } else if len(setInputs) > 1 { + return fmt.Errorf("more than one input method flag specified: %v", maps.Keys(setInputs)) + } + + var spkiHashes [][]byte + var err error + switch maps.Keys(setInputs)[0] { + case "-private-key": + var spkiHash []byte + spkiHash, err = a.spkiHashFromPrivateKey(s.privKey) + spkiHashes = [][]byte{spkiHash} + case "-spki-file": + spkiHashes, err = a.spkiHashesFromFile(s.spkiFile) + case "-cert-file": + spkiHashes, err = a.spkiHashesFromCertPEM(s.certFile) + default: + return errors.New("no recognized input method flag set (this shouldn't happen)") + } + if err != nil { + return fmt.Errorf("collecting spki hashes to block: %w", err) + } + + err = a.blockSPKIHashes(ctx, spkiHashes, s.comment, s.parallelism) + if err != nil { + return err + } + + return nil +} + +func (a *admin) spkiHashFromPrivateKey(keyFile string) ([]byte, error) { + _, publicKey, err := privatekey.Load(keyFile) + if err != nil { + return nil, fmt.Errorf("loading private key file: %w", err) + } + + spkiHash, err := core.KeyDigest(publicKey) + if err != nil { + return nil, fmt.Errorf("computing SPKI hash: %w", err) + } + + return spkiHash[:], nil +} + +func (a *admin) spkiHashesFromFile(filePath string) ([][]byte, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("opening spki hashes file: %w", err) + } + + var spkiHashes [][]byte + scanner := bufio.NewScanner(file) + for scanner.Scan() { + spkiHex := scanner.Text() + if spkiHex == "" { + continue + } + spkiHash, err := hex.DecodeString(spkiHex) + if err != nil { + return nil, fmt.Errorf("decoding hex spki hash %q: %w", spkiHex, err) + } + + if len(spkiHash) != 32 { + return nil, fmt.Errorf("got spki hash of unexpected length: %q (%d)", spkiHex, len(spkiHash)) + } + + spkiHashes = append(spkiHashes, spkiHash) + } + + return spkiHashes, nil +} + +func (a *admin) spkiHashesFromCertPEM(filename string) ([][]byte, error) { + cert, err := core.LoadCert(filename) + if err != nil { + return nil, fmt.Errorf("loading certificate pem: %w", err) + } + + spkiHash, err := core.KeyDigest(cert.PublicKey) + if err != nil { + return nil, fmt.Errorf("computing SPKI hash: %w", err) + } + + return [][]byte{spkiHash[:]}, nil +} + +func (a *admin) blockSPKIHashes(ctx context.Context, spkiHashes [][]byte, comment string, parallelism uint) error { + u, err := user.Current() + if err != nil { + return fmt.Errorf("getting admin username: %w", err) + } + + var errCount atomic.Uint64 + wg := new(sync.WaitGroup) + work := make(chan []byte, parallelism) + for i := uint(0); i < parallelism; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for spkiHash := range work { + err = a.blockSPKIHash(ctx, spkiHash, u, comment) + if err != nil { + errCount.Add(1) + if errors.Is(err, berrors.AlreadyRevoked) { + a.log.Errf("not blocking %x: already blocked", spkiHash) + } else { + a.log.Errf("failed to block %x: %s", spkiHash, err) + } + } + } + }() + } + + for _, spkiHash := range spkiHashes { + work <- spkiHash + } + close(work) + wg.Wait() + + if errCount.Load() > 0 { + return fmt.Errorf("encountered %d errors while revoking certs; see logs above for details", errCount.Load()) + } + + return nil +} + +func (a *admin) blockSPKIHash(ctx context.Context, spkiHash []byte, u *user.User, comment string) error { + exists, err := a.saroc.KeyBlocked(ctx, &sapb.SPKIHash{KeyHash: spkiHash}) + if err != nil { + return fmt.Errorf("checking if key is already blocked: %w", err) + } + if exists.Exists { + return berrors.AlreadyRevokedError("the provided key already exists in the 'blockedKeys' table") + } + + stream, err := a.saroc.GetSerialsByKey(ctx, &sapb.SPKIHash{KeyHash: spkiHash}) + if err != nil { + return fmt.Errorf("setting up stream of serials from SA: %s", err) + } + + var count int + for { + _, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("streaming serials from SA: %s", err) + } + count++ + } + + a.log.Infof("Found %d unexpired certificates matching the provided key", count) + + _, err = a.sac.AddBlockedKey(ctx, &sapb.AddBlockedKeyRequest{ + KeyHash: spkiHash[:], + Added: timestamppb.New(a.clk.Now()), + Source: "admin-revoker", + Comment: fmt.Sprintf("%s: %s", u.Username, comment), + RevokedBy: 0, + }) + if err != nil { + return fmt.Errorf("blocking key: %w", err) + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/key_test.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/key_test.go new file mode 100644 index 000000000..0bb192236 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/key_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + "os/user" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mocks" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +func TestSPKIHashFromPrivateKey(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating test private key") + keyHash, err := core.KeyDigest(privKey.Public()) + test.AssertNotError(t, err, "computing test SPKI hash") + + keyBytes, err := x509.MarshalPKCS8PrivateKey(privKey) + test.AssertNotError(t, err, "marshalling test private key bytes") + keyFile := path.Join(t.TempDir(), "key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}) + err = os.WriteFile(keyFile, keyPEM, os.ModeAppend) + test.AssertNotError(t, err, "writing test private key file") + + a := admin{} + + res, err := a.spkiHashFromPrivateKey(keyFile) + test.AssertNotError(t, err, "") + test.AssertByteEquals(t, res, keyHash[:]) +} + +func TestSPKIHashesFromFile(t *testing.T) { + var spkiHexes []string + for i := range 10 { + h := sha256.Sum256([]byte(strconv.Itoa(i))) + spkiHexes = append(spkiHexes, hex.EncodeToString(h[:])) + } + + spkiFile := path.Join(t.TempDir(), "spkis.txt") + err := os.WriteFile(spkiFile, []byte(strings.Join(spkiHexes, "\n")), os.ModeAppend) + test.AssertNotError(t, err, "writing test spki file") + + a := admin{} + + res, err := a.spkiHashesFromFile(spkiFile) + test.AssertNotError(t, err, "") + for i, spkiHash := range res { + test.AssertEquals(t, hex.EncodeToString(spkiHash), spkiHexes[i]) + } +} + +// mockSARecordingBlocks is a mock which only implements the AddBlockedKey gRPC +// method. +type mockSARecordingBlocks struct { + sapb.StorageAuthorityClient + blockRequests []*sapb.AddBlockedKeyRequest +} + +// AddBlockedKey is a mock which always succeeds and records the request it +// received. +func (msa *mockSARecordingBlocks) AddBlockedKey(ctx context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + msa.blockRequests = append(msa.blockRequests, req) + return &emptypb.Empty{}, nil +} + +func (msa *mockSARecordingBlocks) reset() { + msa.blockRequests = nil +} + +type mockSARO struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (sa *mockSARO) GetSerialsByKey(ctx context.Context, _ *sapb.SPKIHash, _ ...grpc.CallOption) (grpc.ServerStreamingClient[sapb.Serial], error) { + return &mocks.ServerStreamClient[sapb.Serial]{}, nil +} + +func (sa *mockSARO) KeyBlocked(ctx context.Context, req *sapb.SPKIHash, _ ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: false}, nil +} + +func TestBlockSPKIHash(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + log := blog.NewMock() + msa := mockSARecordingBlocks{} + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating test private key") + keyHash, err := core.KeyDigest(privKey.Public()) + test.AssertNotError(t, err, "computing test SPKI hash") + + a := admin{saroc: &mockSARO{}, sac: &msa, clk: fc, log: log} + u := &user.User{} + + // A full run should result in one request with the right fields. + msa.reset() + log.Clear() + a.dryRun = false + err = a.blockSPKIHash(context.Background(), keyHash[:], u, "hello world") + test.AssertNotError(t, err, "") + test.AssertEquals(t, len(log.GetAllMatching("Found 0 unexpired certificates")), 1) + test.AssertEquals(t, len(msa.blockRequests), 1) + test.AssertByteEquals(t, msa.blockRequests[0].KeyHash, keyHash[:]) + test.AssertContains(t, msa.blockRequests[0].Comment, "hello world") + + // A dry-run should result in zero requests and two log lines. + msa.reset() + log.Clear() + a.dryRun = true + a.sac = dryRunSAC{log: log} + err = a.blockSPKIHash(context.Background(), keyHash[:], u, "") + test.AssertNotError(t, err, "") + test.AssertEquals(t, len(log.GetAllMatching("Found 0 unexpired certificates")), 1) + test.AssertEquals(t, len(log.GetAllMatching("dry-run:")), 1) + test.AssertEquals(t, len(msa.blockRequests), 0) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/admin/main.go b/third-party/github.com/letsencrypt/boulder/cmd/admin/main.go new file mode 100644 index 000000000..01397d209 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/admin/main.go @@ -0,0 +1,147 @@ +// Package main provides the "admin" tool, which can perform various +// administrative actions (such as revoking certificates) against a Boulder +// deployment. +// +// Run "admin -h" for a list of flags and subcommands. +// +// Note that the admin tool runs in "dry-run" mode *by default*. All commands +// which mutate the database (either directly or via gRPC requests) will refuse +// to do so, and instead print log lines representing the work they would do, +// unless the "-dry-run=false" flag is passed. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/features" +) + +type Config struct { + Admin struct { + // DB controls the admin tool's direct connection to the database. + DB cmd.DBConfig + // TLS controls the TLS client the admin tool uses for gRPC connections. + TLS cmd.TLSConfig + + RAService *cmd.GRPCClientConfig + SAService *cmd.GRPCClientConfig + + DebugAddr string + + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +// subcommand specifies the set of methods that a struct must implement to be +// usable as an admin subcommand. +type subcommand interface { + // Desc should return a short (one-sentence) description of the subcommand for + // use in help/usage strings. + Desc() string + // Flags should register command line flags on the provided flagset. These + // should use the "TypeVar" methods on the provided flagset, targeting fields + // on the subcommand struct, so that the results of command line parsing can + // be used by other methods on the struct. + Flags(*flag.FlagSet) + // Run should do all of the subcommand's heavy lifting, with behavior gated on + // the subcommand struct's member fields which have been populated from the + // command line. The provided admin object can be used for access to external + // services like the RA, SA, and configured logger. + Run(context.Context, *admin) error +} + +// main is the entry-point for the admin tool. We do not include admin in the +// suite of tools which are subcommands of the "boulder" binary, since it +// should be small and portable and standalone. +func main() { + // Do setup as similarly as possible to all other boulder services, including + // config parsing and stats and logging setup. However, the one downside of + // not being bundled with the boulder binary is that we don't get config + // validation for free. + defer cmd.AuditPanic() + + // This is the registry of all subcommands that the admin tool can run. + subcommands := map[string]subcommand{ + "revoke-cert": &subcommandRevokeCert{}, + "block-key": &subcommandBlockKey{}, + "update-email": &subcommandUpdateEmail{}, + } + + defaultUsage := flag.Usage + flag.Usage = func() { + defaultUsage() + fmt.Printf("\nSubcommands:\n") + for name, command := range subcommands { + fmt.Printf(" %s\n", name) + fmt.Printf("\t%s\n", command.Desc()) + } + fmt.Print("\nYou can run \"admin -help\" to get usage for that subcommand.\n") + } + + // Start by parsing just the global flags before we get to the subcommand, if + // they're present. + configFile := flag.String("config", "", "Path to the configuration file for this service (required)") + dryRun := flag.Bool("dry-run", true, "Print actions instead of mutating the database") + flag.Parse() + + // Figure out which subcommand they want us to run. + unparsedArgs := flag.Args() + if len(unparsedArgs) == 0 { + flag.Usage() + os.Exit(1) + } + + subcommand, ok := subcommands[unparsedArgs[0]] + if !ok { + flag.Usage() + os.Exit(1) + } + + // Then parse the rest of the args according to the selected subcommand's + // flags, and allow the global flags to be placed after the subcommand name. + subflags := flag.NewFlagSet(unparsedArgs[0], flag.ExitOnError) + subcommand.Flags(subflags) + flag.VisitAll(func(f *flag.Flag) { + // For each flag registered at the global/package level, also register it on + // the subflags FlagSet. The `f.Value` here is a pointer to the same var + // that the original global flag would populate, so the same variable can + // be set either way. + subflags.Var(f.Value, f.Name, f.Usage) + }) + _ = subflags.Parse(unparsedArgs[1:]) + + // With the flags all parsed, now we can parse our config and set up our admin + // object. + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + a, err := newAdmin(*configFile, *dryRun) + cmd.FailOnError(err, "creating admin object") + + // Finally, run the selected subcommand. + if a.dryRun { + a.log.AuditInfof("admin tool executing a dry-run with the following arguments: %q", strings.Join(os.Args, " ")) + } else { + a.log.AuditInfof("admin tool executing with the following arguments: %q", strings.Join(os.Args, " ")) + } + + err = subcommand.Run(context.Background(), a) + cmd.FailOnError(err, "executing subcommand") + + if a.dryRun { + a.log.AuditInfof("admin tool has successfully completed executing a dry-run with the following arguments: %q", strings.Join(os.Args, " ")) + a.log.Info("Dry run complete. Pass -dry-run=false to mutate the database.") + } else { + a.log.AuditInfof("admin tool has successfully completed executing with the following arguments: %q", strings.Join(os.Args, " ")) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main.go b/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main.go new file mode 100644 index 000000000..579b80362 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main.go @@ -0,0 +1,459 @@ +package notmain + +import ( + "context" + "errors" + "flag" + "fmt" + "math" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/akamai" + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" +) + +const ( + // akamaiBytesPerResponse is the total bytes of all 3 URLs associated with a + // single OCSP response cached by Akamai. Each response is composed of 3 + // URLs; the POST Cache Key URL is 61 bytes and the encoded and unencoded + // GET URLs are 163 bytes and 151 bytes respectively. This totals 375 bytes, + // which we round up to 400. + akamaiBytesPerResponse = 400 + + // urlsPerQueueEntry is the number of URLs associated with a single cached + // OCSP response. + urlsPerQueueEntry = 3 + + // defaultEntriesPerBatch is the default value for 'queueEntriesPerBatch'. + defaultEntriesPerBatch = 2 + + // defaultPurgeBatchInterval is the default value for 'purgeBatchInterval'. + defaultPurgeBatchInterval = time.Millisecond * 32 + + // defaultQueueSize is the default value for 'maxQueueSize'. A queue size of + // 1.25M cached OCSP responses, assuming 3 URLs per request, is about 6 + // hours of work using the default settings detailed above. + defaultQueueSize = 1250000 + + // akamaiBytesPerReqLimit is the limit of bytes allowed in a single request + // to the Fast-Purge API. With a limit of no more than 50,000 bytes, we + // subtract 1 byte to get the limit, and subtract an additional 19 bytes for + // overhead of the 'objects' key and array. + akamaiBytesPerReqLimit = 50000 - 1 - 19 + + // akamaiAPIReqPerSecondLimit is the limit of requests, per second, that + // we're allowed to make to the Fast-Purge API. + akamaiAPIReqPerSecondLimit = 50 + + // akamaiURLsPerSecondLimit is the limit of URLs, sent per second, that + // we're allowed to make to the Fast-Purge API. + akamaiURLsPerSecondLimit = 200 +) + +// Throughput is a container for all throuput related akamai-purger +// configuration settings. +type Throughput struct { + // QueueEntriesPerBatch the number of cached OCSP responses to included in each + // purge request. One cached OCSP response is composed of 3 URLs totaling < + // 400 bytes. If this value isn't provided it will default to + // 'defaultQueueEntriesPerBatch'. + // + // Deprecated: Only set TotalInstances and let it compute the defaults. + QueueEntriesPerBatch int `validate:"min=0"` + + // PurgeBatchInterval is the duration waited between dispatching an Akamai + // purge request containing 'QueueEntriesPerBatch' * 3 URLs. If this value + // isn't provided it will default to 'defaultPurgeBatchInterval'. + // + // Deprecated: Only set TotalInstances and let it compute the defaults. + PurgeBatchInterval config.Duration `validate:"-"` + + // TotalInstances is the number of akamai-purger instances running at the same + // time, across all data centers. + TotalInstances int `validate:"min=0"` +} + +// optimizeAndValidate updates a Throughput struct in-place, replacing any unset +// fields with sane defaults and ensuring that the resulting configuration will +// not cause us to exceed Akamai's rate limits. +func (t *Throughput) optimizeAndValidate() error { + // Ideally, this is the only variable actually configured, and we derive + // everything else from here. But if it isn't set, assume only 1 is running. + if t.TotalInstances < 0 { + return errors.New("'totalInstances' must be positive or 0 (for the default)") + } else if t.TotalInstances == 0 { + t.TotalInstances = 1 + } + + // For the sake of finding a valid throughput solution, we hold the number of + // queue entries sent per purge batch constant. We set 2 entries (6 urls) as + // the default, and historically we have never had a reason to configure a + // different amount. This default ensures we stay well below the maximum + // request size of 50,000 bytes per request. + if t.QueueEntriesPerBatch < 0 { + return errors.New("'queueEntriesPerBatch' must be positive or 0 (for the default)") + } else if t.QueueEntriesPerBatch == 0 { + t.QueueEntriesPerBatch = defaultEntriesPerBatch + } + + // Send no more than the 50,000 bytes of objects we’re allotted per request. + bytesPerRequest := (t.QueueEntriesPerBatch * akamaiBytesPerResponse) + if bytesPerRequest > akamaiBytesPerReqLimit { + return fmt.Errorf("config exceeds Akamai's bytes per request limit (%d bytes) by %d", + akamaiBytesPerReqLimit, bytesPerRequest-akamaiBytesPerReqLimit) + } + + // Now the purge interval must be set such that we exceed neither the 50 API + // requests per second limit nor the 200 URLs per second limit across all + // concurrent purger instances. We calculated that a value of one request + // every 32ms satisfies both constraints with a bit of breathing room (as long + // as the number of entries per batch is also at its default). By default we + // set this purger's interval to a multiple of 32ms, depending on how many + // other purger instances are running. + if t.PurgeBatchInterval.Duration < 0 { + return errors.New("'purgeBatchInterval' must be positive or 0 (for the default)") + } else if t.PurgeBatchInterval.Duration == 0 { + t.PurgeBatchInterval.Duration = defaultPurgeBatchInterval * time.Duration(t.TotalInstances) + } + + // Send no more than the 50 API requests we’re allotted each second. + requestsPerSecond := int(math.Ceil(float64(time.Second)/float64(t.PurgeBatchInterval.Duration))) * t.TotalInstances + if requestsPerSecond > akamaiAPIReqPerSecondLimit { + return fmt.Errorf("config exceeds Akamai's requests per second limit (%d requests) by %d", + akamaiAPIReqPerSecondLimit, requestsPerSecond-akamaiAPIReqPerSecondLimit) + } + + // Purge no more than the 200 URLs we’re allotted each second. + urlsPurgedPerSecond := requestsPerSecond * (t.QueueEntriesPerBatch * urlsPerQueueEntry) + if urlsPurgedPerSecond > akamaiURLsPerSecondLimit { + return fmt.Errorf("config exceeds Akamai's URLs per second limit (%d URLs) by %d", + akamaiURLsPerSecondLimit, urlsPurgedPerSecond-akamaiURLsPerSecondLimit) + } + + return nil +} + +type Config struct { + AkamaiPurger struct { + cmd.ServiceConfig + + // MaxQueueSize is the maximum size of the purger stack. If this value + // isn't provided it will default to `defaultQueueSize`. + MaxQueueSize int + + BaseURL string `validate:"required,url"` + ClientToken string `validate:"required"` + ClientSecret string `validate:"required"` + AccessToken string `validate:"required"` + V3Network string `validate:"required,oneof=staging production"` + + // Throughput is a container for all throughput related akamai-purger + // settings. + Throughput Throughput + + // PurgeRetries is the maximum number of attempts that will be made to purge a + // batch of URLs before the batch is added back to the stack. + PurgeRetries int + + // PurgeRetryBackoff is the base duration that will be waited before + // attempting to purge a batch of URLs which previously failed to be + // purged. + PurgeRetryBackoff config.Duration `validate:"-"` + } + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +// cachePurgeClient is testing interface. +type cachePurgeClient interface { + Purge(urls []string) error +} + +// akamaiPurger is a mutex protected container for a gRPC server which receives +// requests containing a slice of URLs associated with an OCSP response cached +// by Akamai. This slice of URLs is stored on a stack, and dispatched in batches +// to Akamai's Fast Purge API at regular intervals. +type akamaiPurger struct { + sync.Mutex + akamaipb.UnsafeAkamaiPurgerServer + + // toPurge functions as a stack where each entry contains the three OCSP + // response URLs associated with a given certificate. + toPurge [][]string + maxStackSize int + entriesPerBatch int + client cachePurgeClient + log blog.Logger +} + +var _ akamaipb.AkamaiPurgerServer = (*akamaiPurger)(nil) + +func (ap *akamaiPurger) len() int { + ap.Lock() + defer ap.Unlock() + return len(ap.toPurge) +} + +func (ap *akamaiPurger) purgeBatch(batch [][]string) error { + // Flatten the batch of stack entries into a single slice of URLs. + var urls []string + for _, url := range batch { + urls = append(urls, url...) + } + + err := ap.client.Purge(urls) + if err != nil { + ap.log.Errf("Failed to purge %d OCSP responses (%s): %s", len(batch), strings.Join(urls, ","), err) + return err + } + return nil +} + +// takeBatch returns a slice containing the next batch of entries from the purge stack. +// It copies at most entriesPerBatch entries from the top of the stack into a new slice which is returned. +func (ap *akamaiPurger) takeBatch() [][]string { + ap.Lock() + defer ap.Unlock() + stackSize := len(ap.toPurge) + + // If the stack is empty, return immediately. + if stackSize <= 0 { + return nil + } + + // If the stack contains less than a full batch, set the batch size to the + // current stack size. + batchSize := ap.entriesPerBatch + if stackSize < batchSize { + batchSize = stackSize + } + + batchBegin := stackSize - batchSize + batchEnd := stackSize + batch := make([][]string, batchSize) + for i, entry := range ap.toPurge[batchBegin:batchEnd] { + batch[i] = slices.Clone(entry) + } + ap.toPurge = ap.toPurge[:batchBegin] + return batch +} + +// Purge is an exported gRPC method which receives purge requests containing +// URLs and prepends them to the purger stack. +func (ap *akamaiPurger) Purge(ctx context.Context, req *akamaipb.PurgeRequest) (*emptypb.Empty, error) { + ap.Lock() + defer ap.Unlock() + stackSize := len(ap.toPurge) + if stackSize >= ap.maxStackSize { + // Drop the oldest entry from the bottom of the stack to make room. + ap.toPurge = ap.toPurge[1:] + } + // Add the entry from the new request to the top of the stack. + ap.toPurge = append(ap.toPurge, req.Urls) + return &emptypb.Empty{}, nil +} + +func main() { + daemonFlags := flag.NewFlagSet("daemon", flag.ContinueOnError) + grpcAddr := daemonFlags.String("addr", "", "gRPC listen address override") + debugAddr := daemonFlags.String("debug-addr", "", "Debug server address override") + configFile := daemonFlags.String("config", "", "File path to the configuration file for this service") + + manualFlags := flag.NewFlagSet("manual", flag.ExitOnError) + manualConfigFile := manualFlags.String("config", "", "File path to the configuration file for this service") + tag := manualFlags.String("tag", "", "Single cache tag to purge") + tagFile := manualFlags.String("tag-file", "", "File containing cache tags to purge, one per line") + + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + daemonFlags.PrintDefaults() + fmt.Fprintln(os.Stderr, "OR:") + fmt.Fprintf(os.Stderr, "%s manual \n", os.Args[0]) + manualFlags.PrintDefaults() + os.Exit(1) + } + + // Check if the purger is being started in daemon (URL purging gRPC service) + // or manual (ad-hoc tag purging) mode. + var manualMode bool + if os.Args[1] == "manual" { + manualMode = true + _ = manualFlags.Parse(os.Args[2:]) + if *manualConfigFile == "" { + manualFlags.Usage() + os.Exit(1) + } + if *tag == "" && *tagFile == "" { + cmd.Fail("Must specify one of --tag or --tag-file for manual purge") + } else if *tag != "" && *tagFile != "" { + cmd.Fail("Cannot specify both of --tag and --tag-file for manual purge") + } + configFile = manualConfigFile + } else { + err := daemonFlags.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "OR:\n%s manual -config conf.json [-tag Foo] [-tag-file]\n", os.Args[0]) + os.Exit(1) + } + if *configFile == "" { + daemonFlags.Usage() + os.Exit(1) + } + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + // Make references to the service config cleaner. + apc := &c.AkamaiPurger + + if *grpcAddr != "" { + apc.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + apc.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, apc.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + // Use optimized throughput settings for any that are left unspecified. + err = apc.Throughput.optimizeAndValidate() + cmd.FailOnError(err, "Failed to find valid throughput solution") + + if apc.MaxQueueSize == 0 { + apc.MaxQueueSize = defaultQueueSize + } + + ccu, err := akamai.NewCachePurgeClient( + apc.BaseURL, + apc.ClientToken, + apc.ClientSecret, + apc.AccessToken, + apc.V3Network, + apc.PurgeRetries, + apc.PurgeRetryBackoff.Duration, + logger, + scope, + ) + cmd.FailOnError(err, "Failed to setup Akamai CCU client") + + ap := &akamaiPurger{ + maxStackSize: apc.MaxQueueSize, + entriesPerBatch: apc.Throughput.QueueEntriesPerBatch, + client: ccu, + log: logger, + } + + var gaugePurgeQueueLength = prometheus.NewGaugeFunc( + prometheus.GaugeOpts{ + Name: "ccu_purge_queue_length", + Help: "The length of the akamai-purger queue. Captured on each prometheus scrape.", + }, + func() float64 { return float64(ap.len()) }, + ) + scope.MustRegister(gaugePurgeQueueLength) + + if manualMode { + manualPurge(ccu, *tag, *tagFile) + } else { + daemon(c, ap, logger, scope) + } +} + +// manualPurge is called ad-hoc to purge either a single tag, or a batch of tags, +// passed on the CLI. All tags will be added to a single request, please ensure +// that you don't violate the Fast-Purge API limits for tags detailed here: +// https://techdocs.akamai.com/purge-cache/reference/rate-limiting +func manualPurge(purgeClient *akamai.CachePurgeClient, tag, tagFile string) { + var tags []string + if tag != "" { + tags = []string{tag} + } else { + contents, err := os.ReadFile(tagFile) + cmd.FailOnError(err, fmt.Sprintf("While reading %q", tagFile)) + tags = strings.Split(string(contents), "\n") + } + + err := purgeClient.PurgeTags(tags) + cmd.FailOnError(err, "Purging tags") +} + +// daemon initializes the akamai-purger gRPC service. +func daemon(c Config, ap *akamaiPurger, logger blog.Logger, scope prometheus.Registerer) { + clk := cmd.Clock() + + tlsConfig, err := c.AkamaiPurger.TLS.Load(scope) + cmd.FailOnError(err, "tlsConfig config") + + stop, stopped := make(chan bool, 1), make(chan bool, 1) + ticker := time.NewTicker(c.AkamaiPurger.Throughput.PurgeBatchInterval.Duration) + go func() { + loop: + for { + select { + case <-ticker.C: + batch := ap.takeBatch() + if batch == nil { + continue + } + _ = ap.purgeBatch(batch) + case <-stop: + break loop + } + } + + // As we may have missed a tick by calling ticker.Stop() and + // writing to the stop channel call ap.purge one last time just + // in case there is anything that still needs to be purged. + stackLen := ap.len() + if stackLen > 0 { + logger.Infof("Shutting down; purging OCSP responses for %d certificates before exit.", stackLen) + batch := ap.takeBatch() + err := ap.purgeBatch(batch) + cmd.FailOnError(err, fmt.Sprintf("Shutting down; failed to purge OCSP responses for %d certificates before exit", stackLen)) + logger.Infof("Shutting down; finished purging OCSP responses for %d certificates.", stackLen) + } else { + logger.Info("Shutting down; queue is already empty.") + } + stopped <- true + }() + + // When the gRPC server finally exits, run a clean-up routine that stops the + // ticker and waits for the goroutine above to finish purging the stack. + defer func() { + // Stop the ticker and signal that we want to shutdown by writing to the + // stop channel. We wait 15 seconds for any remaining URLs to be emptied + // from the current stack, if we pass that deadline we exit early. + ticker.Stop() + stop <- true + select { + case <-time.After(time.Second * 15): + cmd.Fail("Timed out waiting for purger to finish work") + case <-stopped: + } + }() + + start, err := bgrpc.NewServer(c.AkamaiPurger.GRPC, logger).Add( + &akamaipb.AkamaiPurger_ServiceDesc, ap).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup Akamai purger gRPC server") + + cmd.FailOnError(start(), "akamai-purger gRPC service failed") +} + +func init() { + cmd.RegisterCommand("akamai-purger", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main_test.go new file mode 100644 index 000000000..1fd4efffa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/akamai-purger/main_test.go @@ -0,0 +1,190 @@ +package notmain + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + "github.com/letsencrypt/boulder/config" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" +) + +func TestThroughput_optimizeAndValidate(t *testing.T) { + dur := func(in time.Duration) config.Duration { return config.Duration{Duration: in} } + + tests := []struct { + name string + input Throughput + want Throughput + wantErr string + }{ + { + "negative instances", + Throughput{defaultEntriesPerBatch, dur(defaultPurgeBatchInterval), -1}, + Throughput{}, + "must be positive", + }, + { + "negative batch interval", + Throughput{defaultEntriesPerBatch, config.Duration{Duration: -1}, -1}, + Throughput{}, + "must be positive", + }, + { + "negative entries per batch", + Throughput{-1, dur(defaultPurgeBatchInterval), 1}, + Throughput{}, + "must be positive", + }, + { + "empty input computes sane defaults", + Throughput{}, + Throughput{defaultEntriesPerBatch, dur(defaultPurgeBatchInterval), 1}, + "", + }, + { + "strict configuration is honored", + Throughput{2, dur(1 * time.Second), 1}, + Throughput{2, dur(1 * time.Second), 1}, + "", + }, + { + "slightly looser configuration still within limits", + Throughput{defaultEntriesPerBatch, dur(defaultPurgeBatchInterval - time.Millisecond), 1}, + Throughput{defaultEntriesPerBatch, dur(defaultPurgeBatchInterval - time.Millisecond), 1}, + "", + }, + { + "too many requests per second", + Throughput{QueueEntriesPerBatch: 1, PurgeBatchInterval: dur(19999 * time.Microsecond)}, + Throughput{}, + "requests per second limit", + }, + { + "too many URLs per second", + Throughput{PurgeBatchInterval: dur(29 * time.Millisecond)}, + Throughput{}, + "URLs per second limit", + }, + { + "too many bytes per request", + Throughput{QueueEntriesPerBatch: 125, PurgeBatchInterval: dur(1 * time.Second)}, + Throughput{}, + "bytes per request limit", + }, + { + "two instances computes sane defaults", + Throughput{TotalInstances: 2}, + Throughput{defaultEntriesPerBatch, dur(defaultPurgeBatchInterval * 2), 2}, + "", + }, + { + "too many requests per second across multiple instances", + Throughput{PurgeBatchInterval: dur(defaultPurgeBatchInterval), TotalInstances: 2}, + Throughput{}, + "requests per second limit", + }, + { + "too many entries per second across multiple instances", + Throughput{PurgeBatchInterval: dur(59 * time.Millisecond), TotalInstances: 2}, + Throughput{}, + "URLs per second limit", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.optimizeAndValidate() + if tc.wantErr != "" { + test.AssertError(t, err, "") + test.AssertContains(t, err.Error(), tc.wantErr) + } else { + test.AssertNotError(t, err, "") + test.AssertEquals(t, tc.input, tc.want) + } + }) + } +} + +type mockCCU struct { + akamaipb.AkamaiPurgerClient +} + +func (m *mockCCU) Purge(urls []string) error { + return errors.New("Lol, I'm a mock") +} + +func TestAkamaiPurgerQueue(t *testing.T) { + ap := &akamaiPurger{ + maxStackSize: 250, + entriesPerBatch: 2, + client: &mockCCU{}, + log: blog.NewMock(), + } + + // Add 250 entries to fill the stack. + for i := range 250 { + req := akamaipb.PurgeRequest{Urls: []string{fmt.Sprintf("http://test.com/%d", i)}} + _, err := ap.Purge(context.Background(), &req) + test.AssertNotError(t, err, fmt.Sprintf("Purge failed for entry %d.", i)) + } + + // Add another entry to the stack and using the Purge method. + req := akamaipb.PurgeRequest{Urls: []string{"http://test.com/250"}} + _, err := ap.Purge(context.Background(), &req) + test.AssertNotError(t, err, "Purge failed.") + + // Verify that the stack is still full. + test.AssertEquals(t, len(ap.toPurge), 250) + + // Verify that the first entry in the stack is the entry we just added. + test.AssertEquals(t, ap.toPurge[len(ap.toPurge)-1][0], "http://test.com/250") + + // Verify that the last entry in the stack is the second entry we added. + test.AssertEquals(t, ap.toPurge[0][0], "http://test.com/1") + + expectedTopEntryAfterFailure := ap.toPurge[len(ap.toPurge)-(ap.entriesPerBatch+1)][0] + + // Fail to purge a batch of entries from the stack. + batch := ap.takeBatch() + test.AssertNotNil(t, batch, "Batch should not be nil.") + + err = ap.purgeBatch(batch) + test.AssertError(t, err, "Mock should have failed to purge.") + + // Verify that the stack is no longer full. + test.AssertEquals(t, len(ap.toPurge), 248) + + // The first entry of the next batch should be on the top after the failed + // purge. + test.AssertEquals(t, ap.toPurge[len(ap.toPurge)-1][0], expectedTopEntryAfterFailure) +} + +func TestAkamaiPurgerQueueWithOneEntry(t *testing.T) { + ap := &akamaiPurger{ + maxStackSize: 250, + entriesPerBatch: 2, + client: &mockCCU{}, + log: blog.NewMock(), + } + + // Add one entry to the stack and using the Purge method. + req := akamaipb.PurgeRequest{Urls: []string{"http://test.com/0"}} + _, err := ap.Purge(context.Background(), &req) + test.AssertNotError(t, err, "Purge failed.") + test.AssertEquals(t, len(ap.toPurge), 1) + test.AssertEquals(t, ap.toPurge[len(ap.toPurge)-1][0], "http://test.com/0") + + // Fail to purge a batch of entries from the stack. + batch := ap.takeBatch() + test.AssertNotNil(t, batch, "Batch should not be nil.") + + err = ap.purgeBatch(batch) + test.AssertError(t, err, "Mock should have failed to purge.") + + // Verify that the stack no longer contains our entry. + test.AssertEquals(t, len(ap.toPurge), 0) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main.go b/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main.go new file mode 100644 index 000000000..b234987f5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main.go @@ -0,0 +1,578 @@ +package notmain + +import ( + "bytes" + "context" + "crypto/x509" + "flag" + "fmt" + "html/template" + netmail "net/mail" + "os" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/db" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mail" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/sa" +) + +const blockedKeysGaugeLimit = 1000 + +var keysToProcess = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "bad_keys_to_process", + Help: fmt.Sprintf("A gauge of blockedKeys rows to process (max: %d)", blockedKeysGaugeLimit), +}) +var keysProcessed = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "bad_keys_processed", + Help: "A counter of blockedKeys rows processed labelled by processing state", +}, []string{"state"}) +var certsRevoked = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bad_keys_certs_revoked", + Help: "A counter of certificates associated with rows in blockedKeys that have been revoked", +}) +var mailErrors = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bad_keys_mail_errors", + Help: "A counter of email send errors", +}) + +// revoker is an interface used to reduce the scope of a RA gRPC client +// to only the single method we need to use, this makes testing significantly +// simpler +type revoker interface { + AdministrativelyRevokeCertificate(ctx context.Context, in *rapb.AdministrativelyRevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type badKeyRevoker struct { + dbMap *db.WrappedMap + maxRevocations int + serialBatchSize int + raClient revoker + mailer mail.Mailer + emailSubject string + emailTemplate *template.Template + logger blog.Logger + clk clock.Clock + backoffIntervalBase time.Duration + backoffIntervalMax time.Duration + backoffFactor float64 + backoffTicker int +} + +// uncheckedBlockedKey represents a row in the blockedKeys table +type uncheckedBlockedKey struct { + KeyHash []byte + RevokedBy int64 +} + +func (ubk uncheckedBlockedKey) String() string { + return fmt.Sprintf("[revokedBy: %d, keyHash: %x]", + ubk.RevokedBy, ubk.KeyHash) +} + +func (bkr *badKeyRevoker) countUncheckedKeys(ctx context.Context) (int, error) { + var count int + err := bkr.dbMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) + FROM (SELECT 1 FROM blockedKeys + WHERE extantCertificatesChecked = false + LIMIT ?) AS a`, + blockedKeysGaugeLimit, + ) + return count, err +} + +func (bkr *badKeyRevoker) selectUncheckedKey(ctx context.Context) (uncheckedBlockedKey, error) { + var row uncheckedBlockedKey + err := bkr.dbMap.SelectOne( + ctx, + &row, + `SELECT keyHash, revokedBy + FROM blockedKeys + WHERE extantCertificatesChecked = false + LIMIT 1`, + ) + return row, err +} + +// unrevokedCertificate represents a yet to be revoked certificate +type unrevokedCertificate struct { + ID int + Serial string + DER []byte + RegistrationID int64 + Status core.OCSPStatus + IsExpired bool +} + +func (uc unrevokedCertificate) String() string { + return fmt.Sprintf("id=%d serial=%s regID=%d status=%s expired=%t", + uc.ID, uc.Serial, uc.RegistrationID, uc.Status, uc.IsExpired) +} + +// findUnrevoked looks for all unexpired, currently valid certificates which have a specific SPKI hash, +// by looking first at the keyHashToSerial table and then the certificateStatus and certificates tables. +// If the number of certificates it finds is larger than bkr.maxRevocations it'll error out. +func (bkr *badKeyRevoker) findUnrevoked(ctx context.Context, unchecked uncheckedBlockedKey) ([]unrevokedCertificate, error) { + var unrevokedCerts []unrevokedCertificate + initialID := 0 + for { + var batch []struct { + ID int + CertSerial string + } + _, err := bkr.dbMap.Select( + ctx, + &batch, + "SELECT id, certSerial FROM keyHashToSerial WHERE keyHash = ? AND id > ? AND certNotAfter > ? ORDER BY id LIMIT ?", + unchecked.KeyHash, + initialID, + bkr.clk.Now().Truncate(time.Second), + bkr.serialBatchSize, + ) + if err != nil { + return nil, err + } + if len(batch) == 0 { + break + } + initialID = batch[len(batch)-1].ID + for _, serial := range batch { + var unrevokedCert unrevokedCertificate + // NOTE: This has a `LIMIT 1` because the certificateStatus and precertificates + // tables do not have a UNIQUE KEY on serial (for partitioning reasons). So it's + // possible we could get multiple results for a single serial number, but they + // would be duplicates. + err = bkr.dbMap.SelectOne( + ctx, + &unrevokedCert, + `SELECT cs.id, cs.serial, c.registrationID, c.der, cs.status, cs.isExpired + FROM certificateStatus AS cs + JOIN precertificates AS c + ON cs.serial = c.serial + WHERE cs.serial = ? + LIMIT 1`, + serial.CertSerial, + ) + if err != nil { + return nil, err + } + if unrevokedCert.IsExpired || unrevokedCert.Status == core.OCSPStatusRevoked { + continue + } + unrevokedCerts = append(unrevokedCerts, unrevokedCert) + } + } + if len(unrevokedCerts) > bkr.maxRevocations { + return nil, fmt.Errorf("too many certificates to revoke associated with %x: got %d, max %d", unchecked.KeyHash, len(unrevokedCerts), bkr.maxRevocations) + } + return unrevokedCerts, nil +} + +// markRowChecked updates a row in the blockedKeys table to mark a keyHash +// as having been checked for extant unrevoked certificates. +func (bkr *badKeyRevoker) markRowChecked(ctx context.Context, unchecked uncheckedBlockedKey) error { + _, err := bkr.dbMap.ExecContext(ctx, "UPDATE blockedKeys SET extantCertificatesChecked = true WHERE keyHash = ?", unchecked.KeyHash) + return err +} + +// resolveContacts builds a map of id -> email addresses +func (bkr *badKeyRevoker) resolveContacts(ctx context.Context, ids []int64) (map[int64][]string, error) { + idToEmail := map[int64][]string{} + for _, id := range ids { + var emails struct { + Contact []string + } + err := bkr.dbMap.SelectOne(ctx, &emails, "SELECT contact FROM registrations WHERE id = ?", id) + if err != nil { + // ErrNoRows is not acceptable here since there should always be a + // row for the registration, even if there are no contacts + return nil, err + } + if len(emails.Contact) != 0 { + for _, email := range emails.Contact { + idToEmail[id] = append(idToEmail[id], strings.TrimPrefix(email, "mailto:")) + } + } else { + // if the account has no contacts add a placeholder empty contact + // so that we don't skip any certificates + idToEmail[id] = append(idToEmail[id], "") + continue + } + } + return idToEmail, nil +} + +var maxSerials = 100 + +// sendMessage sends a single email to the provided address with the revoked +// serials +func (bkr *badKeyRevoker) sendMessage(addr string, serials []string) error { + conn, err := bkr.mailer.Connect() + if err != nil { + return err + } + defer func() { + _ = conn.Close() + }() + mutSerials := make([]string, len(serials)) + copy(mutSerials, serials) + if len(mutSerials) > maxSerials { + more := len(mutSerials) - maxSerials + mutSerials = mutSerials[:maxSerials] + mutSerials = append(mutSerials, fmt.Sprintf("and %d more certificates.", more)) + } + message := bytes.NewBuffer(nil) + err = bkr.emailTemplate.Execute(message, mutSerials) + if err != nil { + return err + } + err = conn.SendMail([]string{addr}, bkr.emailSubject, message.String()) + if err != nil { + return err + } + return nil +} + +// revokeCerts revokes all the certificates associated with a particular key hash and sends +// emails to the users that issued the certificates. Emails are not sent to the user which +// requested revocation of the original certificate which marked the key as compromised. +func (bkr *badKeyRevoker) revokeCerts(revokerEmails []string, emailToCerts map[string][]unrevokedCertificate) error { + revokerEmailsMap := map[string]bool{} + for _, email := range revokerEmails { + revokerEmailsMap[email] = true + } + + alreadyRevoked := map[int]bool{} + for email, certs := range emailToCerts { + var revokedSerials []string + for _, cert := range certs { + revokedSerials = append(revokedSerials, cert.Serial) + if alreadyRevoked[cert.ID] { + continue + } + _, err := bkr.raClient.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Cert: cert.DER, + Serial: cert.Serial, + Code: int64(ocsp.KeyCompromise), + AdminName: "bad-key-revoker", + }) + if err != nil { + return err + } + certsRevoked.Inc() + alreadyRevoked[cert.ID] = true + } + // don't send emails to the person who revoked the certificate + if revokerEmailsMap[email] || email == "" { + continue + } + err := bkr.sendMessage(email, revokedSerials) + if err != nil { + mailErrors.Inc() + bkr.logger.Errf("failed to send message to %q: %s", email, err) + continue + } + } + return nil +} + +// invoke processes a single key in the blockedKeys table and returns whether +// there were any rows to process or not. +func (bkr *badKeyRevoker) invoke(ctx context.Context) (bool, error) { + // Gather a count of rows to be processed. + uncheckedCount, err := bkr.countUncheckedKeys(ctx) + if err != nil { + return false, err + } + + // Set the gauge to the number of rows to be processed (max: + // blockedKeysGaugeLimit). + keysToProcess.Set(float64(uncheckedCount)) + + if uncheckedCount >= blockedKeysGaugeLimit { + bkr.logger.AuditInfof("found >= %d unchecked blocked keys left to process", uncheckedCount) + } else { + bkr.logger.AuditInfof("found %d unchecked blocked keys left to process", uncheckedCount) + } + + // select a row to process + unchecked, err := bkr.selectUncheckedKey(ctx) + if err != nil { + if db.IsNoRows(err) { + return true, nil + } + return false, err + } + bkr.logger.AuditInfo(fmt.Sprintf("found unchecked block key to work on: %s", unchecked)) + + // select all unrevoked, unexpired serials associated with the blocked key hash + unrevokedCerts, err := bkr.findUnrevoked(ctx, unchecked) + if err != nil { + bkr.logger.AuditInfo(fmt.Sprintf("finding unrevoked certificates related to %s: %s", + unchecked, err)) + return false, err + } + if len(unrevokedCerts) == 0 { + bkr.logger.AuditInfo(fmt.Sprintf("found no certificates that need revoking related to %s, marking row as checked", unchecked)) + // mark row as checked + err = bkr.markRowChecked(ctx, unchecked) + if err != nil { + return false, err + } + return false, nil + } + + // build a map of registration ID -> certificates, and collect a + // list of unique registration IDs + ownedBy := map[int64][]unrevokedCertificate{} + var ids []int64 + for _, cert := range unrevokedCerts { + if ownedBy[cert.RegistrationID] == nil { + ids = append(ids, cert.RegistrationID) + } + ownedBy[cert.RegistrationID] = append(ownedBy[cert.RegistrationID], cert) + } + // if the account that revoked the original certificate isn't an owner of any + // extant certificates, still add them to ids so that we can resolve their + // email and avoid sending emails later. If RevokedBy == 0 it was a row + // inserted by admin-revoker with a dummy ID, since there won't be a registration + // to look up, don't bother adding it to ids. + if _, present := ownedBy[unchecked.RevokedBy]; !present && unchecked.RevokedBy != 0 { + ids = append(ids, unchecked.RevokedBy) + } + // get contact addresses for the list of IDs + idToEmails, err := bkr.resolveContacts(ctx, ids) + if err != nil { + return false, err + } + + // build a map of email -> certificates, this de-duplicates accounts with + // the same email addresses + emailsToCerts := map[string][]unrevokedCertificate{} + for id, emails := range idToEmails { + for _, email := range emails { + emailsToCerts[email] = append(emailsToCerts[email], ownedBy[id]...) + } + } + + revokerEmails := idToEmails[unchecked.RevokedBy] + bkr.logger.AuditInfo(fmt.Sprintf("revoking certs. revoked emails=%v, emailsToCerts=%s", + revokerEmails, emailsToCerts)) + + // revoke each certificate and send emails to their owners + err = bkr.revokeCerts(idToEmails[unchecked.RevokedBy], emailsToCerts) + if err != nil { + return false, err + } + + // mark the key as checked + err = bkr.markRowChecked(ctx, unchecked) + if err != nil { + return false, err + } + return false, nil +} + +type Config struct { + BadKeyRevoker struct { + DB cmd.DBConfig + DebugAddr string `validate:"omitempty,hostname_port"` + + TLS cmd.TLSConfig + RAService *cmd.GRPCClientConfig + + // MaximumRevocations specifies the maximum number of certificates associated with + // a key hash that bad-key-revoker will attempt to revoke. If the number of certificates + // is higher than MaximumRevocations bad-key-revoker will error out and refuse to + // progress until this is addressed. + MaximumRevocations int `validate:"gte=0"` + // FindCertificatesBatchSize specifies the maximum number of serials to select from the + // keyHashToSerial table at once + FindCertificatesBatchSize int `validate:"required"` + + // Interval specifies the minimum duration bad-key-revoker + // should sleep between attempting to find blockedKeys rows to + // process when there is an error or no work to do. + Interval config.Duration `validate:"-"` + + // BackoffIntervalMax specifies a maximum duration the backoff + // algorithm will wait before retrying in the event of error + // or no work to do. + BackoffIntervalMax config.Duration `validate:"-"` + + Mailer struct { + cmd.SMTPConfig + // Path to a file containing a list of trusted root certificates for use + // during the SMTP connection (as opposed to the gRPC connections). + SMTPTrustedRootFile string + + From string `validate:"required"` + EmailSubject string `validate:"required"` + EmailTemplate string `validate:"required"` + } + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configPath := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + + if *configPath == "" { + flag.Usage() + os.Exit(1) + } + var config Config + err := cmd.ReadConfigFile(*configPath, &config) + cmd.FailOnError(err, "Failed reading config file") + + if *debugAddr != "" { + config.BadKeyRevoker.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(config.Syslog, config.OpenTelemetry, config.BadKeyRevoker.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + clk := cmd.Clock() + + scope.MustRegister(keysProcessed) + scope.MustRegister(certsRevoked) + scope.MustRegister(mailErrors) + + dbMap, err := sa.InitWrappedDb(config.BadKeyRevoker.DB, scope, logger) + cmd.FailOnError(err, "While initializing dbMap") + + tlsConfig, err := config.BadKeyRevoker.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + conn, err := bgrpc.ClientSetup(config.BadKeyRevoker.RAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA") + rac := rapb.NewRegistrationAuthorityClient(conn) + + var smtpRoots *x509.CertPool + if config.BadKeyRevoker.Mailer.SMTPTrustedRootFile != "" { + pem, err := os.ReadFile(config.BadKeyRevoker.Mailer.SMTPTrustedRootFile) + cmd.FailOnError(err, "Loading trusted roots file") + smtpRoots = x509.NewCertPool() + if !smtpRoots.AppendCertsFromPEM(pem) { + cmd.FailOnError(nil, "Failed to parse root certs PEM") + } + } + + fromAddress, err := netmail.ParseAddress(config.BadKeyRevoker.Mailer.From) + cmd.FailOnError(err, fmt.Sprintf("Could not parse from address: %s", config.BadKeyRevoker.Mailer.From)) + + smtpPassword, err := config.BadKeyRevoker.Mailer.PasswordConfig.Pass() + cmd.FailOnError(err, "Failed to load SMTP password") + mailClient := mail.New( + config.BadKeyRevoker.Mailer.Server, + config.BadKeyRevoker.Mailer.Port, + config.BadKeyRevoker.Mailer.Username, + smtpPassword, + smtpRoots, + *fromAddress, + logger, + scope, + 1*time.Second, // reconnection base backoff + 5*60*time.Second, // reconnection maximum backoff + ) + + if config.BadKeyRevoker.Mailer.EmailSubject == "" { + cmd.Fail("BadKeyRevoker.Mailer.EmailSubject must be populated") + } + templateBytes, err := os.ReadFile(config.BadKeyRevoker.Mailer.EmailTemplate) + cmd.FailOnError(err, fmt.Sprintf("failed to read email template %q: %s", config.BadKeyRevoker.Mailer.EmailTemplate, err)) + emailTemplate, err := template.New("email").Parse(string(templateBytes)) + cmd.FailOnError(err, fmt.Sprintf("failed to parse email template %q: %s", config.BadKeyRevoker.Mailer.EmailTemplate, err)) + + bkr := &badKeyRevoker{ + dbMap: dbMap, + maxRevocations: config.BadKeyRevoker.MaximumRevocations, + serialBatchSize: config.BadKeyRevoker.FindCertificatesBatchSize, + raClient: rac, + mailer: mailClient, + emailSubject: config.BadKeyRevoker.Mailer.EmailSubject, + emailTemplate: emailTemplate, + logger: logger, + clk: clk, + backoffIntervalMax: config.BadKeyRevoker.BackoffIntervalMax.Duration, + backoffIntervalBase: config.BadKeyRevoker.Interval.Duration, + backoffFactor: 1.3, + } + + // If `BackoffIntervalMax` was not set via the config, set it to 60 + // seconds. This will avoid a tight loop on error but not be an + // excessive delay if the config value was not deliberately set. + if bkr.backoffIntervalMax == 0 { + bkr.backoffIntervalMax = time.Second * 60 + } + + // If `Interval` was not set via the config then set + // `bkr.backoffIntervalBase` to a default 1 second. + if bkr.backoffIntervalBase == 0 { + bkr.backoffIntervalBase = time.Second + } + + // Run bad-key-revoker in a loop. Backoff if no work or errors. + for { + noWork, err := bkr.invoke(context.Background()) + if err != nil { + keysProcessed.WithLabelValues("error").Inc() + logger.AuditErrf("failed to process blockedKeys row: %s", err) + // Calculate and sleep for a backoff interval + bkr.backoff() + continue + } + if noWork { + logger.Info("no work to do") + // Calculate and sleep for a backoff interval + bkr.backoff() + } else { + keysProcessed.WithLabelValues("success").Inc() + // Successfully processed, reset backoff. + bkr.backoffReset() + } + } +} + +// backoff increments the backoffTicker, calls core.RetryBackoff to +// calculate a new backoff duration, then logs the backoff and sleeps for +// the calculated duration. +func (bkr *badKeyRevoker) backoff() { + bkr.backoffTicker++ + backoffDur := core.RetryBackoff( + bkr.backoffTicker, + bkr.backoffIntervalBase, + bkr.backoffIntervalMax, + bkr.backoffFactor, + ) + bkr.logger.Infof("backoff trying again in %.2f seconds", backoffDur.Seconds()) + bkr.clk.Sleep(backoffDur) +} + +// reset sets the backoff ticker and duration to zero. +func (bkr *badKeyRevoker) backoffReset() { + bkr.backoffTicker = 0 +} + +func init() { + cmd.RegisterCommand("bad-key-revoker", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main_test.go new file mode 100644 index 000000000..ab654ce32 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/bad-key-revoker/main_test.go @@ -0,0 +1,500 @@ +package notmain + +import ( + "context" + "crypto/rand" + "fmt" + "html/template" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mocks" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" +) + +func randHash(t *testing.T) []byte { + t.Helper() + h := make([]byte, 32) + _, err := rand.Read(h) + test.AssertNotError(t, err, "failed to read rand") + return h +} + +func insertBlockedRow(t *testing.T, dbMap *db.WrappedMap, fc clock.Clock, hash []byte, by int64, checked bool) { + t.Helper() + _, err := dbMap.ExecContext(context.Background(), `INSERT INTO blockedKeys + (keyHash, added, source, revokedBy, extantCertificatesChecked) + VALUES + (?, ?, ?, ?, ?)`, + hash, + fc.Now(), + 1, + by, + checked, + ) + test.AssertNotError(t, err, "failed to add test row") +} + +func TestSelectUncheckedRows(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + bkr := &badKeyRevoker{ + dbMap: dbMap, + logger: blog.NewMock(), + clk: fc, + } + + hashA, hashB, hashC := randHash(t), randHash(t), randHash(t) + insertBlockedRow(t, dbMap, fc, hashA, 1, true) + count, err := bkr.countUncheckedKeys(ctx) + test.AssertNotError(t, err, "countUncheckedKeys failed") + test.AssertEquals(t, count, 0) + _, err = bkr.selectUncheckedKey(ctx) + test.AssertError(t, err, "selectUncheckedKey didn't fail with no rows to process") + test.Assert(t, db.IsNoRows(err), "returned error is not sql.ErrNoRows") + insertBlockedRow(t, dbMap, fc, hashB, 1, false) + insertBlockedRow(t, dbMap, fc, hashC, 1, false) + count, err = bkr.countUncheckedKeys(ctx) + test.AssertNotError(t, err, "countUncheckedKeys failed") + test.AssertEquals(t, count, 2) + row, err := bkr.selectUncheckedKey(ctx) + test.AssertNotError(t, err, "selectUncheckKey failed") + test.AssertByteEquals(t, row.KeyHash, hashB) + test.AssertEquals(t, row.RevokedBy, int64(1)) +} + +func insertRegistration(t *testing.T, dbMap *db.WrappedMap, fc clock.Clock, addrs ...string) int64 { + t.Helper() + jwkHash := make([]byte, 32) + _, err := rand.Read(jwkHash) + test.AssertNotError(t, err, "failed to read rand") + contactStr := "[]" + if len(addrs) > 0 { + contacts := []string{} + for _, addr := range addrs { + contacts = append(contacts, fmt.Sprintf(`"mailto:%s"`, addr)) + } + contactStr = fmt.Sprintf("[%s]", strings.Join(contacts, ",")) + } + res, err := dbMap.ExecContext( + context.Background(), + "INSERT INTO registrations (jwk, jwk_sha256, contact, agreement, initialIP, createdAt, status, LockCol) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + []byte{}, + fmt.Sprintf("%x", jwkHash), + contactStr, + "yes", + []byte{}, + fc.Now(), + string(core.StatusValid), + 0, + ) + test.AssertNotError(t, err, "failed to insert test registrations row") + regID, err := res.LastInsertId() + test.AssertNotError(t, err, "failed to get registration ID") + return regID +} + +type ExpiredStatus bool + +const ( + Expired = ExpiredStatus(true) + Unexpired = ExpiredStatus(false) + Revoked = core.OCSPStatusRevoked + Unrevoked = core.OCSPStatusGood +) + +func insertGoodCert(t *testing.T, dbMap *db.WrappedMap, fc clock.Clock, keyHash []byte, serial string, regID int64) { + insertCert(t, dbMap, fc, keyHash, serial, regID, Unexpired, Unrevoked) +} + +func insertCert(t *testing.T, dbMap *db.WrappedMap, fc clock.Clock, keyHash []byte, serial string, regID int64, expiredStatus ExpiredStatus, status core.OCSPStatus) { + t.Helper() + ctx := context.Background() + + expiresOffset := 0 * time.Second + if !expiredStatus { + expiresOffset = 90*24*time.Hour - 1*time.Second // 90 days exclusive + } + + _, err := dbMap.ExecContext( + ctx, + `INSERT IGNORE INTO keyHashToSerial + (keyHash, certNotAfter, certSerial) VALUES + (?, ?, ?)`, + keyHash, + fc.Now().Add(expiresOffset), + serial, + ) + test.AssertNotError(t, err, "failed to insert test keyHashToSerial row") + + _, err = dbMap.ExecContext( + ctx, + "INSERT INTO certificateStatus (serial, status, isExpired, ocspLastUpdated, revokedDate, revokedReason, lastExpirationNagSent) VALUES (?, ?, ?, ?, ?, ?, ?)", + serial, + status, + expiredStatus, + fc.Now(), + time.Time{}, + 0, + time.Time{}, + ) + test.AssertNotError(t, err, "failed to insert test certificateStatus row") + + _, err = dbMap.ExecContext( + ctx, + "INSERT INTO precertificates (serial, registrationID, der, issued, expires) VALUES (?, ?, ?, ?, ?)", + serial, + regID, + []byte{1, 2, 3}, + fc.Now(), + fc.Now().Add(expiresOffset), + ) + test.AssertNotError(t, err, "failed to insert test certificateStatus row") + + _, err = dbMap.ExecContext( + ctx, + "INSERT INTO certificates (serial, registrationID, der, digest, issued, expires) VALUES (?, ?, ?, ?, ?, ?)", + serial, + regID, + []byte{1, 2, 3}, + []byte{}, + fc.Now(), + fc.Now().Add(expiresOffset), + ) + test.AssertNotError(t, err, "failed to insert test certificates row") +} + +// Test that we produce an error when a serial from the keyHashToSerial table +// does not have a corresponding entry in the certificateStatus and +// precertificates table. +func TestFindUnrevokedNoRows(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + hashA := randHash(t) + _, err = dbMap.ExecContext( + ctx, + "INSERT INTO keyHashToSerial (keyHash, certNotAfter, certSerial) VALUES (?, ?, ?)", + hashA, + fc.Now().Add(90*24*time.Hour-1*time.Second), // 90 days exclusive + "zz", + ) + test.AssertNotError(t, err, "failed to insert test keyHashToSerial row") + + bkr := &badKeyRevoker{dbMap: dbMap, serialBatchSize: 1, maxRevocations: 10, clk: fc} + _, err = bkr.findUnrevoked(ctx, uncheckedBlockedKey{KeyHash: hashA}) + test.Assert(t, db.IsNoRows(err), "expected NoRows error") +} + +func TestFindUnrevoked(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + regID := insertRegistration(t, dbMap, fc) + + bkr := &badKeyRevoker{dbMap: dbMap, serialBatchSize: 1, maxRevocations: 10, clk: fc} + + hashA := randHash(t) + // insert valid, unexpired + insertCert(t, dbMap, fc, hashA, "ff", regID, Unexpired, Unrevoked) + // insert valid, unexpired, duplicate + insertCert(t, dbMap, fc, hashA, "ff", regID, Unexpired, Unrevoked) + // insert valid, expired + insertCert(t, dbMap, fc, hashA, "ee", regID, Expired, Unrevoked) + // insert revoked + insertCert(t, dbMap, fc, hashA, "dd", regID, Unexpired, Revoked) + + rows, err := bkr.findUnrevoked(ctx, uncheckedBlockedKey{KeyHash: hashA}) + test.AssertNotError(t, err, "findUnrevoked failed") + test.AssertEquals(t, len(rows), 1) + test.AssertEquals(t, rows[0].Serial, "ff") + test.AssertEquals(t, rows[0].RegistrationID, int64(1)) + test.AssertByteEquals(t, rows[0].DER, []byte{1, 2, 3}) + + bkr.maxRevocations = 0 + _, err = bkr.findUnrevoked(ctx, uncheckedBlockedKey{KeyHash: hashA}) + test.AssertError(t, err, "findUnrevoked didn't fail with 0 maxRevocations") + test.AssertEquals(t, err.Error(), fmt.Sprintf("too many certificates to revoke associated with %x: got 1, max 0", hashA)) +} + +func TestResolveContacts(t *testing.T) { + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + bkr := &badKeyRevoker{dbMap: dbMap, clk: fc} + + regIDA := insertRegistration(t, dbMap, fc) + regIDB := insertRegistration(t, dbMap, fc, "example.com", "example-2.com") + regIDC := insertRegistration(t, dbMap, fc, "example.com") + regIDD := insertRegistration(t, dbMap, fc, "example-2.com") + + idToEmail, err := bkr.resolveContacts(context.Background(), []int64{regIDA, regIDB, regIDC, regIDD}) + test.AssertNotError(t, err, "resolveContacts failed") + test.AssertDeepEquals(t, idToEmail, map[int64][]string{ + regIDA: {""}, + regIDB: {"example.com", "example-2.com"}, + regIDC: {"example.com"}, + regIDD: {"example-2.com"}, + }) +} + +var testTemplate = template.Must(template.New("testing").Parse("{{range .}}{{.}}\n{{end}}")) + +func TestSendMessage(t *testing.T) { + mm := &mocks.Mailer{} + fc := clock.NewFake() + bkr := &badKeyRevoker{mailer: mm, emailSubject: "testing", emailTemplate: testTemplate, clk: fc} + + maxSerials = 2 + err := bkr.sendMessage("example.com", []string{"a", "b", "c"}) + test.AssertNotError(t, err, "sendMessages failed") + test.AssertEquals(t, len(mm.Messages), 1) + test.AssertEquals(t, mm.Messages[0].To, "example.com") + test.AssertEquals(t, mm.Messages[0].Subject, bkr.emailSubject) + test.AssertEquals(t, mm.Messages[0].Body, "a\nb\nand 1 more certificates.\n") + +} + +type mockRevoker struct { + revoked int + mu sync.Mutex +} + +func (mr *mockRevoker) AdministrativelyRevokeCertificate(ctx context.Context, in *rapb.AdministrativelyRevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + mr.mu.Lock() + defer mr.mu.Unlock() + mr.revoked++ + return nil, nil +} + +func TestRevokeCerts(t *testing.T) { + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + mm := &mocks.Mailer{} + mr := &mockRevoker{} + bkr := &badKeyRevoker{dbMap: dbMap, raClient: mr, mailer: mm, emailSubject: "testing", emailTemplate: testTemplate, clk: fc} + + err = bkr.revokeCerts([]string{"revoker@example.com", "revoker-b@example.com"}, map[string][]unrevokedCertificate{ + "revoker@example.com": {{ID: 0, Serial: "ff"}}, + "revoker-b@example.com": {{ID: 0, Serial: "ff"}}, + "other@example.com": {{ID: 1, Serial: "ee"}}, + }) + test.AssertNotError(t, err, "revokeCerts failed") + test.AssertEquals(t, len(mm.Messages), 1) + test.AssertEquals(t, mm.Messages[0].To, "other@example.com") + test.AssertEquals(t, mm.Messages[0].Subject, bkr.emailSubject) + test.AssertEquals(t, mm.Messages[0].Body, "ee\n") +} + +func TestCertificateAbsent(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + // populate DB with all the test data + regIDA := insertRegistration(t, dbMap, fc, "example.com") + hashA := randHash(t) + insertBlockedRow(t, dbMap, fc, hashA, regIDA, false) + + // Add an entry to keyHashToSerial but not to certificateStatus or certificate + // status, and expect an error. + _, err = dbMap.ExecContext( + ctx, + "INSERT INTO keyHashToSerial (keyHash, certNotAfter, certSerial) VALUES (?, ?, ?)", + hashA, + fc.Now().Add(90*24*time.Hour-1*time.Second), // 90 days exclusive + "ffaaee", + ) + test.AssertNotError(t, err, "failed to insert test keyHashToSerial row") + + bkr := &badKeyRevoker{ + dbMap: dbMap, + maxRevocations: 1, + serialBatchSize: 1, + raClient: &mockRevoker{}, + mailer: &mocks.Mailer{}, + emailSubject: "testing", + emailTemplate: testTemplate, + logger: blog.NewMock(), + clk: fc, + } + _, err = bkr.invoke(ctx) + test.AssertError(t, err, "expected error when row in keyHashToSerial didn't have a matching cert") +} + +func TestInvoke(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + mm := &mocks.Mailer{} + mr := &mockRevoker{} + bkr := &badKeyRevoker{ + dbMap: dbMap, + maxRevocations: 10, + serialBatchSize: 1, + raClient: mr, + mailer: mm, + emailSubject: "testing", + emailTemplate: testTemplate, + logger: blog.NewMock(), + clk: fc, + } + + // populate DB with all the test data + regIDA := insertRegistration(t, dbMap, fc, "example.com") + regIDB := insertRegistration(t, dbMap, fc, "example.com") + regIDC := insertRegistration(t, dbMap, fc, "other.example.com", "uno.example.com") + regIDD := insertRegistration(t, dbMap, fc) + hashA := randHash(t) + insertBlockedRow(t, dbMap, fc, hashA, regIDC, false) + insertGoodCert(t, dbMap, fc, hashA, "ff", regIDA) + insertGoodCert(t, dbMap, fc, hashA, "ee", regIDB) + insertGoodCert(t, dbMap, fc, hashA, "dd", regIDC) + insertGoodCert(t, dbMap, fc, hashA, "cc", regIDD) + + noWork, err := bkr.invoke(ctx) + test.AssertNotError(t, err, "invoke failed") + test.AssertEquals(t, noWork, false) + test.AssertEquals(t, mr.revoked, 4) + test.AssertEquals(t, len(mm.Messages), 1) + test.AssertEquals(t, mm.Messages[0].To, "example.com") + test.AssertMetricWithLabelsEquals(t, keysToProcess, prometheus.Labels{}, 1) + + var checked struct { + ExtantCertificatesChecked bool + } + err = dbMap.SelectOne(ctx, &checked, "SELECT extantCertificatesChecked FROM blockedKeys WHERE keyHash = ?", hashA) + test.AssertNotError(t, err, "failed to select row from blockedKeys") + test.AssertEquals(t, checked.ExtantCertificatesChecked, true) + + // add a row with no associated valid certificates + hashB := randHash(t) + insertBlockedRow(t, dbMap, fc, hashB, regIDC, false) + insertCert(t, dbMap, fc, hashB, "bb", regIDA, Expired, Revoked) + + noWork, err = bkr.invoke(ctx) + test.AssertNotError(t, err, "invoke failed") + test.AssertEquals(t, noWork, false) + + checked.ExtantCertificatesChecked = false + err = dbMap.SelectOne(ctx, &checked, "SELECT extantCertificatesChecked FROM blockedKeys WHERE keyHash = ?", hashB) + test.AssertNotError(t, err, "failed to select row from blockedKeys") + test.AssertEquals(t, checked.ExtantCertificatesChecked, true) + + noWork, err = bkr.invoke(ctx) + test.AssertNotError(t, err, "invoke failed") + test.AssertEquals(t, noWork, true) +} + +func TestInvokeRevokerHasNoExtantCerts(t *testing.T) { + // This test checks that when the user who revoked the initial + // certificate that added the row to blockedKeys doesn't have any + // extant certificates themselves their contact email is still + // resolved and we avoid sending any emails to accounts that + // share the same email. + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + fc := clock.NewFake() + + mm := &mocks.Mailer{} + mr := &mockRevoker{} + bkr := &badKeyRevoker{dbMap: dbMap, + maxRevocations: 10, + serialBatchSize: 1, + raClient: mr, + mailer: mm, + emailSubject: "testing", + emailTemplate: testTemplate, + logger: blog.NewMock(), + clk: fc, + } + + // populate DB with all the test data + regIDA := insertRegistration(t, dbMap, fc, "a@example.com") + regIDB := insertRegistration(t, dbMap, fc, "a@example.com") + regIDC := insertRegistration(t, dbMap, fc, "b@example.com") + + hashA := randHash(t) + + insertBlockedRow(t, dbMap, fc, hashA, regIDA, false) + + insertGoodCert(t, dbMap, fc, hashA, "ee", regIDB) + insertGoodCert(t, dbMap, fc, hashA, "dd", regIDB) + insertGoodCert(t, dbMap, fc, hashA, "cc", regIDC) + insertGoodCert(t, dbMap, fc, hashA, "bb", regIDC) + + noWork, err := bkr.invoke(context.Background()) + test.AssertNotError(t, err, "invoke failed") + test.AssertEquals(t, noWork, false) + test.AssertEquals(t, mr.revoked, 4) + test.AssertEquals(t, len(mm.Messages), 1) + test.AssertEquals(t, mm.Messages[0].To, "b@example.com") +} + +func TestBackoffPolicy(t *testing.T) { + fc := clock.NewFake() + mocklog := blog.NewMock() + bkr := &badKeyRevoker{ + clk: fc, + backoffIntervalMax: time.Second * 60, + backoffIntervalBase: time.Second * 1, + backoffFactor: 1.3, + logger: mocklog, + } + + // Backoff once. Check to make sure the backoff is logged. + bkr.backoff() + resultLog := mocklog.GetAllMatching("INFO: backoff trying again in") + if len(resultLog) == 0 { + t.Fatalf("no backoff loglines found") + } + + // Make sure `backoffReset` resets the ticker. + bkr.backoffReset() + test.AssertEquals(t, bkr.backoffTicker, 0) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-ca/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ca/main.go new file mode 100644 index 000000000..86be24a3e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ca/main.go @@ -0,0 +1,311 @@ +package notmain + +import ( + "context" + "flag" + "os" + "reflect" + "time" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/ca" + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/goodkey/sagoodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/linter" + "github.com/letsencrypt/boulder/policy" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type Config struct { + CA struct { + cmd.ServiceConfig + + cmd.HostnamePolicyConfig + + GRPCCA *cmd.GRPCServerConfig + + SAService *cmd.GRPCClientConfig + + // Issuance contains all information necessary to load and initialize issuers. + Issuance struct { + // The name of the certificate profile to use if one wasn't provided + // by the RA during NewOrder and Finalize requests. Must match a + // configured certificate profile or boulder-ca will fail to start. + DefaultCertificateProfileName string `validate:"omitempty,alphanum,min=1,max=32"` + + // TODO(#7414) Remove this deprecated field. + // Deprecated: Use CertProfiles instead. Profile implicitly takes + // the internal Boulder default value of ca.DefaultCertProfileName. + Profile issuance.ProfileConfig `validate:"required_without=CertProfiles,structonly"` + + // One of the profile names must match the value of + // DefaultCertificateProfileName or boulder-ca will fail to start. + CertProfiles map[string]issuance.ProfileConfig `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"` + + // TODO(#7159): Make this required once all live configs are using it. + CRLProfile issuance.CRLProfileConfig `validate:"-"` + Issuers []issuance.IssuerConfig `validate:"min=1,dive"` + LintConfig string + IgnoredLints []string + } + + // How long issued certificates are valid for. + Expiry config.Duration + + // How far back certificates should be backdated. + Backdate config.Duration + + // What digits we should prepend to serials after randomly generating them. + SerialPrefix int `validate:"required,min=1,max=127"` + + // MaxNames is the maximum number of subjectAltNames in a single cert. + // The value supplied MUST be greater than 0 and no more than 100. These + // limits are per section 7.1 of our combined CP/CPS, under "DV-SSL + // Subscriber Certificate". The value must match the RA and WFE + // configurations. + MaxNames int `validate:"required,min=1,max=100"` + + // LifespanOCSP is how long OCSP responses are valid for. Per the BRs, + // Section 4.9.10, it MUST NOT be more than 10 days. Default 96h. + LifespanOCSP config.Duration + + // LifespanCRL is how long CRLs are valid for. It should be longer than the + // `period` field of the CRL Updater. Per the BRs, Section 4.9.7, it MUST + // NOT be more than 10 days. + // Deprecated: Use Config.CA.Issuance.CRLProfile.ValidityInterval instead. + LifespanCRL config.Duration `validate:"-"` + + // GoodKey is an embedded config stanza for the goodkey library. + GoodKey goodkey.Config + + // Maximum length (in bytes) of a line accumulating OCSP audit log entries. + // Recommended to be around 4000. If this is 0, do not perform OCSP audit + // logging. + OCSPLogMaxLength int + + // Maximum period (in Go duration format) to wait to accumulate a max-length + // OCSP audit log line. We will emit a log line at least once per period, + // if there is anything to be logged. Keeping this low minimizes the risk + // of losing logs during a catastrophic failure. Making it too high + // means logging more often than necessary, which is inefficient in terms + // of bytes and log system resources. + // Recommended to be around 500ms. + OCSPLogPeriod config.Duration + + // Path of a YAML file containing the list of int64 RegIDs + // allowed to request ECDSA issuance + ECDSAAllowListFilename string + + // CTLogListFile is the path to a JSON file on disk containing the set of + // all logs trusted by Chrome. The file must match the v3 log list schema: + // https://www.gstatic.com/ct/log_list/v3/log_list_schema.json + CTLogListFile string + + // DisableCertService causes the CertificateAuthority gRPC service to not + // start, preventing any certificates or precertificates from being issued. + DisableCertService bool + // DisableCertService causes the OCSPGenerator gRPC service to not start, + // preventing any OCSP responses from being issued. + DisableOCSPService bool + // DisableCRLService causes the CRLGenerator gRPC service to not start, + // preventing any CRLs from being issued. + DisableCRLService bool + + Features features.Config + } + + PA cmd.PAConfig + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.CA.Features) + + if *grpcAddr != "" { + c.CA.GRPCCA.Address = *grpcAddr + } + if *debugAddr != "" { + c.CA.DebugAddr = *debugAddr + } + + if c.CA.MaxNames == 0 { + cmd.Fail("Error in CA config: MaxNames must not be 0") + } + + if c.CA.LifespanOCSP.Duration == 0 { + c.CA.LifespanOCSP.Duration = 96 * time.Hour + } + + // TODO(#7159): Remove these fallbacks once all live configs are setting the + // CRL validity interval inside the Issuance.CRLProfile Config. + if c.CA.Issuance.CRLProfile.ValidityInterval.Duration == 0 && c.CA.LifespanCRL.Duration != 0 { + c.CA.Issuance.CRLProfile.ValidityInterval = c.CA.LifespanCRL + } + if c.CA.Issuance.CRLProfile.MaxBackdate.Duration == 0 && c.CA.Backdate.Duration != 0 { + c.CA.Issuance.CRLProfile.MaxBackdate = c.CA.Backdate + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.CA.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + metrics := ca.NewCAMetrics(scope) + + cmd.FailOnError(c.PA.CheckChallenges(), "Invalid PA configuration") + + pa, err := policy.New(c.PA.Challenges, logger) + cmd.FailOnError(err, "Couldn't create PA") + + if c.CA.HostnamePolicyFile == "" { + cmd.Fail("HostnamePolicyFile was empty") + } + err = pa.LoadHostnamePolicyFile(c.CA.HostnamePolicyFile) + cmd.FailOnError(err, "Couldn't load hostname policy file") + + // Do this before creating the issuers to ensure the log list is loaded before + // the linters are initialized. + if c.CA.CTLogListFile != "" { + err = loglist.InitLintList(c.CA.CTLogListFile) + cmd.FailOnError(err, "Failed to load CT Log List") + } + + issuers := make([]*issuance.Issuer, 0, len(c.CA.Issuance.Issuers)) + for _, issuerConfig := range c.CA.Issuance.Issuers { + issuer, err := issuance.LoadIssuer(issuerConfig, cmd.Clock()) + cmd.FailOnError(err, "Loading issuer") + issuers = append(issuers, issuer) + } + + if c.CA.Issuance.DefaultCertificateProfileName == "" { + c.CA.Issuance.DefaultCertificateProfileName = "defaultBoulderCertificateProfile" + } + logger.Infof("Configured default certificate profile name set to: %s", c.CA.Issuance.DefaultCertificateProfileName) + + // TODO(#7414) Remove this check. + if !reflect.ValueOf(c.CA.Issuance.Profile).IsZero() && len(c.CA.Issuance.CertProfiles) > 0 { + cmd.Fail("Only one of Issuance.Profile or Issuance.CertProfiles can be configured") + } + + // TODO(#7414) Remove this check. + // Use the deprecated Profile as a CertProfiles + if len(c.CA.Issuance.CertProfiles) == 0 { + c.CA.Issuance.CertProfiles = make(map[string]issuance.ProfileConfig, 0) + c.CA.Issuance.CertProfiles[c.CA.Issuance.DefaultCertificateProfileName] = c.CA.Issuance.Profile + } + + lints, err := linter.NewRegistry(c.CA.Issuance.IgnoredLints) + cmd.FailOnError(err, "Failed to create zlint registry") + if c.CA.Issuance.LintConfig != "" { + lintconfig, err := lint.NewConfigFromFile(c.CA.Issuance.LintConfig) + cmd.FailOnError(err, "Failed to load zlint config file") + lints.SetConfiguration(lintconfig) + } + + tlsConfig, err := c.CA.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + clk := cmd.Clock() + + conn, err := bgrpc.ClientSetup(c.CA.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sa := sapb.NewStorageAuthorityClient(conn) + + kp, err := sagoodkey.NewPolicy(&c.CA.GoodKey, sa.KeyBlocked) + cmd.FailOnError(err, "Unable to create key policy") + + var ecdsaAllowList *ca.ECDSAAllowList + var entries int + if c.CA.ECDSAAllowListFilename != "" { + // Create an allow list object. + ecdsaAllowList, entries, err = ca.NewECDSAAllowListFromFile(c.CA.ECDSAAllowListFilename) + cmd.FailOnError(err, "Unable to load ECDSA allow list from YAML file") + logger.Infof("Loaded an ECDSA allow list with %d entries", entries) + } + + srv := bgrpc.NewServer(c.CA.GRPCCA, logger) + + if !c.CA.DisableOCSPService { + ocspi, err := ca.NewOCSPImpl( + issuers, + c.CA.LifespanOCSP.Duration, + c.CA.OCSPLogMaxLength, + c.CA.OCSPLogPeriod.Duration, + logger, + scope, + metrics, + clk, + ) + cmd.FailOnError(err, "Failed to create OCSP impl") + go ocspi.LogOCSPLoop() + defer ocspi.Stop() + + srv = srv.Add(&capb.OCSPGenerator_ServiceDesc, ocspi) + } + + if !c.CA.DisableCRLService { + crli, err := ca.NewCRLImpl( + issuers, + c.CA.Issuance.CRLProfile, + c.CA.OCSPLogMaxLength, + logger, + metrics, + ) + cmd.FailOnError(err, "Failed to create CRL impl") + + srv = srv.Add(&capb.CRLGenerator_ServiceDesc, crli) + } + + if !c.CA.DisableCertService { + cai, err := ca.NewCertificateAuthorityImpl( + sa, + pa, + issuers, + c.CA.Issuance.DefaultCertificateProfileName, + c.CA.Issuance.CertProfiles, + lints, + ecdsaAllowList, + c.CA.Expiry.Duration, + c.CA.Backdate.Duration, + c.CA.SerialPrefix, + c.CA.MaxNames, + kp, + logger, + metrics, + clk) + cmd.FailOnError(err, "Failed to create CA impl") + + srv = srv.Add(&capb.CertificateAuthority_ServiceDesc, cai) + } + + start, err := srv.Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup CA gRPC server") + + cmd.FailOnError(start(), "CA gRPC service failed") +} + +func init() { + cmd.RegisterCommand("boulder-ca", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/README.md b/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/README.md new file mode 100644 index 000000000..132565312 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/README.md @@ -0,0 +1,386 @@ +# boulder-observer + +A modular configuration driven approach to black box monitoring with +Prometheus. + +* [boulder-observer](#boulder-observer) + * [Usage](#usage) + * [Options](#options) + * [Starting the boulder-observer + daemon](#starting-the-boulder-observer-daemon) + * [Configuration](#configuration) + * [Root](#root) + * [Schema](#schema) + * [Example](#example) + * [Monitors](#monitors) + * [Schema](#schema-1) + * [Example](#example-1) + * [Probers](#probers) + * [DNS](#dns) + * [Schema](#schema-2) + * [Example](#example-2) + * [HTTP](#http) + * [Schema](#schema-3) + * [Example](#example-3) + * [CRL](#crl) + * [Schema](#schema-4) + * [Example](#example-4) + * [TLS](#tls) + * [Schema](#schema-5) + * [Example](#example-5) + * [Metrics](#metrics) + * [Global Metrics](#global-metrics) + * [obs_monitors](#obs_monitors) + * [obs_observations](#obs_observations) + * [CRL Metrics](#crl-metrics) + * [obs_crl_this_update](#obs_crl_this_update) + * [obs_crl_next_update](#obs_crl_next_update) + * [obs_crl_revoked_cert_count](#obs_crl_revoked_cert_count) + * [TLS Metrics](#tls-metrics) + * [obs_crl_this_update](#obs_tls_not_after) + * [obs_crl_next_update](#obs_tls_reason) + * [Development](#development) + * [Starting Prometheus locally](#starting-prometheus-locally) + * [Viewing metrics locally](#viewing-metrics-locally) + +## Usage + +### Options + +```shell +$ ./boulder-observer -help + -config string + Path to boulder-observer configuration file (default "config.yml") +``` + +### Starting the boulder-observer daemon + +```shell +$ ./boulder-observer -config test/config-next/observer.yml +I152525 boulder-observer _KzylQI Versions: main=(Unspecified Unspecified) Golang=(go1.16.2) BuildHost=(Unspecified) +I152525 boulder-observer q_D84gk Initializing boulder-observer daemon from config: test/config-next/observer.yml +I152525 boulder-observer 7aq68AQ all monitors passed validation +I152527 boulder-observer yaefiAw kind=[HTTP] success=[true] duration=[0.130097] name=[https://letsencrypt.org-[200]] +I152527 boulder-observer 65CuDAA kind=[HTTP] success=[true] duration=[0.148633] name=[http://letsencrypt.org/foo-[200 404]] +I152530 boulder-observer idi4rwE kind=[DNS] success=[false] duration=[0.000093] name=[[2606:4700:4700::1111]:53-udp-A-google.com-recurse] +I152530 boulder-observer prOnrw8 kind=[DNS] success=[false] duration=[0.000242] name=[[2606:4700:4700::1111]:53-tcp-A-google.com-recurse] +I152530 boulder-observer 6uXugQw kind=[DNS] success=[true] duration=[0.022962] name=[1.1.1.1:53-udp-A-google.com-recurse] +I152530 boulder-observer to7h-wo kind=[DNS] success=[true] duration=[0.029860] name=[owen.ns.cloudflare.com:53-udp-A-letsencrypt.org-no-recurse] +I152530 boulder-observer ovDorAY kind=[DNS] success=[true] duration=[0.033820] name=[owen.ns.cloudflare.com:53-tcp-A-letsencrypt.org-no-recurse] +... +``` + +## Configuration + +Configuration is provided via a YAML file. + +### Root + +#### Schema + +`debugaddr`: The Prometheus scrape port prefixed with a single colon +(e.g. `:8040`). + +`buckets`: List of floats representing Prometheus histogram buckets (e.g +`[.001, .002, .005, .01, .02, .05, .1, .2, .5, 1, 2, 5, 10]`) + +`syslog`: Map of log levels, see schema below. + +- `stdoutlevel`: Log level for stdout, see legend below. +- `sysloglevel`:Log level for stdout, see legend below. + +`0`: *EMERG* `1`: *ALERT* `2`: *CRIT* `3`: *ERR* `4`: *WARN* `5`: +*NOTICE* `6`: *INFO* `7`: *DEBUG* + +`monitors`: List of monitors, see [monitors](#monitors) for schema. + +#### Example + +```yaml +debugaddr: :8040 +buckets: [.001, .002, .005, .01, .02, .05, .1, .2, .5, 1, 2, 5, 10] +syslog: + stdoutlevel: 6 + sysloglevel: 6 + - + ... +``` + +### Monitors + +#### Schema + +`period`: Interval between probing attempts (e.g. `1s` `1m` `1h`). + +`kind`: Kind of prober to use, see [probers](#probers) for schema. + +`settings`: Map of prober settings, see [probers](#probers) for schema. + +#### Example + +```yaml +monitors: + - + period: 5s + kind: DNS + settings: + ... +``` + +### Probers + +#### DNS + +##### Schema + +`protocol`: Protocol to use, options are: `udp` or `tcp`. + +`server`: Hostname, IPv4 address, or IPv6 address surrounded with +brackets + port of the DNS server to send the query to (e.g. +`example.com:53`, `1.1.1.1:53`, or `[2606:4700:4700::1111]:53`). + +`recurse`: Bool indicating if recursive resolution is desired. + +`query_name`: Name to query (e.g. `example.com`). + +`query_type`: Record type to query, options are: `A`, `AAAA`, `TXT`, or +`CAA`. + +##### Example + +```yaml +monitors: + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: [2606:4700:4700::1111]:53 + recurse: false + query_name: letsencrypt.org + query_type: A +``` + +#### HTTP + +##### Schema + +`url`: Scheme + Hostname to send a request to (e.g. +`https://example.com`). + +`rcodes`: List of expected HTTP response codes. + +`useragent`: String to set HTTP header User-Agent. If no useragent string +is provided it will default to `letsencrypt/boulder-observer-http-client`. + +##### Example + +```yaml +monitors: + - + period: 2s + kind: HTTP + settings: + url: http://letsencrypt.org/FOO + rcodes: [200, 404] + useragent: letsencrypt/boulder-observer-http-client +``` + +#### CRL + +##### Schema + +`url`: Scheme + Hostname to grab the CRL from (e.g. `http://x1.c.lencr.org/`). + +##### Example + +```yaml +monitors: + - + period: 1h + kind: CRL + settings: + url: http://x1.c.lencr.org/ +``` + +#### TLS + +##### Schema + +`hostname`: Hostname to run TLS check on (e.g. `valid-isrgrootx1.letsencrypt.org`). + +`rootOrg`: Organization to check against the root certificate Organization (e.g. `Internet Security Research Group`). + +`rootCN`: Name to check against the root certificate Common Name (e.g. `ISRG Root X1`). If not provided, root comparison will be skipped. + +`response`: Expected site response; must be one of: `valid`, `revoked` or `expired`. + +##### Example + +```yaml +monitors: + - + period: 1h + kind: TLS + settings: + hostname: valid-isrgrootx1.letsencrypt.org + rootOrg: "Internet Security Research Group" + rootCN: "ISRG Root X1" + response: valid +``` + +## Metrics + +Observer provides the following metrics. + +### Global Metrics + +These metrics will always be available. + +#### obs_monitors + +Count of configured monitors. + +**Labels:** + +`kind`: Kind of Prober the monitor is configured to use. + +`valid`: Bool indicating whether settings provided could be validated +for the `kind` of Prober specified. + +#### obs_observations + +**Labels:** + +`name`: Name of the monitor. + +`kind`: Kind of prober the monitor is configured to use. + +`duration`: Duration of the probing in seconds. + +`success`: Bool indicating whether the result of the probe attempt was +successful. + +**Bucketed response times:** + +This is configurable, see `buckets` under [root/schema](#schema). + +### CRL Metrics + +These metrics will be available whenever a valid CRL prober is configured. + +#### obs_crl_this_update + +Unix timestamp value (in seconds) of the thisUpdate field for a CRL. + +**Labels:** + +`url`: Url of the CRL + +**Example Usage:** + +This is a sample rule that alerts when a CRL has a thisUpdate timestamp in the future, signalling that something may have gone wrong during its creation: + +```yaml +- alert: CRLThisUpdateInFuture + expr: obs_crl_this_update{url="http://x1.c.lencr.org/"} > time() + labels: + severity: critical + annotations: + description: 'CRL thisUpdate is in the future' +``` + +#### obs_crl_next_update + +Unix timestamp value (in seconds) of the nextUpdate field for a CRL. + +**Labels:** + +`url`: Url of the CRL + +**Example Usage:** + +This is a sample rule that alerts when a CRL has a nextUpdate timestamp in the past, signalling that the CRL was not updated on time: + +```yaml +- alert: CRLNextUpdateInPast + expr: obs_crl_next_update{url="http://x1.c.lencr.org/"} < time() + labels: + severity: critical + annotations: + description: 'CRL nextUpdate is in the past' +``` + +Another potentially useful rule would be to notify when nextUpdate is within X days from the current time, as a reminder that the update is coming up soon. + +#### obs_crl_revoked_cert_count + +Count of revoked certificates in a CRL. + +**Labels:** + +`url`: Url of the CRL + +### TLS Metrics + +These metrics will be available whenever a valid TLS prober is configured. + +#### obs_tls_not_after + +Unix timestamp value (in seconds) of the notAfter field for a subscriber certificate. + +**Labels:** + +`hostname`: Hostname of the site of the subscriber certificate + +**Example Usage:** + +This is a sample rule that alerts when a site has a notAfter timestamp indicating that the certificate will expire within the next 20 days: + +```yaml + - alert: CertExpiresSoonWarning + annotations: + description: "The certificate at {{ $labels.hostname }} expires within 20 days, on: {{ $value | humanizeTimestamp }}" + expr: (obs_tls_not_after{hostname=~"^[^e][a-zA-Z]*-isrgrootx[12][.]letsencrypt[.]org"}) <= time() + 1728000 + for: 60m + labels: + severity: warning +``` + +#### obs_tls_reason + +This is a count that increments by one for each resulting reason of a TSL check. The reason is `nil` if the TLS Prober returns `true` and one of the following otherwise: `internalError`, `ocspError`, `rootDidNotMatch`, `responseDidNotMatch`. + +**Labels:** + +`hostname`: Hostname of the site of the subscriber certificate +`reason`: The reason for TLS Probe returning false, and `nil` if it returns true + +**Example Usage:** + +This is a sample rule that alerts when TLS Prober returns false, providing insight on the reason for failure. + +```yaml + - alert: TLSCertCheckFailed + annotations: + description: "The TLS probe for {{ $labels.hostname }} failed for reason: {{ $labels.reason }}. This potentially violents CP 2.2." + expr: (rate(obs_observations_count{success="false",name=~"[a-zA-Z]*-isrgrootx[12][.]letsencrypt[.]org"}[5m])) > 0 + for: 5m + labels: + severity: critical +``` + +## Development + +### Starting Prometheus locally + +Please note, this assumes you've installed a local Prometheus binary. + +```shell +prometheus --config.file=boulder/test/prometheus/prometheus.yml +``` + +### Viewing metrics locally + +When developing with a local Prometheus instance you can use this link +to view metrics: [link](http://0.0.0.0:9090) \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/main.go new file mode 100644 index 000000000..2964d82aa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-observer/main.go @@ -0,0 +1,45 @@ +package notmain + +import ( + "flag" + "os" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/observer" + "github.com/letsencrypt/boulder/strictyaml" +) + +func main() { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configPath := flag.String( + "config", "config.yml", "Path to boulder-observer configuration file") + flag.Parse() + + configYAML, err := os.ReadFile(*configPath) + cmd.FailOnError(err, "failed to read config file") + + // Parse the YAML config file. + var config observer.ObsConf + err = strictyaml.Unmarshal(configYAML, &config) + + if *debugAddr != "" { + config.DebugAddr = *debugAddr + } + + if err != nil { + cmd.FailOnError(err, "failed to parse YAML config") + } + + // Make an `Observer` object. + observer, err := config.MakeObserver() + if err != nil { + cmd.FailOnError(err, "config failed validation") + } + + // Start the `Observer` daemon. + observer.Start() +} + +func init() { + cmd.RegisterCommand("boulder-observer", main, &cmd.ConfigValidator{Config: &observer.ObsConf{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main.go new file mode 100644 index 000000000..1363ce8a8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main.go @@ -0,0 +1,104 @@ +package notmain + +import ( + "context" + "flag" + "fmt" + "os" + "runtime" + + ct "github.com/google/certificate-transparency-go" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/publisher" + pubpb "github.com/letsencrypt/boulder/publisher/proto" +) + +type Config struct { + Publisher struct { + cmd.ServiceConfig + Features features.Config + + // If this is non-zero, profile blocking events such that one even is + // sampled every N nanoseconds. + // https://golang.org/pkg/runtime/#SetBlockProfileRate + BlockProfileRate int + UserAgent string + + // Chains is a list of lists of certificate filenames. Each inner list is + // a chain, starting with the issuing intermediate, followed by one or + // more additional certificates, up to and including a root. + Chains [][]string `validate:"min=1,dive,min=2,dive,required"` + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + features.Set(c.Publisher.Features) + + runtime.SetBlockProfileRate(c.Publisher.BlockProfileRate) + + if *grpcAddr != "" { + c.Publisher.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.Publisher.DebugAddr = *debugAddr + } + if c.Publisher.UserAgent == "" { + c.Publisher.UserAgent = "certificate-transparency-go/1.0" + } + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.Publisher.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + if c.Publisher.Chains == nil { + logger.AuditErr("No chain files provided") + os.Exit(1) + } + + bundles := make(map[issuance.NameID][]ct.ASN1Cert) + for _, files := range c.Publisher.Chains { + chain, err := issuance.LoadChain(files) + cmd.FailOnError(err, "failed to load chain.") + issuer := chain[0] + id := issuer.NameID() + if _, exists := bundles[id]; exists { + cmd.Fail(fmt.Sprintf("Got multiple chains configured for issuer %q", issuer.Subject.CommonName)) + } + bundles[id] = publisher.GetCTBundleForChain(chain) + } + + tlsConfig, err := c.Publisher.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + clk := cmd.Clock() + + pubi := publisher.New(bundles, c.Publisher.UserAgent, logger, scope) + + start, err := bgrpc.NewServer(c.Publisher.GRPC, logger).Add( + &pubpb.Publisher_ServiceDesc, pubi).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup Publisher gRPC server") + + cmd.FailOnError(start(), "Publisher gRPC service failed") +} + +func init() { + cmd.RegisterCommand("boulder-publisher", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main_test.go new file mode 100644 index 000000000..227a9d4af --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-publisher/main_test.go @@ -0,0 +1 @@ +package notmain diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main.go new file mode 100644 index 000000000..c5b994e73 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main.go @@ -0,0 +1,318 @@ +package notmain + +import ( + "context" + "flag" + "os" + "time" + + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/ctpolicy" + "github.com/letsencrypt/boulder/ctpolicy/ctconfig" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/goodkey/sagoodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/policy" + pubpb "github.com/letsencrypt/boulder/publisher/proto" + "github.com/letsencrypt/boulder/ra" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/ratelimits" + bredis "github.com/letsencrypt/boulder/redis" + sapb "github.com/letsencrypt/boulder/sa/proto" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +type Config struct { + RA struct { + cmd.ServiceConfig + cmd.HostnamePolicyConfig + + RateLimitPoliciesFilename string `validate:"required"` + + MaxContactsPerRegistration int + + SAService *cmd.GRPCClientConfig + VAService *cmd.GRPCClientConfig + CAService *cmd.GRPCClientConfig + OCSPService *cmd.GRPCClientConfig + PublisherService *cmd.GRPCClientConfig + AkamaiPurgerService *cmd.GRPCClientConfig + + Limiter struct { + // Redis contains the configuration necessary to connect to Redis + // for rate limiting. This field is required to enable rate + // limiting. + Redis *bredis.Config `validate:"required_with=Defaults"` + + // Defaults is a path to a YAML file containing default rate limits. + // See: ratelimits/README.md for details. This field is required to + // enable rate limiting. If any individual rate limit is not set, + // that limit will be disabled. Limits passed in this file must be + // identical to those in the WFE. + // + // Note: At this time, only the Failed Authorizations rate limit is + // necessary in the RA. + Defaults string `validate:"required_with=Redis"` + + // Overrides is a path to a YAML file containing overrides for the + // default rate limits. See: ratelimits/README.md for details. If + // this field is not set, all requesters will be subject to the + // default rate limits. Overrides passed in this file must be + // identical to those in the WFE. + // + // Note: At this time, only the Failed Authorizations overrides are + // necessary in the RA. + Overrides string + } + + // MaxNames is the maximum number of subjectAltNames in a single cert. + // The value supplied MUST be greater than 0 and no more than 100. These + // limits are per section 7.1 of our combined CP/CPS, under "DV-SSL + // Subscriber Certificate". The value must match the CA and WFE + // configurations. + MaxNames int `validate:"required,min=1,max=100"` + + // AuthorizationLifetimeDays defines how long authorizations will be + // considered valid for. Given a value of 300 days when used with a 90-day + // cert lifetime, this allows creation of certs that will cover a whole + // year, plus a grace period of a month. + AuthorizationLifetimeDays int `validate:"required,min=1,max=397"` + + // PendingAuthorizationLifetimeDays defines how long authorizations may be in + // the pending state. If you can't respond to a challenge this quickly, then + // you need to request a new challenge. + PendingAuthorizationLifetimeDays int `validate:"required,min=1,max=29"` + + // GoodKey is an embedded config stanza for the goodkey library. + GoodKey goodkey.Config + + // OrderLifetime is how far in the future an Order's expiration date should + // be set when it is first created. + OrderLifetime config.Duration + + // FinalizeTimeout is how long the RA is willing to wait for the Order + // finalization process to take. This config parameter only has an effect + // if the AsyncFinalization feature flag is enabled. Any systems which + // manage the shutdown of an RA must be willing to wait at least this long + // after sending the shutdown signal, to allow background goroutines to + // complete. + FinalizeTimeout config.Duration `validate:"-"` + + // CTLogs contains groupings of CT logs organized by what organization + // operates them. When we submit precerts to logs in order to get SCTs, we + // will submit the cert to one randomly-chosen log from each group, and use + // the SCTs from the first two groups which reply. This allows us to comply + // with various CT policies that require (for certs with short lifetimes + // like ours) two SCTs from logs run by different operators. It also holds + // a `Stagger` value controlling how long we wait for one operator group + // to respond before trying a different one. + CTLogs ctconfig.CTConfig + // InformationalCTLogs are a set of CT logs we will always submit to + // but won't ever use the SCTs from. This may be because we want to + // test them or because they are not yet approved by a browser/root + // program but we still want our certs to end up there. + InformationalCTLogs []ctconfig.LogDescription + + // IssuerCerts are paths to all intermediate certificates which may have + // been used to issue certificates in the last 90 days. These are used to + // generate OCSP URLs to purge during revocation. + IssuerCerts []string `validate:"min=1,dive,required"` + + Features features.Config + } + + PA cmd.PAConfig + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.RA.Features) + + if *grpcAddr != "" { + c.RA.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.RA.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.RA.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + // Validate PA config and set defaults if needed + cmd.FailOnError(c.PA.CheckChallenges(), "Invalid PA configuration") + + pa, err := policy.New(c.PA.Challenges, logger) + cmd.FailOnError(err, "Couldn't create PA") + + if c.RA.HostnamePolicyFile == "" { + cmd.Fail("HostnamePolicyFile must be provided.") + } + err = pa.LoadHostnamePolicyFile(c.RA.HostnamePolicyFile) + cmd.FailOnError(err, "Couldn't load hostname policy file") + + tlsConfig, err := c.RA.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + clk := cmd.Clock() + + vaConn, err := bgrpc.ClientSetup(c.RA.VAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to create VA client") + vac := vapb.NewVAClient(vaConn) + caaClient := vapb.NewCAAClient(vaConn) + + caConn, err := bgrpc.ClientSetup(c.RA.CAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to create CA client") + cac := capb.NewCertificateAuthorityClient(caConn) + + ocspConn, err := bgrpc.ClientSetup(c.RA.OCSPService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to create CA OCSP client") + ocspc := capb.NewOCSPGeneratorClient(ocspConn) + + saConn, err := bgrpc.ClientSetup(c.RA.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac := sapb.NewStorageAuthorityClient(saConn) + + conn, err := bgrpc.ClientSetup(c.RA.PublisherService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to Publisher") + pubc := pubpb.NewPublisherClient(conn) + + apConn, err := bgrpc.ClientSetup(c.RA.AkamaiPurgerService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to create a Akamai Purger client") + apc := akamaipb.NewAkamaiPurgerClient(apConn) + + issuerCertPaths := c.RA.IssuerCerts + issuerCerts := make([]*issuance.Certificate, len(issuerCertPaths)) + for i, issuerCertPath := range issuerCertPaths { + issuerCerts[i], err = issuance.LoadCertificate(issuerCertPath) + cmd.FailOnError(err, "Failed to load issuer certificate") + } + + // Boulder's components assume that there will always be CT logs configured. + // Issuing a certificate without SCTs embedded is a misissuance event as per + // our CPS 4.4.2, which declares we will always include at least two SCTs. + // Exit early if no groups are configured. + var ctp *ctpolicy.CTPolicy + if len(c.RA.CTLogs.SCTLogs) <= 0 { + cmd.Fail("Must configure CTLogs") + } + + allLogs, err := loglist.New(c.RA.CTLogs.LogListFile) + cmd.FailOnError(err, "Failed to parse log list") + + sctLogs, err := allLogs.SubsetForPurpose(c.RA.CTLogs.SCTLogs, loglist.Issuance) + cmd.FailOnError(err, "Failed to load SCT logs") + + infoLogs, err := allLogs.SubsetForPurpose(c.RA.CTLogs.InfoLogs, loglist.Informational) + cmd.FailOnError(err, "Failed to load informational logs") + + finalLogs, err := allLogs.SubsetForPurpose(c.RA.CTLogs.FinalLogs, loglist.Informational) + cmd.FailOnError(err, "Failed to load final logs") + + ctp = ctpolicy.New(pubc, sctLogs, infoLogs, finalLogs, c.RA.CTLogs.Stagger.Duration, logger, scope) + + // Baseline Requirements v1.8.1 section 4.2.1: "any reused data, document, + // or completed validation MUST be obtained no more than 398 days prior + // to issuing the Certificate". If unconfigured or the configured value is + // greater than 397 days, bail out. + if c.RA.AuthorizationLifetimeDays <= 0 || c.RA.AuthorizationLifetimeDays > 397 { + cmd.Fail("authorizationLifetimeDays value must be greater than 0 and less than 398") + } + authorizationLifetime := time.Duration(c.RA.AuthorizationLifetimeDays) * 24 * time.Hour + + // The Baseline Requirements v1.8.1 state that validation tokens "MUST + // NOT be used for more than 30 days from its creation". If unconfigured + // or the configured value pendingAuthorizationLifetimeDays is greater + // than 29 days, bail out. + if c.RA.PendingAuthorizationLifetimeDays <= 0 || c.RA.PendingAuthorizationLifetimeDays > 29 { + cmd.Fail("pendingAuthorizationLifetimeDays value must be greater than 0 and less than 30") + } + pendingAuthorizationLifetime := time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour + + if features.Get().AsyncFinalize && c.RA.FinalizeTimeout.Duration == 0 { + cmd.Fail("finalizeTimeout must be supplied when AsyncFinalize feature is enabled") + } + + kp, err := sagoodkey.NewPolicy(&c.RA.GoodKey, sac.KeyBlocked) + cmd.FailOnError(err, "Unable to create key policy") + + if c.RA.MaxNames == 0 { + cmd.Fail("Error in RA config: MaxNames must not be 0") + } + + var limiter *ratelimits.Limiter + var txnBuilder *ratelimits.TransactionBuilder + var limiterRedis *bredis.Ring + if c.RA.Limiter.Defaults != "" { + // Setup rate limiting. + limiterRedis, err = bredis.NewRingFromConfig(*c.RA.Limiter.Redis, scope, logger) + cmd.FailOnError(err, "Failed to create Redis ring") + + source := ratelimits.NewRedisSource(limiterRedis.Ring, clk, scope) + limiter, err = ratelimits.NewLimiter(clk, source, scope) + cmd.FailOnError(err, "Failed to create rate limiter") + txnBuilder, err = ratelimits.NewTransactionBuilder(c.RA.Limiter.Defaults, c.RA.Limiter.Overrides) + cmd.FailOnError(err, "Failed to create rate limits transaction builder") + } + + rai := ra.NewRegistrationAuthorityImpl( + clk, + logger, + scope, + c.RA.MaxContactsPerRegistration, + kp, + limiter, + txnBuilder, + c.RA.MaxNames, + authorizationLifetime, + pendingAuthorizationLifetime, + pubc, + caaClient, + c.RA.OrderLifetime.Duration, + c.RA.FinalizeTimeout.Duration, + ctp, + apc, + issuerCerts, + ) + defer rai.DrainFinalize() + + policyErr := rai.LoadRateLimitPoliciesFile(c.RA.RateLimitPoliciesFilename) + cmd.FailOnError(policyErr, "Couldn't load rate limit policies file") + rai.PA = pa + + rai.VA = vac + rai.CA = cac + rai.OCSP = ocspc + rai.SA = sac + + start, err := bgrpc.NewServer(c.RA.GRPC, logger).Add( + &rapb.RegistrationAuthority_ServiceDesc, rai).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup RA gRPC server") + + cmd.FailOnError(start(), "RA gRPC service failed") +} + +func init() { + cmd.RegisterCommand("boulder-ra", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main_test.go new file mode 100644 index 000000000..227a9d4af --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-ra/main_test.go @@ -0,0 +1 @@ +package notmain diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main.go new file mode 100644 index 000000000..6f9fad259 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main.go @@ -0,0 +1,106 @@ +package notmain + +import ( + "context" + "flag" + "os" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type Config struct { + SA struct { + cmd.ServiceConfig + DB cmd.DBConfig + ReadOnlyDB cmd.DBConfig `validate:"-"` + IncidentsDB cmd.DBConfig `validate:"-"` + + Features features.Config + + // Max simultaneous SQL queries caused by a single RPC. + ParallelismPerRPC int `validate:"omitempty,min=1"` + // LagFactor is how long to sleep before retrying a read request that may + // have failed solely due to replication lag. + LagFactor config.Duration `validate:"-"` + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.SA.Features) + + if *grpcAddr != "" { + c.SA.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.SA.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.SA.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + dbMap, err := sa.InitWrappedDb(c.SA.DB, scope, logger) + cmd.FailOnError(err, "While initializing dbMap") + + dbReadOnlyMap := dbMap + if c.SA.ReadOnlyDB != (cmd.DBConfig{}) { + dbReadOnlyMap, err = sa.InitWrappedDb(c.SA.ReadOnlyDB, scope, logger) + cmd.FailOnError(err, "While initializing dbReadOnlyMap") + } + + dbIncidentsMap := dbMap + if c.SA.IncidentsDB != (cmd.DBConfig{}) { + dbIncidentsMap, err = sa.InitWrappedDb(c.SA.IncidentsDB, scope, logger) + cmd.FailOnError(err, "While initializing dbIncidentsMap") + } + + clk := cmd.Clock() + + parallel := c.SA.ParallelismPerRPC + if parallel < 1 { + parallel = 1 + } + + tls, err := c.SA.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + saroi, err := sa.NewSQLStorageAuthorityRO( + dbReadOnlyMap, dbIncidentsMap, scope, parallel, c.SA.LagFactor.Duration, clk, logger) + cmd.FailOnError(err, "Failed to create read-only SA impl") + + sai, err := sa.NewSQLStorageAuthorityWrapping(saroi, dbMap, scope) + cmd.FailOnError(err, "Failed to create SA impl") + + start, err := bgrpc.NewServer(c.SA.GRPC, logger).WithCheckInterval(c.SA.HealthCheckInterval.Duration).Add( + &sapb.StorageAuthorityReadOnly_ServiceDesc, saroi).Add( + &sapb.StorageAuthority_ServiceDesc, sai).Build( + tls, scope, clk) + cmd.FailOnError(err, "Unable to setup SA gRPC server") + + cmd.FailOnError(start(), "SA gRPC service failed") +} + +func init() { + cmd.RegisterCommand("boulder-sa", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main_test.go new file mode 100644 index 000000000..227a9d4af --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-sa/main_test.go @@ -0,0 +1 @@ +package notmain diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-va/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-va/main.go new file mode 100644 index 000000000..032435fac --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-va/main.go @@ -0,0 +1,130 @@ +package notmain + +import ( + "context" + "flag" + "os" + "time" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/va" + vaConfig "github.com/letsencrypt/boulder/va/config" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +type Config struct { + VA struct { + vaConfig.Common + RemoteVAs []cmd.GRPCClientConfig `validate:"omitempty,dive"` + MaxRemoteValidationFailures int `validate:"omitempty,min=0,required_with=RemoteVAs"` + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + err = c.VA.SetDefaultsAndValidate(grpcAddr, debugAddr) + cmd.FailOnError(err, "Setting and validating default config values") + + features.Set(c.VA.Features) + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.VA.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + clk := cmd.Clock() + + var servers bdns.ServerProvider + proto := "udp" + if features.Get().DOH { + proto = "tcp" + } + + if len(c.VA.DNSStaticResolvers) != 0 { + servers, err = bdns.NewStaticProvider(c.VA.DNSStaticResolvers) + cmd.FailOnError(err, "Couldn't start static DNS server resolver") + } else { + servers, err = bdns.StartDynamicProvider(c.VA.DNSProvider, 60*time.Second, proto) + cmd.FailOnError(err, "Couldn't start dynamic DNS server resolver") + } + defer servers.Stop() + + tlsConfig, err := c.VA.TLS.Load(scope) + cmd.FailOnError(err, "tlsConfig config") + + var resolver bdns.Client + if !c.VA.DNSAllowLoopbackAddresses { + resolver = bdns.New( + c.VA.DNSTimeout.Duration, + servers, + scope, + clk, + c.VA.DNSTries, + logger, + tlsConfig) + } else { + resolver = bdns.NewTest( + c.VA.DNSTimeout.Duration, + servers, + scope, + clk, + c.VA.DNSTries, + logger, + tlsConfig) + } + var remotes []va.RemoteVA + if len(c.VA.RemoteVAs) > 0 { + for _, rva := range c.VA.RemoteVAs { + rva := rva + vaConn, err := bgrpc.ClientSetup(&rva, tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to create remote VA client") + remotes = append( + remotes, + va.RemoteVA{ + RemoteClients: va.RemoteClients{ + VAClient: vapb.NewVAClient(vaConn), + CAAClient: vapb.NewCAAClient(vaConn), + }, + Address: rva.ServerAddress, + }, + ) + } + } + + vai, err := va.NewValidationAuthorityImpl( + resolver, + remotes, + c.VA.MaxRemoteValidationFailures, + c.VA.UserAgent, + c.VA.IssuerDomain, + scope, + clk, + logger, + c.VA.AccountURIPrefixes) + cmd.FailOnError(err, "Unable to create VA server") + + start, err := bgrpc.NewServer(c.VA.GRPC, logger).Add( + &vapb.VA_ServiceDesc, vai).Add( + &vapb.CAA_ServiceDesc, vai).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup VA gRPC server") + cmd.FailOnError(start(), "VA gRPC service failed") +} + +func init() { + cmd.RegisterCommand("boulder-va", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main.go new file mode 100644 index 000000000..1b3b497c6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main.go @@ -0,0 +1,455 @@ +package notmain + +import ( + "bytes" + "context" + "encoding/pem" + "flag" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/goodkey/sagoodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/grpc/noncebalancer" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/nonce" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/ratelimits" + bredis "github.com/letsencrypt/boulder/redis" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/wfe2" +) + +type Config struct { + WFE struct { + DebugAddr string `validate:"omitempty,hostname_port"` + + // ListenAddress is the address:port on which to listen for incoming + // HTTP requests. Defaults to ":80". + ListenAddress string `validate:"omitempty,hostname_port"` + + // TLSListenAddress is the address:port on which to listen for incoming + // HTTPS requests. If none is provided the WFE will not listen for HTTPS + // requests. + TLSListenAddress string `validate:"omitempty,hostname_port"` + + // Timeout is the per-request overall timeout. This should be slightly + // lower than the upstream's timeout when making request to the WFE. + Timeout config.Duration `validate:"-"` + + ServerCertificatePath string `validate:"required_with=TLSListenAddress"` + ServerKeyPath string `validate:"required_with=TLSListenAddress"` + + AllowOrigins []string + + ShutdownStopTimeout config.Duration + + SubscriberAgreementURL string + + TLS cmd.TLSConfig + + RAService *cmd.GRPCClientConfig + SAService *cmd.GRPCClientConfig + + // GetNonceService is a gRPC config which contains a single SRV name + // used to lookup nonce-service instances used exclusively for nonce + // creation. In a multi-DC deployment this should refer to local + // nonce-service instances only. + GetNonceService *cmd.GRPCClientConfig `validate:"required"` + + // RedeemNonceService is a gRPC config which contains a list of SRV + // names used to lookup nonce-service instances used exclusively for + // nonce redemption. In a multi-DC deployment this should contain both + // local and remote nonce-service instances. + RedeemNonceService *cmd.GRPCClientConfig `validate:"required"` + + // NoncePrefixKey is a secret used for deriving the prefix of each nonce + // instance. It should contain 256 bits of random data to be suitable as + // an HMAC-SHA256 key (e.g. the output of `openssl rand -hex 32`). In a + // multi-DC deployment this value should be the same across all + // boulder-wfe and nonce-service instances. + NoncePrefixKey cmd.PasswordConfig `validate:"-"` + + // Chains is a list of lists of certificate filenames. Each inner list is + // a chain (starting with the issuing intermediate, followed by one or + // more additional certificates, up to and including a root) which we are + // willing to serve. Chains that start with a given intermediate will only + // be offered for certificates which were issued by the key pair represented + // by that intermediate. The first chain representing any given issuing + // key pair will be the default for that issuer, served if the client does + // not request a specific chain. + Chains [][]string `validate:"required,min=1,dive,min=2,dive,required"` + + Features features.Config + + // DirectoryCAAIdentity is used for the /directory response's "meta" + // element's "caaIdentities" field. It should match the VA's "issuerDomain" + // configuration value (this value is the one used to enforce CAA) + DirectoryCAAIdentity string `validate:"required,fqdn"` + // DirectoryWebsite is used for the /directory response's "meta" element's + // "website" field. + DirectoryWebsite string `validate:"required,url"` + + // ACMEv2 requests (outside some registration/revocation messages) use a JWS with + // a KeyID header containing the full account URL. For new accounts this + // will be a KeyID based on the HTTP request's Host header and the ACMEv2 + // account path. For legacy ACMEv1 accounts we need to whitelist the account + // ID prefix that legacy accounts would have been using based on the Host + // header of the WFE1 instance and the legacy 'reg' path component. This + // will differ in configuration for production and staging. + LegacyKeyIDPrefix string `validate:"required,url"` + + // GoodKey is an embedded config stanza for the goodkey library. + GoodKey goodkey.Config + + // StaleTimeout determines how old should data be to be accessed via Boulder-specific GET-able APIs + StaleTimeout config.Duration `validate:"-"` + + // AuthorizationLifetimeDays defines how long authorizations will be + // considered valid for. The WFE uses this to find the creation date of + // authorizations by subtracing this value from the expiry. It should match + // the value configured in the RA. + AuthorizationLifetimeDays int `validate:"required,min=1,max=397"` + + // PendingAuthorizationLifetimeDays defines how long authorizations may be in + // the pending state before expiry. The WFE uses this to find the creation + // date of pending authorizations by subtracting this value from the expiry. + // It should match the value configured in the RA. + PendingAuthorizationLifetimeDays int `validate:"required,min=1,max=29"` + + AccountCache *CacheConfig + + Limiter struct { + // Redis contains the configuration necessary to connect to Redis + // for rate limiting. This field is required to enable rate + // limiting. + Redis *bredis.Config `validate:"required_with=Defaults"` + + // Defaults is a path to a YAML file containing default rate limits. + // See: ratelimits/README.md for details. This field is required to + // enable rate limiting. If any individual rate limit is not set, + // that limit will be disabled. Failed Authorizations limits passed + // in this file must be identical to those in the RA. + Defaults string `validate:"required_with=Redis"` + + // Overrides is a path to a YAML file containing overrides for the + // default rate limits. See: ratelimits/README.md for details. If + // this field is not set, all requesters will be subject to the + // default rate limits. Overrides for the Failed Authorizations + // overrides passed in this file must be identical to those in the + // RA. + Overrides string + } + + // MaxNames is the maximum number of subjectAltNames in a single cert. + // The value supplied SHOULD be greater than 0 and no more than 100, + // defaults to 100. These limits are per section 7.1 of our combined + // CP/CPS, under "DV-SSL Subscriber Certificate". The value must match + // the CA and RA configurations. + MaxNames int `validate:"min=0,max=100"` + + // CertificateProfileNames is the list of acceptable certificate profile + // names for newOrder requests. Requests with a profile name not in this + // list will be rejected. This field is optional; if unset, no profile + // names are accepted. + CertificateProfileNames []string `validate:"omitempty,dive,alphanum,min=1,max=32"` + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig + + // OpenTelemetryHTTPConfig configures tracing on incoming HTTP requests + OpenTelemetryHTTPConfig cmd.OpenTelemetryHTTPConfig +} + +type CacheConfig struct { + Size int + TTL config.Duration +} + +// loadChain takes a list of filenames containing pem-formatted certificates, +// and returns a chain representing all of those certificates in order. It +// ensures that the resulting chain is valid. The final file is expected to be +// a root certificate, which the chain will be verified against, but which will +// not be included in the resulting chain. +func loadChain(certFiles []string) (*issuance.Certificate, []byte, error) { + certs, err := issuance.LoadChain(certFiles) + if err != nil { + return nil, nil, err + } + + // Iterate over all certs appending their pem to the buf. + var buf bytes.Buffer + for _, cert := range certs { + buf.Write([]byte("\n")) + buf.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) + } + + return certs[0], buf.Bytes(), nil +} + +func setupWFE(c Config, scope prometheus.Registerer, clk clock.Clock) (rapb.RegistrationAuthorityClient, sapb.StorageAuthorityReadOnlyClient, nonce.Getter, nonce.Redeemer, string) { + tlsConfig, err := c.WFE.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + raConn, err := bgrpc.ClientSetup(c.WFE.RAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA") + rac := rapb.NewRegistrationAuthorityClient(raConn) + + saConn, err := bgrpc.ClientSetup(c.WFE.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac := sapb.NewStorageAuthorityReadOnlyClient(saConn) + + if c.WFE.RedeemNonceService == nil { + cmd.Fail("'redeemNonceService' must be configured.") + } + if c.WFE.GetNonceService == nil { + cmd.Fail("'getNonceService' must be configured") + } + + var rncKey string + if c.WFE.NoncePrefixKey.PasswordFile != "" { + rncKey, err = c.WFE.NoncePrefixKey.Pass() + cmd.FailOnError(err, "Failed to load noncePrefixKey") + } + + getNonceConn, err := bgrpc.ClientSetup(c.WFE.GetNonceService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to get nonce service") + gnc := nonce.NewGetter(getNonceConn) + + if c.WFE.RedeemNonceService.SRVResolver != noncebalancer.SRVResolverScheme { + cmd.Fail(fmt.Sprintf( + "'redeemNonceService.SRVResolver' must be set to %q", noncebalancer.SRVResolverScheme), + ) + } + redeemNonceConn, err := bgrpc.ClientSetup(c.WFE.RedeemNonceService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to redeem nonce service") + rnc := nonce.NewRedeemer(redeemNonceConn) + + return rac, sac, gnc, rnc, rncKey +} + +type errorWriter struct { + blog.Logger +} + +func (ew errorWriter) Write(p []byte) (n int, err error) { + // log.Logger will append a newline to all messages before calling + // Write. Our log checksum checker doesn't like newlines, because + // syslog will strip them out so the calculated checksums will + // differ. So that we don't hit this corner case for every line + // logged from inside net/http.Server we strip the newline before + // we get to the checksum generator. + p = bytes.TrimRight(p, "\n") + ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p))) + return +} + +func main() { + listenAddr := flag.String("addr", "", "HTTP listen address override") + tlsAddr := flag.String("tls-addr", "", "HTTPS listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.WFE.Features) + + if *listenAddr != "" { + c.WFE.ListenAddress = *listenAddr + } + if *tlsAddr != "" { + c.WFE.TLSListenAddress = *tlsAddr + } + if *debugAddr != "" { + c.WFE.DebugAddr = *debugAddr + } + maxNames := c.WFE.MaxNames + if maxNames == 0 { + // Default to 100 names per cert. + maxNames = 100 + } + + certChains := map[issuance.NameID][][]byte{} + issuerCerts := map[issuance.NameID]*issuance.Certificate{} + for _, files := range c.WFE.Chains { + issuer, chain, err := loadChain(files) + cmd.FailOnError(err, "Failed to load chain") + + id := issuer.NameID() + certChains[id] = append(certChains[id], chain) + // This may overwrite a previously-set issuerCert (e.g. if there are two + // chains for the same issuer, but with different versions of the same + // same intermediate issued by different roots). This is okay, as the + // only truly important content here is the public key to verify other + // certs. + issuerCerts[id] = issuer + } + + stats, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.WFE.DebugAddr) + logger.Info(cmd.VersionString()) + + clk := cmd.Clock() + + rac, sac, gnc, rnc, npKey := setupWFE(c, stats, clk) + + kp, err := sagoodkey.NewPolicy(&c.WFE.GoodKey, sac.KeyBlocked) + cmd.FailOnError(err, "Unable to create key policy") + + if c.WFE.StaleTimeout.Duration == 0 { + c.WFE.StaleTimeout.Duration = time.Minute * 10 + } + + // Baseline Requirements v1.8.1 section 4.2.1: "any reused data, document, + // or completed validation MUST be obtained no more than 398 days prior + // to issuing the Certificate". If unconfigured or the configured value is + // greater than 397 days, bail out. + if c.WFE.AuthorizationLifetimeDays <= 0 || c.WFE.AuthorizationLifetimeDays > 397 { + cmd.Fail("authorizationLifetimeDays value must be greater than 0 and less than 398") + } + authorizationLifetime := time.Duration(c.WFE.AuthorizationLifetimeDays) * 24 * time.Hour + + // The Baseline Requirements v1.8.1 state that validation tokens "MUST + // NOT be used for more than 30 days from its creation". If unconfigured + // or the configured value pendingAuthorizationLifetimeDays is greater + // than 29 days, bail out. + if c.WFE.PendingAuthorizationLifetimeDays <= 0 || c.WFE.PendingAuthorizationLifetimeDays > 29 { + cmd.Fail("pendingAuthorizationLifetimeDays value must be greater than 0 and less than 30") + } + pendingAuthorizationLifetime := time.Duration(c.WFE.PendingAuthorizationLifetimeDays) * 24 * time.Hour + + var limiter *ratelimits.Limiter + var txnBuilder *ratelimits.TransactionBuilder + var limiterRedis *bredis.Ring + if c.WFE.Limiter.Defaults != "" { + // Setup rate limiting. + limiterRedis, err = bredis.NewRingFromConfig(*c.WFE.Limiter.Redis, stats, logger) + cmd.FailOnError(err, "Failed to create Redis ring") + + source := ratelimits.NewRedisSource(limiterRedis.Ring, clk, stats) + limiter, err = ratelimits.NewLimiter(clk, source, stats) + cmd.FailOnError(err, "Failed to create rate limiter") + txnBuilder, err = ratelimits.NewTransactionBuilder(c.WFE.Limiter.Defaults, c.WFE.Limiter.Overrides) + cmd.FailOnError(err, "Failed to create rate limits transaction builder") + } + + var accountGetter wfe2.AccountGetter + if c.WFE.AccountCache != nil { + accountGetter = wfe2.NewAccountCache(sac, + c.WFE.AccountCache.Size, + c.WFE.AccountCache.TTL.Duration, + clk, + stats) + } else { + accountGetter = sac + } + wfe, err := wfe2.NewWebFrontEndImpl( + stats, + clk, + kp, + certChains, + issuerCerts, + logger, + c.WFE.Timeout.Duration, + c.WFE.StaleTimeout.Duration, + authorizationLifetime, + pendingAuthorizationLifetime, + rac, + sac, + gnc, + rnc, + npKey, + accountGetter, + limiter, + txnBuilder, + maxNames, + c.WFE.CertificateProfileNames, + ) + cmd.FailOnError(err, "Unable to create WFE") + + wfe.SubscriberAgreementURL = c.WFE.SubscriberAgreementURL + wfe.AllowOrigins = c.WFE.AllowOrigins + wfe.DirectoryCAAIdentity = c.WFE.DirectoryCAAIdentity + wfe.DirectoryWebsite = c.WFE.DirectoryWebsite + wfe.LegacyKeyIDPrefix = c.WFE.LegacyKeyIDPrefix + + logger.Infof("WFE using key policy: %#v", kp) + + if c.WFE.ListenAddress == "" { + cmd.Fail("HTTP listen address is not configured") + } + + logger.Infof("Server running, listening on %s....", c.WFE.ListenAddress) + handler := wfe.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...) + + srv := http.Server{ + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + Addr: c.WFE.ListenAddress, + ErrorLog: log.New(errorWriter{logger}, "", 0), + Handler: handler, + } + + go func() { + err := srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running HTTP server") + } + }() + + tlsSrv := http.Server{ + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + Addr: c.WFE.TLSListenAddress, + ErrorLog: log.New(errorWriter{logger}, "", 0), + Handler: handler, + } + if tlsSrv.Addr != "" { + go func() { + logger.Infof("TLS server listening on %s", tlsSrv.Addr) + err := tlsSrv.ListenAndServeTLS(c.WFE.ServerCertificatePath, c.WFE.ServerKeyPath) + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running TLS server") + } + }() + } + + // When main is ready to exit (because it has received a shutdown signal), + // gracefully shutdown the servers. Calling these shutdown functions causes + // ListenAndServe() and ListenAndServeTLS() to immediately return, then waits + // for any lingering connection-handling goroutines to finish their work. + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), c.WFE.ShutdownStopTimeout.Duration) + defer cancel() + _ = srv.Shutdown(ctx) + _ = tlsSrv.Shutdown(ctx) + limiterRedis.StopLookups() + oTelShutdown(ctx) + }() + + cmd.WaitForSignal() +} + +func init() { + cmd.RegisterCommand("boulder-wfe2", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main_test.go new file mode 100644 index 000000000..a1f79af8d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder-wfe2/main_test.go @@ -0,0 +1,38 @@ +package notmain + +import ( + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestLoadChain(t *testing.T) { + // Most of loadChain's logic is implemented in issuance.LoadChain, so this + // test only covers the construction of the PEM bytes. + _, chainPEM, err := loadChain([]string{ + "../../test/hierarchy/int-e1.cert.pem", + "../../test/hierarchy/root-x2-cross.cert.pem", + "../../test/hierarchy/root-x1.cert.pem", + }) + test.AssertNotError(t, err, "Should load valid chain") + + // Parse the first certificate in the PEM blob. + certPEM, rest := pem.Decode(chainPEM) + test.AssertNotNil(t, certPEM, "Failed to decode chain PEM") + _, err = x509.ParseCertificate(certPEM.Bytes) + test.AssertNotError(t, err, "Failed to parse chain PEM") + + // Parse the second certificate in the PEM blob. + certPEM, rest = pem.Decode(rest) + test.AssertNotNil(t, certPEM, "Failed to decode chain PEM") + _, err = x509.ParseCertificate(certPEM.Bytes) + test.AssertNotError(t, err, "Failed to parse chain PEM") + + // The chain should contain nothing else. + certPEM, rest = pem.Decode(rest) + if certPEM != nil || len(rest) != 0 { + t.Error("Expected chain PEM to contain one cert and nothing else") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder/main.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder/main.go new file mode 100644 index 000000000..c2fcfaab2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "os" + "strings" + + _ "github.com/letsencrypt/boulder/cmd/admin-revoker" + _ "github.com/letsencrypt/boulder/cmd/akamai-purger" + _ "github.com/letsencrypt/boulder/cmd/bad-key-revoker" + _ "github.com/letsencrypt/boulder/cmd/boulder-ca" + _ "github.com/letsencrypt/boulder/cmd/boulder-observer" + _ "github.com/letsencrypt/boulder/cmd/boulder-publisher" + _ "github.com/letsencrypt/boulder/cmd/boulder-ra" + _ "github.com/letsencrypt/boulder/cmd/boulder-sa" + _ "github.com/letsencrypt/boulder/cmd/boulder-va" + _ "github.com/letsencrypt/boulder/cmd/boulder-wfe2" + _ "github.com/letsencrypt/boulder/cmd/cert-checker" + _ "github.com/letsencrypt/boulder/cmd/contact-auditor" + _ "github.com/letsencrypt/boulder/cmd/crl-checker" + _ "github.com/letsencrypt/boulder/cmd/crl-storer" + _ "github.com/letsencrypt/boulder/cmd/crl-updater" + _ "github.com/letsencrypt/boulder/cmd/expiration-mailer" + _ "github.com/letsencrypt/boulder/cmd/id-exporter" + _ "github.com/letsencrypt/boulder/cmd/log-validator" + _ "github.com/letsencrypt/boulder/cmd/nonce-service" + _ "github.com/letsencrypt/boulder/cmd/notify-mailer" + _ "github.com/letsencrypt/boulder/cmd/ocsp-responder" + _ "github.com/letsencrypt/boulder/cmd/remoteva" + _ "github.com/letsencrypt/boulder/cmd/reversed-hostname-checker" + _ "github.com/letsencrypt/boulder/cmd/rocsp-tool" + "github.com/letsencrypt/boulder/core" + + "github.com/letsencrypt/boulder/cmd" +) + +// readAndValidateConfigFile uses the ConfigValidator registered for the given +// command to validate the provided config file. If the command does not have a +// registered ConfigValidator, this function does nothing. +func readAndValidateConfigFile(name, filename string) error { + cv := cmd.LookupConfigValidator(name) + if cv == nil { + return nil + } + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + if name == "boulder-observer" { + // Only the boulder-observer uses YAML config files. + return cmd.ValidateYAMLConfig(cv, file) + } + return cmd.ValidateJSONConfig(cv, file) +} + +// getConfigPath returns the path to the config file if it was provided as a +// command line flag. If the flag was not provided, it returns an empty string. +func getConfigPath() string { + for i := range len(os.Args) { + arg := os.Args[i] + if arg == "--config" || arg == "-config" { + if i+1 < len(os.Args) { + return os.Args[i+1] + } + } + if strings.HasPrefix(arg, "--config=") { + return strings.TrimPrefix(arg, "--config=") + } + if strings.HasPrefix(arg, "-config=") { + return strings.TrimPrefix(arg, "-config=") + } + } + return "" +} + +var boulderUsage = fmt.Sprintf(`Usage: %s [flags] + + Each boulder component has its own subcommand. Use --list to see + a list of the available components. Use --help to + see the usage for a specific component. +`, + core.Command()) + +func main() { + defer cmd.AuditPanic() + var command string + if core.Command() == "boulder" { + // Operator passed the boulder component as a subcommand. + if len(os.Args) <= 1 { + // No arguments passed. + fmt.Fprint(os.Stderr, boulderUsage) + return + } + + if os.Args[1] == "--help" || os.Args[1] == "-help" { + // Help flag passed. + fmt.Fprint(os.Stderr, boulderUsage) + return + } + + if os.Args[1] == "--list" || os.Args[1] == "-list" { + // List flag passed. + for _, c := range cmd.AvailableCommands() { + fmt.Println(c) + } + return + } + command = os.Args[1] + + // Remove the subcommand from the arguments. + os.Args = os.Args[1:] + } else { + // Operator ran a boulder component using a symlink. + command = core.Command() + } + + config := getConfigPath() + if config != "" { + // Config flag passed. + err := readAndValidateConfigFile(command, config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error validating config file %q for command %q: %s\n", config, command, err) + os.Exit(1) + } + } + + commandFunc := cmd.LookupCommand(command) + if commandFunc == nil { + fmt.Fprintf(os.Stderr, "Unknown subcommand %q.\n", command) + os.Exit(1) + } + commandFunc() +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/boulder/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/boulder/main_test.go new file mode 100644 index 000000000..45cfa1d63 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/boulder/main_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "testing" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/test" +) + +// TestConfigValidation checks that each of the components which register a +// validation tagged Config struct at init time can be used to successfully +// validate their corresponding test configuration files. +func TestConfigValidation(t *testing.T) { + configPath := "../../test/config" + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config-next" { + configPath = "../../test/config-next" + } + + // Each component is a set of `cmd` package name and a list of paths to + // configuration files to validate. + components := make(map[string][]string) + + // For each component, add the paths to the configuration files to validate. + // By default we assume that the configuration file is named after the + // component. However, there are some exceptions to this rule. We've added + // special cases for these components. + for _, cmdName := range cmd.AvailableConfigValidators() { + var fileNames []string + switch cmdName { + case "boulder-ca": + fileNames = []string{"ca.json"} + case "boulder-observer": + fileNames = []string{"observer.yml"} + case "boulder-publisher": + fileNames = []string{"publisher.json"} + case "boulder-ra": + fileNames = []string{"ra.json"} + case "boulder-sa": + fileNames = []string{"sa.json"} + case "boulder-va": + fileNames = []string{ + "va.json", + "va-remote-a.json", + "va-remote-b.json", + } + case "remoteva": + fileNames = []string{ + "remoteva-a.json", + "remoteva-b.json", + } + case "boulder-wfe2": + fileNames = []string{"wfe2.json"} + case "nonce-service": + fileNames = []string{ + "nonce-a.json", + "nonce-b.json", + } + default: + fileNames = []string{cmdName + ".json"} + } + components[cmdName] = append(components[cmdName], fileNames...) + } + t.Parallel() + for cmdName, paths := range components { + for _, path := range paths { + t.Run(path, func(t *testing.T) { + err := readAndValidateConfigFile(cmdName, fmt.Sprintf("%s/%s", configPath, path)) + test.AssertNotError(t, err, fmt.Sprintf("Failed to validate config file %q", path)) + }) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/README.md b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/README.md new file mode 100644 index 000000000..2b5b39350 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/README.md @@ -0,0 +1,424 @@ +# `ceremony` + +``` +ceremony --config path/to/config.yml +``` + +`ceremony` is a tool designed for Certificate Authority specific key and certificate ceremonies. The main design principle is that unlike most ceremony tooling there is a single user input, a configuration file, which is required to complete a root, intermediate, or key ceremony. The goal is to make ceremonies as simple as possible and allow for simple verification of a single file, instead of verification of a large number of independent commands. + +`ceremony` has these modes: +* `root` - generates a signing key on HSM and creates a self-signed root certificate that uses the generated key, outputting a PEM public key, and a PEM certificate. After generating such a root for public trust purposes, it should be submitted to [as many root programs as is possible/practical](https://github.com/daknob/root-programs). +* `intermediate` - creates a intermediate certificate and signs it using a signing key already on a HSM, outputting a PEM certificate +* `cross-csr` - creates a CSR for signing by a third party, outputting a PEM CSR. +* `cross-certificate` - issues a certificate for one root, signed by another root. This is distinct from an intermediate because there is no path length constraint and there are no EKUs. +* `ocsp-signer` - creates a delegated OCSP signing certificate and signs it using a signing key already on a HSM, outputting a PEM certificate +* `crl-signer` - creates a delegated CRL signing certificate and signs it using a signing key already on a HSM, outputting a PEM certificate +* `key` - generates a signing key on HSM, outputting a PEM public key +* `ocsp-response` - creates a OCSP response for the provided certificate and signs it using a signing key already on a HSM, outputting a base64 encoded response +* `crl` - creates a CRL with the IDP extension and `onlyContainsCACerts = true` from the provided profile and signs it using a signing key already on a HSM, outputting a PEM CRL + +These modes are set in the `ceremony-type` field of the configuration file. + +This tool always generates key pairs such that the public and private key are both stored on the device with the same label. Ceremony types that use a key on a device ask for a "signing key label". During setup this label is used to find the public key of a keypair. Once the public key is loaded, the private key is looked up by CKA\_ID. + +## Configuration format + +`ceremony` uses YAML for its configuration file, mainly as it allows for commenting. Each ceremony type has a different set of configuration fields. + +### Root ceremony + +- `ceremony-type`: string describing the ceremony type, `root`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `store-key-in-slot` | Specifies which HSM object slot the generated signing key should be stored in. | + | `store-key-with-label` | Specifies the HSM object label for the generated signing key. Both public and private key objects are stored with this label. | +- `key`: object containing key generation related fields. + | Field | Description | + | --- | --- | + | `type` | Specifies the type of key to be generated, either `rsa` or `ecdsa`. If `rsa` the generated key will have an exponent of 65537 and a modulus length specified by `rsa-mod-length`. If `ecdsa` the curve is specified by `ecdsa-curve`. | + | `ecdsa-curve` | Specifies the ECDSA curve to use when generating key, either `P-224`, `P-256`, `P-384`, or `P-521`. | + | `rsa-mod-length` | Specifies the length of the RSA modulus, either `2048` or `4096`. +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `public-key-path` | Path to store generated PEM public key. | + | `certificate-path` | Path to store signed PEM certificate. | +- `certificate-profile`: object containing profile for certificate to generate. Fields are documented [below](#certificate-profile-format). + +Example: + +```yaml +ceremony-type: root +pkcs11: + module: /usr/lib/opensc-pkcs11.so + store-key-in-slot: 0 + store-key-with-label: root signing key +key: + type: ecdsa + ecdsa-curve: P-384 +outputs: + public-key-path: /home/user/root-signing-pub.pem + certificate-path: /home/user/root-cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: CA intermediate + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + key-usages: + - Cert Sign + - CRL Sign +``` + +This config generates a ECDSA P-384 key in the HSM with the object label `root signing key` and uses this key to sign a self-signed certificate. The public key for the key generated is written to `/home/user/root-signing-pub.pem` and the certificate is written to `/home/user/root-cert.pem`. + +### Intermediate or Cross-Certificate ceremony + +- `ceremony-type`: string describing the ceremony type, `intermediate` or `cross-certificate`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `public-key-path` | Path to PEM subject public key for certificate. | + | `issuer-certificate-path` | Path to PEM issuer certificate. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `certificate-path` | Path to store signed PEM certificate. | +- `certificate-profile`: object containing profile for certificate to generate. Fields are documented [below](#certificate-profile-format). + +Example: + +```yaml +ceremony-type: intermediate +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: root signing key +inputs: + public-key-path: /home/user/intermediate-signing-pub.pem + issuer-certificate-path: /home/user/root-cert.pem +outputs: + certificate-path: /home/user/intermediate-cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: CA root + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + ocsp-url: http://good-guys.com/ocsp + crl-url: http://good-guys.com/crl + issuer-url: http://good-guys.com/root + policies: + - oid: 1.2.3 + - oid: 4.5.6 + cps-uri: "http://example.com/cps" + key-usages: + - Digital Signature + - Cert Sign + - CRL Sign +``` + +This config generates an intermediate certificate signed by a key in the HSM, identified by the object label `root signing key` and the object ID `ffff`. The subject key used is taken from `/home/user/intermediate-signing-pub.pem` and the issuer is `/home/user/root-cert.pem`, the resulting certificate is written to `/home/user/intermediate-cert.pem`. + +Note: Intermediate certificates always include the extended key usages id-kp-serverAuth as required by 7.1.2.2.g of the CABF Baseline Requirements. Since we also include id-kp-clientAuth in end-entity certificates in boulder we also include it in intermediates, if this changes we may remove this inclusion. + +### Cross-CSR ceremony + +- `ceremony-type`: string describing the ceremony type, `cross-csr`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `public-key-path` | Path to PEM subject public key for certificate. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `csr-path` | Path to store PEM CSR for cross-signing, optional. | +- `certificate-profile`: object containing profile for certificate to generate. Fields are documented [below](#certificate-profile-format). Should only include Subject related fields `common-name`, `organization`, `country`. + +Example: + +```yaml +ceremony-type: cross-csr +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: intermediate signing key +inputs: + public-key-path: /home/user/intermediate-signing-pub.pem +outputs: + csr-path: /home/user/csr.pem +certificate-profile: + common-name: CA root + organization: good guys + country: US +``` + +This config generates a CSR signed by a key in the HSM, identified by the object label `intermediate signing key`, and writes it to `/home/user/csr.pem`. + +### OCSP Signing Certificate ceremony + +- `ceremony-type`: string describing the ceremony type, `ocsp-signer`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `public-key-path` | Path to PEM subject public key for certificate. | + | `issuer-certificate-path` | Path to PEM issuer certificate. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `certificate-path` | Path to store signed PEM certificate. | +- `certificate-profile`: object containing profile for certificate to generate. Fields are documented [below](#certificate-profile-format). The key-usages, ocsp-url, and crl-url fields must not be set. + +When generating an OCSP signing certificate the key usages field will be set to just Digital Signature and an EKU extension will be included with the id-kp-OCSPSigning usage. Additionally an id-pkix-ocsp-nocheck extension will be included in the certificate. + +Example: + +```yaml +ceremony-type: ocsp-signer +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: intermediate signing key +inputs: + public-key-path: /home/user/ocsp-signer-signing-pub.pem + issuer-certificate-path: /home/user/intermediate-cert.pem +outputs: + certificate-path: /home/user/ocsp-signer-cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: CA OCSP signer + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + issuer-url: http://good-guys.com/root +``` + +This config generates a delegated OCSP signing certificate signed by a key in the HSM, identified by the object label `intermediate signing key` and the object ID `ffff`. The subject key used is taken from `/home/user/ocsp-signer-signing-pub.pem` and the issuer is `/home/user/intermediate-cert.pem`, the resulting certificate is written to `/home/user/ocsp-signer-cert.pem`. + +### CRL Signing Certificate ceremony + +- `ceremony-type`: string describing the ceremony type, `crl-signer`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `public-key-path` | Path to PEM subject public key for certificate. | + | `issuer-certificate-path` | Path to PEM issuer certificate. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `certificate-path` | Path to store signed PEM certificate. | +- `certificate-profile`: object containing profile for certificate to generate. Fields are documented [below](#certificate-profile-format). The key-usages, ocsp-url, and crl-url fields must not be set. + +When generating a CRL signing certificate the key usages field will be set to just CRL Sign. + +Example: + +```yaml +ceremony-type: crl-signer +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: intermediate signing key +inputs: + public-key-path: /home/user/crl-signer-signing-pub.pem + issuer-certificate-path: /home/user/intermediate-cert.pem +outputs: + certificate-path: /home/user/crl-signer-cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: CA CRL signer + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + issuer-url: http://good-guys.com/root +``` + +This config generates a delegated CRL signing certificate signed by a key in the HSM, identified by the object label `intermediate signing key` and the object ID `ffff`. The subject key used is taken from `/home/user/crl-signer-signing-pub.pem` and the issuer is `/home/user/intermediate-cert.pem`, the resulting certificate is written to `/home/user/crl-signer-cert.pem`. + +### Key ceremony + +- `ceremony-type`: string describing the ceremony type, `key`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `store-key-in-slot` | Specifies which HSM object slot the generated signing key should be stored in. | + | `store-key-with-label` | Specifies the HSM object label for the generated signing key. Both public and private key objects are stored with this label. | +- `key`: object containing key generation related fields. + | Field | Description | + | --- | --- | + | `type` | Specifies the type of key to be generated, either `rsa` or `ecdsa`. If `rsa` the generated key will have an exponent of 65537 and a modulus length specified by `rsa-mod-length`. If `ecdsa` the curve is specified by `ecdsa-curve`. | + | `ecdsa-curve` | Specifies the ECDSA curve to use when generating key, either `P-224`, `P-256`, `P-384`, or `P-521`. | + | `rsa-mod-length` | Specifies the length of the RSA modulus, either `2048` or `4096`. +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `public-key-path` | Path to store generated PEM public key. | + +Example: + +```yaml +ceremony-type: key +pkcs11: + module: /usr/lib/opensc-pkcs11.so + store-key-in-slot: 0 + store-key-with-label: intermediate signing key +key: + type: ecdsa + ecdsa-curve: P-384 +outputs: + public-key-path: /home/user/intermediate-signing-pub.pem +``` + +This config generates an ECDSA P-384 key in the HSM with the object label `intermediate signing key`. The public key is written to `/home/user/intermediate-signing-pub.pem`. + +### OCSP Response ceremony + +- `ceremony-type`: string describing the ceremony type, `ocsp-response`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `certificate-path` | Path to PEM certificate to create a response for. | + | `issuer-certificate-path` | Path to PEM issuer certificate. | + | `delegated-issuer-certificate-path` | Path to PEM delegated issuer certificate, if one is being used. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `response-path` | Path to store signed base64 encoded response. | +- `ocsp-profile`: object containing profile for the OCSP response. + | Field | Description | + | --- | --- | + | `this-update` | Specifies the OCSP response thisUpdate date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | + | `next-update` | Specifies the OCSP response nextUpdate date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | + | `status` | Specifies the OCSP response status, either `good` or `revoked`. | + +Example: + +```yaml +ceremony-type: ocsp-response +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: root signing key +inputs: + certificate-path: /home/user/certificate.pem + issuer-certificate-path: /home/user/root-cert.pem +outputs: + response-path: /home/user/ocsp-resp.b64 +ocsp-profile: + this-update: 2020-01-01 12:00:00 + next-update: 2021-01-01 12:00:00 + status: good +``` + +This config generates a OCSP response signed by a key in the HSM, identified by the object label `root signing key` and object ID `ffff`. The response will be for the certificate in `/home/user/certificate.pem`, and will be written to `/home/user/ocsp-resp.b64`. + +### CRL ceremony + +- `ceremony-type`: string describing the ceremony type, `crl`. +- `pkcs11`: object containing PKCS#11 related fields. + | Field | Description | + | --- | --- | + | `module` | Path to the PKCS#11 module to use to communicate with a HSM. | + | `pin` | Specifies the login PIN, should only be provided if the HSM device requires one to interact with the slot. | + | `signing-key-slot` | Specifies which HSM object slot the signing key is in. | + | `signing-key-label` | Specifies the HSM object label for the signing keypair's public key. | +- `inputs`: object containing paths for inputs + | Field | Description | + | --- | --- | + | `issuer-certificate-path` | Path to PEM issuer certificate. | +- `outputs`: object containing paths to write outputs. + | Field | Description | + | --- | --- | + | `crl-path` | Path to store signed PEM CRL. | +- `crl-profile`: object containing profile for the CRL. + | Field | Description | + | --- | --- | + | `this-update` | Specifies the CRL thisUpdate date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | + | `next-update` | Specifies the CRL nextUpdate date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | + | `number` | Specifies the CRL number. Each CRL should have a unique monotonically increasing number. | + | `revoked-certificates` | Specifies any revoked certificates that should be included in the CRL. May be empty. If present it should be a list of objects with the fields `certificate-path`, containing the path to the revoked certificate, `revocation-date`, containing the date the certificate was revoked, in the format `2006-01-02 15:04:05`, and `revocation-reason`, containing a non-zero CRLReason code for the revocation taken from RFC 5280. | + +Example: + +```yaml +ceremony-type: crl +pkcs11: + module: /usr/lib/opensc-pkcs11.so + signing-key-slot: 0 + signing-key-label: root signing key +inputs: + issuer-certificate-path: /home/user/root-cert.pem +outputs: + crl-path: /home/user/crl.pem +crl-profile: + this-update: 2020-01-01 12:00:00 + next-update: 2021-01-01 12:00:00 + number: 80 + revoked-certificates: + - certificate-path: /home/user/revoked-cert.pem + revocation-date: 2019-12-31 12:00:00 +``` + +This config generates a CRL that must only contain subordinate CA certificates signed by a key in the HSM, identified by the object label `root signing key` and object ID `ffff`. The CRL will have the number `80` and will contain revocation information for the certificate `/home/user/revoked-cert.pem`. Each of the revoked certificates provided are checked to ensure they have the `IsCA` flag set to `true`. + +### Certificate profile format + +The certificate profile defines a restricted set of fields that are used to generate root and intermediate certificates. + +| Field | Description | +| --- | --- | +| `signature-algorithm` | Specifies the signing algorithm to use, one of `SHA256WithRSA`, `SHA384WithRSA`, `SHA512WithRSA`, `ECDSAWithSHA256`, `ECDSAWithSHA384`, `ECDSAWithSHA512` | +| `common-name` | Specifies the subject commonName | +| `organization` | Specifies the subject organization | +| `country` | Specifies the subject country | +| `not-before` | Specifies the certificate notBefore date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | +| `not-after` | Specifies the certificate notAfter date, in the format `2006-01-02 15:04:05`. The time will be interpreted as UTC. | +| `ocsp-url` | Specifies the AIA OCSP responder URL | +| `crl-url` | Specifies the cRLDistributionPoints URL | +| `issuer-url` | Specifies the AIA caIssuer URL | +| `policies` | Specifies contents of a certificatePolicies extension. Should contain a list of policies with the fields `oid`, indicating the policy OID, and a `cps-uri` field, containing the CPS URI to use, if the policy should contain a id-qt-cps qualifier. Only single CPS values are supported. | +| `key-usages` | Specifies list of key usage bits should be set, list can contain `Digital Signature`, `CRL Sign`, and `Cert Sign` | diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert.go new file mode 100644 index 000000000..6c8a5c4f5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert.go @@ -0,0 +1,354 @@ +package main + +import ( + "crypto" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "io" + "math/big" + "strconv" + "strings" + "time" +) + +type policyInfoConfig struct { + OID string + // Deprecated: we do not include the id-qt-cps policy qualifier in our + // certificate policy extensions anymore. + CPSURI string `yaml:"cps-uri"` +} + +// certProfile contains the information required to generate a certificate +type certProfile struct { + // SignatureAlgorithm should contain one of the allowed signature algorithms + // in AllowedSigAlgs + SignatureAlgorithm string `yaml:"signature-algorithm"` + + // CommonName should contain the requested subject common name + CommonName string `yaml:"common-name"` + // Organization should contain the requested subject organization + Organization string `yaml:"organization"` + // Country should contain the requested subject country code + Country string `yaml:"country"` + + // NotBefore should contain the requested NotBefore date for the + // certificate in the format "2006-01-02 15:04:05". Dates will + // always be UTC. + NotBefore string `yaml:"not-before"` + // NotAfter should contain the requested NotAfter date for the + // certificate in the format "2006-01-02 15:04:05". Dates will + // always be UTC. + NotAfter string `yaml:"not-after"` + + // OCSPURL should contain the URL at which a OCSP responder that + // can respond to OCSP requests for this certificate operates + OCSPURL string `yaml:"ocsp-url"` + // CRLURL should contain the URL at which CRLs for this certificate + // can be found + CRLURL string `yaml:"crl-url"` + // IssuerURL should contain the URL at which the issuing certificate + // can be found, this is only required if generating an intermediate + // certificate + IssuerURL string `yaml:"issuer-url"` + + // Policies should contain any OIDs to be inserted in a certificate + // policies extension. It should be empty for Root certs, and contain the + // BRs "domain-validated" Reserved Policy Identifier for Intermediates. + Policies []policyInfoConfig `yaml:"policies"` + + // KeyUsages should contain the set of key usage bits to set + KeyUsages []string `yaml:"key-usages"` +} + +// AllowedSigAlgs contains the allowed signature algorithms +var AllowedSigAlgs = map[string]x509.SignatureAlgorithm{ + "SHA256WithRSA": x509.SHA256WithRSA, + "SHA384WithRSA": x509.SHA384WithRSA, + "SHA512WithRSA": x509.SHA512WithRSA, + "ECDSAWithSHA256": x509.ECDSAWithSHA256, + "ECDSAWithSHA384": x509.ECDSAWithSHA384, + "ECDSAWithSHA512": x509.ECDSAWithSHA512, +} + +type certType int + +const ( + rootCert certType = iota + intermediateCert + ocspCert + crlCert + crossCert + requestCert +) + +// Subject returns a pkix.Name from the appropriate certProfile fields +func (profile *certProfile) Subject() pkix.Name { + return pkix.Name{ + CommonName: profile.CommonName, + Organization: []string{profile.Organization}, + Country: []string{profile.Country}, + } +} + +func (profile *certProfile) verifyProfile(ct certType) error { + if ct == requestCert { + if profile.NotBefore != "" { + return errors.New("not-before cannot be set for a CSR") + } + if profile.NotAfter != "" { + return errors.New("not-after cannot be set for a CSR") + } + if profile.SignatureAlgorithm != "" { + return errors.New("signature-algorithm cannot be set for a CSR") + } + if profile.OCSPURL != "" { + return errors.New("ocsp-url cannot be set for a CSR") + } + if profile.CRLURL != "" { + return errors.New("crl-url cannot be set for a CSR") + } + if profile.IssuerURL != "" { + return errors.New("issuer-url cannot be set for a CSR") + } + if profile.Policies != nil { + return errors.New("policies cannot be set for a CSR") + } + if profile.KeyUsages != nil { + return errors.New("key-usages cannot be set for a CSR") + } + } else { + if profile.NotBefore == "" { + return errors.New("not-before is required") + } + if profile.NotAfter == "" { + return errors.New("not-after is required") + } + if profile.SignatureAlgorithm == "" { + return errors.New("signature-algorithm is required") + } + } + if profile.CommonName == "" { + return errors.New("common-name is required") + } + if profile.Organization == "" { + return errors.New("organization is required") + } + if profile.Country == "" { + return errors.New("country is required") + } + + if ct == rootCert { + if len(profile.Policies) != 0 { + return errors.New("policies should not be set on root certs") + } + } + + if ct == intermediateCert || ct == crossCert { + if profile.CRLURL == "" { + return errors.New("crl-url is required for subordinate CAs") + } + if profile.IssuerURL == "" { + return errors.New("issuer-url is required for subordinate CAs") + } + + // BR 7.1.2.10.5 CA Certificate Certificate Policies + // OID 2.23.140.1.2.1 is an anyPolicy + if len(profile.Policies) != 1 || profile.Policies[0].OID != "2.23.140.1.2.1" { + return errors.New("policy should be exactly BRs domain-validated for subordinate CAs") + } + } + + if ct == ocspCert || ct == crlCert { + if len(profile.KeyUsages) != 0 { + return errors.New("key-usages cannot be set for a delegated signer") + } + if profile.CRLURL != "" { + return errors.New("crl-url cannot be set for a delegated signer") + } + if profile.OCSPURL != "" { + return errors.New("ocsp-url cannot be set for a delegated signer") + } + } + return nil +} + +func parseOID(oidStr string) (asn1.ObjectIdentifier, error) { + var oid asn1.ObjectIdentifier + for _, a := range strings.Split(oidStr, ".") { + i, err := strconv.Atoi(a) + if err != nil { + return nil, err + } + if i <= 0 { + return nil, errors.New("OID components must be >= 1") + } + oid = append(oid, i) + } + return oid, nil +} + +var stringToKeyUsage = map[string]x509.KeyUsage{ + "Digital Signature": x509.KeyUsageDigitalSignature, + "CRL Sign": x509.KeyUsageCRLSign, + "Cert Sign": x509.KeyUsageCertSign, +} + +var oidOCSPNoCheck = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5} + +func generateSKID(pk []byte) ([]byte, error) { + var pkixPublicKey struct { + Algo pkix.AlgorithmIdentifier + BitString asn1.BitString + } + if _, err := asn1.Unmarshal(pk, &pkixPublicKey); err != nil { + return nil, err + } + + // RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: The + // keyIdentifier [may be] composed of the leftmost 160-bits of the SHA-256 + // hash of the value of the BIT STRING subjectPublicKey (excluding the tag, + // length, and number of unused bits). + skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) + return skid[0:20:20], nil +} + +// makeTemplate generates the certificate template for use in x509.CreateCertificate +func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbcs *x509.Certificate, ct certType) (*x509.Certificate, error) { + // Handle "unrestricted" vs "restricted" subordinate CA profile specifics. + if ct == crossCert && tbcs == nil { + return nil, fmt.Errorf("toBeCrossSigned cert field was nil, but was required to gather EKUs for the lint cert") + } + + var ocspServer []string + if profile.OCSPURL != "" { + ocspServer = []string{profile.OCSPURL} + } + var crlDistributionPoints []string + if profile.CRLURL != "" { + crlDistributionPoints = []string{profile.CRLURL} + } + var issuingCertificateURL []string + if profile.IssuerURL != "" { + issuingCertificateURL = []string{profile.IssuerURL} + } + + subjectKeyID, err := generateSKID(pubKey) + if err != nil { + return nil, err + } + + serial := make([]byte, 16) + _, err = randReader.Read(serial) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %s", err) + } + + var ku x509.KeyUsage + for _, kuStr := range profile.KeyUsages { + kuBit, ok := stringToKeyUsage[kuStr] + if !ok { + return nil, fmt.Errorf("unknown key usage %q", kuStr) + } + ku |= kuBit + } + if ct == ocspCert { + ku = x509.KeyUsageDigitalSignature + } else if ct == crlCert { + ku = x509.KeyUsageCRLSign + } + if ku == 0 { + return nil, errors.New("at least one key usage must be set") + } + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(0).SetBytes(serial), + BasicConstraintsValid: true, + IsCA: true, + Subject: profile.Subject(), + OCSPServer: ocspServer, + CRLDistributionPoints: crlDistributionPoints, + IssuingCertificateURL: issuingCertificateURL, + KeyUsage: ku, + SubjectKeyId: subjectKeyID, + } + + if ct != requestCert { + sigAlg, ok := AllowedSigAlgs[profile.SignatureAlgorithm] + if !ok { + return nil, fmt.Errorf("unsupported signature algorithm %q", profile.SignatureAlgorithm) + } + cert.SignatureAlgorithm = sigAlg + notBefore, err := time.Parse(time.DateTime, profile.NotBefore) + if err != nil { + return nil, err + } + cert.NotBefore = notBefore + notAfter, err := time.Parse(time.DateTime, profile.NotAfter) + if err != nil { + return nil, err + } + cert.NotAfter = notAfter + } + + switch ct { + // rootCert does not get EKU or MaxPathZero. + // BR 7.1.2.1.2 Root CA Extensions + // Extension Presence Critical Description + // extKeyUsage MUST NOT N - + case ocspCert: + cert.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning} + // ASN.1 NULL is 0x05, 0x00 + ocspNoCheckExt := pkix.Extension{Id: oidOCSPNoCheck, Value: []byte{5, 0}} + cert.ExtraExtensions = append(cert.ExtraExtensions, ocspNoCheckExt) + cert.IsCA = false + case crlCert: + cert.IsCA = false + case requestCert, intermediateCert: + // id-kp-serverAuth and id-kp-clientAuth are included in intermediate + // certificates in order to technically constrain them. id-kp-serverAuth + // is required by 7.1.2.2.g of the CABF Baseline Requirements, but + // id-kp-clientAuth isn't. We include id-kp-clientAuth as we also include + // it in our end-entity certificates. + cert.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth} + cert.MaxPathLenZero = true + case crossCert: + cert.ExtKeyUsage = tbcs.ExtKeyUsage + cert.MaxPathLenZero = tbcs.MaxPathLenZero + } + + for _, policyConfig := range profile.Policies { + oid, err := parseOID(policyConfig.OID) + if err != nil { + return nil, err + } + cert.PolicyIdentifiers = append(cert.PolicyIdentifiers, oid) + } + + return cert, nil +} + +// failReader exists to be passed to x509.CreateCertificate which requires +// a source of randomness for signing methods that require a source of +// randomness. Since HSM based signing will generate its own randomness +// we don't need a real reader. Instead of passing a nil reader we use one +// that always returns errors in case the internal usage of this reader +// changes. +type failReader struct{} + +func (fr *failReader) Read([]byte) (int, error) { + return 0, errors.New("empty reader used by x509.CreateCertificate") +} + +func generateCSR(profile *certProfile, signer crypto.Signer) ([]byte, error) { + csrDER, err := x509.CreateCertificateRequest(&failReader{}, &x509.CertificateRequest{ + Subject: profile.Subject(), + }, signer) + if err != nil { + return nil, fmt.Errorf("failed to create and sign CSR: %s", err) + } + return csrDER, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert_test.go new file mode 100644 index 000000000..95a2b3375 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/cert_test.go @@ -0,0 +1,586 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "math/big" + "testing" + "time" + + "github.com/miekg/pkcs11" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/letsencrypt/boulder/test" +) + +// samplePubkey returns a slice of bytes containing an encoded +// SubjectPublicKeyInfo for an example public key. +func samplePubkey() []byte { + pubKey, err := hex.DecodeString("3059301306072a8648ce3d020106082a8648ce3d03010703420004b06745ef0375c9c54057098f077964e18d3bed0aacd54545b16eab8c539b5768cc1cea93ba56af1e22a7a01c33048c8885ed17c9c55ede70649b707072689f5e") + if err != nil { + panic(err) + } + return pubKey +} + +func realRand(_ pkcs11.SessionHandle, length int) ([]byte, error) { + r := make([]byte, length) + _, err := rand.Read(r) + return r, err +} + +func TestParseOID(t *testing.T) { + _, err := parseOID("") + test.AssertError(t, err, "parseOID accepted an empty OID") + _, err = parseOID("a.b.c") + test.AssertError(t, err, "parseOID accepted an OID containing non-ints") + _, err = parseOID("1.0.2") + test.AssertError(t, err, "parseOID accepted an OID containing zero") + oid, err := parseOID("1.2.3") + test.AssertNotError(t, err, "parseOID failed with a valid OID") + test.Assert(t, oid.Equal(asn1.ObjectIdentifier{1, 2, 3}), "parseOID returned incorrect OID") +} + +func TestMakeSubject(t *testing.T) { + profile := &certProfile{ + CommonName: "common name", + Organization: "organization", + Country: "country", + } + expectedSubject := pkix.Name{ + CommonName: "common name", + Organization: []string{"organization"}, + Country: []string{"country"}, + } + test.AssertDeepEquals(t, profile.Subject(), expectedSubject) +} + +func TestMakeTemplateRoot(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + profile := &certProfile{} + randReader := newRandReader(s) + pubKey := samplePubkey() + ctx.GenerateRandomFunc = realRand + + profile.NotBefore = "1234" + _, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with invalid not before") + + profile.NotBefore = "2018-05-18 11:31:00" + profile.NotAfter = "1234" + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with invalid not after") + + profile.NotAfter = "2018-05-18 11:31:00" + profile.SignatureAlgorithm = "nope" + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with invalid signature algorithm") + + profile.SignatureAlgorithm = "SHA256WithRSA" + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return nil, errors.New("bad") + } + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail when GenerateRandom failed") + + ctx.GenerateRandomFunc = realRand + + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with empty key usages") + + profile.KeyUsages = []string{"asd"} + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with invalid key usages") + + profile.KeyUsages = []string{"Digital Signature", "CRL Sign"} + profile.Policies = []policyInfoConfig{{}} + _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertError(t, err, "makeTemplate didn't fail with invalid (empty) policy OID") + + profile.Policies = []policyInfoConfig{{OID: "1.2.3"}, {OID: "1.2.3.4"}} + profile.CommonName = "common name" + profile.Organization = "organization" + profile.Country = "country" + profile.OCSPURL = "ocsp" + profile.CRLURL = "crl" + profile.IssuerURL = "issuer" + cert, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") + test.AssertEquals(t, cert.Subject.CommonName, profile.CommonName) + test.AssertEquals(t, len(cert.Subject.Organization), 1) + test.AssertEquals(t, cert.Subject.Organization[0], profile.Organization) + test.AssertEquals(t, len(cert.Subject.Country), 1) + test.AssertEquals(t, cert.Subject.Country[0], profile.Country) + test.AssertEquals(t, len(cert.OCSPServer), 1) + test.AssertEquals(t, cert.OCSPServer[0], profile.OCSPURL) + test.AssertEquals(t, len(cert.CRLDistributionPoints), 1) + test.AssertEquals(t, cert.CRLDistributionPoints[0], profile.CRLURL) + test.AssertEquals(t, len(cert.IssuingCertificateURL), 1) + test.AssertEquals(t, cert.IssuingCertificateURL[0], profile.IssuerURL) + test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageDigitalSignature|x509.KeyUsageCRLSign) + test.AssertEquals(t, len(cert.PolicyIdentifiers), 2) + test.AssertEquals(t, len(cert.ExtKeyUsage), 0) + + cert, err = makeTemplate(randReader, profile, pubKey, nil, intermediateCert) + test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") + test.Assert(t, cert.MaxPathLenZero, "MaxPathLenZero not set in intermediate template") + test.AssertEquals(t, len(cert.ExtKeyUsage), 2) + test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageClientAuth) + test.AssertEquals(t, cert.ExtKeyUsage[1], x509.ExtKeyUsageServerAuth) +} + +func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + ctx.GenerateRandomFunc = realRand + randReader := newRandReader(s) + pubKey := samplePubkey() + profile := &certProfile{ + SignatureAlgorithm: "SHA256WithRSA", + CommonName: "common name", + Organization: "organization", + Country: "country", + KeyUsages: []string{"Digital Signature", "CRL Sign"}, + OCSPURL: "ocsp", + CRLURL: "crl", + IssuerURL: "issuer", + NotAfter: "2020-10-10 11:31:00", + NotBefore: "2020-10-10 11:31:00", + } + + tbcsCert := x509.Certificate{ + SerialNumber: big.NewInt(666), + Subject: pkix.Name{ + Organization: []string{"While Eek Ayote"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + cert, err := makeTemplate(randReader, profile, pubKey, &tbcsCert, crossCert) + test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") + test.Assert(t, !cert.MaxPathLenZero, "MaxPathLenZero was set in cross-sign") + test.AssertEquals(t, len(cert.ExtKeyUsage), 1) + test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageServerAuth) +} + +func TestMakeTemplateOCSP(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + ctx.GenerateRandomFunc = realRand + randReader := newRandReader(s) + profile := &certProfile{ + SignatureAlgorithm: "SHA256WithRSA", + CommonName: "common name", + Organization: "organization", + Country: "country", + OCSPURL: "ocsp", + CRLURL: "crl", + IssuerURL: "issuer", + NotAfter: "2018-05-18 11:31:00", + NotBefore: "2018-05-18 11:31:00", + } + pubKey := samplePubkey() + + cert, err := makeTemplate(randReader, profile, pubKey, nil, ocspCert) + test.AssertNotError(t, err, "makeTemplate failed") + + test.Assert(t, !cert.IsCA, "IsCA is set") + // Check KU is only KeyUsageDigitalSignature + test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageDigitalSignature) + // Check there is a single EKU with id-kp-OCSPSigning + test.AssertEquals(t, len(cert.ExtKeyUsage), 1) + test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageOCSPSigning) + // Check ExtraExtensions contains a single id-pkix-ocsp-nocheck + hasExt := false + asnNULL := []byte{5, 0} + for _, ext := range cert.ExtraExtensions { + if ext.Id.Equal(oidOCSPNoCheck) { + if hasExt { + t.Error("template contains multiple id-pkix-ocsp-nocheck extensions") + } + hasExt = true + if !bytes.Equal(ext.Value, asnNULL) { + t.Errorf("id-pkix-ocsp-nocheck has unexpected content: want %x, got %x", asnNULL, ext.Value) + } + } + } + test.Assert(t, hasExt, "template doesn't contain id-pkix-ocsp-nocheck extensions") +} + +func TestMakeTemplateCRL(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + ctx.GenerateRandomFunc = realRand + randReader := newRandReader(s) + profile := &certProfile{ + SignatureAlgorithm: "SHA256WithRSA", + CommonName: "common name", + Organization: "organization", + Country: "country", + OCSPURL: "ocsp", + CRLURL: "crl", + IssuerURL: "issuer", + NotAfter: "2018-05-18 11:31:00", + NotBefore: "2018-05-18 11:31:00", + } + pubKey := samplePubkey() + + cert, err := makeTemplate(randReader, profile, pubKey, nil, crlCert) + test.AssertNotError(t, err, "makeTemplate failed") + + test.Assert(t, !cert.IsCA, "IsCA is set") + test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageCRLSign) +} + +func TestVerifyProfile(t *testing.T) { + for _, tc := range []struct { + profile certProfile + certType []certType + expectedErr string + }{ + { + profile: certProfile{}, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "not-before is required", + }, + { + profile: certProfile{ + NotBefore: "a", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "not-after is required", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "signature-algorithm is required", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "common-name is required", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "organization is required", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "country is required", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "crl-url is required for subordinate CAs", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "issuer-url is required for subordinate CAs", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{{OID: "1.2.3"}, {OID: "4.5.6"}}, + }, + certType: []certType{intermediateCert, crossCert}, + expectedErr: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + }, + certType: []certType{rootCert}, + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + KeyUsages: []string{"j"}, + }, + certType: []certType{ocspCert}, + expectedErr: "key-usages cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + CRLURL: "i", + }, + certType: []certType{ocspCert}, + expectedErr: "crl-url cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + OCSPURL: "h", + }, + certType: []certType{ocspCert}, + expectedErr: "ocsp-url cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + }, + certType: []certType{ocspCert}, + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + KeyUsages: []string{"j"}, + }, + certType: []certType{crlCert}, + expectedErr: "key-usages cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + CRLURL: "i", + }, + certType: []certType{crlCert}, + expectedErr: "crl-url cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + OCSPURL: "h", + }, + certType: []certType{crlCert}, + expectedErr: "ocsp-url cannot be set for a delegated signer", + }, + { + profile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + IssuerURL: "g", + }, + certType: []certType{crlCert}, + }, + { + profile: certProfile{ + NotBefore: "a", + }, + certType: []certType{requestCert}, + expectedErr: "not-before cannot be set for a CSR", + }, + { + profile: certProfile{ + NotAfter: "a", + }, + certType: []certType{requestCert}, + expectedErr: "not-after cannot be set for a CSR", + }, + { + profile: certProfile{ + SignatureAlgorithm: "a", + }, + certType: []certType{requestCert}, + expectedErr: "signature-algorithm cannot be set for a CSR", + }, + { + profile: certProfile{ + OCSPURL: "a", + }, + certType: []certType{requestCert}, + expectedErr: "ocsp-url cannot be set for a CSR", + }, + { + profile: certProfile{ + CRLURL: "a", + }, + certType: []certType{requestCert}, + expectedErr: "crl-url cannot be set for a CSR", + }, + { + profile: certProfile{ + IssuerURL: "a", + }, + certType: []certType{requestCert}, + expectedErr: "issuer-url cannot be set for a CSR", + }, + { + profile: certProfile{ + Policies: []policyInfoConfig{{OID: "1.2.3"}}, + }, + certType: []certType{requestCert}, + expectedErr: "policies cannot be set for a CSR", + }, + { + profile: certProfile{ + KeyUsages: []string{"a"}, + }, + certType: []certType{requestCert}, + expectedErr: "key-usages cannot be set for a CSR", + }, + } { + for _, ct := range tc.certType { + err := tc.profile.verifyProfile(ct) + if err != nil { + if tc.expectedErr != err.Error() { + t.Fatalf("Expected %q, got %q", tc.expectedErr, err.Error()) + } + } else if tc.expectedErr != "" { + t.Fatalf("verifyProfile didn't fail, expected %q", tc.expectedErr) + } + } + } +} + +func TestGenerateCSR(t *testing.T) { + profile := &certProfile{ + CommonName: "common name", + Organization: "organization", + Country: "country", + } + + signer, err := rsa.GenerateKey(rand.Reader, 1024) + test.AssertNotError(t, err, "failed to generate test key") + + csrBytes, err := generateCSR(profile, &wrappedSigner{signer}) + test.AssertNotError(t, err, "failed to generate CSR") + + csr, err := x509.ParseCertificateRequest(csrBytes) + test.AssertNotError(t, err, "failed to parse CSR") + test.AssertNotError(t, csr.CheckSignature(), "CSR signature check failed") + test.AssertEquals(t, len(csr.Extensions), 0) + + test.AssertEquals(t, csr.Subject.String(), fmt.Sprintf("CN=%s,O=%s,C=%s", + profile.CommonName, profile.Organization, profile.Country)) +} + +func TestLoadCert(t *testing.T) { + _, err := loadCert("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "should not have errored") + + _, err = loadCert("/path/that/will/not/ever/exist/ever") + test.AssertError(t, err, "should have failed opening certificate at non-existent path") + test.AssertErrorIs(t, err, fs.ErrNotExist) + + _, err = loadCert("../../test/hierarchy/int-e1.key.pem") + test.AssertError(t, err, "should have failed when trying to parse a private key") +} + +func TestGenerateSKID(t *testing.T) { + sha256skid, err := generateSKID(samplePubkey()) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, len(sha256skid), 20) + test.AssertEquals(t, cap(sha256skid), 20) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl.go new file mode 100644 index 000000000..98790d906 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl.go @@ -0,0 +1,61 @@ +package main + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" + + "github.com/letsencrypt/boulder/crl/idp" + "github.com/letsencrypt/boulder/linter" +) + +func generateCRL(signer crypto.Signer, issuer *x509.Certificate, thisUpdate, nextUpdate time.Time, number int64, revokedCertificates []x509.RevocationListEntry) ([]byte, error) { + template := &x509.RevocationList{ + RevokedCertificateEntries: revokedCertificates, + Number: big.NewInt(number), + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + } + + if nextUpdate.Before(thisUpdate) { + return nil, errors.New("thisUpdate must be before nextUpdate") + } + if thisUpdate.Before(issuer.NotBefore) { + return nil, errors.New("thisUpdate is before issuing certificate's notBefore") + } else if nextUpdate.After(issuer.NotAfter) { + return nil, errors.New("nextUpdate is after issuing certificate's notAfter") + } + + // Verify that the CRL is not valid for more than 12 months as specified in + // CABF BRs Section 4.9.7 + if nextUpdate.Sub(thisUpdate) > time.Hour*24*365 { + return nil, errors.New("nextUpdate must be less than 12 months after thisUpdate") + } + // Add the Issuing Distribution Point extension. + idp, err := idp.MakeCACertsExt() + if err != nil { + return nil, fmt.Errorf("creating IDP extension: %w", err) + } + template.ExtraExtensions = append(template.ExtraExtensions, *idp) + + err = linter.CheckCRL(template, issuer, signer, []string{}) + if err != nil { + return nil, fmt.Errorf("crl failed pre-issuance lint: %w", err) + } + + // x509.CreateRevocationList uses an io.Reader here for signing methods that require + // a source of randomness. Since PKCS#11 based signing generates needed randomness + // at the HSM we don't need to pass a real reader. Instead of passing a nil reader + // we use one that always returns errors in case the internal usage of this reader + // changes. + crlBytes, err := x509.CreateRevocationList(&failReader{}, template, issuer, signer) + if err != nil { + return nil, err + } + + return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crlBytes}), nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl_test.go new file mode 100644 index 000000000..7deec56f0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/crl_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "io" + "math/big" + "testing" + "time" + + "github.com/letsencrypt/boulder/test" +) + +func TestGenerateCRLTimeBounds(t *testing.T) { + _, err := generateCRL(nil, nil, time.Now().Add(time.Hour), time.Now(), 1, nil) + test.AssertError(t, err, "generateCRL did not fail") + test.AssertEquals(t, err.Error(), "thisUpdate must be before nextUpdate") + + _, err = generateCRL(nil, &x509.Certificate{ + NotBefore: time.Now().Add(time.Hour), + NotAfter: time.Now(), + }, time.Now(), time.Now(), 1, nil) + test.AssertError(t, err, "generateCRL did not fail") + test.AssertEquals(t, err.Error(), "thisUpdate is before issuing certificate's notBefore") + + _, err = generateCRL(nil, &x509.Certificate{ + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 2), + }, time.Now().Add(time.Hour), time.Now().Add(time.Hour*3), 1, nil) + test.AssertError(t, err, "generateCRL did not fail") + test.AssertEquals(t, err.Error(), "nextUpdate is after issuing certificate's notAfter") + + _, err = generateCRL(nil, &x509.Certificate{ + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 370), + }, time.Now(), time.Now().Add(time.Hour*24*366), 1, nil) + test.AssertError(t, err, "generateCRL did not fail") + test.AssertEquals(t, err.Error(), "nextUpdate must be less than 12 months after thisUpdate") +} + +// wrappedSigner wraps a crypto.Signer. In order to use a crypto.Signer in tests +// we need to wrap it as we pass a purposefully broken io.Reader to Sign in order +// to verify that go isn't using it as a source of randomness (we expect this +// randomness to come from the HSM). If we directly call Sign on the crypto.Signer +// it would fail, so we wrap it so that we can use a shim rand.Reader in the Sign +// call. +type wrappedSigner struct{ k crypto.Signer } + +func (p wrappedSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + return p.k.Sign(rand.Reader, digest, opts) +} + +func (p wrappedSigner) Public() crypto.PublicKey { + return p.k.Public() +} + +func TestGenerateCRLLints(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + + cert := &x509.Certificate{ + Subject: pkix.Name{CommonName: "asd"}, + SerialNumber: big.NewInt(7), + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCRLSign, + SubjectKeyId: []byte{1, 2, 3}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, k.Public(), k) + test.AssertNotError(t, err, "failed to generate test cert") + cert, err = x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse test cert") + + // This CRL should fail the following lint: + // - e_crl_acceptable_reason_codes (because 6 is forbidden) + _, err = generateCRL(&wrappedSigner{k}, cert, time.Now().Add(time.Hour), time.Now().Add(100*24*time.Hour), 1, []x509.RevocationListEntry{ + { + SerialNumber: big.NewInt(12345), + RevocationTime: time.Now().Add(time.Hour), + ReasonCode: 6, + }, + }) + test.AssertError(t, err, "generateCRL did not fail") + test.AssertContains(t, err.Error(), "e_crl_acceptable_reason_codes") +} + +func TestGenerateCRL(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + + template := &x509.Certificate{ + Subject: pkix.Name{CommonName: "asd"}, + SerialNumber: big.NewInt(7), + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCRLSign, + SubjectKeyId: []byte{1, 2, 3}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, k.Public(), k) + test.AssertNotError(t, err, "failed to generate test cert") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse test cert") + + crlPEM, err := generateCRL(&wrappedSigner{k}, cert, time.Now().Add(time.Hour), time.Now().Add(time.Hour*2), 1, nil) + test.AssertNotError(t, err, "generateCRL failed with valid profile") + + pemBlock, _ := pem.Decode(crlPEM) + crlDER := pemBlock.Bytes + + // use crypto/x509 to check signature is valid and list is empty + goCRL, err := x509.ParseRevocationList(crlDER) + test.AssertNotError(t, err, "failed to parse CRL") + err = goCRL.CheckSignatureFrom(cert) + test.AssertNotError(t, err, "CRL signature check failed") + test.AssertEquals(t, len(goCRL.RevokedCertificateEntries), 0) + + // fully parse the CRL to check that the version is correct, and that + // it contains the CRL number extension containing the number we expect + var crl asn1CRL + _, err = asn1.Unmarshal(crlDER, &crl) + test.AssertNotError(t, err, "failed to parse CRL") + test.AssertEquals(t, crl.TBS.Version, 1) // x509v2 == 1 + test.AssertEquals(t, len(crl.TBS.Extensions), 3) // AKID, CRL number, IssuingDistributionPoint + test.Assert(t, crl.TBS.Extensions[1].Id.Equal(asn1.ObjectIdentifier{2, 5, 29, 20}), "unexpected OID in extension") + test.Assert(t, crl.TBS.Extensions[2].Id.Equal(asn1.ObjectIdentifier{2, 5, 29, 28}), "unexpected OID in extension") + var number int + _, err = asn1.Unmarshal(crl.TBS.Extensions[1].Value, &number) + test.AssertNotError(t, err, "failed to parse CRL number extension") + test.AssertEquals(t, number, 1) +} + +type asn1CRL struct { + TBS struct { + Version int `asn1:"optional"` + SigAlg pkix.AlgorithmIdentifier + Issuer struct { + Raw asn1.RawContent + } + ThisUpdate time.Time + NextUpdate time.Time `asn1:"optional"` + RevokedCertificates []struct { + Serial *big.Int + RevokedAt time.Time + Extensions []pkix.Extension `asn1:"optional"` + } `asn1:"optional"` + Extensions []pkix.Extension `asn1:"optional,explicit,tag:0"` + } + SigAlg pkix.AlgorithmIdentifier + Sig asn1.BitString +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa.go new file mode 100644 index 000000000..65f5c6f99 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "errors" + "fmt" + "log" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/miekg/pkcs11" +) + +var stringToCurve = map[string]elliptic.Curve{ + elliptic.P224().Params().Name: elliptic.P224(), + elliptic.P256().Params().Name: elliptic.P256(), + elliptic.P384().Params().Name: elliptic.P384(), + elliptic.P521().Params().Name: elliptic.P521(), +} + +// curveToOIDDER maps the name of the curves to their DER encoded OIDs +var curveToOIDDER = map[string][]byte{ + elliptic.P224().Params().Name: {6, 5, 43, 129, 4, 0, 33}, + elliptic.P256().Params().Name: {6, 8, 42, 134, 72, 206, 61, 3, 1, 7}, + elliptic.P384().Params().Name: {6, 5, 43, 129, 4, 0, 34}, + elliptic.P521().Params().Name: {6, 5, 43, 129, 4, 0, 35}, +} + +// ecArgs constructs the private and public key template attributes sent to the +// device and specifies which mechanism should be used. curve determines which +// type of key should be generated. +func ecArgs(label string, curve elliptic.Curve, keyID []byte) generateArgs { + encodedCurve := curveToOIDDER[curve.Params().Name] + log.Printf("\tEncoded curve parameters for %s: %X\n", curve.Params().Name, encodedCurve) + return generateArgs{ + mechanism: []*pkcs11.Mechanism{ + pkcs11.NewMechanism(pkcs11.CKM_EC_KEY_PAIR_GEN, nil), + }, + publicAttrs: []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_ID, keyID), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, label), + pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), + pkcs11.NewAttribute(pkcs11.CKA_VERIFY, true), + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, encodedCurve), + }, + privateAttrs: []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_ID, keyID), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, label), + pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), + // Prevent attributes being retrieved + pkcs11.NewAttribute(pkcs11.CKA_SENSITIVE, true), + // Prevent the key being extracted from the device + pkcs11.NewAttribute(pkcs11.CKA_EXTRACTABLE, false), + // Allow the key to sign data + pkcs11.NewAttribute(pkcs11.CKA_SIGN, true), + }, + } +} + +// ecPub extracts the generated public key, specified by the provided object +// handle, and constructs an ecdsa.PublicKey. It also checks that the key is of +// the correct curve type. +func ecPub( + session *pkcs11helpers.Session, + object pkcs11.ObjectHandle, + expectedCurve elliptic.Curve, +) (*ecdsa.PublicKey, error) { + pubKey, err := session.GetECDSAPublicKey(object) + if err != nil { + return nil, err + } + if pubKey.Curve != expectedCurve { + return nil, errors.New("Returned EC parameters doesn't match expected curve") + } + log.Printf("\tX: %X\n", pubKey.X.Bytes()) + log.Printf("\tY: %X\n", pubKey.Y.Bytes()) + return pubKey, nil +} + +// ecGenerate is used to generate and verify a ECDSA key pair of the type +// specified by curveStr and with the provided label. It returns the public +// part of the generated key pair as a ecdsa.PublicKey and the random key ID +// that the HSM uses to identify the key pair. +func ecGenerate(session *pkcs11helpers.Session, label, curveStr string) (*ecdsa.PublicKey, []byte, error) { + curve, present := stringToCurve[curveStr] + if !present { + return nil, nil, fmt.Errorf("curve %q not supported", curveStr) + } + keyID := make([]byte, 4) + _, err := newRandReader(session).Read(keyID) + if err != nil { + return nil, nil, err + } + log.Printf("Generating ECDSA key with curve %s and ID %x\n", curveStr, keyID) + args := ecArgs(label, curve, keyID) + pub, _, err := session.GenerateKeyPair(args.mechanism, args.publicAttrs, args.privateAttrs) + if err != nil { + return nil, nil, err + } + log.Println("Key generated") + log.Println("Extracting public key") + pk, err := ecPub(session, pub, curve) + if err != nil { + return nil, nil, err + } + log.Println("Extracted public key") + return pk, keyID, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa_test.go new file mode 100644 index 000000000..8bd348675 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ecdsa_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "testing" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/letsencrypt/boulder/test" + "github.com/miekg/pkcs11" +) + +func TestECPub(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + + // test we fail when pkcs11helpers.GetECDSAPublicKey fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("bad!") + } + _, err := ecPub(s, 0, elliptic.P256()) + test.AssertError(t, err, "ecPub didn't fail with non-matching curve") + test.AssertEquals(t, err.Error(), "Failed to retrieve key attributes: bad!") + + // test we fail to construct key with non-matching curve + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 5, 43, 129, 4, 0, 33}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{4, 217, 225, 246, 210, 153, 134, 246, 104, 95, 79, 122, 206, 135, 241, 37, 114, 199, 87, 56, 167, 83, 56, 136, 174, 6, 145, 97, 239, 221, 49, 67, 148, 13, 126, 65, 90, 208, 195, 193, 171, 105, 40, 98, 132, 124, 30, 189, 215, 197, 178, 226, 166, 238, 240, 57, 215}), + }, nil + } + _, err = ecPub(s, 0, elliptic.P256()) + test.AssertError(t, err, "ecPub didn't fail with non-matching curve") +} + +func TestECGenerate(t *testing.T) { + ctx := pkcs11helpers.MockCtx{} + s := &pkcs11helpers.Session{Module: &ctx, Session: 0} + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return []byte{1, 2, 3}, nil + } + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Failed to generate a ECDSA test key") + + // Test ecGenerate fails with unknown curve + _, _, err = ecGenerate(s, "", "bad-curve") + test.AssertError(t, err, "ecGenerate accepted unknown curve") + + // Test ecGenerate fails when GenerateKeyPair fails + ctx.GenerateKeyPairFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return 0, 0, errors.New("bad") + } + _, _, err = ecGenerate(s, "", "P-256") + test.AssertError(t, err, "ecGenerate didn't fail on GenerateKeyPair error") + + // Test ecGenerate fails when ecPub fails + ctx.GenerateKeyPairFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return 0, 0, nil + } + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("bad") + } + _, _, err = ecGenerate(s, "", "P-256") + test.AssertError(t, err, "ecGenerate didn't fail on ecPub error") + + // Test ecGenerate fails when ecVerify fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 8, 42, 134, 72, 206, 61, 3, 1, 7}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, elliptic.Marshal(elliptic.P256(), priv.X, priv.Y)), + }, nil + } + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return nil, errors.New("yup") + } + _, _, err = ecGenerate(s, "", "P-256") + test.AssertError(t, err, "ecGenerate didn't fail on ecVerify error") + + // Test ecGenerate doesn't fail when everything works + ctx.SignInitFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error { + return nil + } + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return []byte{1, 2, 3}, nil + } + ctx.SignFunc = func(_ pkcs11.SessionHandle, msg []byte) ([]byte, error) { + return ecPKCS11Sign(priv, msg) + } + _, _, err = ecGenerate(s, "", "P-256") + test.AssertNotError(t, err, "ecGenerate didn't succeed when everything worked as expected") +} + +func ecPKCS11Sign(priv *ecdsa.PrivateKey, msg []byte) ([]byte, error) { + r, s, err := ecdsa.Sign(rand.Reader, priv, msg[:]) + if err != nil { + return nil, err + } + rBytes := r.Bytes() + sBytes := s.Bytes() + // http://docs.oasis-open.org/pkcs11/pkcs11-curr/v2.40/os/pkcs11-curr-v2.40-os.html + // Section 2.3.1: EC Signatures + // "If r and s have different octet length, the shorter of both must be padded with + // leading zero octets such that both have the same octet length." + switch { + case len(rBytes) < len(sBytes): + padding := make([]byte, len(sBytes)-len(rBytes)) + rBytes = append(padding, rBytes...) + case len(rBytes) > len(sBytes): + padding := make([]byte, len(rBytes)-len(sBytes)) + sBytes = append(padding, sBytes...) + } + return append(rBytes, sBytes...), nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file.go new file mode 100644 index 000000000..752d7b746 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file.go @@ -0,0 +1,14 @@ +package main + +import "os" + +// writeFile creates a file at the given filename and writes the provided bytes +// to it. Errors if the file already exists. +func writeFile(filename string, bytes []byte) error { + f, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + return err + } + _, err = f.Write(bytes) + return err +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file_test.go new file mode 100644 index 000000000..e46be8913 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/file_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" +) + +func TestWriteFileSuccess(t *testing.T) { + dir := t.TempDir() + err := writeFile(dir+"/example", []byte("hi")) + if err != nil { + t.Fatal(err) + } +} + +func TestWriteFileFail(t *testing.T) { + dir := t.TempDir() + err := writeFile(dir+"/example", []byte("hi")) + if err != nil { + t.Fatal(err) + } + err = writeFile(dir+"/example", []byte("hi")) + if err == nil { + t.Fatal("expected error, got none") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key.go new file mode 100644 index 000000000..e0ed20594 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/miekg/pkcs11" +) + +type hsmRandReader struct { + *pkcs11helpers.Session +} + +func newRandReader(session *pkcs11helpers.Session) *hsmRandReader { + return &hsmRandReader{session} +} + +func (hrr hsmRandReader) Read(p []byte) (n int, err error) { + r, err := hrr.Module.GenerateRandom(hrr.Session.Session, len(p)) + if err != nil { + return 0, err + } + copy(p[:], r) + return len(r), nil +} + +type generateArgs struct { + mechanism []*pkcs11.Mechanism + privateAttrs []*pkcs11.Attribute + publicAttrs []*pkcs11.Attribute +} + +// keyInfo is a struct used to pass around information about the public key +// associated with the generated private key. der contains the DER encoding +// of the SubjectPublicKeyInfo structure for the public key. id contains the +// HSM key pair object ID. +type keyInfo struct { + key crypto.PublicKey + der []byte + id []byte +} + +func generateKey(session *pkcs11helpers.Session, label string, outputPath string, config keyGenConfig) (*keyInfo, error) { + _, err := session.FindObject([]*pkcs11.Attribute{ + {Type: pkcs11.CKA_LABEL, Value: []byte(label)}, + }) + if err != pkcs11helpers.ErrNoObject { + return nil, fmt.Errorf("expected no preexisting objects with label %q in slot for key storage. got error: %s", label, err) + } + + var pubKey crypto.PublicKey + var keyID []byte + switch config.Type { + case "rsa": + pubKey, keyID, err = rsaGenerate(session, label, config.RSAModLength) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key pair: %s", err) + } + case "ecdsa": + pubKey, keyID, err = ecGenerate(session, label, config.ECDSACurve) + if err != nil { + return nil, fmt.Errorf("failed to generate ECDSA key pair: %s", err) + } + } + + der, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return nil, fmt.Errorf("Failed to marshal public key: %s", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + log.Printf("Public key PEM:\n%s\n", pemBytes) + err = writeFile(outputPath, pemBytes) + if err != nil { + return nil, fmt.Errorf("Failed to write public key to %q: %s", outputPath, err) + } + log.Printf("Public key written to %q\n", outputPath) + + return &keyInfo{key: pubKey, der: der, id: keyID}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key_test.go new file mode 100644 index 000000000..5a1768c49 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/key_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "math/big" + "os" + "path" + "strings" + "testing" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/letsencrypt/boulder/test" + "github.com/miekg/pkcs11" +) + +func setupCtx() pkcs11helpers.MockCtx { + return pkcs11helpers.MockCtx{ + GenerateKeyPairFunc: func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return 0, 0, nil + }, + SignInitFunc: func(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error { + return nil + }, + GenerateRandomFunc: func(pkcs11.SessionHandle, int) ([]byte, error) { + return []byte{1, 2, 3}, nil + }, + FindObjectsInitFunc: func(pkcs11.SessionHandle, []*pkcs11.Attribute) error { + return nil + }, + FindObjectsFunc: func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return nil, false, nil + }, + FindObjectsFinalFunc: func(pkcs11.SessionHandle) error { + return nil + }, + } +} + +func TestGenerateKeyRSA(t *testing.T) { + tmp := t.TempDir() + + ctx := setupCtx() + rsaPriv, err := rsa.GenerateKey(rand.Reader, 1024) + test.AssertNotError(t, err, "Failed to generate a test RSA key") + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, big.NewInt(int64(rsaPriv.E)).Bytes()), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, rsaPriv.N.Bytes()), + }, nil + } + ctx.SignFunc = func(_ pkcs11.SessionHandle, msg []byte) ([]byte, error) { + // Chop of the hash identifier and feed back into rsa.SignPKCS1v15 + return rsa.SignPKCS1v15(rand.Reader, rsaPriv, crypto.SHA256, msg[19:]) + } + s := &pkcs11helpers.Session{Module: &ctx, Session: 0} + keyPath := path.Join(tmp, "test-rsa-key.pem") + keyInfo, err := generateKey(s, "", keyPath, keyGenConfig{ + Type: "rsa", + RSAModLength: 1024, + }) + test.AssertNotError(t, err, "Failed to generate RSA key") + diskKeyBytes, err := os.ReadFile(keyPath) + test.AssertNotError(t, err, "Failed to load key from disk") + block, _ := pem.Decode(diskKeyBytes) + diskKey, err := x509.ParsePKIXPublicKey(block.Bytes) + test.AssertNotError(t, err, "Failed to parse disk key") + test.AssertDeepEquals(t, diskKey, keyInfo.key) +} + +func setECGenerateFuncs(ctx *pkcs11helpers.MockCtx) { + ecPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 8, 42, 134, 72, 206, 61, 3, 1, 7}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, elliptic.Marshal(elliptic.P256(), ecPriv.X, ecPriv.Y)), + }, nil + } + ctx.SignFunc = func(_ pkcs11.SessionHandle, msg []byte) ([]byte, error) { + return ecPKCS11Sign(ecPriv, msg) + } +} + +func TestGenerateKeyEC(t *testing.T) { + tmp := t.TempDir() + + ctx := setupCtx() + setECGenerateFuncs(&ctx) + keyPath := path.Join(tmp, "test-ecdsa-key.pem") + s := &pkcs11helpers.Session{Module: &ctx, Session: 0} + keyInfo, err := generateKey(s, "", keyPath, keyGenConfig{ + Type: "ecdsa", + ECDSACurve: "P-256", + }) + test.AssertNotError(t, err, "Failed to generate ECDSA key") + diskKeyBytes, err := os.ReadFile(keyPath) + test.AssertNotError(t, err, "Failed to load key from disk") + block, _ := pem.Decode(diskKeyBytes) + diskKey, err := x509.ParsePKIXPublicKey(block.Bytes) + test.AssertNotError(t, err, "Failed to parse disk key") + test.AssertDeepEquals(t, diskKey, keyInfo.key) +} + +func setFindObjectsFuncs(label string, ctx *pkcs11helpers.MockCtx) { + var objectsFound []pkcs11.ObjectHandle + ctx.FindObjectsInitFunc = func(_ pkcs11.SessionHandle, template []*pkcs11.Attribute) error { + for _, attr := range template { + if attr.Type == pkcs11.CKA_LABEL && string(attr.Value) == label { + objectsFound = []pkcs11.ObjectHandle{1} + } + } + return nil + } + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return objectsFound, false, nil + } + ctx.FindObjectsFinalFunc = func(pkcs11.SessionHandle) error { + objectsFound = nil + return nil + } +} + +func TestGenerateKeySlotHasSomethingWithLabel(t *testing.T) { + tmp := t.TempDir() + + ctx := setupCtx() + label := "someLabel" + setFindObjectsFuncs(label, &ctx) + keyPath := path.Join(tmp, "should-not-exist.pem") + s := &pkcs11helpers.Session{Module: &ctx, Session: 0} + _, err := generateKey(s, label, keyPath, keyGenConfig{ + Type: "ecdsa", + ECDSACurve: "P-256", + }) + test.AssertError(t, err, "expected failure for a slot with an object already in it") + test.Assert(t, strings.HasPrefix(err.Error(), "expected no preexisting objects with label"), "wrong error") +} + +func TestGenerateKeySlotHasSomethingWithDifferentLabel(t *testing.T) { + tmp := t.TempDir() + + ctx := setupCtx() + setECGenerateFuncs(&ctx) + setFindObjectsFuncs("someLabel", &ctx) + keyPath := path.Join(tmp, "should-not-exist.pem") + s := &pkcs11helpers.Session{Module: &ctx, Session: 0} + _, err := generateKey(s, "someOtherLabel", keyPath, keyGenConfig{ + Type: "ecdsa", + ECDSACurve: "P-256", + }) + test.AssertNotError(t, err, "expected success even though there was an object with a different label") +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main.go new file mode 100644 index 000000000..a026a461a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main.go @@ -0,0 +1,1089 @@ +package main + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "errors" + "flag" + "fmt" + "log" + "os" + "slices" + "time" + + "golang.org/x/crypto/ocsp" + "gopkg.in/yaml.v3" + + zlintx509 "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3" + + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/linter" + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/letsencrypt/boulder/strictyaml" +) + +var kp goodkey.KeyPolicy + +func init() { + var err error + kp, err = goodkey.NewPolicy(&goodkey.Config{FermatRounds: 100}, nil) + if err != nil { + log.Fatal("Could not create goodkey.KeyPolicy") + } +} + +type lintCert *x509.Certificate + +// issueLintCertAndPerformLinting issues a linting certificate from a given +// template certificate signed by a given issuer and returns a *lintCert or an +// error. The lint certificate is linted prior to being returned. The public key +// from the just issued lint certificate is checked by the GoodKey package. +func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, skipLints []string) (lintCert, error) { + bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, skipLints) + if err != nil { + return nil, fmt.Errorf("certificate failed pre-issuance lint: %w", err) + } + lc, err := x509.ParseCertificate(bytes) + if err != nil { + return nil, err + } + err = kp.GoodKey(context.Background(), lc.PublicKey) + if err != nil { + return nil, err + } + + return lc, nil +} + +// postIssuanceLinting performs post-issuance linting on the raw bytes of a +// given certificate with the same set of lints as +// issueLintCertAndPerformLinting. The public key is also checked by the GoodKey +// package. +func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error { + if fc == nil { + return fmt.Errorf("certificate was not provided") + } + parsed, err := zlintx509.ParseCertificate(fc.Raw) + if err != nil { + // If zlintx509.ParseCertificate fails, the certificate is too broken to + // lint. This should be treated as ZLint rejecting the certificate + return fmt.Errorf("unable to parse certificate: %s", err) + } + registry, err := linter.NewRegistry(skipLints) + if err != nil { + return fmt.Errorf("unable to create zlint registry: %s", err) + } + lintRes := zlint.LintCertificateEx(parsed, registry) + err = linter.ProcessResultSet(lintRes) + if err != nil { + return err + } + err = kp.GoodKey(context.Background(), fc.PublicKey) + if err != nil { + return err + } + + return nil +} + +type keyGenConfig struct { + Type string `yaml:"type"` + RSAModLength uint `yaml:"rsa-mod-length"` + ECDSACurve string `yaml:"ecdsa-curve"` +} + +var allowedCurves = map[string]bool{ + "P-224": true, + "P-256": true, + "P-384": true, + "P-521": true, +} + +func (kgc keyGenConfig) validate() error { + if kgc.Type == "" { + return errors.New("key.type is required") + } + if kgc.Type != "rsa" && kgc.Type != "ecdsa" { + return errors.New("key.type can only be 'rsa' or 'ecdsa'") + } + if kgc.Type == "rsa" && (kgc.RSAModLength != 2048 && kgc.RSAModLength != 4096) { + return errors.New("key.rsa-mod-length can only be 2048 or 4096") + } + if kgc.Type == "rsa" && kgc.ECDSACurve != "" { + return errors.New("if key.type = 'rsa' then key.ecdsa-curve is not used") + } + if kgc.Type == "ecdsa" && !allowedCurves[kgc.ECDSACurve] { + return errors.New("key.ecdsa-curve can only be 'P-224', 'P-256', 'P-384', or 'P-521'") + } + if kgc.Type == "ecdsa" && kgc.RSAModLength != 0 { + return errors.New("if key.type = 'ecdsa' then key.rsa-mod-length is not used") + } + + return nil +} + +type PKCS11KeyGenConfig struct { + Module string `yaml:"module"` + PIN string `yaml:"pin"` + StoreSlot uint `yaml:"store-key-in-slot"` + StoreLabel string `yaml:"store-key-with-label"` +} + +func (pkgc PKCS11KeyGenConfig) validate() error { + if pkgc.Module == "" { + return errors.New("pkcs11.module is required") + } + if pkgc.StoreLabel == "" { + return errors.New("pkcs11.store-key-with-label is required") + } + // key-slot is allowed to be 0 (which is a valid slot). + // PIN is allowed to be "", which will commonly happen when + // PIN entry is done via PED. + return nil +} + +// checkOutputFile returns an error if the filename is empty, +// or if a file already exists with that filename. +func checkOutputFile(filename, fieldname string) error { + if filename == "" { + return fmt.Errorf("outputs.%s is required", fieldname) + } + if _, err := os.Stat(filename); !os.IsNotExist(err) { + return fmt.Errorf("outputs.%s is %q, which already exists", + fieldname, filename) + } + + return nil +} + +type rootConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11KeyGenConfig `yaml:"pkcs11"` + Key keyGenConfig `yaml:"key"` + Outputs struct { + PublicKeyPath string `yaml:"public-key-path"` + CertificatePath string `yaml:"certificate-path"` + } `yaml:"outputs"` + CertProfile certProfile `yaml:"certificate-profile"` + SkipLints []string `yaml:"skip-lints"` +} + +func (rc rootConfig) validate() error { + err := rc.PKCS11.validate() + if err != nil { + return err + } + + // Key gen fields + err = rc.Key.validate() + if err != nil { + return err + } + + // Output fields + err = checkOutputFile(rc.Outputs.PublicKeyPath, "public-key-path") + if err != nil { + return err + } + err = checkOutputFile(rc.Outputs.CertificatePath, "certificate-path") + if err != nil { + return err + } + + // Certificate profile + err = rc.CertProfile.verifyProfile(rootCert) + if err != nil { + return err + } + + return nil +} + +type PKCS11SigningConfig struct { + Module string `yaml:"module"` + PIN string `yaml:"pin"` + SigningSlot uint `yaml:"signing-key-slot"` + SigningLabel string `yaml:"signing-key-label"` +} + +func (psc PKCS11SigningConfig) validate() error { + if psc.Module == "" { + return errors.New("pkcs11.module is required") + } + if psc.SigningLabel == "" { + return errors.New("pkcs11.signing-key-label is required") + } + // key-slot is allowed to be 0 (which is a valid slot). + return nil +} + +type intermediateConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11SigningConfig `yaml:"pkcs11"` + Inputs struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + } `yaml:"inputs"` + Outputs struct { + CertificatePath string `yaml:"certificate-path"` + } `yaml:"outputs"` + CertProfile certProfile `yaml:"certificate-profile"` + SkipLints []string `yaml:"skip-lints"` +} + +func (ic intermediateConfig) validate(ct certType) error { + err := ic.PKCS11.validate() + if err != nil { + return err + } + + // Input fields + if ic.Inputs.PublicKeyPath == "" { + return errors.New("inputs.public-key-path is required") + } + if ic.Inputs.IssuerCertificatePath == "" { + return errors.New("inputs.issuer-certificate is required") + } + + // Output fields + err = checkOutputFile(ic.Outputs.CertificatePath, "certificate-path") + if err != nil { + return err + } + + // Certificate profile + err = ic.CertProfile.verifyProfile(ct) + if err != nil { + return err + } + + return nil +} + +type crossCertConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11SigningConfig `yaml:"pkcs11"` + Inputs struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + } `yaml:"inputs"` + Outputs struct { + CertificatePath string `yaml:"certificate-path"` + } `yaml:"outputs"` + CertProfile certProfile `yaml:"certificate-profile"` + SkipLints []string `yaml:"skip-lints"` +} + +func (csc crossCertConfig) validate() error { + err := csc.PKCS11.validate() + if err != nil { + return err + } + if csc.Inputs.PublicKeyPath == "" { + return errors.New("inputs.public-key-path is required") + } + if csc.Inputs.IssuerCertificatePath == "" { + return errors.New("inputs.issuer-certificate is required") + } + if csc.Inputs.CertificateToCrossSignPath == "" { + return errors.New("inputs.certificate-to-cross-sign-path is required") + } + err = checkOutputFile(csc.Outputs.CertificatePath, "certificate-path") + if err != nil { + return err + } + err = csc.CertProfile.verifyProfile(crossCert) + if err != nil { + return err + } + + return nil +} + +type csrConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11SigningConfig `yaml:"pkcs11"` + Inputs struct { + PublicKeyPath string `yaml:"public-key-path"` + } `yaml:"inputs"` + Outputs struct { + CSRPath string `yaml:"csr-path"` + } `yaml:"outputs"` + CertProfile certProfile `yaml:"certificate-profile"` +} + +func (cc csrConfig) validate() error { + err := cc.PKCS11.validate() + if err != nil { + return err + } + + // Input fields + if cc.Inputs.PublicKeyPath == "" { + return errors.New("inputs.public-key-path is required") + } + + // Output fields + err = checkOutputFile(cc.Outputs.CSRPath, "csr-path") + if err != nil { + return err + } + + // Certificate profile + err = cc.CertProfile.verifyProfile(requestCert) + if err != nil { + return err + } + + return nil +} + +type keyConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11KeyGenConfig `yaml:"pkcs11"` + Key keyGenConfig `yaml:"key"` + Outputs struct { + PublicKeyPath string `yaml:"public-key-path"` + PKCS11ConfigPath string `yaml:"pkcs11-config-path"` + } `yaml:"outputs"` +} + +func (kc keyConfig) validate() error { + err := kc.PKCS11.validate() + if err != nil { + return err + } + + // Key gen fields + err = kc.Key.validate() + if err != nil { + return err + } + + // Output fields + err = checkOutputFile(kc.Outputs.PublicKeyPath, "public-key-path") + if err != nil { + return err + } + + return nil +} + +type ocspRespConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11SigningConfig `yaml:"pkcs11"` + Inputs struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + } `yaml:"inputs"` + Outputs struct { + ResponsePath string `yaml:"response-path"` + } `yaml:"outputs"` + OCSPProfile struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Status string `yaml:"status"` + } `yaml:"ocsp-profile"` +} + +func (orc ocspRespConfig) validate() error { + err := orc.PKCS11.validate() + if err != nil { + return err + } + + // Input fields + if orc.Inputs.CertificatePath == "" { + return errors.New("inputs.certificate-path is required") + } + if orc.Inputs.IssuerCertificatePath == "" { + return errors.New("inputs.issuer-certificate-path is required") + } + // DelegatedIssuerCertificatePath may be omitted + + // Output fields + err = checkOutputFile(orc.Outputs.ResponsePath, "response-path") + if err != nil { + return err + } + + // OCSP fields + if orc.OCSPProfile.ThisUpdate == "" { + return errors.New("ocsp-profile.this-update is required") + } + if orc.OCSPProfile.NextUpdate == "" { + return errors.New("ocsp-profile.next-update is required") + } + if orc.OCSPProfile.Status != "good" && orc.OCSPProfile.Status != "revoked" { + return errors.New("ocsp-profile.status must be either \"good\" or \"revoked\"") + } + + return nil +} + +type crlConfig struct { + CeremonyType string `yaml:"ceremony-type"` + PKCS11 PKCS11SigningConfig `yaml:"pkcs11"` + Inputs struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + } `yaml:"inputs"` + Outputs struct { + CRLPath string `yaml:"crl-path"` + } `yaml:"outputs"` + CRLProfile struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + } `yaml:"crl-profile"` +} + +func (cc crlConfig) validate() error { + err := cc.PKCS11.validate() + if err != nil { + return err + } + + // Input fields + if cc.Inputs.IssuerCertificatePath == "" { + return errors.New("inputs.issuer-certificate-path is required") + } + + // Output fields + err = checkOutputFile(cc.Outputs.CRLPath, "crl-path") + if err != nil { + return err + } + + // CRL profile fields + if cc.CRLProfile.ThisUpdate == "" { + return errors.New("crl-profile.this-update is required") + } + if cc.CRLProfile.NextUpdate == "" { + return errors.New("crl-profile.next-update is required") + } + if cc.CRLProfile.Number == 0 { + return errors.New("crl-profile.number must be non-zero") + } + for _, rc := range cc.CRLProfile.RevokedCertificates { + if rc.CertificatePath == "" { + return errors.New("crl-profile.revoked-certificates.certificate-path is required") + } + if rc.RevocationDate == "" { + return errors.New("crl-profile.revoked-certificates.revocation-date is required") + } + if rc.RevocationReason == 0 { + return errors.New("crl-profile.revoked-certificates.revocation-reason is required") + } + } + + return nil +} + +// loadCert loads a PEM certificate specified by filename or returns an error. +// The public key from the loaded certificate is checked by the GoodKey package. +func loadCert(filename string) (*x509.Certificate, error) { + certPEM, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + log.Printf("Loaded certificate from %s\n", filename) + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, fmt.Errorf("No data in cert PEM file %s", filename) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + goodkeyErr := kp.GoodKey(context.Background(), cert.PublicKey) + if goodkeyErr != nil { + return nil, goodkeyErr + } + + return cert, nil +} + +// publicKeysEqual determines whether two public keys are identical. +func publicKeysEqual(a, b crypto.PublicKey) (bool, error) { + switch ak := a.(type) { + case *rsa.PublicKey: + return ak.Equal(b), nil + case *ecdsa.PublicKey: + return ak.Equal(b), nil + default: + return false, fmt.Errorf("unsupported public key type %T", ak) + } +} + +func openSigner(cfg PKCS11SigningConfig, pubKey crypto.PublicKey) (crypto.Signer, *hsmRandReader, error) { + session, err := pkcs11helpers.Initialize(cfg.Module, cfg.SigningSlot, cfg.PIN) + if err != nil { + return nil, nil, fmt.Errorf("failed to setup session and PKCS#11 context for slot %d: %s", + cfg.SigningSlot, err) + } + log.Printf("Opened PKCS#11 session for slot %d\n", cfg.SigningSlot) + signer, err := session.NewSigner(cfg.SigningLabel, pubKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to retrieve private key handle: %s", err) + } + ok, err := publicKeysEqual(signer.Public(), pubKey) + if !ok { + return nil, nil, err + } + + return signer, newRandReader(session), nil +} + +func signAndWriteCert(tbs, issuer *x509.Certificate, lintCert lintCert, subjectPubKey crypto.PublicKey, signer crypto.Signer, certPath string) (*x509.Certificate, error) { + if lintCert == nil { + return nil, fmt.Errorf("linting was not performed prior to issuance") + } + // x509.CreateCertificate uses a io.Reader here for signing methods that require + // a source of randomness. Since PKCS#11 based signing generates needed randomness + // at the HSM we don't need to pass a real reader. Instead of passing a nil reader + // we use one that always returns errors in case the internal usage of this reader + // changes. + certBytes, err := x509.CreateCertificate(&failReader{}, tbs, issuer, subjectPubKey, signer) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %s", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + log.Printf("Signed certificate PEM:\n%s", pemBytes) + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse signed certificate: %s", err) + } + if tbs == issuer { + // If cert is self-signed we need to populate the issuer subject key to + // verify the signature + issuer.PublicKey = cert.PublicKey + issuer.PublicKeyAlgorithm = cert.PublicKeyAlgorithm + } + err = cert.CheckSignatureFrom(issuer) + if err != nil { + return nil, fmt.Errorf("failed to verify certificate signature: %s", err) + } + err = writeFile(certPath, pemBytes) + if err != nil { + return nil, fmt.Errorf("failed to write certificate to %q: %s", certPath, err) + } + log.Printf("Certificate written to %q\n", certPath) + + return cert, nil +} + +// loadPubKey loads a PEM public key specified by filename. It returns a +// crypto.PublicKey, the PEM bytes of the public key, and an error. If an error +// exists, no public key or bytes are returned. The public key is checked by the +// GoodKey package. +func loadPubKey(filename string) (crypto.PublicKey, []byte, error) { + keyPEM, err := os.ReadFile(filename) + if err != nil { + return nil, nil, err + } + log.Printf("Loaded public key from %s\n", filename) + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, nil, fmt.Errorf("No data in cert PEM file %s", filename) + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, nil, err + } + err = kp.GoodKey(context.Background(), key) + if err != nil { + return nil, nil, err + } + + return key, block.Bytes, nil +} + +func rootCeremony(configBytes []byte) error { + var config rootConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + log.Printf("Preparing root ceremony for %s\n", config.Outputs.CertificatePath) + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + session, err := pkcs11helpers.Initialize(config.PKCS11.Module, config.PKCS11.StoreSlot, config.PKCS11.PIN) + if err != nil { + return fmt.Errorf("failed to setup session and PKCS#11 context for slot %d: %s", config.PKCS11.StoreSlot, err) + } + log.Printf("Opened PKCS#11 session for slot %d\n", config.PKCS11.StoreSlot) + keyInfo, err := generateKey(session, config.PKCS11.StoreLabel, config.Outputs.PublicKeyPath, config.Key) + if err != nil { + return err + } + signer, err := session.NewSigner(config.PKCS11.StoreLabel, keyInfo.key) + if err != nil { + return fmt.Errorf("failed to retrieve signer: %s", err) + } + template, err := makeTemplate(newRandReader(session), &config.CertProfile, keyInfo.der, nil, rootCert) + if err != nil { + return fmt.Errorf("failed to create certificate profile: %s", err) + } + lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, config.SkipLints) + if err != nil { + return err + } + finalCert, err := signAndWriteCert(template, template, lintCert, keyInfo.key, signer, config.Outputs.CertificatePath) + if err != nil { + return err + } + err = postIssuanceLinting(finalCert, config.SkipLints) + if err != nil { + return err + } + log.Printf("Post issuance linting completed for %s\n", config.Outputs.CertificatePath) + + return nil +} + +func intermediateCeremony(configBytes []byte, ct certType) error { + if ct != intermediateCert && ct != ocspCert && ct != crlCert { + return fmt.Errorf("wrong certificate type provided") + } + var config intermediateConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + log.Printf("Preparing intermediate ceremony for %s\n", config.Outputs.CertificatePath) + err = config.validate(ct) + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + if err != nil { + return err + } + issuer, err := loadCert(config.Inputs.IssuerCertificatePath) + if err != nil { + return fmt.Errorf("failed to load issuer certificate %q: %s", config.Inputs.IssuerCertificatePath, err) + } + signer, randReader, err := openSigner(config.PKCS11, issuer.PublicKey) + if err != nil { + return err + } + template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, nil, ct) + if err != nil { + return fmt.Errorf("failed to create certificate profile: %s", err) + } + template.AuthorityKeyId = issuer.SubjectKeyId + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + if err != nil { + return err + } + finalCert, err := signAndWriteCert(template, issuer, lintCert, pub, signer, config.Outputs.CertificatePath) + if err != nil { + return err + } + // Verify that x509.CreateCertificate is deterministic and produced + // identical DER bytes between the lintCert and finalCert signing + // operations. If this fails it's mississuance, but it's better to know + // about the problem sooner than later. + if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { + return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) + } + err = postIssuanceLinting(finalCert, config.SkipLints) + if err != nil { + return err + } + log.Printf("Post issuance linting completed for %s\n", config.Outputs.CertificatePath) + + return nil +} + +func crossCertCeremony(configBytes []byte, ct certType) error { + if ct != crossCert { + return fmt.Errorf("wrong certificate type provided") + } + var config crossCertConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + log.Printf("Preparing cross-certificate ceremony for %s\n", config.Outputs.CertificatePath) + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + if err != nil { + return err + } + issuer, err := loadCert(config.Inputs.IssuerCertificatePath) + if err != nil { + return fmt.Errorf("failed to load issuer certificate %q: %s", config.Inputs.IssuerCertificatePath, err) + } + toBeCrossSigned, err := loadCert(config.Inputs.CertificateToCrossSignPath) + if err != nil { + return fmt.Errorf("failed to load toBeCrossSigned certificate %q: %s", config.Inputs.CertificateToCrossSignPath, err) + } + signer, randReader, err := openSigner(config.PKCS11, issuer.PublicKey) + if err != nil { + return err + } + template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, toBeCrossSigned, ct) + if err != nil { + return fmt.Errorf("failed to create certificate profile: %s", err) + } + template.AuthorityKeyId = issuer.SubjectKeyId + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + if err != nil { + return err + } + // Ensure that we've configured the correct certificate to cross-sign compared to the profile. + // + // Example of a misconfiguration below: + // ... + // inputs: + // certificate-to-cross-sign-path: int-e6.cert.pem + // certificate-profile: + // common-name: (FAKE) E5 + // organization: (FAKE) Let's Encrypt + // ... + // + if !bytes.Equal(toBeCrossSigned.RawSubject, lintCert.RawSubject) { + return fmt.Errorf("mismatch between toBeCrossSigned and lintCert RawSubject DER bytes: \"%x\" != \"%x\"", toBeCrossSigned.RawSubject, lintCert.RawSubject) + } + // BR 7.1.2.2.1 Cross-Certified Subordinate CA Validity + // The earlier of one day prior to the time of signing or the earliest + // notBefore date of the existing CA Certificate(s). + if lintCert.NotBefore.Before(toBeCrossSigned.NotBefore) { + return fmt.Errorf("cross-signed subordinate CA's NotBefore predates the existing CA's NotBefore") + } + // BR 7.1.2.2.3 Cross-Certified Subordinate CA Extensions + if !slices.Equal(lintCert.ExtKeyUsage, toBeCrossSigned.ExtKeyUsage) { + return fmt.Errorf("lint cert and toBeCrossSigned cert EKUs differ") + } + if len(lintCert.ExtKeyUsage) == 0 { + // "Unrestricted" case, the issuer and subject need to be the same or at least affiliates. + if !slices.Equal(lintCert.Subject.Organization, issuer.Subject.Organization) { + return fmt.Errorf("attempted unrestricted cross-sign of certificate operated by a different organization") + } + } + // Issue the cross-signed certificate. + finalCert, err := signAndWriteCert(template, issuer, lintCert, pub, signer, config.Outputs.CertificatePath) + if err != nil { + return err + } + // Verify that x509.CreateCertificate is deterministic and produced + // identical DER bytes between the lintCert and finalCert signing + // operations. If this fails it's mississuance, but it's better to know + // about the problem sooner than later. + if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { + return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) + } + err = postIssuanceLinting(finalCert, config.SkipLints) + if err != nil { + return err + } + log.Printf("Post issuance linting completed for %s\n", config.Outputs.CertificatePath) + + return nil +} + +func csrCeremony(configBytes []byte) error { + var config csrConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + + pub, _, err := loadPubKey(config.Inputs.PublicKeyPath) + if err != nil { + return err + } + + signer, _, err := openSigner(config.PKCS11, pub) + if err != nil { + return err + } + + csrDER, err := generateCSR(&config.CertProfile, signer) + if err != nil { + return fmt.Errorf("failed to generate CSR: %s", err) + } + csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) + err = writeFile(config.Outputs.CSRPath, csrPEM) + if err != nil { + return fmt.Errorf("failed to write CSR to %q: %s", config.Outputs.CSRPath, err) + } + log.Printf("CSR written to %q\n", config.Outputs.CSRPath) + + return nil +} + +func keyCeremony(configBytes []byte) error { + var config keyConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + session, err := pkcs11helpers.Initialize(config.PKCS11.Module, config.PKCS11.StoreSlot, config.PKCS11.PIN) + if err != nil { + return fmt.Errorf("failed to setup session and PKCS#11 context for slot %d: %s", config.PKCS11.StoreSlot, err) + } + log.Printf("Opened PKCS#11 session for slot %d\n", config.PKCS11.StoreSlot) + if _, err = generateKey(session, config.PKCS11.StoreLabel, config.Outputs.PublicKeyPath, config.Key); err != nil { + return err + } + + if config.Outputs.PKCS11ConfigPath != "" { + contents := fmt.Sprintf( + `{"module": %q, "tokenLabel": %q, "pin": %q}`, + config.PKCS11.Module, config.PKCS11.StoreLabel, config.PKCS11.PIN, + ) + err = writeFile(config.Outputs.PKCS11ConfigPath, []byte(contents)) + if err != nil { + return err + } + } + + return nil +} + +func ocspRespCeremony(configBytes []byte) error { + var config ocspRespConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + + cert, err := loadCert(config.Inputs.CertificatePath) + if err != nil { + return fmt.Errorf("failed to load certificate %q: %s", config.Inputs.CertificatePath, err) + } + issuer, err := loadCert(config.Inputs.IssuerCertificatePath) + if err != nil { + return fmt.Errorf("failed to load issuer certificate %q: %s", config.Inputs.IssuerCertificatePath, err) + } + var signer crypto.Signer + var delegatedIssuer *x509.Certificate + if config.Inputs.DelegatedIssuerCertificatePath != "" { + delegatedIssuer, err = loadCert(config.Inputs.DelegatedIssuerCertificatePath) + if err != nil { + return fmt.Errorf("failed to load delegated issuer certificate %q: %s", config.Inputs.DelegatedIssuerCertificatePath, err) + } + + signer, _, err = openSigner(config.PKCS11, delegatedIssuer.PublicKey) + if err != nil { + return err + } + } else { + signer, _, err = openSigner(config.PKCS11, issuer.PublicKey) + if err != nil { + return err + } + } + + thisUpdate, err := time.Parse(time.DateTime, config.OCSPProfile.ThisUpdate) + if err != nil { + return fmt.Errorf("unable to parse ocsp-profile.this-update: %s", err) + } + nextUpdate, err := time.Parse(time.DateTime, config.OCSPProfile.NextUpdate) + if err != nil { + return fmt.Errorf("unable to parse ocsp-profile.next-update: %s", err) + } + var status int + switch config.OCSPProfile.Status { + case "good": + status = int(ocsp.Good) + case "revoked": + status = int(ocsp.Revoked) + default: + // this shouldn't happen if the config is validated + return fmt.Errorf("unexpected ocsp-profile.stats: %s", config.OCSPProfile.Status) + } + + resp, err := generateOCSPResponse(signer, issuer, delegatedIssuer, cert, thisUpdate, nextUpdate, status) + if err != nil { + return err + } + + err = writeFile(config.Outputs.ResponsePath, resp) + if err != nil { + return fmt.Errorf("failed to write OCSP response to %q: %s", config.Outputs.ResponsePath, err) + } + + return nil +} + +func crlCeremony(configBytes []byte) error { + var config crlConfig + err := strictyaml.Unmarshal(configBytes, &config) + if err != nil { + return fmt.Errorf("failed to parse config: %s", err) + } + err = config.validate() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + + issuer, err := loadCert(config.Inputs.IssuerCertificatePath) + if err != nil { + return fmt.Errorf("failed to load issuer certificate %q: %s", config.Inputs.IssuerCertificatePath, err) + } + signer, _, err := openSigner(config.PKCS11, issuer.PublicKey) + if err != nil { + return err + } + + thisUpdate, err := time.Parse(time.DateTime, config.CRLProfile.ThisUpdate) + if err != nil { + return fmt.Errorf("unable to parse crl-profile.this-update: %s", err) + } + nextUpdate, err := time.Parse(time.DateTime, config.CRLProfile.NextUpdate) + if err != nil { + return fmt.Errorf("unable to parse crl-profile.next-update: %s", err) + } + + var revokedCertificates []x509.RevocationListEntry + for _, rc := range config.CRLProfile.RevokedCertificates { + cert, err := loadCert(rc.CertificatePath) + if err != nil { + return fmt.Errorf("failed to load revoked certificate %q: %s", rc.CertificatePath, err) + } + if !cert.IsCA { + return fmt.Errorf("certificate with serial %d is not a CA certificate", cert.SerialNumber) + } + revokedAt, err := time.Parse(time.DateTime, rc.RevocationDate) + if err != nil { + return fmt.Errorf("unable to parse crl-profile.revoked-certificates.revocation-date") + } + revokedCert := x509.RevocationListEntry{ + SerialNumber: cert.SerialNumber, + RevocationTime: revokedAt, + } + encReason, err := asn1.Marshal(rc.RevocationReason) + if err != nil { + return fmt.Errorf("failed to marshal revocation reason %q: %s", rc.RevocationReason, err) + } + revokedCert.Extensions = []pkix.Extension{{ + Id: asn1.ObjectIdentifier{2, 5, 29, 21}, // id-ce-reasonCode + Value: encReason, + }} + revokedCertificates = append(revokedCertificates, revokedCert) + } + + crlBytes, err := generateCRL(signer, issuer, thisUpdate, nextUpdate, config.CRLProfile.Number, revokedCertificates) + if err != nil { + return err + } + + log.Printf("Signed CRL PEM:\n%s", crlBytes) + + err = writeFile(config.Outputs.CRLPath, crlBytes) + if err != nil { + return fmt.Errorf("failed to write CRL to %q: %s", config.Outputs.CRLPath, err) + } + + return nil +} + +func main() { + configPath := flag.String("config", "", "Path to ceremony configuration file") + flag.Parse() + + if *configPath == "" { + log.Fatal("--config is required") + } + configBytes, err := os.ReadFile(*configPath) + if err != nil { + log.Fatalf("Failed to read config file: %s", err) + } + var ct struct { + CeremonyType string `yaml:"ceremony-type"` + } + + // We are intentionally using non-strict unmarshaling to read the top level + // tags to populate the "ct" struct for use in the switch statement below. + // Further strict processing of each yaml node is done on a case by case basis + // inside the switch statement. + err = yaml.Unmarshal(configBytes, &ct) + if err != nil { + log.Fatalf("Failed to parse config: %s", err) + } + + switch ct.CeremonyType { + case "root": + err = rootCeremony(configBytes) + if err != nil { + log.Fatalf("root ceremony failed: %s", err) + } + case "cross-certificate": + err = crossCertCeremony(configBytes, crossCert) + if err != nil { + log.Fatalf("cross-certificate ceremony failed: %s", err) + } + case "intermediate": + err = intermediateCeremony(configBytes, intermediateCert) + if err != nil { + log.Fatalf("intermediate ceremony failed: %s", err) + } + case "cross-csr": + err = csrCeremony(configBytes) + if err != nil { + log.Fatalf("cross-csr ceremony failed: %s", err) + } + case "ocsp-signer": + err = intermediateCeremony(configBytes, ocspCert) + if err != nil { + log.Fatalf("ocsp signer ceremony failed: %s", err) + } + case "key": + err = keyCeremony(configBytes) + if err != nil { + log.Fatalf("key ceremony failed: %s", err) + } + case "ocsp-response": + err = ocspRespCeremony(configBytes) + if err != nil { + log.Fatalf("ocsp response ceremony failed: %s", err) + } + case "crl": + err = crlCeremony(configBytes) + if err != nil { + log.Fatalf("crl ceremony failed: %s", err) + } + case "crl-signer": + err = intermediateCeremony(configBytes, crlCert) + if err != nil { + log.Fatalf("crl signer ceremony failed: %s", err) + } + default: + log.Fatalf("unknown ceremony-type, must be one of: root, cross-certificate, intermediate, cross-csr, ocsp-signer, key, ocsp-response, crl, crl-signer") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main_test.go new file mode 100644 index 000000000..44dae91e7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/main_test.go @@ -0,0 +1,1432 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "io/fs" + "math/big" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/test" +) + +func TestLoadPubKey(t *testing.T) { + tmp := t.TempDir() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + _, _, err := loadPubKey(path.Join(tmp, "does", "not", "exist")) + test.AssertError(t, err, "should fail on non-existent file") + test.AssertErrorIs(t, err, fs.ErrNotExist) + + _, _, err = loadPubKey("../../test/hierarchy/README.md") + test.AssertError(t, err, "should fail on non-PEM file") + + priv, _ := x509.MarshalPKCS8PrivateKey(key) + _ = os.WriteFile(path.Join(tmp, "priv.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv}), 0644) + _, _, err = loadPubKey(path.Join(tmp, "priv.pem")) + test.AssertError(t, err, "should fail on non-pubkey PEM") + + pub, _ := x509.MarshalPKIXPublicKey(key.Public()) + _ = os.WriteFile(path.Join(tmp, "pub.pem"), pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}), 0644) + _, _, err = loadPubKey(path.Join(tmp, "pub.pem")) + test.AssertNotError(t, err, "should not have errored") +} + +func TestCheckOutputFileSucceeds(t *testing.T) { + dir := t.TempDir() + err := checkOutputFile(dir+"/example", "foo") + if err != nil { + t.Fatal(err) + } +} + +func TestCheckOutputFileEmpty(t *testing.T) { + err := checkOutputFile("", "foo") + if err == nil { + t.Fatal("expected error, got none") + } + if err.Error() != "outputs.foo is required" { + t.Fatalf("wrong error: %s", err) + } +} + +func TestCheckOutputFileExists(t *testing.T) { + dir := t.TempDir() + filename := dir + "/example" + err := writeFile(filename, []byte("hi")) + if err != nil { + t.Fatal(err) + } + err = checkOutputFile(filename, "foo") + if err == nil { + t.Fatal("expected error, got none") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("wrong error: %s", err) + } +} + +func TestKeyGenConfigValidate(t *testing.T) { + cases := []struct { + name string + config keyGenConfig + expectedError string + }{ + { + name: "no key.type", + config: keyGenConfig{}, + expectedError: "key.type is required", + }, + { + name: "bad key.type", + config: keyGenConfig{ + Type: "doop", + }, + expectedError: "key.type can only be 'rsa' or 'ecdsa'", + }, + { + name: "bad key.rsa-mod-length", + config: keyGenConfig{ + Type: "rsa", + RSAModLength: 1337, + }, + expectedError: "key.rsa-mod-length can only be 2048 or 4096", + }, + { + name: "key.type is rsa but key.ecdsa-curve is present", + config: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + ECDSACurve: "bad", + }, + expectedError: "if key.type = 'rsa' then key.ecdsa-curve is not used", + }, + { + name: "bad key.ecdsa-curve", + config: keyGenConfig{ + Type: "ecdsa", + ECDSACurve: "bad", + }, + expectedError: "key.ecdsa-curve can only be 'P-224', 'P-256', 'P-384', or 'P-521'", + }, + { + name: "key.type is ecdsa but key.rsa-mod-length is present", + config: keyGenConfig{ + Type: "ecdsa", + RSAModLength: 2048, + ECDSACurve: "P-256", + }, + expectedError: "if key.type = 'ecdsa' then key.rsa-mod-length is not used", + }, + { + name: "good rsa config", + config: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + }, + { + name: "good ecdsa config", + config: keyGenConfig{ + Type: "ecdsa", + ECDSACurve: "P-256", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestRootConfigValidate(t *testing.T) { + cases := []struct { + name string + config rootConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: rootConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.store-key-with-label", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.store-key-with-label is required", + }, + { + name: "bad key fields", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + }, + expectedError: "key.type is required", + }, + { + name: "no outputs.public-key-path", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + }, + expectedError: "outputs.public-key-path is required", + }, + { + name: "no outputs.certificate-path", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + Outputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + CertificatePath string `yaml:"certificate-path"` + }{ + PublicKeyPath: "path", + }, + }, + expectedError: "outputs.certificate-path is required", + }, + { + name: "bad certificate-profile", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + Outputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + CertificatePath string `yaml:"certificate-path"` + }{ + PublicKeyPath: "path", + CertificatePath: "path", + }, + }, + expectedError: "not-before is required", + }, + { + name: "good config", + config: rootConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + Outputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + CertificatePath string `yaml:"certificate-path"` + }{ + PublicKeyPath: "path", + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + }, + SkipLints: []string{ + "e_ext_authority_key_identifier_missing", + "e_ext_authority_key_identifier_no_key_identifier", + "e_sub_ca_aia_missing", + "e_sub_ca_certificate_policies_missing", + "e_sub_ca_crl_distribution_points_missing", + "n_ca_digital_signature_not_set", + "n_mp_allowed_eku", + "n_sub_ca_eku_missing", + "w_sub_ca_aia_does_not_contain_issuing_ca_url", + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestIntermediateConfigValidate(t *testing.T) { + cases := []struct { + name string + config intermediateConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: intermediateConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.signing-key-label", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.signing-key-label is required", + }, + { + name: "no inputs.public-key-path", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + }, + expectedError: "inputs.public-key-path is required", + }, + { + name: "no inputs.issuer-certificate-path", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + }, + }, + expectedError: "inputs.issuer-certificate is required", + }, + { + name: "no outputs.certificate-path", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + }, + expectedError: "outputs.certificate-path is required", + }, + { + name: "bad certificate-profile", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + }, + expectedError: "not-before is required", + }, + { + name: "too many policy OIDs", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{{OID: "2.23.140.1.2.1"}, {OID: "6.6.6"}}, + }, + SkipLints: []string{}, + }, + expectedError: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + name: "too few policy OIDs", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{}, + }, + SkipLints: []string{}, + }, + expectedError: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + name: "good config", + config: intermediateConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{{OID: "2.23.140.1.2.1"}}, + }, + SkipLints: []string{}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate(intermediateCert) + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestCrossCertConfigValidate(t *testing.T) { + cases := []struct { + name string + config crossCertConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: crossCertConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.signing-key-label", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.signing-key-label is required", + }, + { + name: "no inputs.public-key-path", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + }, + expectedError: "inputs.public-key-path is required", + }, + { + name: "no inputs.issuer-certificate-path", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + CertificateToCrossSignPath: "path", + }, + }, + expectedError: "inputs.issuer-certificate is required", + }, + { + name: "no inputs.certificate-to-cross-sign-path", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + }, + }, + expectedError: "inputs.certificate-to-cross-sign-path is required", + }, + { + name: "no outputs.certificate-path", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + CertificateToCrossSignPath: "path", + }, + }, + expectedError: "outputs.certificate-path is required", + }, + { + name: "bad certificate-profile", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + CertificateToCrossSignPath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + }, + expectedError: "not-before is required", + }, + { + name: "too many policy OIDs", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + CertificateToCrossSignPath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{{OID: "2.23.140.1.2.1"}, {OID: "6.6.6"}}, + }, + SkipLints: []string{}, + }, + expectedError: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + name: "too few policy OIDs", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + CertificateToCrossSignPath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{}, + }, + SkipLints: []string{}, + }, + expectedError: "policy should be exactly BRs domain-validated for subordinate CAs", + }, + { + name: "good config", + config: crossCertConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + CertificateToCrossSignPath string `yaml:"certificate-to-cross-sign-path"` + }{ + PublicKeyPath: "path", + IssuerCertificatePath: "path", + CertificateToCrossSignPath: "path", + }, + Outputs: struct { + CertificatePath string `yaml:"certificate-path"` + }{ + CertificatePath: "path", + }, + CertProfile: certProfile{ + NotBefore: "a", + NotAfter: "b", + SignatureAlgorithm: "c", + CommonName: "d", + Organization: "e", + Country: "f", + OCSPURL: "g", + CRLURL: "h", + IssuerURL: "i", + Policies: []policyInfoConfig{{OID: "2.23.140.1.2.1"}}, + }, + SkipLints: []string{}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestCSRConfigValidate(t *testing.T) { + cases := []struct { + name string + config csrConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: csrConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.signing-key-label", + config: csrConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.signing-key-label is required", + }, + { + name: "no inputs.public-key-path", + config: csrConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + }, + expectedError: "inputs.public-key-path is required", + }, + { + name: "no outputs.csr-path", + config: csrConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + }{ + PublicKeyPath: "path", + }, + }, + expectedError: "outputs.csr-path is required", + }, + { + name: "bad certificate-profile", + config: csrConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + }{ + PublicKeyPath: "path", + }, + Outputs: struct { + CSRPath string `yaml:"csr-path"` + }{ + CSRPath: "path", + }, + }, + expectedError: "common-name is required", + }, + { + name: "good config", + config: csrConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + }{ + PublicKeyPath: "path", + }, + Outputs: struct { + CSRPath string `yaml:"csr-path"` + }{ + CSRPath: "path", + }, + CertProfile: certProfile{ + CommonName: "d", + Organization: "e", + Country: "f", + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestKeyConfigValidate(t *testing.T) { + cases := []struct { + name string + config keyConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: keyConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.store-key-with-label", + config: keyConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.store-key-with-label is required", + }, + { + name: "bad key fields", + config: keyConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + }, + expectedError: "key.type is required", + }, + { + name: "no outputs.public-key-path", + config: keyConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + }, + expectedError: "outputs.public-key-path is required", + }, + { + name: "good config", + config: keyConfig{ + PKCS11: PKCS11KeyGenConfig{ + Module: "module", + StoreLabel: "label", + }, + Key: keyGenConfig{ + Type: "rsa", + RSAModLength: 2048, + }, + Outputs: struct { + PublicKeyPath string `yaml:"public-key-path"` + PKCS11ConfigPath string `yaml:"pkcs11-config-path"` + }{ + PublicKeyPath: "path", + PKCS11ConfigPath: "path.json", + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestOCSPRespConfig(t *testing.T) { + cases := []struct { + name string + config ocspRespConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: ocspRespConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.signing-key-label", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.signing-key-label is required", + }, + { + name: "no inputs.certificate-path", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + }, + expectedError: "inputs.certificate-path is required", + }, + { + name: "no inputs.issuer-certificate-path", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + }, + }, + expectedError: "inputs.issuer-certificate-path is required", + }, + { + name: "no outputs.response-path", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + IssuerCertificatePath: "path", + }, + }, + expectedError: "outputs.response-path is required", + }, + { + name: "no ocsp-profile.this-update", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + ResponsePath string `yaml:"response-path"` + }{ + ResponsePath: "path", + }, + }, + expectedError: "ocsp-profile.this-update is required", + }, + { + name: "no ocsp-profile.next-update", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + ResponsePath string `yaml:"response-path"` + }{ + ResponsePath: "path", + }, + OCSPProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Status string `yaml:"status"` + }{ + ThisUpdate: "this-update", + }, + }, + expectedError: "ocsp-profile.next-update is required", + }, + { + name: "no ocsp-profile.status", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + ResponsePath string `yaml:"response-path"` + }{ + ResponsePath: "path", + }, + OCSPProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Status string `yaml:"status"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + }, + }, + expectedError: "ocsp-profile.status must be either \"good\" or \"revoked\"", + }, + { + name: "good config", + config: ocspRespConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + CertificatePath string `yaml:"certificate-path"` + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + DelegatedIssuerCertificatePath string `yaml:"delegated-issuer-certificate-path"` + }{ + CertificatePath: "path", + IssuerCertificatePath: "path", + }, + Outputs: struct { + ResponsePath string `yaml:"response-path"` + }{ + ResponsePath: "path", + }, + OCSPProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Status string `yaml:"status"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + Status: "good", + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestCRLConfig(t *testing.T) { + cases := []struct { + name string + config crlConfig + expectedError string + }{ + { + name: "no pkcs11.module", + config: crlConfig{}, + expectedError: "pkcs11.module is required", + }, + { + name: "no pkcs11.signing-key-label", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + }, + }, + expectedError: "pkcs11.signing-key-label is required", + }, + { + name: "no inputs.issuer-certificate-path", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + }, + expectedError: "inputs.issuer-certificate-path is required", + }, + { + name: "no outputs.crl-path", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + }, + expectedError: "outputs.crl-path is required", + }, + { + name: "no crl-profile.this-update", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + }, + expectedError: "crl-profile.this-update is required", + }, + { + name: "no crl-profile.next-update", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + }, + }, + expectedError: "crl-profile.next-update is required", + }, + { + name: "no crl-profile.number", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + }, + }, + expectedError: "crl-profile.number must be non-zero", + }, + { + name: "no crl-profile.revoked-certificates.certificate-path", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + Number: 1, + RevokedCertificates: []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + }{{}}, + }, + }, + expectedError: "crl-profile.revoked-certificates.certificate-path is required", + }, + { + name: "no crl-profile.revoked-certificates.revocation-date", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + Number: 1, + RevokedCertificates: []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + }{{ + CertificatePath: "path", + }}, + }, + }, + expectedError: "crl-profile.revoked-certificates.revocation-date is required", + }, + { + name: "no revocation reason", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + Number: 1, + RevokedCertificates: []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + }{{ + CertificatePath: "path", + RevocationDate: "date", + }}, + }, + }, + expectedError: "crl-profile.revoked-certificates.revocation-reason is required", + }, + { + name: "good", + config: crlConfig{ + PKCS11: PKCS11SigningConfig{ + Module: "module", + SigningLabel: "label", + }, + Inputs: struct { + IssuerCertificatePath string `yaml:"issuer-certificate-path"` + }{ + IssuerCertificatePath: "path", + }, + Outputs: struct { + CRLPath string `yaml:"crl-path"` + }{ + CRLPath: "path", + }, + CRLProfile: struct { + ThisUpdate string `yaml:"this-update"` + NextUpdate string `yaml:"next-update"` + Number int64 `yaml:"number"` + RevokedCertificates []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + } `yaml:"revoked-certificates"` + }{ + ThisUpdate: "this-update", + NextUpdate: "next-update", + Number: 1, + RevokedCertificates: []struct { + CertificatePath string `yaml:"certificate-path"` + RevocationDate string `yaml:"revocation-date"` + RevocationReason int `yaml:"revocation-reason"` + }{{ + CertificatePath: "path", + RevocationDate: "date", + RevocationReason: 1, + }}, + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + if err != nil && err.Error() != tc.expectedError { + t.Fatalf("Unexpected error, wanted: %q, got: %q", tc.expectedError, err) + } else if err == nil && tc.expectedError != "" { + t.Fatalf("validate didn't fail, wanted: %q", err) + } + }) + } +} + +func TestSignAndWriteNoLintCert(t *testing.T) { + _, err := signAndWriteCert(nil, nil, nil, nil, nil, "") + test.AssertError(t, err, "should have failed because no lintCert was provided") + test.AssertDeepEquals(t, err, fmt.Errorf("linting was not performed prior to issuance")) +} + +func TestPostIssuanceLinting(t *testing.T) { + clk := clock.New() + err := postIssuanceLinting(nil, nil) + test.AssertError(t, err, "should have failed because no certificate was provided") + + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "unable to generate ECDSA private key") + template := &x509.Certificate{ + NotAfter: clk.Now().Add(1 * time.Hour), + DNSNames: []string{"example.com"}, + SerialNumber: big.NewInt(1), + } + certDer, err := x509.CreateCertificate(rand.Reader, template, template, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "unable to create certificate") + parsedCert, err := x509.ParseCertificate(certDer) + test.AssertNotError(t, err, "unable to parse DER bytes") + err = postIssuanceLinting(parsedCert, nil) + test.AssertNotError(t, err, "should not have errored") +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp.go new file mode 100644 index 000000000..3dbefeb92 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp.go @@ -0,0 +1,69 @@ +package main + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "time" + + "golang.org/x/crypto/ocsp" +) + +func generateOCSPResponse(signer crypto.Signer, issuer, delegatedIssuer, cert *x509.Certificate, thisUpdate, nextUpdate time.Time, status int) ([]byte, error) { + err := cert.CheckSignatureFrom(issuer) + if err != nil { + return nil, fmt.Errorf("invalid signature on certificate from issuer: %s", err) + } + + signingCert := issuer + if delegatedIssuer != nil { + signingCert = delegatedIssuer + err := delegatedIssuer.CheckSignatureFrom(issuer) + if err != nil { + return nil, fmt.Errorf("invalid signature on delegated issuer from issuer: %s", err) + } + + gotOCSPEKU := false + for _, eku := range delegatedIssuer.ExtKeyUsage { + if eku == x509.ExtKeyUsageOCSPSigning { + gotOCSPEKU = true + break + } + } + if !gotOCSPEKU { + return nil, errors.New("delegated issuer certificate doesn't contain OCSPSigning extended key usage") + } + } + + if nextUpdate.Before(thisUpdate) { + return nil, errors.New("thisUpdate must be before nextUpdate") + } + if thisUpdate.Before(signingCert.NotBefore) { + return nil, errors.New("thisUpdate is before signing certificate's notBefore") + } else if nextUpdate.After(signingCert.NotAfter) { + return nil, errors.New("nextUpdate is after signing certificate's notAfter") + } + + template := ocsp.Response{ + SerialNumber: cert.SerialNumber, + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + Status: status, + } + if delegatedIssuer != nil { + template.Certificate = delegatedIssuer + } + + resp, err := ocsp.CreateResponse(issuer, signingCert, template, signer) + if err != nil { + return nil, fmt.Errorf("failed to create response: %s", err) + } + + encodedResp := make([]byte, base64.StdEncoding.EncodedLen(len(resp))+1) + base64.StdEncoding.Encode(encodedResp, resp) + encodedResp[len(encodedResp)-1] = '\n' + + return encodedResp, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp_test.go new file mode 100644 index 000000000..7fb9e3621 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/ocsp_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/letsencrypt/boulder/test" +) + +func TestGenerateOCSPResponse(t *testing.T) { + kA, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + kB, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + kC, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + + template := &x509.Certificate{ + SerialNumber: big.NewInt(9), + Subject: pkix.Name{ + CommonName: "cn", + }, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + NotBefore: time.Time{}.Add(time.Hour * 10), + NotAfter: time.Time{}.Add(time.Hour * 20), + } + issuerBytes, err := x509.CreateCertificate(rand.Reader, template, template, kA.Public(), kA) + test.AssertNotError(t, err, "failed to create test issuer") + issuer, err := x509.ParseCertificate(issuerBytes) + test.AssertNotError(t, err, "failed to parse test issuer") + delegatedIssuerBytes, err := x509.CreateCertificate(rand.Reader, template, issuer, kB.Public(), kA) + test.AssertNotError(t, err, "failed to create test delegated issuer") + badDelegatedIssuer, err := x509.ParseCertificate(delegatedIssuerBytes) + test.AssertNotError(t, err, "failed to parse test delegated issuer") + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning} + delegatedIssuerBytes, err = x509.CreateCertificate(rand.Reader, template, issuer, kB.Public(), kA) + test.AssertNotError(t, err, "failed to create test delegated issuer") + goodDelegatedIssuer, err := x509.ParseCertificate(delegatedIssuerBytes) + test.AssertNotError(t, err, "failed to parse test delegated issuer") + template.BasicConstraintsValid, template.IsCA = false, false + certBytes, err := x509.CreateCertificate(rand.Reader, template, issuer, kC.Public(), kA) + test.AssertNotError(t, err, "failed to create test cert") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse test cert") + + cases := []struct { + name string + issuer *x509.Certificate + delegatedIssuer *x509.Certificate + cert *x509.Certificate + thisUpdate time.Time + nextUpdate time.Time + expectedError string + }{ + { + name: "invalid signature from issuer on certificate", + issuer: &x509.Certificate{}, + cert: &x509.Certificate{}, + expectedError: "invalid signature on certificate from issuer: x509: cannot verify signature: algorithm unimplemented", + }, + { + name: "nextUpdate before thisUpdate", + issuer: issuer, + cert: cert, + thisUpdate: time.Time{}.Add(time.Hour), + nextUpdate: time.Time{}, + expectedError: "thisUpdate must be before nextUpdate", + }, + { + name: "thisUpdate before signer notBefore", + issuer: issuer, + cert: cert, + thisUpdate: time.Time{}, + nextUpdate: time.Time{}.Add(time.Hour), + expectedError: "thisUpdate is before signing certificate's notBefore", + }, + { + name: "nextUpdate after signer notAfter", + issuer: issuer, + cert: cert, + thisUpdate: time.Time{}.Add(time.Hour * 11), + nextUpdate: time.Time{}.Add(time.Hour * 21), + expectedError: "nextUpdate is after signing certificate's notAfter", + }, + { + name: "bad delegated issuer signature", + issuer: issuer, + cert: cert, + delegatedIssuer: &x509.Certificate{}, + expectedError: "invalid signature on delegated issuer from issuer: x509: cannot verify signature: algorithm unimplemented", + }, + { + name: "good", + issuer: issuer, + cert: cert, + thisUpdate: time.Time{}.Add(time.Hour * 11), + nextUpdate: time.Time{}.Add(time.Hour * 12), + }, + { + name: "bad delegated issuer without EKU", + issuer: issuer, + cert: cert, + delegatedIssuer: badDelegatedIssuer, + expectedError: "delegated issuer certificate doesn't contain OCSPSigning extended key usage", + }, + { + name: "good delegated issuer", + issuer: issuer, + cert: cert, + delegatedIssuer: goodDelegatedIssuer, + thisUpdate: time.Time{}.Add(time.Hour * 11), + nextUpdate: time.Time{}.Add(time.Hour * 12), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := generateOCSPResponse(kA, tc.issuer, tc.delegatedIssuer, tc.cert, tc.thisUpdate, tc.nextUpdate, 0) + if err != nil { + if tc.expectedError != "" && tc.expectedError != err.Error() { + t.Errorf("unexpected error: got %q, want %q", err.Error(), tc.expectedError) + } else if tc.expectedError == "" { + t.Errorf("unexpected error: %s", err) + } + } else if tc.expectedError != "" { + t.Errorf("expected error: %s", tc.expectedError) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa.go new file mode 100644 index 000000000..69e326b39 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa.go @@ -0,0 +1,98 @@ +package main + +import ( + "crypto/rsa" + "errors" + "log" + "math/big" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/miekg/pkcs11" +) + +const ( + rsaExp = 65537 +) + +// rsaArgs constructs the private and public key template attributes sent to the +// device and specifies which mechanism should be used. modulusLen specifies the +// length of the modulus to be generated on the device in bits and exponent +// specifies the public exponent that should be used. +func rsaArgs(label string, modulusLen, exponent uint, keyID []byte) generateArgs { + // Encode as unpadded big endian encoded byte slice + expSlice := big.NewInt(int64(exponent)).Bytes() + log.Printf("\tEncoded public exponent (%d) as: %0X\n", exponent, expSlice) + return generateArgs{ + mechanism: []*pkcs11.Mechanism{ + pkcs11.NewMechanism(pkcs11.CKM_RSA_PKCS_KEY_PAIR_GEN, nil), + }, + publicAttrs: []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_ID, keyID), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, label), + pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), + // Allow the key to verify signatures + pkcs11.NewAttribute(pkcs11.CKA_VERIFY, true), + // Set requested modulus length + pkcs11.NewAttribute(pkcs11.CKA_MODULUS_BITS, modulusLen), + // Set requested public exponent + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, expSlice), + }, + privateAttrs: []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_ID, keyID), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, label), + pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true), + // Prevent attributes being retrieved + pkcs11.NewAttribute(pkcs11.CKA_SENSITIVE, true), + // Prevent the key being extracted from the device + pkcs11.NewAttribute(pkcs11.CKA_EXTRACTABLE, false), + // Allow the key to create signatures + pkcs11.NewAttribute(pkcs11.CKA_SIGN, true), + }, + } +} + +// rsaPub extracts the generated public key, specified by the provided object +// handle, and constructs a rsa.PublicKey. It also checks that the key has the +// correct length modulus and that the public exponent is what was requested in +// the public key template. +func rsaPub(session *pkcs11helpers.Session, object pkcs11.ObjectHandle, modulusLen, exponent uint) (*rsa.PublicKey, error) { + pubKey, err := session.GetRSAPublicKey(object) + if err != nil { + return nil, err + } + if pubKey.E != int(exponent) { + return nil, errors.New("returned CKA_PUBLIC_EXPONENT doesn't match expected exponent") + } + if pubKey.N.BitLen() != int(modulusLen) { + return nil, errors.New("returned CKA_MODULUS isn't of the expected bit length") + } + log.Printf("\tPublic exponent: %d\n", pubKey.E) + log.Printf("\tModulus: (%d bits) %X\n", pubKey.N.BitLen(), pubKey.N.Bytes()) + return pubKey, nil +} + +// rsaGenerate is used to generate and verify a RSA key pair of the size +// specified by modulusLen and with the exponent 65537. +// It returns the public part of the generated key pair as a rsa.PublicKey +// and the random key ID that the HSM uses to identify the key pair. +func rsaGenerate(session *pkcs11helpers.Session, label string, modulusLen uint) (*rsa.PublicKey, []byte, error) { + keyID := make([]byte, 4) + _, err := newRandReader(session).Read(keyID) + if err != nil { + return nil, nil, err + } + log.Printf("Generating RSA key with %d bit modulus and public exponent %d and ID %x\n", modulusLen, rsaExp, keyID) + args := rsaArgs(label, modulusLen, rsaExp, keyID) + pub, _, err := session.GenerateKeyPair(args.mechanism, args.publicAttrs, args.privateAttrs) + if err != nil { + return nil, nil, err + } + log.Println("Key generated") + log.Println("Extracting public key") + pk, err := rsaPub(session, pub, modulusLen, rsaExp) + if err != nil { + return nil, nil, err + } + log.Println("Extracted public key") + return pk, keyID, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa_test.go new file mode 100644 index 000000000..f0dc37071 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ceremony/rsa_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "errors" + "math/big" + "testing" + + "github.com/letsencrypt/boulder/pkcs11helpers" + "github.com/letsencrypt/boulder/test" + "github.com/miekg/pkcs11" +) + +func TestRSAPub(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + + // test we fail to construct key with non-matching exp + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, []byte{1, 0, 1}), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, []byte{255}), + }, nil + } + _, err := rsaPub(s, 0, 0, 255) + test.AssertError(t, err, "rsaPub didn't fail with non-matching exp") + + // test we fail to construct key with non-matching modulus + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, []byte{1, 0, 1}), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, []byte{255}), + }, nil + } + _, err = rsaPub(s, 0, 16, 65537) + test.AssertError(t, err, "rsaPub didn't fail with non-matching modulus size") + + // test we don't fail with the correct attributes + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, []byte{1, 0, 1}), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, []byte{255}), + }, nil + } + _, err = rsaPub(s, 0, 8, 65537) + test.AssertNotError(t, err, "rsaPub failed with valid attributes") +} + +func TestRSAGenerate(t *testing.T) { + s, ctx := pkcs11helpers.NewSessionWithMock() + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return []byte{1, 2, 3}, nil + } + + priv, err := rsa.GenerateKey(rand.Reader, 1024) + test.AssertNotError(t, err, "Failed to generate a RSA test key") + + // Test rsaGenerate fails when GenerateKeyPair fails + ctx.GenerateKeyPairFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return 0, 0, errors.New("bad") + } + _, _, err = rsaGenerate(s, "", 1024) + test.AssertError(t, err, "rsaGenerate didn't fail on GenerateKeyPair error") + + // Test rsaGenerate fails when rsaPub fails + ctx.GenerateKeyPairFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return 0, 0, nil + } + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("bad") + } + _, _, err = rsaGenerate(s, "", 1024) + test.AssertError(t, err, "rsaGenerate didn't fail on rsaPub error") + + // Test rsaGenerate fails when rsaVerify fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, big.NewInt(int64(priv.E)).Bytes()), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, priv.N.Bytes()), + }, nil + } + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return nil, errors.New("yup") + } + _, _, err = rsaGenerate(s, "", 1024) + test.AssertError(t, err, "rsaGenerate didn't fail on rsaVerify error") + + // Test rsaGenerate doesn't fail when everything works + ctx.SignInitFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error { + return nil + } + ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { + return []byte{1, 2, 3}, nil + } + ctx.SignFunc = func(_ pkcs11.SessionHandle, msg []byte) ([]byte, error) { + // Chop of the hash identifier and feed back into rsa.SignPKCS1v15 + return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, msg[19:]) + } + _, _, err = rsaGenerate(s, "", 1024) + test.AssertNotError(t, err, "rsaGenerate didn't succeed when everything worked as expected") +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main.go b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main.go new file mode 100644 index 000000000..d432fde00 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main.go @@ -0,0 +1,627 @@ +package notmain + +import ( + "bytes" + "context" + "crypto/x509" + "database/sql" + "encoding/json" + "flag" + "fmt" + "os" + "regexp" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + zX509 "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/goodkey/sagoodkey" + _ "github.com/letsencrypt/boulder/linter" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/precert" + "github.com/letsencrypt/boulder/sa" +) + +// For defense-in-depth in addition to using the PA & its hostnamePolicy to +// check domain names we also perform a check against the regex's from the +// forbiddenDomains array +var forbiddenDomainPatterns = []*regexp.Regexp{ + regexp.MustCompile(`^\s*$`), + regexp.MustCompile(`\.local$`), + regexp.MustCompile(`^localhost$`), + regexp.MustCompile(`\.localhost$`), +} + +func isForbiddenDomain(name string) (bool, string) { + for _, r := range forbiddenDomainPatterns { + if matches := r.FindAllStringSubmatch(name, -1); len(matches) > 0 { + return true, r.String() + } + } + return false, "" +} + +var batchSize = 1000 + +type report struct { + begin time.Time + end time.Time + GoodCerts int64 `json:"good-certs"` + BadCerts int64 `json:"bad-certs"` + DbErrs int64 `json:"db-errs"` + Entries map[string]reportEntry `json:"entries"` +} + +func (r *report) dump() error { + content, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, string(content)) + return nil +} + +type reportEntry struct { + Valid bool `json:"valid"` + DNSNames []string `json:"dnsNames"` + Problems []string `json:"problems,omitempty"` +} + +// certDB is an interface collecting the borp.DbMap functions that the various +// parts of cert-checker rely on. Using this adapter shim allows tests to swap +// out the saDbMap implementation. +type certDB interface { + Select(ctx context.Context, i interface{}, query string, args ...interface{}) ([]interface{}, error) + SelectOne(ctx context.Context, i interface{}, query string, args ...interface{}) error + SelectNullInt(ctx context.Context, query string, args ...interface{}) (sql.NullInt64, error) +} + +// A function that looks up a precertificate by serial and returns its DER bytes. Used for +// mocking in tests. +type precertGetter func(context.Context, string) ([]byte, error) + +type certChecker struct { + pa core.PolicyAuthority + kp goodkey.KeyPolicy + dbMap certDB + getPrecert precertGetter + certs chan core.Certificate + clock clock.Clock + rMu *sync.Mutex + issuedReport report + checkPeriod time.Duration + acceptableValidityDurations map[time.Duration]bool + logger blog.Logger +} + +func newChecker(saDbMap certDB, + clk clock.Clock, + pa core.PolicyAuthority, + kp goodkey.KeyPolicy, + period time.Duration, + avd map[time.Duration]bool, + logger blog.Logger, +) certChecker { + precertGetter := func(ctx context.Context, serial string) ([]byte, error) { + precertPb, err := sa.SelectPrecertificate(ctx, saDbMap, serial) + if err != nil { + return nil, err + } + return precertPb.DER, nil + } + return certChecker{ + pa: pa, + kp: kp, + dbMap: saDbMap, + getPrecert: precertGetter, + certs: make(chan core.Certificate, batchSize), + rMu: new(sync.Mutex), + clock: clk, + issuedReport: report{Entries: make(map[string]reportEntry)}, + checkPeriod: period, + acceptableValidityDurations: avd, + logger: logger, + } +} + +// findStartingID returns the lowest `id` in the certificates table within the +// time window specified. The time window is a half-open interval [begin, end). +func (c *certChecker) findStartingID(ctx context.Context, begin, end time.Time) (int64, error) { + var output sql.NullInt64 + var err error + var retries int + + // Rather than querying `MIN(id)` across that whole window, we query it across the first + // hour of the window. This allows the query planner to use the index on `issued` more + // effectively. For a busy, actively issuing CA, that will always return results in the + // first query. For a less busy CA, or during integration tests, there may only exist + // certificates towards the end of the window, so we try querying later hourly chunks until + // we find a certificate or hit the end of the window. We also retry transient errors. + queryBegin := begin + queryEnd := begin.Add(time.Hour) + + for queryBegin.Compare(end) < 0 { + output, err = c.dbMap.SelectNullInt( + ctx, + `SELECT MIN(id) FROM certificates + WHERE issued >= :begin AND + issued < :end`, + map[string]interface{}{ + "begin": queryBegin, + "end": queryEnd, + }, + ) + if err != nil { + c.logger.AuditErrf("finding starting certificate: %s", err) + retries++ + time.Sleep(core.RetryBackoff(retries, time.Second, time.Minute, 2)) + continue + } + // https://mariadb.com/kb/en/min/ + // MIN() returns NULL if there were no matching rows + // https://pkg.go.dev/database/sql#NullInt64 + // Valid is true if Int64 is not NULL + if !output.Valid { + // No matching rows, try the next hour + queryBegin = queryBegin.Add(time.Hour) + queryEnd = queryEnd.Add(time.Hour) + if queryEnd.Compare(end) > 0 { + queryEnd = end + } + continue + } + + return output.Int64, nil + } + + // Fell through the loop without finding a valid ID + return 0, fmt.Errorf("no rows found for certificates issued between %s and %s", begin, end) +} + +func (c *certChecker) getCerts(ctx context.Context) error { + // The end of the report is the current time, rounded up to the nearest second. + c.issuedReport.end = c.clock.Now().Truncate(time.Second).Add(time.Second) + // The beginning of the report is the end minus the check period, rounded down to the nearest second. + c.issuedReport.begin = c.issuedReport.end.Add(-c.checkPeriod).Truncate(time.Second) + + initialID, err := c.findStartingID(ctx, c.issuedReport.begin, c.issuedReport.end) + if err != nil { + return err + } + if initialID > 0 { + // decrement the initial ID so that we select below as we aren't using >= + initialID -= 1 + } + + batchStartID := initialID + var retries int + for { + certs, err := sa.SelectCertificates( + ctx, + c.dbMap, + `WHERE id > :id AND + issued >= :begin AND + issued < :end + ORDER BY id LIMIT :limit`, + map[string]interface{}{ + "begin": c.issuedReport.begin, + "end": c.issuedReport.end, + // Retrieve certs in batches of 1000 (the size of the certificate channel) + // so that we don't eat unnecessary amounts of memory and avoid the 16MB MySQL + // packet limit. + "limit": batchSize, + "id": batchStartID, + }, + ) + if err != nil { + c.logger.AuditErrf("selecting certificates: %s", err) + retries++ + time.Sleep(core.RetryBackoff(retries, time.Second, time.Minute, 2)) + continue + } + retries = 0 + for _, cert := range certs { + c.certs <- cert.Certificate + } + if len(certs) == 0 { + break + } + lastCert := certs[len(certs)-1] + batchStartID = lastCert.ID + if lastCert.Issued.After(c.issuedReport.end) { + break + } + } + + // Close channel so range operations won't block once the channel empties out + close(c.certs) + return nil +} + +func (c *certChecker) processCerts(ctx context.Context, wg *sync.WaitGroup, badResultsOnly bool, ignoredLints map[string]bool) { + for cert := range c.certs { + dnsNames, problems := c.checkCert(ctx, cert, ignoredLints) + valid := len(problems) == 0 + c.rMu.Lock() + if !badResultsOnly || (badResultsOnly && !valid) { + c.issuedReport.Entries[cert.Serial] = reportEntry{ + Valid: valid, + DNSNames: dnsNames, + Problems: problems, + } + } + c.rMu.Unlock() + if !valid { + atomic.AddInt64(&c.issuedReport.BadCerts, 1) + } else { + atomic.AddInt64(&c.issuedReport.GoodCerts, 1) + } + } + wg.Done() +} + +// Extensions that we allow in certificates +var allowedExtensions = map[string]bool{ + "1.3.6.1.5.5.7.1.1": true, // Authority info access + "2.5.29.35": true, // Authority key identifier + "2.5.29.19": true, // Basic constraints + "2.5.29.32": true, // Certificate policies + "2.5.29.31": true, // CRL distribution points + "2.5.29.37": true, // Extended key usage + "2.5.29.15": true, // Key usage + "2.5.29.17": true, // Subject alternative name + "2.5.29.14": true, // Subject key identifier + "1.3.6.1.4.1.11129.2.4.2": true, // SCT list + "1.3.6.1.5.5.7.1.24": true, // TLS feature +} + +// For extensions that have a fixed value we check that it contains that value +var expectedExtensionContent = map[string][]byte{ + "1.3.6.1.5.5.7.1.24": {0x30, 0x03, 0x02, 0x01, 0x05}, // Must staple feature +} + +// checkValidations checks the database for matching authorizations that were +// likely valid at the time the certificate was issued. Authorizations with +// status = "deactivated" are counted for this, so long as their validatedAt +// is before the issuance and expiration is after. +func (c *certChecker) checkValidations(ctx context.Context, cert core.Certificate, dnsNames []string) error { + authzs, err := sa.SelectAuthzsMatchingIssuance(ctx, c.dbMap, cert.RegistrationID, cert.Issued, dnsNames) + if err != nil { + return fmt.Errorf("error checking authzs for certificate %s: %w", cert.Serial, err) + } + + if len(authzs) == 0 { + return fmt.Errorf("no relevant authzs found valid at %s", cert.Issued) + } + + // We may get multiple authorizations for the same name, but that's okay. + // Any authorization for a given name is sufficient. + nameToAuthz := make(map[string]*corepb.Authorization) + for _, m := range authzs { + nameToAuthz[m.Identifier] = m + } + + var errors []error + for _, name := range dnsNames { + _, ok := nameToAuthz[name] + if !ok { + errors = append(errors, fmt.Errorf("missing authz for %q", name)) + continue + } + } + if len(errors) > 0 { + return fmt.Errorf("%s", errors) + } + return nil +} + +// checkCert returns a list of DNS names in the certificate and a list of problems with the certificate. +func (c *certChecker) checkCert(ctx context.Context, cert core.Certificate, ignoredLints map[string]bool) ([]string, []string) { + var dnsNames []string + var problems []string + + // Check that the digests match. + if cert.Digest != core.Fingerprint256(cert.DER) { + problems = append(problems, "Stored digest doesn't match certificate digest") + } + // Parse the certificate. + parsedCert, err := zX509.ParseCertificate(cert.DER) + if err != nil { + problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err)) + } else { + dnsNames = parsedCert.DNSNames + // Run zlint checks. + results := zlint.LintCertificate(parsedCert) + for name, res := range results.Results { + if ignoredLints[name] || res.Status <= lint.Pass { + continue + } + prob := fmt.Sprintf("zlint %s: %s", res.Status, name) + if res.Details != "" { + prob = fmt.Sprintf("%s %s", prob, res.Details) + } + problems = append(problems, prob) + } + // Check if stored serial is correct. + storedSerial, err := core.StringToSerial(cert.Serial) + if err != nil { + problems = append(problems, "Stored serial is invalid") + } else if parsedCert.SerialNumber.Cmp(storedSerial) != 0 { + problems = append(problems, "Stored serial doesn't match certificate serial") + } + // Check that we have the correct expiration time. + if !parsedCert.NotAfter.Equal(cert.Expires) { + problems = append(problems, "Stored expiration doesn't match certificate NotAfter") + } + // Check if basic constraints are set. + if !parsedCert.BasicConstraintsValid { + problems = append(problems, "Certificate doesn't have basic constraints set") + } + // Check that the cert isn't able to sign other certificates. + if parsedCert.IsCA { + problems = append(problems, "Certificate can sign other certificates") + } + // Check that the cert has a valid validity period. The validity + // period is computed inclusive of the whole final second indicated by + // notAfter. + validityDuration := parsedCert.NotAfter.Add(time.Second).Sub(parsedCert.NotBefore) + _, ok := c.acceptableValidityDurations[validityDuration] + if !ok { + problems = append(problems, "Certificate has unacceptable validity period") + } + // Check that the stored issuance time isn't too far back/forward dated. + if parsedCert.NotBefore.Before(cert.Issued.Add(-6*time.Hour)) || parsedCert.NotBefore.After(cert.Issued.Add(6*time.Hour)) { + problems = append(problems, "Stored issuance date is outside of 6 hour window of certificate NotBefore") + } + if parsedCert.Subject.CommonName != "" { + // Check if the CommonName is <= 64 characters. + if len(parsedCert.Subject.CommonName) > 64 { + problems = append( + problems, + fmt.Sprintf("Certificate has common name >64 characters long (%d)", len(parsedCert.Subject.CommonName)), + ) + } + + // Check that the CommonName is included in the SANs. + if !slices.Contains(parsedCert.DNSNames, parsedCert.Subject.CommonName) { + problems = append(problems, fmt.Sprintf("Certificate Common Name does not appear in Subject Alternative Names: %q !< %v", + parsedCert.Subject.CommonName, parsedCert.DNSNames)) + } + } + // Check that the PA is still willing to issue for each name in DNSNames. + // We do not check the CommonName here, as (if it exists) we already checked + // that it is identical to one of the DNSNames in the SAN. + for _, name := range parsedCert.DNSNames { + err = c.pa.WillingToIssue([]string{name}) + if err != nil { + problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err)) + } else { + // For defense-in-depth, even if the PA was willing to issue for a name + // we double check it against a list of forbidden domains. This way even + // if the hostnamePolicyFile malfunctions we will flag the forbidden + // domain matches + if forbidden, pattern := isForbiddenDomain(name); forbidden { + problems = append(problems, fmt.Sprintf( + "Policy Authority was willing to issue but domain '%s' matches "+ + "forbiddenDomains entry %q", name, pattern)) + } + } + } + // Check the cert has the correct key usage extensions + if !slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth, zX509.ExtKeyUsageClientAuth}) { + problems = append(problems, "Certificate has incorrect key usage extensions") + } + + for _, ext := range parsedCert.Extensions { + _, ok := allowedExtensions[ext.Id.String()] + if !ok { + problems = append(problems, fmt.Sprintf("Certificate contains an unexpected extension: %s", ext.Id)) + } + expectedContent, ok := expectedExtensionContent[ext.Id.String()] + if ok { + if !bytes.Equal(ext.Value, expectedContent) { + problems = append(problems, fmt.Sprintf("Certificate extension %s contains unexpected content: has %x, expected %x", ext.Id, ext.Value, expectedContent)) + } + } + } + + // Check that the cert has a good key. Note that this does not perform + // checks which rely on external resources such as weak or blocked key + // lists, or the list of blocked keys in the database. This only performs + // static checks, such as against the RSA key size and the ECDSA curve. + p, err := x509.ParseCertificate(cert.DER) + if err != nil { + problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err)) + } + err = c.kp.GoodKey(ctx, p.PublicKey) + if err != nil { + problems = append(problems, fmt.Sprintf("Key Policy isn't willing to issue for public key: %s", err)) + } + + precertDER, err := c.getPrecert(ctx, cert.Serial) + if err != nil { + // Log and continue, since we want the problems slice to only contains + // problems with the cert itself. + c.logger.Errf("fetching linting precertificate for %s: %s", cert.Serial, err) + atomic.AddInt64(&c.issuedReport.DbErrs, 1) + } else { + err = precert.Correspond(precertDER, cert.DER) + if err != nil { + problems = append(problems, + fmt.Sprintf("Certificate does not correspond to precert for %s: %s", cert.Serial, err)) + } + } + + if features.Get().CertCheckerChecksValidations { + err = c.checkValidations(ctx, cert, parsedCert.DNSNames) + if err != nil { + if features.Get().CertCheckerRequiresValidations { + problems = append(problems, err.Error()) + } else { + c.logger.Errf("Certificate %s %s: %s", cert.Serial, parsedCert.DNSNames, err) + } + } + } + } + return dnsNames, problems +} + +type Config struct { + CertChecker struct { + DB cmd.DBConfig + cmd.HostnamePolicyConfig + + Workers int `validate:"required,min=1"` + // Deprecated: this is ignored, and cert checker always checks both expired and unexpired. + UnexpiredOnly bool + BadResultsOnly bool + CheckPeriod config.Duration + + // AcceptableValidityDurations is a list of durations which are + // acceptable for certificates we issue. + AcceptableValidityDurations []config.Duration + + // GoodKey is an embedded config stanza for the goodkey library. If this + // is populated, the cert-checker will perform static checks against the + // public keys in the certs it checks. + GoodKey goodkey.Config + + // IgnoredLints is a list of zlint names. Any lint results from a lint in + // the IgnoredLists list are ignored regardless of LintStatus level. + IgnoredLints []string + + // CTLogListFile is the path to a JSON file on disk containing the set of + // all logs trusted by Chrome. The file must match the v3 log list schema: + // https://www.gstatic.com/ct/log_list/v3/log_list_schema.json + CTLogListFile string + + Features features.Config + } + PA cmd.PAConfig + Syslog cmd.SyslogConfig +} + +func main() { + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var config Config + err := cmd.ReadConfigFile(*configFile, &config) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(config.CertChecker.Features) + + logger := cmd.NewLogger(config.Syslog) + logger.Info(cmd.VersionString()) + + acceptableValidityDurations := make(map[time.Duration]bool) + if len(config.CertChecker.AcceptableValidityDurations) > 0 { + for _, entry := range config.CertChecker.AcceptableValidityDurations { + acceptableValidityDurations[entry.Duration] = true + } + } else { + // For backwards compatibility, assume only a single valid validity + // period of exactly 90 days if none is configured. + ninetyDays := (time.Hour * 24) * 90 + acceptableValidityDurations[ninetyDays] = true + } + + // Validate PA config and set defaults if needed. + cmd.FailOnError(config.PA.CheckChallenges(), "Invalid PA configuration") + + if config.CertChecker.GoodKey.WeakKeyFile != "" { + cmd.Fail("cert-checker does not support checking against weak key files") + } + if config.CertChecker.GoodKey.BlockedKeyFile != "" { + cmd.Fail("cert-checker does not support checking against blocked key files") + } + kp, err := sagoodkey.NewPolicy(&config.CertChecker.GoodKey, nil) + cmd.FailOnError(err, "Unable to create key policy") + + saDbMap, err := sa.InitWrappedDb(config.CertChecker.DB, prometheus.DefaultRegisterer, logger) + cmd.FailOnError(err, "While initializing dbMap") + + checkerLatency := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "cert_checker_latency", + Help: "Histogram of latencies a cert-checker worker takes to complete a batch", + }) + prometheus.DefaultRegisterer.MustRegister(checkerLatency) + + pa, err := policy.New(config.PA.Challenges, logger) + cmd.FailOnError(err, "Failed to create PA") + + err = pa.LoadHostnamePolicyFile(config.CertChecker.HostnamePolicyFile) + cmd.FailOnError(err, "Failed to load HostnamePolicyFile") + + if config.CertChecker.CTLogListFile != "" { + err = loglist.InitLintList(config.CertChecker.CTLogListFile) + cmd.FailOnError(err, "Failed to load CT Log List") + } + + checker := newChecker( + saDbMap, + cmd.Clock(), + pa, + kp, + config.CertChecker.CheckPeriod.Duration, + acceptableValidityDurations, + logger, + ) + fmt.Fprintf(os.Stderr, "# Getting certificates issued in the last %s\n", config.CertChecker.CheckPeriod) + + ignoredLintsMap := make(map[string]bool) + for _, name := range config.CertChecker.IgnoredLints { + ignoredLintsMap[name] = true + } + + // Since we grab certificates in batches we don't want this to block, when it + // is finished it will close the certificate channel which allows the range + // loops in checker.processCerts to break + go func() { + err := checker.getCerts(context.TODO()) + cmd.FailOnError(err, "Batch retrieval of certificates failed") + }() + + fmt.Fprintf(os.Stderr, "# Processing certificates using %d workers\n", config.CertChecker.Workers) + wg := new(sync.WaitGroup) + for range config.CertChecker.Workers { + wg.Add(1) + go func() { + s := checker.clock.Now() + checker.processCerts(context.TODO(), wg, config.CertChecker.BadResultsOnly, ignoredLintsMap) + checkerLatency.Observe(checker.clock.Since(s).Seconds()) + }() + } + wg.Wait() + fmt.Fprintf( + os.Stderr, + "# Finished processing certificates, report length: %d, good: %d, bad: %d\n", + len(checker.issuedReport.Entries), + checker.issuedReport.GoodCerts, + checker.issuedReport.BadCerts, + ) + err = checker.issuedReport.dump() + cmd.FailOnError(err, "Failed to dump results: %s\n") +} + +func init() { + cmd.RegisterCommand("cert-checker", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main_test.go new file mode 100644 index 000000000..3ebda1c80 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/main_test.go @@ -0,0 +1,696 @@ +package notmain + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "database/sql" + "encoding/asn1" + "encoding/pem" + "errors" + "log" + "math/big" + mrand "math/rand" + "os" + "slices" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/goodkey/sagoodkey" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/sa/satest" + "github.com/letsencrypt/boulder/test" + isa "github.com/letsencrypt/boulder/test/inmem/sa" + "github.com/letsencrypt/boulder/test/vars" +) + +var ( + testValidityDuration = 24 * 90 * time.Hour + testValidityDurations = map[time.Duration]bool{testValidityDuration: true} + pa *policy.AuthorityImpl + kp goodkey.KeyPolicy +) + +func init() { + var err error + pa, err = policy.New(map[core.AcmeChallenge]bool{}, blog.NewMock()) + if err != nil { + log.Fatal(err) + } + err = pa.LoadHostnamePolicyFile("../../test/hostname-policy.yaml") + if err != nil { + log.Fatal(err) + } + kp, err = sagoodkey.NewPolicy(&goodkey.Config{FermatRounds: 100}, nil) + if err != nil { + log.Fatal(err) + } +} + +func BenchmarkCheckCert(b *testing.B) { + checker := newChecker(nil, clock.New(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + testKey, _ := rsa.GenerateKey(rand.Reader, 1024) + expiry := time.Now().AddDate(0, 0, 1) + serial := big.NewInt(1337) + rawCert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "example.com", + }, + NotAfter: expiry, + DNSNames: []string{"example-a.com"}, + SerialNumber: serial, + } + certDer, _ := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey) + cert := core.Certificate{ + Serial: core.SerialToString(serial), + Digest: core.Fingerprint256(certDer), + DER: certDer, + Issued: time.Now(), + Expires: expiry, + } + b.ResetTimer() + for range b.N { + checker.checkCert(context.Background(), cert, nil) + } +} + +func TestCheckWildcardCert(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + saCleanup := test.ResetBoulderTestDatabase(t) + defer func() { + saCleanup() + }() + + testKey, _ := rsa.GenerateKey(rand.Reader, 2048) + fc := clock.NewFake() + checker := newChecker(saDbMap, fc, pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + issued := checker.clock.Now().Add(-time.Minute) + goodExpiry := issued.Add(testValidityDuration - time.Second) + serial := big.NewInt(1337) + + wildcardCert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "*.example.com", + }, + NotBefore: issued, + NotAfter: goodExpiry, + DNSNames: []string{"*.example.com"}, + SerialNumber: serial, + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + OCSPServer: []string{"http://example.com/ocsp"}, + IssuingCertificateURL: []string{"http://example.com/cert"}, + } + wildcardCertDer, err := x509.CreateCertificate(rand.Reader, &wildcardCert, &wildcardCert, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "Couldn't create certificate") + parsed, err := x509.ParseCertificate(wildcardCertDer) + test.AssertNotError(t, err, "Couldn't parse created certificate") + cert := core.Certificate{ + Serial: core.SerialToString(serial), + Digest: core.Fingerprint256(wildcardCertDer), + Expires: parsed.NotAfter, + Issued: parsed.NotBefore, + DER: wildcardCertDer, + } + _, problems := checker.checkCert(context.Background(), cert, nil) + for _, p := range problems { + t.Errorf(p) + } +} + +func TestCheckCertReturnsDNSNames(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + saCleanup := test.ResetBoulderTestDatabase(t) + defer func() { + saCleanup() + }() + checker := newChecker(saDbMap, clock.NewFake(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + + certPEM, err := os.ReadFile("testdata/quite_invalid.pem") + if err != nil { + t.Fatal(err) + } + + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("failed to parse cert PEM") + } + + cert := core.Certificate{ + Serial: "00000000000", + Digest: core.Fingerprint256(block.Bytes), + Expires: time.Now().Add(time.Hour), + Issued: time.Now(), + DER: block.Bytes, + } + + names, problems := checker.checkCert(context.Background(), cert, nil) + if !slices.Equal(names, []string{"quite_invalid.com", "al--so--wr--ong.com"}) { + t.Errorf("didn't get expected DNS names. other problems: %s", strings.Join(problems, "\n")) + } +} + +type keyGen interface { + genKey() (crypto.Signer, error) +} + +type ecP256Generator struct{} + +func (*ecP256Generator) genKey() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +} + +type rsa2048Generator struct{} + +func (*rsa2048Generator) genKey() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) +} + +func TestCheckCert(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + saCleanup := test.ResetBoulderTestDatabase(t) + defer func() { + saCleanup() + }() + + testCases := []struct { + name string + key keyGen + }{ + { + name: "RSA 2048 key", + key: &rsa2048Generator{}, + }, + { + name: "ECDSA P256 key", + key: &ecP256Generator{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testKey, _ := tc.key.genKey() + + checker := newChecker(saDbMap, clock.NewFake(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + + // Create a RFC 7633 OCSP Must Staple Extension. + // OID 1.3.6.1.5.5.7.1.24 + ocspMustStaple := pkix.Extension{ + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}, + Critical: false, + Value: []uint8{0x30, 0x3, 0x2, 0x1, 0x5}, + } + + // Create a made up PKIX extension + imaginaryExtension := pkix.Extension{ + Id: asn1.ObjectIdentifier{1, 3, 3, 7}, + Critical: false, + Value: []uint8{0xC0, 0xFF, 0xEE}, + } + + issued := checker.clock.Now().Add(-time.Minute) + goodExpiry := issued.Add(testValidityDuration - time.Second) + serial := big.NewInt(1337) + longName := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeexample.com" + rawCert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: longName, + }, + NotBefore: issued, + NotAfter: goodExpiry.AddDate(0, 0, 1), // Period too long + DNSNames: []string{ + "example-a.com", + "foodnotbombs.mil", + // `dev-myqnapcloud.com` is included because it is an exact private + // entry on the public suffix list + "dev-myqnapcloud.com", + // don't include longName in the SANs, so the unique CN gets flagged + }, + SerialNumber: serial, + BasicConstraintsValid: false, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + OCSPServer: []string{"http://example.com/ocsp"}, + IssuingCertificateURL: []string{"http://example.com/cert"}, + ExtraExtensions: []pkix.Extension{ocspMustStaple, imaginaryExtension}, + } + brokenCertDer, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, testKey.Public(), testKey) + test.AssertNotError(t, err, "Couldn't create certificate") + // Problems + // Digest doesn't match + // Serial doesn't match + // Expiry doesn't match + // Issued doesn't match + cert := core.Certificate{ + Serial: "8485f2687eba29ad455ae4e31c8679206fec", + DER: brokenCertDer, + Issued: issued.Add(12 * time.Hour), + Expires: goodExpiry.AddDate(0, 0, 2), // Expiration doesn't match + } + + _, problems := checker.checkCert(context.Background(), cert, nil) + + problemsMap := map[string]int{ + "Stored digest doesn't match certificate digest": 1, + "Stored serial doesn't match certificate serial": 1, + "Stored expiration doesn't match certificate NotAfter": 1, + "Certificate doesn't have basic constraints set": 1, + "Certificate has unacceptable validity period": 1, + "Stored issuance date is outside of 6 hour window of certificate NotBefore": 1, + "Certificate has incorrect key usage extensions": 1, + "Certificate has common name >64 characters long (65)": 1, + "Certificate contains an unexpected extension: 1.3.3.7": 1, + "Certificate Common Name does not appear in Subject Alternative Names: \"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeexample.com\" !< [example-a.com foodnotbombs.mil dev-myqnapcloud.com]": 1, + } + for _, p := range problems { + _, ok := problemsMap[p] + if !ok { + t.Errorf("Found unexpected problem '%s'.", p) + } + delete(problemsMap, p) + } + for k := range problemsMap { + t.Errorf("Expected problem but didn't find it: '%s'.", k) + } + + // Same settings as above, but the stored serial number in the DB is invalid. + cert.Serial = "not valid" + _, problems = checker.checkCert(context.Background(), cert, nil) + foundInvalidSerialProblem := false + for _, p := range problems { + if p == "Stored serial is invalid" { + foundInvalidSerialProblem = true + } + } + test.Assert(t, foundInvalidSerialProblem, "Invalid certificate serial number in DB did not trigger problem.") + + // Fix the problems + rawCert.Subject.CommonName = "example-a.com" + rawCert.DNSNames = []string{"example-a.com"} + rawCert.NotAfter = goodExpiry + rawCert.BasicConstraintsValid = true + rawCert.ExtraExtensions = []pkix.Extension{ocspMustStaple} + rawCert.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + goodCertDer, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, testKey.Public(), testKey) + test.AssertNotError(t, err, "Couldn't create certificate") + parsed, err := x509.ParseCertificate(goodCertDer) + test.AssertNotError(t, err, "Couldn't parse created certificate") + cert.Serial = core.SerialToString(serial) + cert.Digest = core.Fingerprint256(goodCertDer) + cert.DER = goodCertDer + cert.Expires = parsed.NotAfter + cert.Issued = parsed.NotBefore + _, problems = checker.checkCert(context.Background(), cert, nil) + test.AssertEquals(t, len(problems), 0) + }) + } +} + +func TestGetAndProcessCerts(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + fc := clock.NewFake() + fc.Set(fc.Now().Add(time.Hour)) + + checker := newChecker(saDbMap, fc, pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + sa, err := sa.NewSQLStorageAuthority(saDbMap, saDbMap, nil, 1, 0, fc, blog.NewMock(), metrics.NoopRegisterer) + test.AssertNotError(t, err, "Couldn't create SA to insert certificates") + saCleanUp := test.ResetBoulderTestDatabase(t) + defer func() { + saCleanUp() + }() + + testKey, _ := rsa.GenerateKey(rand.Reader, 1024) + // Problems + // Expiry period is too long + rawCert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "not-blacklisted.com", + }, + BasicConstraintsValid: true, + DNSNames: []string{"not-blacklisted.com"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + reg := satest.CreateWorkingRegistration(t, isa.SA{Impl: sa}) + test.AssertNotError(t, err, "Couldn't create registration") + for range 5 { + rawCert.SerialNumber = big.NewInt(mrand.Int63()) + certDER, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "Couldn't create certificate") + _, err = sa.AddCertificate(context.Background(), &sapb.AddCertificateRequest{ + Der: certDER, + RegID: reg.Id, + Issued: timestamppb.New(fc.Now()), + }) + test.AssertNotError(t, err, "Couldn't add certificate") + } + + batchSize = 2 + err = checker.getCerts(context.Background()) + test.AssertNotError(t, err, "Failed to retrieve certificates") + test.AssertEquals(t, len(checker.certs), 5) + wg := new(sync.WaitGroup) + wg.Add(1) + checker.processCerts(context.Background(), wg, false, nil) + test.AssertEquals(t, checker.issuedReport.BadCerts, int64(5)) + test.AssertEquals(t, len(checker.issuedReport.Entries), 5) +} + +// mismatchedCountDB is a certDB implementation for `getCerts` that returns one +// high value when asked how many rows there are, and then returns nothing when +// asked for the actual rows. +type mismatchedCountDB struct{} + +// `getCerts` calls `SelectInt` first to determine how many rows there are +// matching the `getCertsCountQuery` criteria. For this mock we return +// a non-zero number +func (db mismatchedCountDB) SelectNullInt(_ context.Context, _ string, _ ...interface{}) (sql.NullInt64, error) { + return sql.NullInt64{ + Int64: 99999, + Valid: true, + }, + nil +} + +// `getCerts` then calls `Select` to retrieve the Certificate rows. We pull +// a dastardly switch-a-roo here and return an empty set +func (db mismatchedCountDB) Select(_ context.Context, output interface{}, _ string, _ ...interface{}) ([]interface{}, error) { + // But actually return nothing + outputPtr, _ := output.(*[]sa.CertWithID) + *outputPtr = []sa.CertWithID{} + return nil, nil +} + +func (db mismatchedCountDB) SelectOne(_ context.Context, _ interface{}, _ string, _ ...interface{}) error { + return errors.New("unimplemented") +} + +/* + * In Boulder #2004[0] we identified that there is a race in `getCerts` + * between the first call to `SelectOne` to identify how many rows there are, + * and the subsequent call to `Select` to get the actual rows in batches. This + * manifests in an index out of range panic where the cert checker thinks there + * are more rows than there are and indexes into an empty set of certificates to + * update the lastSerial field of the query `args`. This has been fixed by + * adding a len() check in the inner `getCerts` loop that processes the certs + * one batch at a time. + * + * TestGetCertsEmptyResults tests the fix remains in place by using a mock that + * exploits this corner case deliberately. The `mismatchedCountDB` mock (defined + * above) will return a high count for the `SelectOne` call, but an empty slice + * for the `Select` call. Without the fix in place this reliably produced the + * "index out of range" panic from #2004. With the fix in place the test passes. + * + * 0: https://github.com/letsencrypt/boulder/issues/2004 + */ +func TestGetCertsEmptyResults(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + checker := newChecker(saDbMap, clock.NewFake(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + checker.dbMap = mismatchedCountDB{} + + batchSize = 3 + err = checker.getCerts(context.Background()) + test.AssertNotError(t, err, "Failed to retrieve certificates") +} + +// emptyDB is a certDB object with methods used for testing that 'null' +// responses received from the database are handled properly. +type emptyDB struct { + certDB +} + +// SelectNullInt is a method that returns a false sql.NullInt64 struct to +// mock a null DB response +func (db emptyDB) SelectNullInt(_ context.Context, _ string, _ ...interface{}) (sql.NullInt64, error) { + return sql.NullInt64{Valid: false}, + nil +} + +// TestGetCertsNullResults tests that a null response from the database will +// be handled properly. It uses the emptyDB above to mock the response +// expected if the DB finds no certificates to match the SELECT query and +// should return an error. +func TestGetCertsNullResults(t *testing.T) { + checker := newChecker(emptyDB{}, clock.NewFake(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + + err := checker.getCerts(context.Background()) + test.AssertError(t, err, "Should have gotten error from empty DB") + if !strings.Contains(err.Error(), "no rows found for certificates issued between") { + t.Errorf("expected error to contain 'no rows found for certificates issued between', got '%s'", err.Error()) + } +} + +// lateDB is a certDB object that helps with TestGetCertsLate. +// It pretends to contain a single cert issued at the given time. +type lateDB struct { + issuedTime time.Time + selectedACert bool +} + +// SelectNullInt is a method that returns a false sql.NullInt64 struct to +// mock a null DB response +func (db *lateDB) SelectNullInt(_ context.Context, _ string, args ...interface{}) (sql.NullInt64, error) { + args2 := args[0].(map[string]interface{}) + begin := args2["begin"].(time.Time) + end := args2["end"].(time.Time) + if begin.Compare(db.issuedTime) < 0 && end.Compare(db.issuedTime) > 0 { + return sql.NullInt64{Int64: 23, Valid: true}, nil + } + return sql.NullInt64{Valid: false}, nil +} + +func (db *lateDB) Select(_ context.Context, output interface{}, _ string, args ...interface{}) ([]interface{}, error) { + db.selectedACert = true + // For expediency we respond with an empty list of certificates; the checker will treat this as if it's + // reached the end of the list of certificates to process. + return nil, nil +} + +func (db *lateDB) SelectOne(_ context.Context, _ interface{}, _ string, _ ...interface{}) error { + return nil +} + +// TestGetCertsLate checks for correct behavior when certificates exist only late in the provided window. +func TestGetCertsLate(t *testing.T) { + clk := clock.NewFake() + db := &lateDB{issuedTime: clk.Now().Add(-time.Hour)} + checkPeriod := 24 * time.Hour + checker := newChecker(db, clk, pa, kp, checkPeriod, testValidityDurations, blog.NewMock()) + + err := checker.getCerts(context.Background()) + test.AssertNotError(t, err, "getting certs") + + if !db.selectedACert { + t.Errorf("checker never selected a certificate after getting a MIN(id)") + } +} + +func TestSaveReport(t *testing.T) { + r := report{ + begin: time.Time{}, + end: time.Time{}, + GoodCerts: 2, + BadCerts: 1, + Entries: map[string]reportEntry{ + "020000000000004b475da49b91da5c17": { + Valid: true, + }, + "020000000000004d1613e581432cba7e": { + Valid: true, + }, + "020000000000004e402bc21035c6634a": { + Valid: false, + Problems: []string{"None really..."}, + }, + }, + } + + err := r.dump() + test.AssertNotError(t, err, "Failed to dump results") +} + +func TestIsForbiddenDomain(t *testing.T) { + // Note: These testcases are not an exhaustive representation of domains + // Boulder won't issue for, but are instead testing the defense-in-depth + // `isForbiddenDomain` function called *after* the PA has vetted the name + // against the complex hostname policy file. + testcases := []struct { + Name string + Expected bool + }{ + /* Expected to be forbidden test cases */ + // Whitespace only + {Name: "", Expected: true}, + {Name: " ", Expected: true}, + // Anything .local + {Name: "yokel.local", Expected: true}, + {Name: "off.on.remote.local", Expected: true}, + {Name: ".local", Expected: true}, + // Localhost is verboten + {Name: "localhost", Expected: true}, + // Anything .localhost + {Name: ".localhost", Expected: true}, + {Name: "local.localhost", Expected: true}, + {Name: "extremely.local.localhost", Expected: true}, + + /* Expected to be allowed test cases */ + {Name: "ok.computer.com", Expected: false}, + {Name: "ok.millionaires", Expected: false}, + {Name: "ok.milly", Expected: false}, + {Name: "ok", Expected: false}, + {Name: "nearby.locals", Expected: false}, + {Name: "yocalhost", Expected: false}, + {Name: "jokes.yocalhost", Expected: false}, + } + + for _, tc := range testcases { + result, _ := isForbiddenDomain(tc.Name) + test.AssertEquals(t, result, tc.Expected) + } +} + +func TestIgnoredLint(t *testing.T) { + saDbMap, err := sa.DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't connect to database") + saCleanup := test.ResetBoulderTestDatabase(t) + defer func() { + saCleanup() + }() + + err = loglist.InitLintList("../../test/ct-test-srv/log_list.json") + test.AssertNotError(t, err, "failed to load ct log list") + testKey, _ := rsa.GenerateKey(rand.Reader, 2048) + checker := newChecker(saDbMap, clock.NewFake(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + serial := big.NewInt(1337) + + template := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "CPU's Cool CA", + }, + SerialNumber: serial, + NotBefore: time.Now(), + NotAfter: time.Now().Add(testValidityDuration - time.Second), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + PolicyIdentifiers: []asn1.ObjectIdentifier{ + {1, 2, 3}, + }, + BasicConstraintsValid: true, + IsCA: true, + IssuingCertificateURL: []string{"http://aia.example.org"}, + SubjectKeyId: []byte("foobar"), + } + + // Create a self-signed issuer certificate to use + issuerDer, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "failed to create self-signed issuer cert") + issuerCert, err := x509.ParseCertificate(issuerDer) + test.AssertNotError(t, err, "failed to parse self-signed issuer cert") + + // Reconfigure the template for an EE cert with a Subj. CN + serial = big.NewInt(1338) + template.SerialNumber = serial + template.Subject.CommonName = "zombo.com" + template.DNSNames = []string{"zombo.com"} + template.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + template.IsCA = false + + subjectCertDer, err := x509.CreateCertificate(rand.Reader, template, issuerCert, testKey.Public(), testKey) + test.AssertNotError(t, err, "failed to create EE cert") + subjectCert, err := x509.ParseCertificate(subjectCertDer) + test.AssertNotError(t, err, "failed to parse EE cert") + + cert := core.Certificate{ + Serial: core.SerialToString(serial), + DER: subjectCertDer, + Digest: core.Fingerprint256(subjectCertDer), + Issued: subjectCert.NotBefore, + Expires: subjectCert.NotAfter, + } + + // Without any ignored lints we expect one error level result due to the + // missing OCSP url in the template. + expectedProblems := []string{ + "zlint error: e_sub_cert_aia_does_not_contain_ocsp_url", + "zlint warn: w_subject_common_name_included", + "zlint info: w_ct_sct_policy_count_unsatisfied Certificate had 0 embedded SCTs. Browser policy may require 2 for this certificate.", + "zlint error: e_scts_from_same_operator Certificate had too few embedded SCTs; browser policy requires 2.", + } + sort.Strings(expectedProblems) + + // Check the certificate with a nil ignore map. This should return the + // expected zlint problems. + _, problems := checker.checkCert(context.Background(), cert, nil) + sort.Strings(problems) + test.AssertDeepEquals(t, problems, expectedProblems) + + // Check the certificate again with an ignore map that excludes the affected + // lints. This should return no problems. + _, problems = checker.checkCert(context.Background(), cert, map[string]bool{ + "e_sub_cert_aia_does_not_contain_ocsp_url": true, + "w_subject_common_name_included": true, + "w_ct_sct_policy_count_unsatisfied": true, + "e_scts_from_same_operator": true, + }) + test.AssertEquals(t, len(problems), 0) +} + +func TestPrecertCorrespond(t *testing.T) { + checker := newChecker(nil, clock.New(), pa, kp, time.Hour, testValidityDurations, blog.NewMock()) + checker.getPrecert = func(_ context.Context, _ string) ([]byte, error) { + return []byte("hello"), nil + } + testKey, _ := rsa.GenerateKey(rand.Reader, 2048) + expiry := time.Now().AddDate(0, 0, 1) + serial := big.NewInt(1337) + rawCert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "example.com", + }, + NotAfter: expiry, + DNSNames: []string{"example-a.com"}, + SerialNumber: serial, + } + certDer, _ := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey) + cert := core.Certificate{ + Serial: core.SerialToString(serial), + Digest: core.Fingerprint256(certDer), + DER: certDer, + Issued: time.Now(), + Expires: expiry, + } + _, problems := checker.checkCert(context.Background(), cert, nil) + if len(problems) == 0 { + t.Errorf("expected precert correspondence problem") + } + // Ensure that at least one of the problems was related to checking correspondence + for _, p := range problems { + if strings.Contains(p, "does not correspond to precert") { + return + } + } + t.Fatalf("expected precert correspondence problem, but got: %v", problems) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/testdata/quite_invalid.pem b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/testdata/quite_invalid.pem new file mode 100644 index 000000000..632b8b67e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/cert-checker/testdata/quite_invalid.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUzCCAjugAwIBAgIILgLqdMwyzT4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE +AxMVbWluaWNhIHJvb3QgY2EgOTMzZTM5MB4XDTIxMTExMTIwMjMzMloXDTIzMTIx +MTIwMjMzMlowHDEaMBgGA1UEAwwRcXVpdGVfaW52YWxpZC5jb20wggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDi4jBbqMyvhMonDngNsvie9SHPB16mdpiy +Y/agreU84xUz/roKK07TpVmeqvwWvDkvHTFov7ytKdnCY+z/NXKJ3hNqflWCwU7h +Uk9TmpBp0vg+5NvalYul/+bq/B4qDhEvTBzAX3k/UYzd0GQdMyAbwXtG41f5cSK6 +cWTQYfJL3gGR5/KLoTz3/VemLgEgAP/CvgcUJPbQceQViiZ4opi9hFIfUqxX2NsD +49klw8cDFu/BG2LEC+XtbdT8XevD0aGIOuYVr+Pa2mxb2QCDXu4tXOsDXH9Y/Cmk +8103QbdB8Y+usOiHG/IXxK2q4J7QNPal4ER4/PGA06V0gwrjNH8BAgMBAAGjgZQw +gZEwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFNIcaCjv32YRafE065dZO57ONWuk +MDEGA1UdEQQqMCiCEXF1aXRlX2ludmFsaWQuY29tghNhbC0tc28tLXdyLS1vbmcu +Y29tMA0GCSqGSIb3DQEBCwUAA4IBAQAjSv0o5G4VuLnnwHON4P53bLvGnYqaqYju +TEafi3hSgHAfBuhOQUVgwujoYpPp1w1fm5spfcbSwNNRte79HgV97kAuZ4R4RHk1 +5Xux1ITLalaHR/ilu002N0eJ7dFYawBgV2xMudULzohwmW2RjPJ5811iWwtiVf1b +A3V5SZJWSJll1BhANBs7R0pBbyTSNHR470N8TGG0jfXqgTKd0xZaH91HrwEMo+96 +llbfp90Y5OfHIfym/N1sH2hVgd+ZAkhiVEiNBWZlbSyOgbZ1cCBvBXg6TuwpQMZK +9RWjlpni8yuzLGduPl8qHG1dqsUvbVqcG+WhHLbaZMNhiMfiWInL +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/cmd/clock_generic.go b/third-party/github.com/letsencrypt/boulder/cmd/clock_generic.go new file mode 100644 index 000000000..32634ae22 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/clock_generic.go @@ -0,0 +1,14 @@ +//go:build !integration + +package cmd + +import "github.com/jmhodges/clock" + +// Clock functions similarly to clock.New(), but the returned value can be +// changed using the FAKECLOCK environment variable if the 'integration' build +// flag is set. +// +// This function returns the default Clock. +func Clock() clock.Clock { + return clock.New() +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/clock_integration.go b/third-party/github.com/letsencrypt/boulder/cmd/clock_integration.go new file mode 100644 index 000000000..beb5b0103 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/clock_integration.go @@ -0,0 +1,32 @@ +//go:build integration + +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/jmhodges/clock" + + blog "github.com/letsencrypt/boulder/log" +) + +// Clock functions similarly to clock.New(), but the returned value can be +// changed using the FAKECLOCK environment variable if the 'integration' build +// flag is set. +// +// The FAKECLOCK env var is in the time.UnixDate format, returned by `date -d`. +func Clock() clock.Clock { + if tgt := os.Getenv("FAKECLOCK"); tgt != "" { + targetTime, err := time.Parse(time.UnixDate, tgt) + FailOnError(err, fmt.Sprintf("cmd.Clock: bad format for FAKECLOCK: %v\n", err)) + + cl := clock.NewFake() + cl.Set(targetTime) + blog.Get().Infof("Time was set to %v via FAKECLOCK", targetTime) + return cl + } + + return clock.New() +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/config.go b/third-party/github.com/letsencrypt/boulder/cmd/config.go new file mode 100644 index 000000000..1a3edabff --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/config.go @@ -0,0 +1,555 @@ +package cmd + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "os" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "google.golang.org/grpc/resolver" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" +) + +// PasswordConfig contains a path to a file containing a password. +type PasswordConfig struct { + PasswordFile string `validate:"required"` +} + +// Pass returns a password, extracted from the PasswordConfig's PasswordFile +func (pc *PasswordConfig) Pass() (string, error) { + // Make PasswordConfigs optional, for backwards compatibility. + if pc.PasswordFile == "" { + return "", nil + } + contents, err := os.ReadFile(pc.PasswordFile) + if err != nil { + return "", err + } + return strings.TrimRight(string(contents), "\n"), nil +} + +// ServiceConfig contains config items that are common to all our services, to +// be embedded in other config structs. +type ServiceConfig struct { + // DebugAddr is the address to run the /debug handlers on. + DebugAddr string `validate:"omitempty,hostname_port"` + GRPC *GRPCServerConfig + TLS TLSConfig + + // HealthCheckInterval is the duration between deep health checks of the + // service. Defaults to 5 seconds. + HealthCheckInterval config.Duration `validate:"-"` +} + +// DBConfig defines how to connect to a database. The connect string is +// stored in a file separate from the config, because it can contain a password, +// which we want to keep out of configs. +type DBConfig struct { + // A file containing a connect URL for the DB. + DBConnectFile string `validate:"required"` + + // MaxOpenConns sets the maximum number of open connections to the + // database. If MaxIdleConns is greater than 0 and MaxOpenConns is + // less than MaxIdleConns, then MaxIdleConns will be reduced to + // match the new MaxOpenConns limit. If n < 0, then there is no + // limit on the number of open connections. + MaxOpenConns int `validate:"min=-1"` + + // MaxIdleConns sets the maximum number of connections in the idle + // connection pool. If MaxOpenConns is greater than 0 but less than + // MaxIdleConns, then MaxIdleConns will be reduced to match the + // MaxOpenConns limit. If n < 0, no idle connections are retained. + MaxIdleConns int `validate:"min=-1"` + + // ConnMaxLifetime sets the maximum amount of time a connection may + // be reused. Expired connections may be closed lazily before reuse. + // If d < 0, connections are not closed due to a connection's age. + ConnMaxLifetime config.Duration `validate:"-"` + + // ConnMaxIdleTime sets the maximum amount of time a connection may + // be idle. Expired connections may be closed lazily before reuse. + // If d < 0, connections are not closed due to a connection's idle + // time. + ConnMaxIdleTime config.Duration `validate:"-"` +} + +// URL returns the DBConnect URL represented by this DBConfig object, loading it +// from the file on disk. Leading and trailing whitespace is stripped. +func (d *DBConfig) URL() (string, error) { + url, err := os.ReadFile(d.DBConnectFile) + return strings.TrimSpace(string(url)), err +} + +type SMTPConfig struct { + PasswordConfig + Server string `validate:"required"` + Port string `validate:"required,numeric,min=1,max=65535"` + Username string `validate:"required"` +} + +// PAConfig specifies how a policy authority should connect to its +// database, what policies it should enforce, and what challenges +// it should offer. +type PAConfig struct { + DBConfig `validate:"-"` + Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01,endkeys"` +} + +// CheckChallenges checks whether the list of challenges in the PA config +// actually contains valid challenge names +func (pc PAConfig) CheckChallenges() error { + if len(pc.Challenges) == 0 { + return errors.New("empty challenges map in the Policy Authority config is not allowed") + } + for c := range pc.Challenges { + if !c.IsValid() { + return fmt.Errorf("invalid challenge in PA config: %s", c) + } + } + return nil +} + +// HostnamePolicyConfig specifies a file from which to load a policy regarding +// what hostnames to issue for. +type HostnamePolicyConfig struct { + HostnamePolicyFile string `validate:"required"` +} + +// TLSConfig represents certificates and a key for authenticated TLS. +type TLSConfig struct { + CertFile string `validate:"required"` + KeyFile string `validate:"required"` + // The CACertFile file may contain any number of root certificates and will + // be deduplicated internally. + CACertFile string `validate:"required"` +} + +// Load reads and parses the certificates and key listed in the TLSConfig, and +// returns a *tls.Config suitable for either client or server use. The +// CACertFile file may contain any number of root certificates and will be +// deduplicated internally. Prometheus metrics for various certificate fields +// will be exported. +func (t *TLSConfig) Load(scope prometheus.Registerer) (*tls.Config, error) { + if t == nil { + return nil, fmt.Errorf("nil TLS section in config") + } + if t.CertFile == "" { + return nil, fmt.Errorf("nil CertFile in TLSConfig") + } + if t.KeyFile == "" { + return nil, fmt.Errorf("nil KeyFile in TLSConfig") + } + if t.CACertFile == "" { + return nil, fmt.Errorf("nil CACertFile in TLSConfig") + } + caCertBytes, err := os.ReadFile(t.CACertFile) + if err != nil { + return nil, fmt.Errorf("reading CA cert from %q: %s", t.CACertFile, err) + } + rootCAs := x509.NewCertPool() + if ok := rootCAs.AppendCertsFromPEM(caCertBytes); !ok { + return nil, fmt.Errorf("parsing CA certs from %s failed", t.CACertFile) + } + cert, err := tls.LoadX509KeyPair(t.CertFile, t.KeyFile) + if err != nil { + return nil, fmt.Errorf("loading key pair from %q and %q: %s", + t.CertFile, t.KeyFile, err) + } + + tlsNotBefore := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tlsconfig_notbefore_seconds", + Help: "TLS certificate NotBefore field expressed as Unix epoch time", + }, + []string{"serial"}) + err = scope.Register(tlsNotBefore) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + tlsNotBefore = are.ExistingCollector.(*prometheus.GaugeVec) + } else { + return nil, err + } + } + + tlsNotAfter := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tlsconfig_notafter_seconds", + Help: "TLS certificate NotAfter field expressed as Unix epoch time", + }, + []string{"serial"}) + err = scope.Register(tlsNotAfter) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + tlsNotAfter = are.ExistingCollector.(*prometheus.GaugeVec) + } else { + return nil, err + } + } + + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, err + } + + serial := leaf.SerialNumber.String() + tlsNotBefore.WithLabelValues(serial).Set(float64(leaf.NotBefore.Unix())) + tlsNotAfter.WithLabelValues(serial).Set(float64(leaf.NotAfter.Unix())) + + return &tls.Config{ + RootCAs: rootCAs, + ClientCAs: rootCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + Certificates: []tls.Certificate{cert}, + // Set the only acceptable TLS to v1.3. + MinVersion: tls.VersionTLS13, + }, nil +} + +// SyslogConfig defines the config for syslogging. +// 3 means "error", 4 means "warning", 6 is "info" and 7 is "debug". +// Configuring a given level causes all messages at that level and below to +// be logged. +type SyslogConfig struct { + // When absent or zero, this causes no logs to be emitted on stdout/stderr. + // Errors and warnings will be emitted on stderr if the configured level + // allows. + StdoutLevel int `validate:"min=-1,max=7"` + // When absent or zero, this defaults to logging all messages of level 6 + // or below. To disable syslog logging entirely, set this to -1. + SyslogLevel int `validate:"min=-1,max=7"` +} + +// ServiceDomain contains the service and domain name the gRPC or bdns provider +// will use to construct a SRV DNS query to lookup backends. +type ServiceDomain struct { + // Service is the service name to be used for SRV lookups. For example: if + // record is 'foo.service.consul', then the Service is 'foo'. + Service string `validate:"required"` + + // Domain is the domain name to be used for SRV lookups. For example: if the + // record is 'foo.service.consul', then the Domain is 'service.consul'. + Domain string `validate:"required"` +} + +// GRPCClientConfig contains the information necessary to setup a gRPC client +// connection. The following field combinations are allowed: +// +// ServerIPAddresses, [Timeout] +// ServerAddress, DNSAuthority, [Timeout], [HostOverride] +// SRVLookup, DNSAuthority, [Timeout], [HostOverride], [SRVResolver] +// SRVLookups, DNSAuthority, [Timeout], [HostOverride], [SRVResolver] +type GRPCClientConfig struct { + // DNSAuthority is a single : of the DNS server + // to be used for resolution of gRPC backends. If the address contains a + // hostname the gRPC client will resolve it via the system DNS. If the + // address contains a port, the client will use it directly, otherwise port + // 53 is used. + DNSAuthority string `validate:"required_with=SRVLookup SRVLookups,omitempty,ip|hostname|hostname_port"` + + // SRVLookup contains the service and domain name the gRPC client will use + // to construct a SRV DNS query to lookup backends. For example: if the + // resource record is 'foo.service.consul', then the 'Service' is 'foo' and + // the 'Domain' is 'service.consul'. The expected dNSName to be + // authenticated in the server certificate would be 'foo.service.consul'. + // + // Note: The 'proto' field of the SRV record MUST contain 'tcp' and the + // 'port' field MUST be a valid port. In a Consul configuration file you + // would specify 'foo.service.consul' as: + // + // services { + // id = "some-unique-id-1" + // name = "foo" + // address = "10.77.77.77" + // port = 8080 + // tags = ["tcp"] + // } + // services { + // id = "some-unique-id-2" + // name = "foo" + // address = "10.77.77.77" + // port = 8180 + // tags = ["tcp"] + // } + // + // If you've added the above to your Consul configuration file (and reloaded + // Consul) then you should be able to resolve the following dig query: + // + // $ dig @10.55.55.10 -t SRV _foo._tcp.service.consul +short + // 1 1 8080 0a585858.addr.dc1.consul. + // 1 1 8080 0a4d4d4d.addr.dc1.consul. + SRVLookup *ServiceDomain `validate:"required_without_all=SRVLookups ServerAddress ServerIPAddresses"` + + // SRVLookups allows you to pass multiple SRV records to the gRPC client. + // The gRPC client will resolves each SRV record and use the results to + // construct a list of backends to connect to. For more details, see the + // documentation for the SRVLookup field. Note: while you can pass multiple + // targets to the gRPC client using this field, all of the targets will use + // the same HostOverride and TLS configuration. + SRVLookups []*ServiceDomain `validate:"required_without_all=SRVLookup ServerAddress ServerIPAddresses"` + + // SRVResolver is an optional override to indicate that a specific + // implementation of the SRV resolver should be used. The default is 'srv' + // For more details, see the documentation in: + // grpc/internal/resolver/dns/dns_resolver.go. + SRVResolver string `validate:"excluded_with=ServerAddress ServerIPAddresses,isdefault|oneof=srv nonce-srv"` + + // ServerAddress is a single : or `:` that + // the gRPC client will, if necessary, resolve via DNS and then connect to. + // If the address provided is 'foo.service.consul:8080' then the dNSName to + // be authenticated in the server certificate would be 'foo.service.consul'. + // + // In a Consul configuration file you would specify 'foo.service.consul' as: + // + // services { + // id = "some-unique-id-1" + // name = "foo" + // address = "10.77.77.77" + // } + // services { + // id = "some-unique-id-2" + // name = "foo" + // address = "10.88.88.88" + // } + // + // If you've added the above to your Consul configuration file (and reloaded + // Consul) then you should be able to resolve the following dig query: + // + // $ dig A @10.55.55.10 foo.service.consul +short + // 10.77.77.77 + // 10.88.88.88 + ServerAddress string `validate:"required_without_all=ServerIPAddresses SRVLookup SRVLookups,omitempty,hostname_port"` + + // ServerIPAddresses is a comma separated list of IP addresses, in the + // format `:` or `:`, that the gRPC client will + // connect to. If the addresses provided are ["10.77.77.77", "10.88.88.88"] + // then the iPAddress' to be authenticated in the server certificate would + // be '10.77.77.77' and '10.88.88.88'. + ServerIPAddresses []string `validate:"required_without_all=ServerAddress SRVLookup SRVLookups,omitempty,dive,hostname_port"` + + // HostOverride is an optional override for the dNSName the client will + // verify in the certificate presented by the server. + HostOverride string `validate:"excluded_with=ServerIPAddresses,omitempty,hostname"` + Timeout config.Duration + + // NoWaitForReady turns off our (current) default of setting grpc.WaitForReady(true). + // This means if all of a GRPC client's backends are down, it will error immediately. + // The current default, grpc.WaitForReady(true), means that if all of a GRPC client's + // backends are down, it will wait until either one becomes available or the RPC + // times out. + NoWaitForReady bool +} + +// MakeTargetAndHostOverride constructs the target URI that the gRPC client will +// connect to and the hostname (only for 'ServerAddress' and 'SRVLookup') that +// will be validated during the mTLS handshake. An error is returned if the +// provided configuration is invalid. +func (c *GRPCClientConfig) MakeTargetAndHostOverride() (string, string, error) { + var hostOverride string + if c.ServerAddress != "" { + if c.ServerIPAddresses != nil || c.SRVLookup != nil { + return "", "", errors.New( + "both 'serverAddress' and 'serverIPAddresses' or 'SRVLookup' in gRPC client config. Only one should be provided", + ) + } + // Lookup backends using DNS A records. + targetHost, _, err := net.SplitHostPort(c.ServerAddress) + if err != nil { + return "", "", err + } + + hostOverride = targetHost + if c.HostOverride != "" { + hostOverride = c.HostOverride + } + return fmt.Sprintf("dns://%s/%s", c.DNSAuthority, c.ServerAddress), hostOverride, nil + + } else if c.SRVLookup != nil { + if c.DNSAuthority == "" { + return "", "", errors.New("field 'dnsAuthority' is required in gRPC client config with SRVLookup") + } + scheme, err := c.makeSRVScheme() + if err != nil { + return "", "", err + } + if c.ServerIPAddresses != nil { + return "", "", errors.New( + "both 'SRVLookup' and 'serverIPAddresses' in gRPC client config. Only one should be provided", + ) + } + // Lookup backends using DNS SRV records. + targetHost := c.SRVLookup.Service + "." + c.SRVLookup.Domain + + hostOverride = targetHost + if c.HostOverride != "" { + hostOverride = c.HostOverride + } + return fmt.Sprintf("%s://%s/%s", scheme, c.DNSAuthority, targetHost), hostOverride, nil + + } else if c.SRVLookups != nil { + if c.DNSAuthority == "" { + return "", "", errors.New("field 'dnsAuthority' is required in gRPC client config with SRVLookups") + } + scheme, err := c.makeSRVScheme() + if err != nil { + return "", "", err + } + if c.ServerIPAddresses != nil { + return "", "", errors.New( + "both 'SRVLookups' and 'serverIPAddresses' in gRPC client config. Only one should be provided", + ) + } + // Lookup backends using multiple DNS SRV records. + var targetHosts []string + for _, s := range c.SRVLookups { + targetHosts = append(targetHosts, s.Service+"."+s.Domain) + } + if c.HostOverride != "" { + hostOverride = c.HostOverride + } + return fmt.Sprintf("%s://%s/%s", scheme, c.DNSAuthority, strings.Join(targetHosts, ",")), hostOverride, nil + + } else { + if c.ServerIPAddresses == nil { + return "", "", errors.New( + "neither 'serverAddress', 'SRVLookup', 'SRVLookups' nor 'serverIPAddresses' in gRPC client config. One should be provided", + ) + } + // Specify backends as a list of IP addresses. + return "static:///" + strings.Join(c.ServerIPAddresses, ","), "", nil + } +} + +// makeSRVScheme returns the scheme to use for SRV lookups. If the SRVResolver +// field is empty, it returns "srv". Otherwise it checks that the specified +// SRVResolver is registered with the gRPC runtime and returns it. +func (c *GRPCClientConfig) makeSRVScheme() (string, error) { + if c.SRVResolver == "" { + return "srv", nil + } + rb := resolver.Get(c.SRVResolver) + if rb == nil { + return "", fmt.Errorf("resolver %q is not registered", c.SRVResolver) + } + return c.SRVResolver, nil +} + +// GRPCServerConfig contains the information needed to start a gRPC server. +type GRPCServerConfig struct { + Address string `json:"address" validate:"omitempty,hostname_port"` + // Services is a map of service names to configuration specific to that service. + // These service names must match the service names advertised by gRPC itself, + // which are identical to the names set in our gRPC .proto files prefixed by + // the package names set in those files (e.g. "ca.CertificateAuthority"). + Services map[string]GRPCServiceConfig `json:"services" validate:"required,dive,required"` + // MaxConnectionAge specifies how long a connection may live before the server sends a GoAway to the + // client. Because gRPC connections re-resolve DNS after a connection close, + // this controls how long it takes before a client learns about changes to its + // backends. + // https://pkg.go.dev/google.golang.org/grpc/keepalive#ServerParameters + MaxConnectionAge config.Duration `validate:"required"` +} + +// GRPCServiceConfig contains the information needed to configure a gRPC service. +type GRPCServiceConfig struct { + // PerServiceClientNames is a map of gRPC service names to client certificate + // SANs. The upstream listening server will reject connections from clients + // which do not appear in this list, and the server interceptor will reject + // RPC calls for this service from clients which are not listed here. + ClientNames []string `json:"clientNames" validate:"min=1,dive,hostname,required"` +} + +// OpenTelemetryConfig configures tracing via OpenTelemetry. +// To enable tracing, set a nonzero SampleRatio and configure an Endpoint +type OpenTelemetryConfig struct { + // Endpoint to connect to with the OTLP protocol over gRPC. + // It should be of the form "localhost:4317" + // + // It always connects over plaintext, and so is only intended to connect + // to a local OpenTelemetry collector. This should not be used over an + // insecure network. + Endpoint string + + // SampleRatio is the ratio of new traces to head sample. + // This only affects new traces without a parent with its own sampling + // decision, and otherwise use the parent's sampling decision. + // + // Set to something between 0 and 1, where 1 is sampling all traces. + // This is primarily meant as a pressure relief if the Endpoint we connect to + // is being overloaded, and we otherwise handle sampling in the collectors. + // See otel trace.ParentBased and trace.TraceIDRatioBased for details. + SampleRatio float64 +} + +// OpenTelemetryHTTPConfig configures the otelhttp server tracing. +type OpenTelemetryHTTPConfig struct { + // TrustIncomingSpans should only be set true if there's a trusted service + // connecting to Boulder, such as a load balancer that's tracing-aware. + // If false, the default, incoming traces won't be set as the parent. + // See otelhttp.WithPublicEndpoint + TrustIncomingSpans bool +} + +// Options returns the otelhttp options for this configuration. They can be +// passed to otelhttp.NewHandler or Boulder's wrapper, measured_http.New. +func (c *OpenTelemetryHTTPConfig) Options() []otelhttp.Option { + var options []otelhttp.Option + if !c.TrustIncomingSpans { + options = append(options, otelhttp.WithPublicEndpoint()) + } + return options +} + +// DNSProvider contains the configuration for a DNS provider in the bdns package +// which supports dynamic reloading of its backends. +type DNSProvider struct { + // DNSAuthority is the single : of the DNS + // server to be used for resolution of DNS backends. If the address contains + // a hostname it will be resolved via the system DNS. If the port is left + // unspecified it will default to '53'. If this field is left unspecified + // the system DNS will be used for resolution of DNS backends. + DNSAuthority string `validate:"required,ip|hostname|hostname_port"` + + // SRVLookup contains the service and domain name used to construct a SRV + // DNS query to lookup DNS backends. 'Domain' is required. 'Service' is + // optional and will be defaulted to 'dns' if left unspecified. + // + // Usage: If the resource record is 'unbound.service.consul', then the + // 'Service' is 'unbound' and the 'Domain' is 'service.consul'. The expected + // dNSName to be authenticated in the server certificate would be + // 'unbound.service.consul'. The 'proto' field of the SRV record MUST + // contain 'udp' and the 'port' field MUST be a valid port. In a Consul + // configuration file you would specify 'unbound.service.consul' as: + // + // services { + // id = "unbound-1" // Must be unique + // name = "unbound" + // address = "10.77.77.77" + // port = 8053 + // tags = ["udp"] + // } + // + // services { + // id = "unbound-2" // Must be unique + // name = "unbound" + // address = "10.77.77.77" + // port = 8153 + // tags = ["udp"] + // } + // + // If you've added the above to your Consul configuration file (and reloaded + // Consul) then you should be able to resolve the following dig query: + // + // $ dig @10.55.55.10 -t SRV _unbound._udp.service.consul +short + // 1 1 8053 0a4d4d4d.addr.dc1.consul. + // 1 1 8153 0a4d4d4d.addr.dc1.consul. + SRVLookup ServiceDomain `validate:"required"` +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/config_test.go b/third-party/github.com/letsencrypt/boulder/cmd/config_test.go new file mode 100644 index 000000000..b6eeb9860 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/config_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path" + "regexp" + "strings" + "testing" + "time" + + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func TestDBConfigURL(t *testing.T) { + tests := []struct { + conf DBConfig + expected string + }{ + { + // Test with one config file that has no trailing newline + conf: DBConfig{DBConnectFile: "testdata/test_dburl"}, + expected: "test@tcp(testhost:3306)/testDB?readTimeout=800ms&writeTimeout=800ms", + }, + { + // Test with a config file that *has* a trailing newline + conf: DBConfig{DBConnectFile: "testdata/test_dburl_newline"}, + expected: "test@tcp(testhost:3306)/testDB?readTimeout=800ms&writeTimeout=800ms", + }, + } + + for _, tc := range tests { + url, err := tc.conf.URL() + test.AssertNotError(t, err, "Failed calling URL() on DBConfig") + test.AssertEquals(t, url, tc.expected) + } +} + +func TestPasswordConfig(t *testing.T) { + tests := []struct { + pc PasswordConfig + expected string + }{ + {pc: PasswordConfig{}, expected: ""}, + {pc: PasswordConfig{PasswordFile: "testdata/test_secret"}, expected: "secret"}, + } + + for _, tc := range tests { + password, err := tc.pc.Pass() + test.AssertNotError(t, err, "Failed to retrieve password") + test.AssertEquals(t, password, tc.expected) + } +} + +func TestTLSConfigLoad(t *testing.T) { + null := "/dev/null" + nonExistent := "[nonexistent]" + tmp := t.TempDir() + cert := path.Join(tmp, "TestTLSConfigLoad.cert.pem") + key := path.Join(tmp, "TestTLSConfigLoad.key.pem") + caCert := path.Join(tmp, "TestTLSConfigLoad.cacert.pem") + + rootKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + test.AssertNotError(t, err, "creating test root key") + rootTemplate := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test root"}, + SerialNumber: big.NewInt(12345), + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + } + rootCert, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, rootKey.Public(), rootKey) + test.AssertNotError(t, err, "creating test root cert") + err = os.WriteFile(caCert, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert}), os.ModeAppend) + test.AssertNotError(t, err, "writing test root cert to disk") + + intKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + test.AssertNotError(t, err, "creating test intermediate key") + intKeyBytes, err := x509.MarshalECPrivateKey(intKey) + test.AssertNotError(t, err, "marshalling test intermediate key") + err = os.WriteFile(key, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: intKeyBytes}), os.ModeAppend) + test.AssertNotError(t, err, "writing test intermediate key cert to disk") + + intTemplate := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test intermediate"}, + SerialNumber: big.NewInt(67890), + NotBefore: time.Now().Add(-12 * time.Hour), + NotAfter: time.Now().Add(12 * time.Hour), + IsCA: true, + } + intCert, err := x509.CreateCertificate(rand.Reader, intTemplate, rootTemplate, intKey.Public(), rootKey) + test.AssertNotError(t, err, "creating test intermediate cert") + err = os.WriteFile(cert, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intCert}), os.ModeAppend) + test.AssertNotError(t, err, "writing test intermediate cert to disk") + + testCases := []struct { + TLSConfig + want string + }{ + {TLSConfig{"", null, null}, "nil CertFile in TLSConfig"}, + {TLSConfig{null, "", null}, "nil KeyFile in TLSConfig"}, + {TLSConfig{null, null, ""}, "nil CACertFile in TLSConfig"}, + {TLSConfig{nonExistent, key, caCert}, "loading key pair.*no such file or directory"}, + {TLSConfig{cert, nonExistent, caCert}, "loading key pair.*no such file or directory"}, + {TLSConfig{cert, key, nonExistent}, "reading CA cert from.*no such file or directory"}, + {TLSConfig{null, key, caCert}, "loading key pair.*failed to find any PEM data"}, + {TLSConfig{cert, null, caCert}, "loading key pair.*failed to find any PEM data"}, + {TLSConfig{cert, key, null}, "parsing CA certs"}, + {TLSConfig{cert, key, caCert}, ""}, + } + for _, tc := range testCases { + title := [3]string{tc.CertFile, tc.KeyFile, tc.CACertFile} + for i := range title { + if title[i] == "" { + title[i] = "nil" + } + } + t.Run(strings.Join(title[:], "_"), func(t *testing.T) { + _, err := tc.TLSConfig.Load(metrics.NoopRegisterer) + if err == nil && tc.want == "" { + return + } + if err == nil { + t.Errorf("got no error") + } + if matched, _ := regexp.MatchString(tc.want, err.Error()); !matched { + t.Errorf("got error %q, wanted %q", err, tc.want) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/README.md b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/README.md new file mode 100644 index 000000000..39083c894 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/README.md @@ -0,0 +1,84 @@ +# Contact-Auditor + +Audits subscriber registrations for e-mail addresses that +`notify-mailer` is currently configured to skip. + +# Usage: + +```shell + -config string + File containing a JSON config. + -to-file + Write the audit results to a file. + -to-stdout + Print the audit results to stdout. +``` + +## Results format: + +``` + "" "" +``` + +## Example output: + +### Successful run with no violations encountered and `--to-file`: + +``` +I004823 contact-auditor nfWK_gM Running contact-auditor +I004823 contact-auditor qJ_zsQ4 Beginning database query +I004823 contact-auditor je7V9QM Query completed successfully +I004823 contact-auditor 7LzGvQI Audit finished successfully +I004823 contact-auditor 5Pbk_QM Audit results were written to: audit-2006-01-02T15:04.tsv +``` + +### Contact contains entries that violate policy and `--to-stdout`: + +``` +I004823 contact-auditor nfWK_gM Running contact-auditor +I004823 contact-auditor qJ_zsQ4 Beginning database query +I004823 contact-auditor je7V9QM Query completed successfully +1 2006-01-02 15:04:05 validation "" "" +... +I004823 contact-auditor 2fv7-QY Audit finished successfully +``` + +### Contact is not valid JSON and `--to-stdout`: + +``` +I004823 contact-auditor nfWK_gM Running contact-auditor +I004823 contact-auditor qJ_zsQ4 Beginning database query +I004823 contact-auditor je7V9QM Query completed successfully +3 2006-01-02 15:04:05 unmarshal "" "" +... +I004823 contact-auditor 2fv7-QY Audit finished successfully +``` + +### Audit incomplete, query ended prematurely: + +``` +I004823 contact-auditor nfWK_gM Running contact-auditor +I004823 contact-auditor qJ_zsQ4 Beginning database query +... +E004823 contact-auditor 8LmTgww [AUDIT] Audit was interrupted, results may be incomplete: +exit status 1 +``` + +# Configuration file: +The path to a database config file like the one below must be provided +following the `-config` flag. + +```json +{ + "contactAuditor": { + "db": { + "dbConnectFile": , + "maxOpenConns": , + "maxIdleConns": , + "connMaxLifetime": , + "connMaxIdleTime": + } + } + } + +``` diff --git a/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main.go b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main.go new file mode 100644 index 000000000..d6b366b6b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main.go @@ -0,0 +1,212 @@ +package notmain + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/sa" +) + +type contactAuditor struct { + db *db.WrappedMap + resultsFile *os.File + writeToStdout bool + logger blog.Logger +} + +type result struct { + id int64 + contacts []string + createdAt string +} + +func unmarshalContact(contact []byte) ([]string, error) { + var contacts []string + err := json.Unmarshal(contact, &contacts) + if err != nil { + return nil, err + } + return contacts, nil +} + +func validateContacts(id int64, createdAt string, contacts []string) error { + // Setup a buffer to store any validation problems we encounter. + var probsBuff strings.Builder + + // Helper to write validation problems to our buffer. + writeProb := func(contact string, prob string) { + // Add validation problem to buffer. + fmt.Fprintf(&probsBuff, "%d\t%s\tvalidation\t%q\t%q\t%q\n", id, createdAt, contact, prob, contacts) + } + + for _, contact := range contacts { + if strings.HasPrefix(contact, "mailto:") { + err := policy.ValidEmail(strings.TrimPrefix(contact, "mailto:")) + if err != nil { + writeProb(contact, err.Error()) + } + } else { + writeProb(contact, "missing 'mailto:' prefix") + } + } + + if probsBuff.Len() != 0 { + return errors.New(probsBuff.String()) + } + return nil +} + +// beginAuditQuery executes the audit query and returns a cursor used to +// stream the results. +func (c contactAuditor) beginAuditQuery(ctx context.Context) (*sql.Rows, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT DISTINCT id, contact, createdAt + FROM registrations + WHERE contact NOT IN ('[]', 'null');`) + if err != nil { + return nil, err + } + return rows, nil +} + +func (c contactAuditor) writeResults(result string) { + if c.writeToStdout { + _, err := fmt.Print(result) + if err != nil { + c.logger.Errf("Error while writing result to stdout: %s", err) + } + } + + if c.resultsFile != nil { + _, err := c.resultsFile.WriteString(result) + if err != nil { + c.logger.Errf("Error while writing result to file: %s", err) + } + } +} + +// run retrieves a cursor from `beginAuditQuery` and then audits the +// `contact` column of all returned rows for abnormalities or policy +// violations. +func (c contactAuditor) run(ctx context.Context, resChan chan *result) error { + c.logger.Infof("Beginning database query") + rows, err := c.beginAuditQuery(ctx) + if err != nil { + return err + } + + for rows.Next() { + var id int64 + var contact []byte + var createdAt string + err := rows.Scan(&id, &contact, &createdAt) + if err != nil { + return err + } + + contacts, err := unmarshalContact(contact) + if err != nil { + c.writeResults(fmt.Sprintf("%d\t%s\tunmarshal\t%q\t%q\n", id, createdAt, contact, err)) + } + + err = validateContacts(id, createdAt, contacts) + if err != nil { + c.writeResults(err.Error()) + } + + // Only used for testing. + if resChan != nil { + resChan <- &result{id, contacts, createdAt} + } + } + // Ensure the query wasn't interrupted before it could complete. + err = rows.Close() + if err != nil { + return err + } else { + c.logger.Info("Query completed successfully") + } + + // Only used for testing. + if resChan != nil { + close(resChan) + } + + return nil +} + +type Config struct { + ContactAuditor struct { + DB cmd.DBConfig + } +} + +func main() { + configFile := flag.String("config", "", "File containing a JSON config.") + writeToStdout := flag.Bool("to-stdout", false, "Print the audit results to stdout.") + writeToFile := flag.Bool("to-file", false, "Write the audit results to a file.") + flag.Parse() + + logger := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7}) + logger.Info(cmd.VersionString()) + + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + // Load config from JSON. + configData, err := os.ReadFile(*configFile) + cmd.FailOnError(err, fmt.Sprintf("Error reading config file: %q", *configFile)) + + var cfg Config + err = json.Unmarshal(configData, &cfg) + cmd.FailOnError(err, "Couldn't unmarshal config") + + db, err := sa.InitWrappedDb(cfg.ContactAuditor.DB, nil, logger) + cmd.FailOnError(err, "Couldn't setup database client") + + var resultsFile *os.File + if *writeToFile { + resultsFile, err = os.Create( + fmt.Sprintf("contact-audit-%s.tsv", time.Now().Format("2006-01-02T15:04")), + ) + cmd.FailOnError(err, "Failed to create results file") + } + + // Setup and run contact-auditor. + auditor := contactAuditor{ + db: db, + resultsFile: resultsFile, + writeToStdout: *writeToStdout, + logger: logger, + } + + logger.Info("Running contact-auditor") + + err = auditor.run(context.TODO(), nil) + cmd.FailOnError(err, "Audit was interrupted, results may be incomplete") + + logger.Info("Audit finished successfully") + + if *writeToFile { + logger.Infof("Audit results were written to: %s", resultsFile.Name()) + resultsFile.Close() + } + +} + +func init() { + cmd.RegisterCommand("contact-auditor", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main_test.go new file mode 100644 index 000000000..c9c2a2edf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/contact-auditor/main_test.go @@ -0,0 +1,219 @@ +package notmain + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +var ( + regA *corepb.Registration + regB *corepb.Registration + regC *corepb.Registration + regD *corepb.Registration +) + +const ( + emailARaw = "test@example.com" + emailBRaw = "example@notexample.com" + emailCRaw = "test-example@notexample.com" + telNum = "666-666-7777" +) + +func TestContactAuditor(t *testing.T) { + testCtx := setup(t) + defer testCtx.cleanUp() + + // Add some test registrations. + testCtx.addRegistrations(t) + + resChan := make(chan *result, 10) + err := testCtx.c.run(context.Background(), resChan) + test.AssertNotError(t, err, "received error") + + // We should get back A, B, C, and D + test.AssertEquals(t, len(resChan), 4) + for entry := range resChan { + err := validateContacts(entry.id, entry.createdAt, entry.contacts) + switch entry.id { + case regA.Id: + // Contact validation policy sad path. + test.AssertDeepEquals(t, entry.contacts, []string{"mailto:test@example.com"}) + test.AssertError(t, err, "failed to error on a contact that violates our e-mail policy") + case regB.Id: + // Ensure grace period was respected. + test.AssertDeepEquals(t, entry.contacts, []string{"mailto:example@notexample.com"}) + test.AssertNotError(t, err, "received error for a valid contact entry") + case regC.Id: + // Contact validation happy path. + test.AssertDeepEquals(t, entry.contacts, []string{"mailto:test-example@notexample.com"}) + test.AssertNotError(t, err, "received error for a valid contact entry") + + // Unmarshal Contact sad path. + _, err := unmarshalContact([]byte("[ mailto:test@example.com ]")) + test.AssertError(t, err, "failed to error while unmarshaling invalid Contact JSON") + + // Fix our JSON and ensure that the contact field returns + // errors for our 2 additional contacts + contacts, err := unmarshalContact([]byte(`[ "mailto:test@example.com", "tel:666-666-7777" ]`)) + test.AssertNotError(t, err, "received error while unmarshaling valid Contact JSON") + + // Ensure Contact validation now fails. + err = validateContacts(entry.id, entry.createdAt, contacts) + test.AssertError(t, err, "failed to error on 2 invalid Contact entries") + case regD.Id: + test.AssertDeepEquals(t, entry.contacts, []string{"tel:666-666-7777"}) + test.AssertError(t, err, "failed to error on an invalid contact entry") + default: + t.Errorf("ID: %d was not expected", entry.id) + } + } + + // Load results file. + data, err := os.ReadFile(testCtx.c.resultsFile.Name()) + if err != nil { + t.Error(err) + } + + // Results file should contain 2 newlines, 1 for each result. + contentLines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + test.AssertEquals(t, len(contentLines), 2) + + // Each result entry should contain six tab separated columns. + for _, line := range contentLines { + test.AssertEquals(t, len(strings.Split(line, "\t")), 6) + } +} + +type testCtx struct { + c contactAuditor + dbMap *db.WrappedMap + ssa *sa.SQLStorageAuthority + cleanUp func() +} + +func (tc testCtx) addRegistrations(t *testing.T) { + emailA := "mailto:" + emailARaw + emailB := "mailto:" + emailBRaw + emailC := "mailto:" + emailCRaw + tel := "tel:" + telNum + + // Every registration needs a unique JOSE key + jsonKeyA := []byte(`{ + "kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB" +}`) + jsonKeyB := []byte(`{ + "kty":"RSA", + "n":"z8bp-jPtHt4lKBqepeKF28g_QAEOuEsCIou6sZ9ndsQsEjxEOQxQ0xNOQezsKa63eogw8YS3vzjUcPP5BJuVzfPfGd5NVUdT-vSSwxk3wvk_jtNqhrpcoG0elRPQfMVsQWmxCAXCVRz3xbcFI8GTe-syynG3l-g1IzYIIZVNI6jdljCZML1HOMTTW4f7uJJ8mM-08oQCeHbr5ejK7O2yMSSYxW03zY-Tj1iVEebROeMv6IEEJNFSS4yM-hLpNAqVuQxFGetwtwjDMC1Drs1dTWrPuUAAjKGrP151z1_dE74M5evpAhZUmpKv1hY-x85DC6N0hFPgowsanmTNNiV75w", + "e":"AAEAAQ" +}`) + jsonKeyC := []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-sCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" +}`) + jsonKeyD := []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-FCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" +}`) + + initialIP, err := net.ParseIP("127.0.0.1").MarshalText() + test.AssertNotError(t, err, "Couldn't create initialIP") + + regA = &corepb.Registration{ + Id: 1, + Contact: []string{emailA}, + Key: jsonKeyA, + InitialIP: initialIP, + } + regB = &corepb.Registration{ + Id: 2, + Contact: []string{emailB}, + Key: jsonKeyB, + InitialIP: initialIP, + } + regC = &corepb.Registration{ + Id: 3, + Contact: []string{emailC}, + Key: jsonKeyC, + InitialIP: initialIP, + } + // Reg D has a `tel:` contact ACME URL + regD = &corepb.Registration{ + Id: 4, + Contact: []string{tel}, + Key: jsonKeyD, + InitialIP: initialIP, + } + + // Add the four test registrations + ctx := context.Background() + regA, err = tc.ssa.NewRegistration(ctx, regA) + test.AssertNotError(t, err, "Couldn't store regA") + regB, err = tc.ssa.NewRegistration(ctx, regB) + test.AssertNotError(t, err, "Couldn't store regB") + regC, err = tc.ssa.NewRegistration(ctx, regC) + test.AssertNotError(t, err, "Couldn't store regC") + regD, err = tc.ssa.NewRegistration(ctx, regD) + test.AssertNotError(t, err, "Couldn't store regD") +} + +func setup(t *testing.T) testCtx { + log := blog.UseMock() + + // Using DBConnSAFullPerms to be able to insert registrations and + // certificates + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + if err != nil { + t.Fatalf("Couldn't connect to the database: %s", err) + } + + // Make temp results file + file, err := os.CreateTemp("", fmt.Sprintf("audit-%s", time.Now().Format("2006-01-02T15:04"))) + if err != nil { + t.Fatal(err) + } + + cleanUp := func() { + test.ResetBoulderTestDatabase(t) + file.Close() + os.Remove(file.Name()) + } + + db, err := sa.DBMapForTest(vars.DBConnSAMailer) + if err != nil { + t.Fatalf("Couldn't connect to the database: %s", err) + } + + ssa, err := sa.NewSQLStorageAuthority(dbMap, dbMap, nil, 1, 0, clock.New(), log, metrics.NoopRegisterer) + if err != nil { + t.Fatalf("unable to create SQLStorageAuthority: %s", err) + } + + return testCtx{ + c: contactAuditor{ + db: db, + resultsFile: file, + logger: blog.NewMock(), + }, + dbMap: dbMap, + ssa: ssa, + cleanUp: cleanUp, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/crl-checker/main.go b/third-party/github.com/letsencrypt/boulder/cmd/crl-checker/main.go new file mode 100644 index 000000000..fca7a3adc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/crl-checker/main.go @@ -0,0 +1,149 @@ +package notmain + +import ( + "crypto/x509" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/crl/checker" +) + +func downloadShard(url string) (*x509.RevocationList, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("downloading crl: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading crl: http status %d", resp.StatusCode) + } + + crlBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading CRL bytes: %w", err) + } + + crl, err := x509.ParseRevocationList(crlBytes) + if err != nil { + return nil, fmt.Errorf("parsing CRL: %w", err) + } + + return crl, nil +} + +func main() { + urlFile := flag.String("crls", "", "path to a file containing a JSON Array of CRL URLs") + issuerFile := flag.String("issuer", "", "path to an issuer certificate on disk, required, '-' to disable validation") + ageLimitStr := flag.String("ageLimit", "168h", "maximum allowable age of a CRL shard") + emitRevoked := flag.Bool("emitRevoked", false, "emit revoked serial numbers on stdout, one per line, hex-encoded") + save := flag.Bool("save", false, "save CRLs to files named after the URL") + flag.Parse() + + logger := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 6, SyslogLevel: -1}) + logger.Info(cmd.VersionString()) + + urlFileContents, err := os.ReadFile(*urlFile) + cmd.FailOnError(err, "Reading CRL URLs file") + + var urls []string + err = json.Unmarshal(urlFileContents, &urls) + cmd.FailOnError(err, "Parsing JSON Array of CRL URLs") + + if *issuerFile == "" { + cmd.Fail("-issuer is required, but may be '-' to disable validation") + } + + var issuer *x509.Certificate + if *issuerFile != "-" { + issuer, err = core.LoadCert(*issuerFile) + cmd.FailOnError(err, "Loading issuer certificate") + } else { + logger.Warning("CRL signature validation disabled") + } + + ageLimit, err := time.ParseDuration(*ageLimitStr) + cmd.FailOnError(err, "Parsing age limit") + + errCount := 0 + seenSerials := make(map[string]struct{}) + totalBytes := 0 + oldestTimestamp := time.Time{} + for _, u := range urls { + crl, err := downloadShard(u) + if err != nil { + errCount += 1 + logger.Errf("fetching CRL %q failed: %s", u, err) + continue + } + + if *save { + parsedURL, err := url.Parse(u) + if err != nil { + logger.Errf("parsing url: %s", err) + continue + } + filename := fmt.Sprintf("%s%s", parsedURL.Host, strings.ReplaceAll(parsedURL.Path, "/", "_")) + err = os.WriteFile(filename, crl.Raw, 0660) + if err != nil { + logger.Errf("writing file: %s", err) + continue + } + } + + totalBytes += len(crl.Raw) + + zcrl, err := x509.ParseRevocationList(crl.Raw) + if err != nil { + errCount += 1 + logger.Errf("parsing CRL %q failed: %s", u, err) + continue + } + + err = checker.Validate(zcrl, issuer, ageLimit) + if err != nil { + errCount += 1 + logger.Errf("checking CRL %q failed: %s", u, err) + continue + } + + if oldestTimestamp.IsZero() || crl.ThisUpdate.Before(oldestTimestamp) { + oldestTimestamp = crl.ThisUpdate + } + + for _, c := range crl.RevokedCertificateEntries { + serial := core.SerialToString(c.SerialNumber) + if _, seen := seenSerials[serial]; seen { + errCount += 1 + logger.Errf("serial seen in multiple shards: %s", serial) + continue + } + seenSerials[serial] = struct{}{} + } + } + + if *emitRevoked { + for serial := range seenSerials { + fmt.Println(serial) + } + } + + if errCount != 0 { + cmd.Fail(fmt.Sprintf("Encountered %d errors", errCount)) + } + + logger.AuditInfof( + "Validated %d CRLs, %d serials, %d bytes. Oldest CRL: %s", + len(urls), len(seenSerials), totalBytes, oldestTimestamp.Format(time.RFC3339)) +} + +func init() { + cmd.RegisterCommand("crl-checker", main, nil) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/crl-storer/main.go b/third-party/github.com/letsencrypt/boulder/cmd/crl-storer/main.go new file mode 100644 index 000000000..4dddfaa9f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/crl-storer/main.go @@ -0,0 +1,144 @@ +package notmain + +import ( + "context" + "flag" + "net/http" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + awsl "github.com/aws/smithy-go/logging" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/crl/storer" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" +) + +type Config struct { + CRLStorer struct { + cmd.ServiceConfig + + // IssuerCerts is a list of paths to issuer certificates on disk. These will + // be used to validate the CRLs received by this service before uploading + // them. + IssuerCerts []string `validate:"min=1,dive,required"` + + // S3Endpoint is the URL at which the S3-API-compatible object storage + // service can be reached. This can be used to point to a non-Amazon storage + // service, or to point to a fake service for testing. It should be left + // blank by default. + S3Endpoint string + // S3Bucket is the AWS Bucket that uploads should go to. Must be created + // (and have appropriate permissions set) beforehand. + S3Bucket string + // AWSConfigFile is the path to a file on disk containing an AWS config. + // The format of the configuration file is specified at + // https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html. + AWSConfigFile string + // AWSCredsFile is the path to a file on disk containing AWS credentials. + // The format of the credentials file is specified at + // https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html. + AWSCredsFile string + + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +// awsLogger implements the github.com/aws/smithy-go/logging.Logger interface. +type awsLogger struct { + blog.Logger +} + +func (log awsLogger) Logf(c awsl.Classification, format string, v ...interface{}) { + switch c { + case awsl.Debug: + log.Debugf(format, v...) + case awsl.Warn: + log.Warningf(format, v...) + } +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.CRLStorer.Features) + + if *grpcAddr != "" { + c.CRLStorer.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.CRLStorer.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.CRLStorer.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + clk := cmd.Clock() + + tlsConfig, err := c.CRLStorer.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + issuers := make([]*issuance.Certificate, 0, len(c.CRLStorer.IssuerCerts)) + for _, filepath := range c.CRLStorer.IssuerCerts { + cert, err := issuance.LoadCertificate(filepath) + cmd.FailOnError(err, "Failed to load issuer cert") + issuers = append(issuers, cert) + } + + // Load the "default" AWS configuration, but override the set of config and + // credential files it reads from to just those specified in our JSON config, + // to ensure that it's not accidentally reading anything from the homedir or + // its other default config locations. + awsConfig, err := config.LoadDefaultConfig( + context.Background(), + config.WithSharedConfigFiles([]string{c.CRLStorer.AWSConfigFile}), + config.WithSharedCredentialsFiles([]string{c.CRLStorer.AWSCredsFile}), + config.WithHTTPClient(new(http.Client)), + config.WithLogger(awsLogger{logger}), + config.WithClientLogMode(aws.LogRequestEventMessage|aws.LogResponseEventMessage), + ) + cmd.FailOnError(err, "Failed to load AWS config") + + s3opts := make([]func(*s3.Options), 0) + if c.CRLStorer.S3Endpoint != "" { + s3opts = append( + s3opts, + s3.WithEndpointResolver(s3.EndpointResolverFromURL(c.CRLStorer.S3Endpoint)), + func(o *s3.Options) { o.UsePathStyle = true }, + ) + } + s3client := s3.NewFromConfig(awsConfig, s3opts...) + + csi, err := storer.New(issuers, s3client, c.CRLStorer.S3Bucket, scope, logger, clk) + cmd.FailOnError(err, "Failed to create CRLStorer impl") + + start, err := bgrpc.NewServer(c.CRLStorer.GRPC, logger).Add( + &cspb.CRLStorer_ServiceDesc, csi).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup CRLStorer gRPC server") + + cmd.FailOnError(start(), "CRLStorer gRPC service failed") +} + +func init() { + cmd.RegisterCommand("crl-storer", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/crl-updater/main.go b/third-party/github.com/letsencrypt/boulder/cmd/crl-updater/main.go new file mode 100644 index 000000000..23032f130 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/crl-updater/main.go @@ -0,0 +1,206 @@ +package notmain + +import ( + "context" + "errors" + "flag" + "os" + "time" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/crl/updater" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type Config struct { + CRLUpdater struct { + DebugAddr string `validate:"omitempty,hostname_port"` + + // TLS client certificate, private key, and trusted root bundle. + TLS cmd.TLSConfig + + SAService *cmd.GRPCClientConfig + CRLGeneratorService *cmd.GRPCClientConfig + CRLStorerService *cmd.GRPCClientConfig + + // IssuerCerts is a list of paths to issuer certificates on disk. This + // controls the set of CRLs which will be published by this updater: it will + // publish one set of NumShards CRL shards for each issuer in this list. + IssuerCerts []string `validate:"min=1,dive,required"` + + // NumShards is the number of shards into which each issuer's "full and + // complete" CRL will be split. + // WARNING: When this number is changed, the "JSON Array of CRL URLs" field + // in CCADB MUST be updated. + NumShards int `validate:"min=1"` + + // ShardWidth is the amount of time (width on a timeline) that a single + // shard should cover. Ideally, NumShards*ShardWidth should be an amount of + // time noticeably larger than the current longest certificate lifetime, + // but the updater will continue to work if this is not the case (albeit + // with more confusing mappings of serials to shards). + // WARNING: When this number is changed, revocation entries will move + // between shards. + ShardWidth config.Duration `validate:"-"` + + // LookbackPeriod is how far back the updater should look for revoked expired + // certificates. We are required to include every revoked cert in at least + // one CRL, even if it is revoked seconds before it expires, so this must + // always be greater than the UpdatePeriod, and should be increased when + // recovering from an outage to ensure continuity of coverage. + LookbackPeriod config.Duration `validate:"-"` + + // CertificateLifetime is the validity period (usually expressed in hours, + // like "2160h") of the longest-lived currently-unexpired certificate. For + // Let's Encrypt, this is usually ninety days. If the validity period of + // the issued certificates ever changes upwards, this value must be updated + // immediately; if the validity period of the issued certificates ever + // changes downwards, the value must not change until after all certificates with + // the old validity period have expired. + // Deprecated: This config value is no longer used. + // TODO(#6438): Remove this value. + CertificateLifetime config.Duration `validate:"-"` + + // UpdatePeriod controls how frequently the crl-updater runs and publishes + // new versions of every CRL shard. The Baseline Requirements, Section 4.9.7 + // state that this MUST NOT be more than 7 days. We believe that future + // updates may require that this not be more than 24 hours, and currently + // recommend an UpdatePeriod of 6 hours. + UpdatePeriod config.Duration + + // UpdateOffset controls the times at which crl-updater runs, to avoid + // scheduling the batch job at exactly midnight. The updater runs every + // UpdatePeriod, starting from the Unix Epoch plus UpdateOffset, and + // continuing forward into the future forever. This value must be strictly + // less than the UpdatePeriod. + // Deprecated: This config value is not relevant with continuous updating. + // TODO(#7023): Remove this value. + UpdateOffset config.Duration `validate:"-"` + + // UpdateTimeout controls how long a single CRL shard is allowed to attempt + // to update before being timed out. The total CRL updating process may take + // significantly longer, since a full update cycle may consist of updating + // many shards with varying degrees of parallelism. This value must be + // strictly less than the UpdatePeriod. Defaults to 10 minutes, one order + // of magnitude greater than our p99 update latency. + UpdateTimeout config.Duration `validate:"-"` + + // MaxParallelism controls how many workers may be running in parallel. + // A higher value reduces the total time necessary to update all CRL shards + // that this updater is responsible for, but also increases the memory used + // by this updater. Only relevant in -runOnce mode. + MaxParallelism int `validate:"min=0"` + + // MaxAttempts control how many times the updater will attempt to generate + // a single CRL shard. A higher number increases the likelihood of a fully + // successful run, but also increases the worst-case runtime and db/network + // load of said run. The default is 1. + MaxAttempts int `validate:"omitempty,min=1"` + + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + configFile := flag.String("config", "", "File path to the configuration file for this service") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + runOnce := flag.Bool("runOnce", false, "If true, run once immediately and then exit") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + if *debugAddr != "" { + c.CRLUpdater.DebugAddr = *debugAddr + } + + features.Set(c.CRLUpdater.Features) + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.CRLUpdater.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + clk := cmd.Clock() + + tlsConfig, err := c.CRLUpdater.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + issuers := make([]*issuance.Certificate, 0, len(c.CRLUpdater.IssuerCerts)) + for _, filepath := range c.CRLUpdater.IssuerCerts { + cert, err := issuance.LoadCertificate(filepath) + cmd.FailOnError(err, "Failed to load issuer cert") + issuers = append(issuers, cert) + } + + if c.CRLUpdater.ShardWidth.Duration == 0 { + c.CRLUpdater.ShardWidth.Duration = 16 * time.Hour + } + if c.CRLUpdater.LookbackPeriod.Duration == 0 { + c.CRLUpdater.LookbackPeriod.Duration = 24 * time.Hour + } + if c.CRLUpdater.UpdateTimeout.Duration == 0 { + c.CRLUpdater.UpdateTimeout.Duration = 10 * time.Minute + } + + saConn, err := bgrpc.ClientSetup(c.CRLUpdater.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac := sapb.NewStorageAuthorityClient(saConn) + + caConn, err := bgrpc.ClientSetup(c.CRLUpdater.CRLGeneratorService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to CRLGenerator") + cac := capb.NewCRLGeneratorClient(caConn) + + csConn, err := bgrpc.ClientSetup(c.CRLUpdater.CRLStorerService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to CRLStorer") + csc := cspb.NewCRLStorerClient(csConn) + + u, err := updater.NewUpdater( + issuers, + c.CRLUpdater.NumShards, + c.CRLUpdater.ShardWidth.Duration, + c.CRLUpdater.LookbackPeriod.Duration, + c.CRLUpdater.UpdatePeriod.Duration, + c.CRLUpdater.UpdateTimeout.Duration, + c.CRLUpdater.MaxParallelism, + c.CRLUpdater.MaxAttempts, + sac, + cac, + csc, + scope, + logger, + clk, + ) + cmd.FailOnError(err, "Failed to create crl-updater") + + ctx, cancel := context.WithCancel(context.Background()) + go cmd.CatchSignals(cancel) + + if *runOnce { + err = u.RunOnce(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + cmd.FailOnError(err, "") + } + } else { + err = u.Run(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + cmd.FailOnError(err, "") + } + } +} + +func init() { + cmd.RegisterCommand("crl-updater", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main.go b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main.go new file mode 100644 index 000000000..46fa939a6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main.go @@ -0,0 +1,968 @@ +package notmain + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "errors" + "flag" + "fmt" + "math" + netmail "net/mail" + "net/url" + "os" + "sort" + "strings" + "sync" + "text/template" + "time" + + "github.com/jmhodges/clock" + "google.golang.org/grpc" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" + bmail "github.com/letsencrypt/boulder/mail" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +const ( + defaultExpirationSubject = "Let's Encrypt certificate expiration notice for domain {{.ExpirationSubject}}" +) + +var ( + errNoValidEmail = errors.New("no usable contact address") +) + +type regStore interface { + GetRegistration(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) +} + +// limiter tracks how many mails we've sent to a given address in a given day. +// Note that this does not track mails across restarts of the process. +// Modifications to `counts` and `currentDay` are protected by a mutex. +type limiter struct { + sync.RWMutex + // currentDay is a day in UTC, truncated to 24 hours. When the current + // time is more than 24 hours past this date, all counts reset and this + // date is updated. + currentDay time.Time + + // counts is a map from address to number of mails we have attempted to + // send during `currentDay`. + counts map[string]int + + // limit is the number of sends after which we'll return an error from + // check() + limit int + + clk clock.Clock +} + +const oneDay = 24 * time.Hour + +// maybeBumpDay updates lim.currentDay if its current value is more than 24 +// hours ago, and resets the counts map. Expects limiter is locked. +func (lim *limiter) maybeBumpDay() { + today := lim.clk.Now().Truncate(oneDay) + if (today.Sub(lim.currentDay) >= oneDay && len(lim.counts) > 0) || + lim.counts == nil { + // Throw away counts so far and switch to a new day. + // This also does the initialization of counts and currentDay the first + // time inc() is called. + lim.counts = make(map[string]int) + lim.currentDay = today + } +} + +// inc increments the count for the current day, and cleans up previous days +// if needed. +func (lim *limiter) inc(address string) { + lim.Lock() + defer lim.Unlock() + + lim.maybeBumpDay() + + lim.counts[address] += 1 +} + +// check checks whether the count for the given address is at the limit, +// and returns an error if so. +func (lim *limiter) check(address string) error { + lim.RLock() + defer lim.RUnlock() + + lim.maybeBumpDay() + if lim.counts[address] >= lim.limit { + return fmt.Errorf("daily mail limit exceeded for %q", address) + } + return nil +} + +type mailer struct { + log blog.Logger + dbMap *db.WrappedMap + rs regStore + mailer bmail.Mailer + emailTemplate *template.Template + subjectTemplate *template.Template + nagTimes []time.Duration + parallelSends uint + certificatesPerTick int + // addressLimiter limits how many mails we'll send to a single address in + // a single day. + addressLimiter *limiter + // Maximum number of rows to update in a single SQL UPDATE statement. + updateChunkSize int + clk clock.Clock + stats mailerStats +} + +type certDERWithRegID struct { + DER core.CertDER + RegID int64 +} + +type mailerStats struct { + sendDelay *prometheus.GaugeVec + sendDelayHistogram *prometheus.HistogramVec + nagsAtCapacity *prometheus.GaugeVec + errorCount *prometheus.CounterVec + sendLatency prometheus.Histogram + processingLatency prometheus.Histogram + certificatesExamined prometheus.Counter + certificatesAlreadyRenewed prometheus.Counter + certificatesPerAccountNeedingMail prometheus.Histogram +} + +func (m *mailer) sendNags(conn bmail.Conn, contacts []string, certs []*x509.Certificate) error { + if len(certs) == 0 { + return errors.New("no certs given to send nags for") + } + emails := []string{} + for _, contact := range contacts { + parsed, err := url.Parse(contact) + if err != nil { + m.log.Errf("parsing contact email %s: %s", contact, err) + continue + } + if parsed.Scheme != "mailto" { + continue + } + address := parsed.Opaque + err = policy.ValidEmail(address) + if err != nil { + m.log.Debugf("skipping invalid email %q: %s", address, err) + continue + } + err = m.addressLimiter.check(address) + if err != nil { + m.log.Infof("not sending mail: %s", err) + continue + } + m.addressLimiter.inc(address) + emails = append(emails, parsed.Opaque) + } + if len(emails) == 0 { + return errNoValidEmail + } + + expiresIn := time.Duration(math.MaxInt64) + expDate := m.clk.Now() + domains := []string{} + serials := []string{} + + // Pick out the expiration date that is closest to being hit. + for _, cert := range certs { + domains = append(domains, cert.DNSNames...) + serials = append(serials, core.SerialToString(cert.SerialNumber)) + possible := cert.NotAfter.Sub(m.clk.Now()) + if possible < expiresIn { + expiresIn = possible + expDate = cert.NotAfter + } + } + domains = core.UniqueLowerNames(domains) + sort.Strings(domains) + + const maxSerials = 100 + truncatedSerials := serials + if len(truncatedSerials) > maxSerials { + truncatedSerials = serials[0:maxSerials] + } + + const maxDomains = 100 + truncatedDomains := domains + if len(truncatedDomains) > maxDomains { + truncatedDomains = domains[0:maxDomains] + } + + // Construct the information about the expiring certificates for use in the + // subject template + expiringSubject := fmt.Sprintf("%q", domains[0]) + if len(domains) > 1 { + expiringSubject += fmt.Sprintf(" (and %d more)", len(domains)-1) + } + + // Execute the subjectTemplate by filling in the ExpirationSubject + subjBuf := new(bytes.Buffer) + err := m.subjectTemplate.Execute(subjBuf, struct { + ExpirationSubject string + }{ + ExpirationSubject: expiringSubject, + }) + if err != nil { + m.stats.errorCount.With(prometheus.Labels{"type": "SubjectTemplateFailure"}).Inc() + return err + } + + email := struct { + ExpirationDate string + DaysToExpiration int + DNSNames string + TruncatedDNSNames string + NumDNSNamesOmitted int + }{ + ExpirationDate: expDate.UTC().Format(time.DateOnly), + DaysToExpiration: int(expiresIn.Hours() / 24), + DNSNames: strings.Join(domains, "\n"), + TruncatedDNSNames: strings.Join(truncatedDomains, "\n"), + NumDNSNamesOmitted: len(domains) - len(truncatedDomains), + } + msgBuf := new(bytes.Buffer) + err = m.emailTemplate.Execute(msgBuf, email) + if err != nil { + m.stats.errorCount.With(prometheus.Labels{"type": "TemplateFailure"}).Inc() + return err + } + + logItem := struct { + Rcpt []string + DaysToExpiration int + TruncatedDNSNames []string + TruncatedSerials []string + }{ + Rcpt: emails, + DaysToExpiration: email.DaysToExpiration, + TruncatedDNSNames: truncatedDomains, + TruncatedSerials: truncatedSerials, + } + logStr, err := json.Marshal(logItem) + if err != nil { + m.log.Errf("logItem could not be serialized to JSON. Raw: %+v", logItem) + return err + } + m.log.Infof("attempting send JSON=%s", string(logStr)) + + startSending := m.clk.Now() + err = conn.SendMail(emails, subjBuf.String(), msgBuf.String()) + if err != nil { + m.log.Errf("failed send JSON=%s err=%s", string(logStr), err) + return err + } + finishSending := m.clk.Now() + elapsed := finishSending.Sub(startSending) + m.stats.sendLatency.Observe(elapsed.Seconds()) + return nil +} + +// updateLastNagTimestamps updates the lastExpirationNagSent column for every cert in +// the given list. Even though it can encounter errors, it only logs them and +// does not return them, because we always prefer to simply continue. +func (m *mailer) updateLastNagTimestamps(ctx context.Context, certs []*x509.Certificate) { + for len(certs) > 0 { + size := len(certs) + if m.updateChunkSize > 0 && size > m.updateChunkSize { + size = m.updateChunkSize + } + chunk := certs[0:size] + certs = certs[size:] + m.updateLastNagTimestampsChunk(ctx, chunk) + } +} + +// updateLastNagTimestampsChunk processes a single chunk (up to 65k) of certificates. +func (m *mailer) updateLastNagTimestampsChunk(ctx context.Context, certs []*x509.Certificate) { + params := make([]interface{}, len(certs)+1) + for i, cert := range certs { + params[i+1] = core.SerialToString(cert.SerialNumber) + } + + query := fmt.Sprintf( + "UPDATE certificateStatus SET lastExpirationNagSent = ? WHERE serial IN (%s)", + db.QuestionMarks(len(certs)), + ) + params[0] = m.clk.Now() + + _, err := m.dbMap.ExecContext(ctx, query, params...) + if err != nil { + m.log.AuditErrf("Error updating certificate status for %d certs: %s", len(certs), err) + m.stats.errorCount.With(prometheus.Labels{"type": "UpdateCertificateStatus"}).Inc() + } +} + +func (m *mailer) certIsRenewed(ctx context.Context, names []string, issued time.Time) (bool, error) { + namehash := core.HashNames(names) + + var present bool + err := m.dbMap.SelectOne( + ctx, + &present, + `SELECT EXISTS (SELECT id FROM fqdnSets WHERE setHash = ? AND issued > ? LIMIT 1)`, + namehash, + issued, + ) + return present, err +} + +type work struct { + regID int64 + certDERs []core.CertDER +} + +func (m *mailer) processCerts( + ctx context.Context, + allCerts []certDERWithRegID, + expiresIn time.Duration, +) error { + regIDToCertDERs := make(map[int64][]core.CertDER) + + for _, cert := range allCerts { + cs := regIDToCertDERs[cert.RegID] + cs = append(cs, cert.DER) + regIDToCertDERs[cert.RegID] = cs + } + + parallelSends := m.parallelSends + if parallelSends == 0 { + parallelSends = 1 + } + + var wg sync.WaitGroup + workChan := make(chan work, len(regIDToCertDERs)) + + // Populate the work chan on a goroutine so work is available as soon + // as one of the sender routines starts. + go func(ch chan<- work) { + for regID, certs := range regIDToCertDERs { + ch <- work{regID, certs} + } + close(workChan) + }(workChan) + + for senderNum := uint(0); senderNum < parallelSends; senderNum++ { + // For politeness' sake, don't open more than 1 new connection per + // second. + if senderNum > 0 { + time.Sleep(time.Second) + } + + if ctx.Err() != nil { + return ctx.Err() + } + + conn, err := m.mailer.Connect() + if err != nil { + m.log.AuditErrf("connecting parallel sender %d: %s", senderNum, err) + return err + } + wg.Add(1) + go func(conn bmail.Conn, ch <-chan work) { + defer wg.Done() + for w := range ch { + err := m.sendToOneRegID(ctx, conn, w.regID, w.certDERs, expiresIn) + if err != nil { + m.log.AuditErr(err.Error()) + } + } + conn.Close() + }(conn, workChan) + } + wg.Wait() + return nil +} + +func (m *mailer) sendToOneRegID(ctx context.Context, conn bmail.Conn, regID int64, certDERs []core.CertDER, expiresIn time.Duration) error { + if ctx.Err() != nil { + return ctx.Err() + } + if len(certDERs) == 0 { + return errors.New("shouldn't happen: empty certificate list in sendToOneRegID") + } + reg, err := m.rs.GetRegistration(ctx, &sapb.RegistrationID{Id: regID}) + if err != nil { + m.stats.errorCount.With(prometheus.Labels{"type": "GetRegistration"}).Inc() + return fmt.Errorf("Error fetching registration %d: %s", regID, err) + } + + parsedCerts := []*x509.Certificate{} + for i, certDER := range certDERs { + if ctx.Err() != nil { + return ctx.Err() + } + parsedCert, err := x509.ParseCertificate(certDER) + if err != nil { + // TODO(#1420): tell registration about this error + m.log.AuditErrf("Error parsing certificate: %s. Body: %x", err, certDER) + m.stats.errorCount.With(prometheus.Labels{"type": "ParseCertificate"}).Inc() + continue + } + + // The histogram version of send delay reports the worst case send delay for + // a single regID in this cycle. + if i == 0 { + sendDelay := expiresIn - parsedCert.NotAfter.Sub(m.clk.Now()) + m.stats.sendDelayHistogram.With(prometheus.Labels{"nag_group": expiresIn.String()}).Observe( + sendDelay.Truncate(time.Second).Seconds()) + } + + renewed, err := m.certIsRenewed(ctx, parsedCert.DNSNames, parsedCert.NotBefore) + if err != nil { + m.log.AuditErrf("expiration-mailer: error fetching renewal state: %v", err) + // assume not renewed + } else if renewed { + m.log.Debugf("Cert %s is already renewed", core.SerialToString(parsedCert.SerialNumber)) + m.stats.certificatesAlreadyRenewed.Add(1) + m.updateLastNagTimestamps(ctx, []*x509.Certificate{parsedCert}) + continue + } + + parsedCerts = append(parsedCerts, parsedCert) + } + + m.stats.certificatesPerAccountNeedingMail.Observe(float64(len(parsedCerts))) + + if len(parsedCerts) == 0 { + // all certificates are renewed + return nil + } + + err = m.sendNags(conn, reg.Contact, parsedCerts) + if err != nil { + // If the error was due to the address(es) being unusable or the mail being + // undeliverable, we don't want to try again later. + var badAddrErr *bmail.BadAddressSMTPError + if errors.Is(err, errNoValidEmail) || errors.As(err, &badAddrErr) { + m.updateLastNagTimestamps(ctx, parsedCerts) + // Some accounts have no email; some accounts have an invalid email. + // Treat those as non-error cases. + return nil + } + + m.stats.errorCount.With(prometheus.Labels{"type": "SendNags"}).Inc() + return fmt.Errorf("sending nag emails: %s", err) + } + + m.updateLastNagTimestamps(ctx, parsedCerts) + return nil +} + +// findExpiringCertificates finds certificates that might need an expiration mail, filters them, +// groups by account, sends mail, and updates their status in the DB so we don't examine them again. +// +// Invariant: findExpiringCertificates should examine each certificate at most N times, where +// N is the number of reminders. For every certificate examined (barring errors), this function +// should update the lastExpirationNagSent field of certificateStatus, so it does not need to +// examine the same certificate again on the next go-round. This ensures we make forward progress +// and don't clog up the window of certificates to be examined. +func (m *mailer) findExpiringCertificates(ctx context.Context) error { + now := m.clk.Now() + // E.g. m.nagTimes = [2, 4, 8, 15] days from expiration + for i, expiresIn := range m.nagTimes { + left := now + if i > 0 { + left = left.Add(m.nagTimes[i-1]) + } + right := now.Add(expiresIn) + + m.log.Infof("expiration-mailer: Searching for certificates that expire between %s and %s and had last nag >%s before expiry", + left.UTC(), right.UTC(), expiresIn) + + var certs []certDERWithRegID + var err error + if features.Get().ExpirationMailerUsesJoin { + certs, err = m.getCertsWithJoin(ctx, left, right, expiresIn) + } else { + certs, err = m.getCerts(ctx, left, right, expiresIn) + } + if err != nil { + return err + } + + m.stats.certificatesExamined.Add(float64(len(certs))) + + // If the number of rows was exactly `m.certificatesPerTick` rows we need to increment + // a stat indicating that this nag group is at capacity. If this condition + // continually occurs across mailer runs then we will not catch up, + // resulting in under-sending expiration mails. The effects of this + // were initially described in issue #2002[0]. + // + // 0: https://github.com/letsencrypt/boulder/issues/2002 + atCapacity := float64(0) + if len(certs) == m.certificatesPerTick { + m.log.Infof("nag group %s expiring certificates at configured capacity (select limit %d)", + expiresIn.String(), m.certificatesPerTick) + atCapacity = float64(1) + } + m.stats.nagsAtCapacity.With(prometheus.Labels{"nag_group": expiresIn.String()}).Set(atCapacity) + + m.log.Infof("Found %d certificates expiring between %s and %s", len(certs), + left.Format(time.DateTime), right.Format(time.DateTime)) + + if len(certs) == 0 { + continue // nothing to do + } + + processingStarted := m.clk.Now() + err = m.processCerts(ctx, certs, expiresIn) + if err != nil { + m.log.AuditErr(err.Error()) + } + processingEnded := m.clk.Now() + elapsed := processingEnded.Sub(processingStarted) + m.stats.processingLatency.Observe(elapsed.Seconds()) + } + + return nil +} + +func (m *mailer) getCertsWithJoin(ctx context.Context, left, right time.Time, expiresIn time.Duration) ([]certDERWithRegID, error) { + // First we do a query on the certificateStatus table to find certificates + // nearing expiry meeting our criteria for email notification. We later + // sequentially fetch the certificate details. This avoids an expensive + // JOIN. + var certs []certDERWithRegID + _, err := m.dbMap.Select( + ctx, + &certs, + `SELECT + cert.der as der, cert.registrationID as regID + FROM certificateStatus AS cs + JOIN certificates as cert + ON cs.serial = cert.serial + AND cs.notAfter > :cutoffA + AND cs.notAfter <= :cutoffB + AND cs.status != "revoked" + AND COALESCE(TIMESTAMPDIFF(SECOND, cs.lastExpirationNagSent, cs.notAfter) > :nagCutoff, 1) + ORDER BY cs.notAfter ASC + LIMIT :certificatesPerTick`, + map[string]interface{}{ + "cutoffA": left, + "cutoffB": right, + "nagCutoff": expiresIn.Seconds(), + "certificatesPerTick": m.certificatesPerTick, + }, + ) + if err != nil { + m.log.AuditErrf("expiration-mailer: Error loading certificate serials: %s", err) + return nil, err + } + m.log.Debugf("found %d certificates", len(certs)) + return certs, nil +} + +func (m *mailer) getCerts(ctx context.Context, left, right time.Time, expiresIn time.Duration) ([]certDERWithRegID, error) { + // First we do a query on the certificateStatus table to find certificates + // nearing expiry meeting our criteria for email notification. We later + // sequentially fetch the certificate details. This avoids an expensive + // JOIN. + var serials []string + _, err := m.dbMap.Select( + ctx, + &serials, + `SELECT + cs.serial + FROM certificateStatus AS cs + WHERE cs.notAfter > :cutoffA + AND cs.notAfter <= :cutoffB + AND cs.status != "revoked" + AND COALESCE(TIMESTAMPDIFF(SECOND, cs.lastExpirationNagSent, cs.notAfter) > :nagCutoff, 1) + ORDER BY cs.notAfter ASC + LIMIT :certificatesPerTick`, + map[string]interface{}{ + "cutoffA": left, + "cutoffB": right, + "nagCutoff": expiresIn.Seconds(), + "certificatesPerTick": m.certificatesPerTick, + }, + ) + if err != nil { + m.log.AuditErrf("expiration-mailer: Error loading certificate serials: %s", err) + return nil, err + } + m.log.Debugf("found %d certificates", len(serials)) + + // Now we can sequentially retrieve the certificate details for each of the + // certificate status rows + var certs []certDERWithRegID + for i, serial := range serials { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var cert core.Certificate + cert, err := sa.SelectCertificate(ctx, m.dbMap, serial) + if err != nil { + // We can get a NoRowsErr when processing a serial number corresponding + // to a precertificate with no final certificate. Since this certificate + // is not being used by a subscriber, we don't send expiration email about + // it. + if db.IsNoRows(err) { + m.log.Infof("no rows for serial %q", serial) + continue + } + m.log.AuditErrf("expiration-mailer: Error loading cert %q: %s", cert.Serial, err) + continue + } + certs = append(certs, certDERWithRegID{ + DER: cert.DER, + RegID: cert.RegistrationID, + }) + if i == 0 { + // Report the send delay metric. Note: this is the worst-case send delay + // of any certificate in this batch because it's based on the first (oldest). + sendDelay := expiresIn - cert.Expires.Sub(m.clk.Now()) + m.stats.sendDelay.With(prometheus.Labels{"nag_group": expiresIn.String()}).Set( + sendDelay.Truncate(time.Second).Seconds()) + } + } + + return certs, nil +} + +type durationSlice []time.Duration + +func (ds durationSlice) Len() int { + return len(ds) +} + +func (ds durationSlice) Less(a, b int) bool { + return ds[a] < ds[b] +} + +func (ds durationSlice) Swap(a, b int) { + ds[a], ds[b] = ds[b], ds[a] +} + +type Config struct { + Mailer struct { + DebugAddr string `validate:"omitempty,hostname_port"` + DB cmd.DBConfig + cmd.SMTPConfig + + // From is an RFC 5322 formatted "From" address for reminder messages, + // e.g. "Example " + From string `validate:"required"` + + // Subject is the Subject line of reminder messages. This is a Go + // template with a single variable: ExpirationSubject, which contains + // a list of affected hostnames, possibly truncated. + Subject string + + // CertLimit is the maximum number of certificates to investigate in a + // single batch. Defaults to 100. + CertLimit int `validate:"min=0"` + + // MailsPerAddressPerDay is the maximum number of emails we'll send to + // a single address in a single day. Defaults to 0 (unlimited). + // Note that this does not track sends across restarts of the process, + // so we may send more than this when we restart expiration-mailer. + // This is a best-effort limitation. Defaults to math.MaxInt. + MailsPerAddressPerDay int `validate:"min=0"` + + // UpdateChunkSize is the maximum number of rows to update in a single + // SQL UPDATE statement. + UpdateChunkSize int `validate:"min=0,max=65535"` + + NagTimes []string `validate:"min=1,dive,required"` + + // Path to a text/template email template with a .gotmpl or .txt file + // extension. + EmailTemplate string `validate:"required"` + + // How often to process a batch of certificates + Frequency config.Duration + + // ParallelSends is the number of parallel goroutines used to process + // each batch of emails. Defaults to 1. + ParallelSends uint + + TLS cmd.TLSConfig + SAService *cmd.GRPCClientConfig + + // Path to a file containing a list of trusted root certificates for use + // during the SMTP connection (as opposed to the gRPC connections). + SMTPTrustedRootFile string + + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func initStats(stats prometheus.Registerer) mailerStats { + sendDelay := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "send_delay", + Help: "For the last batch of certificates, difference between the idealized send time and actual send time. Will always be nonzero, bigger numbers are worse", + }, + []string{"nag_group"}) + stats.MustRegister(sendDelay) + + sendDelayHistogram := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "send_delay_histogram", + Help: "For each mail sent, difference between the idealized send time and actual send time. Will always be nonzero, bigger numbers are worse", + Buckets: prometheus.LinearBuckets(86400, 86400, 10), + }, + []string{"nag_group"}) + stats.MustRegister(sendDelayHistogram) + + nagsAtCapacity := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "nags_at_capacity", + Help: "Count of nag groups at capacity", + }, + []string{"nag_group"}) + stats.MustRegister(nagsAtCapacity) + + errorCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "errors", + Help: "Number of errors", + }, + []string{"type"}) + stats.MustRegister(errorCount) + + sendLatency := prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "send_latency", + Help: "Time the mailer takes sending messages in seconds", + Buckets: metrics.InternetFacingBuckets, + }) + stats.MustRegister(sendLatency) + + processingLatency := prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "processing_latency", + Help: "Time the mailer takes processing certificates in seconds", + Buckets: []float64{30, 60, 75, 90, 120, 600, 3600}, + }) + stats.MustRegister(processingLatency) + + certificatesExamined := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "certificates_examined", + Help: "Number of certificates looked at that are potentially due for an expiration mail", + }) + stats.MustRegister(certificatesExamined) + + certificatesAlreadyRenewed := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "certificates_already_renewed", + Help: "Number of certificates from certificates_examined that were ignored because they were already renewed", + }) + stats.MustRegister(certificatesAlreadyRenewed) + + accountsNeedingMail := prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "certificates_per_account_needing_mail", + Help: "After ignoring certificates_already_renewed and grouping the remaining certificates by account, how many accounts needed to get an email; grouped by how many certificates each account needed", + Buckets: []float64{0, 1, 2, 100, 1000, 10000, 100000}, + }) + stats.MustRegister(accountsNeedingMail) + + return mailerStats{ + sendDelay: sendDelay, + sendDelayHistogram: sendDelayHistogram, + nagsAtCapacity: nagsAtCapacity, + errorCount: errorCount, + sendLatency: sendLatency, + processingLatency: processingLatency, + certificatesExamined: certificatesExamined, + certificatesAlreadyRenewed: certificatesAlreadyRenewed, + certificatesPerAccountNeedingMail: accountsNeedingMail, + } +} + +func main() { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + certLimit := flag.Int("cert_limit", 0, "Count of certificates to process per expiration period") + reconnBase := flag.Duration("reconnectBase", 1*time.Second, "Base sleep duration between reconnect attempts") + reconnMax := flag.Duration("reconnectMax", 5*60*time.Second, "Max sleep duration between reconnect attempts after exponential backoff") + daemon := flag.Bool("daemon", false, "Run in daemon mode") + flag.Parse() + + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.Mailer.Features) + + if *debugAddr != "" { + c.Mailer.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.Mailer.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + if *daemon && c.Mailer.Frequency.Duration == 0 { + fmt.Fprintln(os.Stderr, "mailer.frequency is not set in the JSON config") + os.Exit(1) + } + + if *certLimit > 0 { + c.Mailer.CertLimit = *certLimit + } + // Default to 100 if no certLimit is set + if c.Mailer.CertLimit == 0 { + c.Mailer.CertLimit = 100 + } + + if c.Mailer.MailsPerAddressPerDay == 0 { + c.Mailer.MailsPerAddressPerDay = math.MaxInt + } + + dbMap, err := sa.InitWrappedDb(c.Mailer.DB, scope, logger) + cmd.FailOnError(err, "While initializing dbMap") + + tlsConfig, err := c.Mailer.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + clk := cmd.Clock() + + conn, err := bgrpc.ClientSetup(c.Mailer.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac := sapb.NewStorageAuthorityClient(conn) + + var smtpRoots *x509.CertPool + if c.Mailer.SMTPTrustedRootFile != "" { + pem, err := os.ReadFile(c.Mailer.SMTPTrustedRootFile) + cmd.FailOnError(err, "Loading trusted roots file") + smtpRoots = x509.NewCertPool() + if !smtpRoots.AppendCertsFromPEM(pem) { + cmd.FailOnError(nil, "Failed to parse root certs PEM") + } + } + + // Load email template + emailTmpl, err := os.ReadFile(c.Mailer.EmailTemplate) + cmd.FailOnError(err, fmt.Sprintf("Could not read email template file [%s]", c.Mailer.EmailTemplate)) + tmpl, err := template.New("expiry-email").Parse(string(emailTmpl)) + cmd.FailOnError(err, "Could not parse email template") + + // If there is no configured subject template, use a default + if c.Mailer.Subject == "" { + c.Mailer.Subject = defaultExpirationSubject + } + // Load subject template + subjTmpl, err := template.New("expiry-email-subject").Parse(c.Mailer.Subject) + cmd.FailOnError(err, "Could not parse email subject template") + + fromAddress, err := netmail.ParseAddress(c.Mailer.From) + cmd.FailOnError(err, fmt.Sprintf("Could not parse from address: %s", c.Mailer.From)) + + smtpPassword, err := c.Mailer.PasswordConfig.Pass() + cmd.FailOnError(err, "Failed to load SMTP password") + mailClient := bmail.New( + c.Mailer.Server, + c.Mailer.Port, + c.Mailer.Username, + smtpPassword, + smtpRoots, + *fromAddress, + logger, + scope, + *reconnBase, + *reconnMax) + + var nags durationSlice + for _, nagDuration := range c.Mailer.NagTimes { + dur, err := time.ParseDuration(nagDuration) + if err != nil { + logger.AuditErrf("Failed to parse nag duration string [%s]: %s", nagDuration, err) + return + } + // Add some padding to the nag times so we send _before_ the configured + // time rather than after. See https://github.com/letsencrypt/boulder/pull/1029 + adjustedInterval := dur + c.Mailer.Frequency.Duration + nags = append(nags, adjustedInterval) + } + // Make sure durations are sorted in increasing order + sort.Sort(nags) + + if c.Mailer.UpdateChunkSize > 65535 { + // MariaDB limits the number of placeholders parameters to max_uint16: + // https://github.com/MariaDB/server/blob/10.5/sql/sql_prepare.cc#L2629-L2635 + cmd.Fail(fmt.Sprintf("UpdateChunkSize of %d is too big", c.Mailer.UpdateChunkSize)) + } + + m := mailer{ + log: logger, + dbMap: dbMap, + rs: sac, + mailer: mailClient, + subjectTemplate: subjTmpl, + emailTemplate: tmpl, + nagTimes: nags, + certificatesPerTick: c.Mailer.CertLimit, + addressLimiter: &limiter{clk: cmd.Clock(), limit: c.Mailer.MailsPerAddressPerDay}, + updateChunkSize: c.Mailer.UpdateChunkSize, + parallelSends: c.Mailer.ParallelSends, + clk: clk, + stats: initStats(scope), + } + + // Prefill this labelled stat with the possible label values, so each value is + // set to 0 on startup, rather than being missing from stats collection until + // the first mail run. + for _, expiresIn := range nags { + m.stats.nagsAtCapacity.With(prometheus.Labels{"nag_group": expiresIn.String()}).Set(0) + } + + ctx, cancel := context.WithCancel(context.Background()) + go cmd.CatchSignals(cancel) + + if *daemon { + t := time.NewTicker(c.Mailer.Frequency.Duration) + for { + select { + case <-t.C: + err = m.findExpiringCertificates(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + cmd.FailOnError(err, "expiration-mailer has failed") + } + case <-ctx.Done(): + return + } + } + } else { + err = m.findExpiringCertificates(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + cmd.FailOnError(err, "expiration-mailer has failed") + } + } +} + +func init() { + cmd.RegisterCommand("expiration-mailer", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main_test.go new file mode 100644 index 000000000..e5c86147e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/main_test.go @@ -0,0 +1,1007 @@ +package notmain + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "errors" + "fmt" + "math/big" + "net" + "strings" + "testing" + "text/template" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + io_prometheus_client "github.com/prometheus/client_model/go" + "google.golang.org/grpc" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + bmail "github.com/letsencrypt/boulder/mail" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/sa/satest" + "github.com/letsencrypt/boulder/test" + isa "github.com/letsencrypt/boulder/test/inmem/sa" + "github.com/letsencrypt/boulder/test/vars" +) + +type fakeRegStore struct { + RegByID map[int64]*corepb.Registration +} + +func (f fakeRegStore) GetRegistration(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { + r, ok := f.RegByID[req.Id] + if !ok { + return r, berrors.NotFoundError("no registration found for %q", req.Id) + } + return r, nil +} + +func newFakeRegStore() fakeRegStore { + return fakeRegStore{RegByID: make(map[int64]*corepb.Registration)} +} + +const testTmpl = `hi, cert for DNS names {{.DNSNames}} is going to expire in {{.DaysToExpiration}} days ({{.ExpirationDate}})` +const testEmailSubject = `email subject for test` +const emailARaw = "rolandshoemaker@gmail.com" +const emailBRaw = "test@gmail.com" + +var ( + emailA = "mailto:" + emailARaw + emailB = "mailto:" + emailBRaw + jsonKeyA = []byte(`{ + "kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB" +}`) + jsonKeyB = []byte(`{ + "kty":"RSA", + "n":"z8bp-jPtHt4lKBqepeKF28g_QAEOuEsCIou6sZ9ndsQsEjxEOQxQ0xNOQezsKa63eogw8YS3vzjUcPP5BJuVzfPfGd5NVUdT-vSSwxk3wvk_jtNqhrpcoG0elRPQfMVsQWmxCAXCVRz3xbcFI8GTe-syynG3l-g1IzYIIZVNI6jdljCZML1HOMTTW4f7uJJ8mM-08oQCeHbr5ejK7O2yMSSYxW03zY-Tj1iVEebROeMv6IEEJNFSS4yM-hLpNAqVuQxFGetwtwjDMC1Drs1dTWrPuUAAjKGrP151z1_dE74M5evpAhZUmpKv1hY-x85DC6N0hFPgowsanmTNNiV75w", + "e":"AAEAAQ" +}`) + jsonKeyC = []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-sCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" +}`) + tmpl = template.Must(template.New("expiry-email").Parse(testTmpl)) + subjTmpl = template.Must(template.New("expiry-email-subject").Parse("Testing: " + defaultExpirationSubject)) +) + +func TestSendNagsManyCerts(t *testing.T) { + mc := mocks.Mailer{} + rs := newFakeRegStore() + fc := clock.NewFake() + + staticTmpl := template.Must(template.New("expiry-email-subject-static").Parse(testEmailSubject)) + tmpl := template.Must(template.New("expiry-email").Parse( + `cert for DNS names {{.TruncatedDNSNames}} is going to expire in {{.DaysToExpiration}} days ({{.ExpirationDate}})`)) + + m := mailer{ + log: blog.NewMock(), + mailer: &mc, + emailTemplate: tmpl, + addressLimiter: &limiter{clk: fc, limit: 4}, + // Explicitly override the default subject to use testEmailSubject + subjectTemplate: staticTmpl, + rs: rs, + clk: fc, + stats: initStats(metrics.NoopRegisterer), + } + + var certs []*x509.Certificate + for i := range 101 { + certs = append(certs, &x509.Certificate{ + SerialNumber: big.NewInt(0x0304), + NotAfter: fc.Now().AddDate(0, 0, 2), + DNSNames: []string{fmt.Sprintf("example-%d.com", i)}, + }) + } + + conn, err := m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + err = m.sendNags(conn, []string{emailA}, certs) + test.AssertNotError(t, err, "sending mail") + + test.AssertEquals(t, len(mc.Messages), 1) + if len(strings.Split(mc.Messages[0].Body, "\n")) > 100 { + t.Errorf("Expected mailed message to truncate after 100 domains, got: %q", mc.Messages[0].Body) + } +} + +func TestSendNags(t *testing.T) { + mc := mocks.Mailer{} + rs := newFakeRegStore() + fc := clock.NewFake() + + staticTmpl := template.Must(template.New("expiry-email-subject-static").Parse(testEmailSubject)) + + log := blog.NewMock() + m := mailer{ + log: log, + mailer: &mc, + emailTemplate: tmpl, + addressLimiter: &limiter{clk: fc, limit: 4}, + // Explicitly override the default subject to use testEmailSubject + subjectTemplate: staticTmpl, + rs: rs, + clk: fc, + stats: initStats(metrics.NoopRegisterer), + } + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(0x0304), + NotAfter: fc.Now().AddDate(0, 0, 2), + DNSNames: []string{"example.com"}, + } + + conn, err := m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + err = m.sendNags(conn, []string{emailA}, []*x509.Certificate{cert}) + test.AssertNotError(t, err, "Failed to send warning messages") + test.AssertEquals(t, len(mc.Messages), 1) + test.AssertEquals(t, mc.Messages[0], mocks.MailerMessage{ + To: emailARaw, + Subject: testEmailSubject, + Body: fmt.Sprintf(`hi, cert for DNS names example.com is going to expire in 2 days (%s)`, cert.NotAfter.Format(time.DateOnly)), + }) + + mc.Clear() + conn, err = m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + err = m.sendNags(conn, []string{emailA, emailB}, []*x509.Certificate{cert}) + test.AssertNotError(t, err, "Failed to send warning messages") + test.AssertEquals(t, len(mc.Messages), 2) + test.AssertEquals(t, mc.Messages[0], mocks.MailerMessage{ + To: emailARaw, + Subject: testEmailSubject, + Body: fmt.Sprintf(`hi, cert for DNS names example.com is going to expire in 2 days (%s)`, cert.NotAfter.Format(time.DateOnly)), + }) + test.AssertEquals(t, mc.Messages[1], mocks.MailerMessage{ + To: emailBRaw, + Subject: testEmailSubject, + Body: fmt.Sprintf(`hi, cert for DNS names example.com is going to expire in 2 days (%s)`, cert.NotAfter.Format(time.DateOnly)), + }) + + mc.Clear() + conn, err = m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + err = m.sendNags(conn, []string{}, []*x509.Certificate{cert}) + test.AssertErrorIs(t, err, errNoValidEmail) + test.AssertEquals(t, len(mc.Messages), 0) + + sendLogs := log.GetAllMatching("INFO: attempting send JSON=.*") + if len(sendLogs) != 2 { + t.Errorf("expected 2 'attempting send' log line, got %d: %s", len(sendLogs), strings.Join(sendLogs, "\n")) + } + if !strings.Contains(sendLogs[0], `"Rcpt":["rolandshoemaker@gmail.com"]`) { + t.Errorf("expected first 'attempting send' log line to have one address, got %q", sendLogs[0]) + } + if !strings.Contains(sendLogs[0], `"TruncatedSerials":["000000000000000000000000000000000304"]`) { + t.Errorf("expected first 'attempting send' log line to have one serial, got %q", sendLogs[0]) + } + if !strings.Contains(sendLogs[0], `"DaysToExpiration":2`) { + t.Errorf("expected first 'attempting send' log line to have 2 days to expiration, got %q", sendLogs[0]) + } + if !strings.Contains(sendLogs[0], `"TruncatedDNSNames":["example.com"]`) { + t.Errorf("expected first 'attempting send' log line to have 1 domain, 'example.com', got %q", sendLogs[0]) + } +} + +func TestSendNagsAddressLimited(t *testing.T) { + mc := mocks.Mailer{} + rs := newFakeRegStore() + fc := clock.NewFake() + + staticTmpl := template.Must(template.New("expiry-email-subject-static").Parse(testEmailSubject)) + + log := blog.NewMock() + m := mailer{ + log: log, + mailer: &mc, + emailTemplate: tmpl, + addressLimiter: &limiter{clk: fc, limit: 1}, + // Explicitly override the default subject to use testEmailSubject + subjectTemplate: staticTmpl, + rs: rs, + clk: fc, + stats: initStats(metrics.NoopRegisterer), + } + + m.addressLimiter.inc(emailARaw) + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(0x0304), + NotAfter: fc.Now().AddDate(0, 0, 2), + DNSNames: []string{"example.com"}, + } + + conn, err := m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + + // Try sending a message to an over-the-limit address + err = m.sendNags(conn, []string{emailA}, []*x509.Certificate{cert}) + test.AssertErrorIs(t, err, errNoValidEmail) + // Expect that no messages were sent because this address was over the limit + test.AssertEquals(t, len(mc.Messages), 0) + + // Try sending a message to an over-the-limit address and an under-the-limit + // one. It should only go to the under-the-limit one. + err = m.sendNags(conn, []string{emailA, emailB}, []*x509.Certificate{cert}) + test.AssertNotError(t, err, "sending warning messages to two addresses") + test.AssertEquals(t, len(mc.Messages), 1) + test.AssertEquals(t, mc.Messages[0], mocks.MailerMessage{ + To: emailBRaw, + Subject: testEmailSubject, + Body: fmt.Sprintf(`hi, cert for DNS names example.com is going to expire in 2 days (%s)`, cert.NotAfter.Format(time.DateOnly)), + }) +} + +var serial1 = big.NewInt(0x1336) +var serial2 = big.NewInt(0x1337) +var serial3 = big.NewInt(0x1338) +var serial4 = big.NewInt(0x1339) +var serial4String = core.SerialToString(serial4) +var serial5 = big.NewInt(0x1340) +var serial5String = core.SerialToString(serial5) +var serial6 = big.NewInt(0x1341) +var serial7 = big.NewInt(0x1342) +var serial8 = big.NewInt(0x1343) +var serial9 = big.NewInt(0x1344) + +var testKey *ecdsa.PrivateKey + +func init() { + var err error + testKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } +} + +func TestProcessCerts(t *testing.T) { + expiresIn := time.Hour * 24 * 7 + testCtx := setup(t, []time.Duration{expiresIn}) + + certs := addExpiringCerts(t, testCtx) + err := testCtx.m.processCerts(context.Background(), certs, expiresIn) + test.AssertNotError(t, err, "processing certs") + // Test that the lastExpirationNagSent was updated for the certificate + // corresponding to serial4, which is set up as "already renewed" by + // addExpiringCerts. + if len(testCtx.log.GetAllMatching("UPDATE certificateStatus.*000000000000000000000000000000001339")) != 1 { + t.Errorf("Expected an update to certificateStatus, got these log lines:\n%s", + strings.Join(testCtx.log.GetAll(), "\n")) + } +} + +// There's an account with an expiring certificate but no email address. We shouldn't examine +// that certificate repeatedly; we should mark it as if it had an email sent already. +func TestNoContactCertIsNotRenewed(t *testing.T) { + expiresIn := time.Hour * 24 * 7 + testCtx := setup(t, []time.Duration{expiresIn}) + + reg, err := makeRegistration(testCtx.ssa, 1, jsonKeyA, nil) + test.AssertNotError(t, err, "Couldn't store regA") + + cert, err := makeCertificate( + reg.Id, + serial1, + []string{"example-a.com"}, + 23*time.Hour, + testCtx.fc) + test.AssertNotError(t, err, "creating cert A") + + err = insertCertificate(cert, time.Time{}) + test.AssertNotError(t, err, "inserting certificate") + + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "finding expired certificates") + + // We should have sent no mail, because there was no contact address + test.AssertEquals(t, len(testCtx.mc.Messages), 0) + + // We should have examined exactly one certificate + certsExamined := testCtx.m.stats.certificatesExamined + test.AssertMetricWithLabelsEquals(t, certsExamined, prometheus.Labels{}, 1.0) + + certsAlreadyRenewed := testCtx.m.stats.certificatesAlreadyRenewed + test.AssertMetricWithLabelsEquals(t, certsAlreadyRenewed, prometheus.Labels{}, 0.0) + + // Run findExpiringCertificates again. The count of examined certificates + // should not increase again. + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "finding expired certificates") + test.AssertMetricWithLabelsEquals(t, certsExamined, prometheus.Labels{}, 1.0) + test.AssertMetricWithLabelsEquals(t, certsAlreadyRenewed, prometheus.Labels{}, 0.0) +} + +// An account with no contact info has a certificate that is expiring but has been renewed. +// We should only examine that certificate once. +func TestNoContactCertIsRenewed(t *testing.T) { + ctx := context.Background() + + testCtx := setup(t, []time.Duration{time.Hour * 24 * 7}) + + reg, err := makeRegistration(testCtx.ssa, 1, jsonKeyA, []string{}) + test.AssertNotError(t, err, "Couldn't store regA") + + names := []string{"example-a.com"} + cert, err := makeCertificate( + reg.Id, + serial1, + names, + 23*time.Hour, + testCtx.fc) + test.AssertNotError(t, err, "creating cert A") + + expires := testCtx.fc.Now().Add(23 * time.Hour) + + err = insertCertificate(cert, time.Time{}) + test.AssertNotError(t, err, "inserting certificate") + + setupDBMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "setting up DB") + err = setupDBMap.Insert(ctx, &core.FQDNSet{ + SetHash: core.HashNames(names), + Serial: core.SerialToString(serial2), + Issued: testCtx.fc.Now().Add(time.Hour), + Expires: expires.Add(time.Hour), + }) + test.AssertNotError(t, err, "inserting FQDNSet for renewal") + + err = testCtx.m.findExpiringCertificates(ctx) + test.AssertNotError(t, err, "finding expired certificates") + + // We should have examined exactly one certificate + certsExamined := testCtx.m.stats.certificatesExamined + test.AssertMetricWithLabelsEquals(t, certsExamined, prometheus.Labels{}, 1.0) + + certsAlreadyRenewed := testCtx.m.stats.certificatesAlreadyRenewed + test.AssertMetricWithLabelsEquals(t, certsAlreadyRenewed, prometheus.Labels{}, 1.0) + + // Run findExpiringCertificates again. The count of examined certificates + // should not increase again. + err = testCtx.m.findExpiringCertificates(ctx) + test.AssertNotError(t, err, "finding expired certificates") + test.AssertMetricWithLabelsEquals(t, certsExamined, prometheus.Labels{}, 1.0) + test.AssertMetricWithLabelsEquals(t, certsAlreadyRenewed, prometheus.Labels{}, 1.0) +} + +func TestProcessCertsParallel(t *testing.T) { + expiresIn := time.Hour * 24 * 7 + testCtx := setup(t, []time.Duration{expiresIn}) + + testCtx.m.parallelSends = 2 + certs := addExpiringCerts(t, testCtx) + err := testCtx.m.processCerts(context.Background(), certs, expiresIn) + test.AssertNotError(t, err, "processing certs") + // Test that the lastExpirationNagSent was updated for the certificate + // corresponding to serial4, which is set up as "already renewed" by + // addExpiringCerts. + if len(testCtx.log.GetAllMatching("UPDATE certificateStatus.*000000000000000000000000000000001339")) != 1 { + t.Errorf("Expected an update to certificateStatus, got these log lines:\n%s", + strings.Join(testCtx.log.GetAll(), "\n")) + } +} + +type erroringMailClient struct{} + +func (e erroringMailClient) Connect() (bmail.Conn, error) { + return nil, errors.New("whoopsie-doo") +} + +func TestProcessCertsConnectError(t *testing.T) { + expiresIn := time.Hour * 24 * 7 + testCtx := setup(t, []time.Duration{expiresIn}) + + testCtx.m.mailer = erroringMailClient{} + certs := addExpiringCerts(t, testCtx) + // Checking that this terminates rather than deadlocks + err := testCtx.m.processCerts(context.Background(), certs, expiresIn) + test.AssertError(t, err, "processing certs") +} + +func TestFindExpiringCertificates(t *testing.T) { + testCtx := setup(t, []time.Duration{time.Hour * 24, time.Hour * 24 * 4, time.Hour * 24 * 7}) + + addExpiringCerts(t, testCtx) + + err := testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "Failed on no certificates") + test.AssertEquals(t, len(testCtx.log.GetAllMatching("Searching for certificates that expire between.*")), 3) + + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "Failed to find expiring certs") + // Should get 001 and 003 + if len(testCtx.mc.Messages) != 2 { + builder := new(strings.Builder) + for _, m := range testCtx.mc.Messages { + fmt.Fprintf(builder, "%s\n", m) + } + t.Fatalf("Expected two messages when finding expiring certificates, got:\n%s", + builder.String()) + } + + test.AssertEquals(t, testCtx.mc.Messages[0], mocks.MailerMessage{ + To: emailARaw, + // A certificate with only one domain should have only one domain listed in + // the subject + Subject: "Testing: Let's Encrypt certificate expiration notice for domain \"example-a.com\"", + Body: "hi, cert for DNS names example-a.com is going to expire in 0 days (1970-01-01)", + }) + test.AssertEquals(t, testCtx.mc.Messages[1], mocks.MailerMessage{ + To: emailBRaw, + // A certificate with two domains should have only one domain listed and an + // additional count included + Subject: "Testing: Let's Encrypt certificate expiration notice for domain \"another.example-c.com\" (and 1 more)", + Body: "hi, cert for DNS names another.example-c.com\nexample-c.com is going to expire in 7 days (1970-01-08)", + }) + + // Check that regC's only certificate being renewed does not cause a log + test.AssertEquals(t, len(testCtx.log.GetAllMatching("no certs given to send nags for")), 0) + + // A consecutive run shouldn't find anything + testCtx.mc.Clear() + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "Failed to find expiring certs") + test.AssertEquals(t, len(testCtx.mc.Messages), 0) + test.AssertMetricWithLabelsEquals(t, testCtx.m.stats.sendDelay, prometheus.Labels{"nag_group": "48h0m0s"}, 90000) + test.AssertMetricWithLabelsEquals(t, testCtx.m.stats.sendDelay, prometheus.Labels{"nag_group": "192h0m0s"}, 82800) +} + +func makeRegistration(sac sapb.StorageAuthorityClient, id int64, jsonKey []byte, contacts []string) (*corepb.Registration, error) { + var ip [4]byte + _, err := rand.Reader.Read(ip[:]) + if err != nil { + return nil, err + } + ipText, err := net.IP(ip[:]).MarshalText() + if err != nil { + return nil, fmt.Errorf("formatting IP address: %s", err) + } + reg, err := sac.NewRegistration(context.Background(), &corepb.Registration{ + Id: id, + Contact: contacts, + Key: jsonKey, + InitialIP: ipText, + }) + if err != nil { + return nil, fmt.Errorf("storing registration: %s", err) + } + return reg, nil +} + +func makeCertificate(regID int64, serial *big.Int, dnsNames []string, expires time.Duration, fc clock.FakeClock) (certDERWithRegID, error) { + // Expires in <1d, last nag was the 4d nag + template := &x509.Certificate{ + NotAfter: fc.Now().Add(expires), + DNSNames: dnsNames, + SerialNumber: serial, + } + certDer, err := x509.CreateCertificate(rand.Reader, template, template, &testKey.PublicKey, testKey) + if err != nil { + return certDERWithRegID{}, err + } + return certDERWithRegID{ + RegID: regID, + DER: certDer, + }, nil +} + +func insertCertificate(cert certDERWithRegID, lastNagSent time.Time) error { + ctx := context.Background() + + parsedCert, err := x509.ParseCertificate(cert.DER) + if err != nil { + return err + } + + setupDBMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + if err != nil { + return err + } + err = setupDBMap.Insert(ctx, &core.Certificate{ + RegistrationID: cert.RegID, + Serial: core.SerialToString(parsedCert.SerialNumber), + Issued: parsedCert.NotBefore, + Expires: parsedCert.NotAfter, + DER: cert.DER, + }) + if err != nil { + return fmt.Errorf("inserting certificate: %w", err) + } + + return setupDBMap.Insert(ctx, &core.CertificateStatus{ + Serial: core.SerialToString(parsedCert.SerialNumber), + LastExpirationNagSent: lastNagSent, + Status: core.OCSPStatusGood, + NotAfter: parsedCert.NotAfter, + OCSPLastUpdated: time.Time{}, + RevokedDate: time.Time{}, + RevokedReason: 0, + }) +} + +func addExpiringCerts(t *testing.T, ctx *testCtx) []certDERWithRegID { + // Add some expiring certificates and registrations + regA, err := makeRegistration(ctx.ssa, 1, jsonKeyA, []string{emailA}) + test.AssertNotError(t, err, "Couldn't store regA") + regB, err := makeRegistration(ctx.ssa, 2, jsonKeyB, []string{emailB}) + test.AssertNotError(t, err, "Couldn't store regB") + regC, err := makeRegistration(ctx.ssa, 3, jsonKeyC, []string{emailB}) + test.AssertNotError(t, err, "Couldn't store regC") + + // Expires in <1d, last nag was the 4d nag + certA, err := makeCertificate( + regA.Id, + serial1, + []string{"example-a.com"}, + 23*time.Hour, + ctx.fc) + test.AssertNotError(t, err, "creating cert A") + + // Expires in 3d, already sent 4d nag at 4.5d + certB, err := makeCertificate( + regA.Id, + serial2, + []string{"example-b.com"}, + 72*time.Hour, + ctx.fc) + test.AssertNotError(t, err, "creating cert B") + + // Expires in 7d and change, no nag sent at all yet + certC, err := makeCertificate( + regB.Id, + serial3, + []string{"example-c.com", "another.example-c.com"}, + (7*24+1)*time.Hour, + ctx.fc) + test.AssertNotError(t, err, "creating cert C") + + // Expires in 3d, renewed + certDNames := []string{"example-d.com"} + certD, err := makeCertificate( + regC.Id, + serial4, + certDNames, + 72*time.Hour, + ctx.fc) + test.AssertNotError(t, err, "creating cert D") + + fqdnStatusD := &core.FQDNSet{ + SetHash: core.HashNames(certDNames), + Serial: serial4String, + Issued: ctx.fc.Now().AddDate(0, 0, -87), + Expires: ctx.fc.Now().AddDate(0, 0, 3), + } + fqdnStatusDRenewed := &core.FQDNSet{ + SetHash: core.HashNames(certDNames), + Serial: serial5String, + Issued: ctx.fc.Now().AddDate(0, 0, -3), + Expires: ctx.fc.Now().AddDate(0, 0, 87), + } + + err = insertCertificate(certA, ctx.fc.Now().Add(-72*time.Hour)) + test.AssertNotError(t, err, "inserting certA") + err = insertCertificate(certB, ctx.fc.Now().Add(-36*time.Hour)) + test.AssertNotError(t, err, "inserting certB") + err = insertCertificate(certC, ctx.fc.Now().Add(-36*time.Hour)) + test.AssertNotError(t, err, "inserting certC") + err = insertCertificate(certD, ctx.fc.Now().Add(-36*time.Hour)) + test.AssertNotError(t, err, "inserting certD") + + setupDBMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "setting up DB") + err = setupDBMap.Insert(context.Background(), fqdnStatusD) + test.AssertNotError(t, err, "Couldn't add fqdnStatusD") + err = setupDBMap.Insert(context.Background(), fqdnStatusDRenewed) + test.AssertNotError(t, err, "Couldn't add fqdnStatusDRenewed") + return []certDERWithRegID{certA, certB, certC, certD} +} + +func countGroupsAtCapacity(group string, counter *prometheus.GaugeVec) int { + ch := make(chan prometheus.Metric, 10) + counter.With(prometheus.Labels{"nag_group": group}).Collect(ch) + m := <-ch + var iom io_prometheus_client.Metric + _ = m.Write(&iom) + return int(iom.Gauge.GetValue()) +} + +func TestFindCertsAtCapacity(t *testing.T) { + testCtx := setup(t, []time.Duration{time.Hour * 24}) + + addExpiringCerts(t, testCtx) + + // Set the limit to 1 so we are "at capacity" with one result + testCtx.m.certificatesPerTick = 1 + + err := testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "Failed to find expiring certs") + test.AssertEquals(t, len(testCtx.mc.Messages), 1) + + // The "48h0m0s" nag group should have its prometheus stat incremented once. + // Note: this is not the 24h0m0s nag as you would expect sending time.Hour + // * 24 to setup() for the nag duration. This is because all of the nags are + // offset by 24 hours in this test file's setup() function, to mimic a 24h + // setting for the "Frequency" field in the JSON config. + test.AssertEquals(t, countGroupsAtCapacity("48h0m0s", testCtx.m.stats.nagsAtCapacity), 1) + + // A consecutive run shouldn't find anything + testCtx.mc.Clear() + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "Failed to find expiring certs") + test.AssertEquals(t, len(testCtx.mc.Messages), 0) + + // The "48h0m0s" nag group should now be reporting that it isn't at capacity + test.AssertEquals(t, countGroupsAtCapacity("48h0m0s", testCtx.m.stats.nagsAtCapacity), 0) +} + +func TestCertIsRenewed(t *testing.T) { + testCtx := setup(t, []time.Duration{time.Hour * 24, time.Hour * 24 * 4, time.Hour * 24 * 7}) + + reg := satest.CreateWorkingRegistration(t, testCtx.ssa) + + testCerts := []*struct { + Serial *big.Int + stringSerial string + DNS []string + NotBefore time.Time + NotAfter time.Time + // this field is the test assertion + IsRenewed bool + }{ + { + Serial: serial1, + DNS: []string{"a.example.com", "a2.example.com"}, + NotBefore: testCtx.fc.Now().Add((-1 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((89 * 24) * time.Hour), + IsRenewed: true, + }, + { + Serial: serial2, + DNS: []string{"a.example.com", "a2.example.com"}, + NotBefore: testCtx.fc.Now().Add((0 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((90 * 24) * time.Hour), + IsRenewed: false, + }, + { + Serial: serial3, + DNS: []string{"b.example.net"}, + NotBefore: testCtx.fc.Now().Add((0 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((90 * 24) * time.Hour), + IsRenewed: false, + }, + { + Serial: serial4, + DNS: []string{"c.example.org"}, + NotBefore: testCtx.fc.Now().Add((-100 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((-10 * 24) * time.Hour), + IsRenewed: true, + }, + { + Serial: serial5, + DNS: []string{"c.example.org"}, + NotBefore: testCtx.fc.Now().Add((-80 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((10 * 24) * time.Hour), + IsRenewed: true, + }, + { + Serial: serial6, + DNS: []string{"c.example.org"}, + NotBefore: testCtx.fc.Now().Add((-75 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((15 * 24) * time.Hour), + IsRenewed: true, + }, + { + Serial: serial7, + DNS: []string{"c.example.org"}, + NotBefore: testCtx.fc.Now().Add((-1 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((89 * 24) * time.Hour), + IsRenewed: false, + }, + { + Serial: serial8, + DNS: []string{"d.example.com", "d2.example.com"}, + NotBefore: testCtx.fc.Now().Add((-1 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((89 * 24) * time.Hour), + IsRenewed: false, + }, + { + Serial: serial9, + DNS: []string{"d.example.com", "d2.example.com", "d3.example.com"}, + NotBefore: testCtx.fc.Now().Add((0 * 24) * time.Hour), + NotAfter: testCtx.fc.Now().Add((90 * 24) * time.Hour), + IsRenewed: false, + }, + } + + setupDBMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + if err != nil { + t.Fatal(err) + } + + for _, testData := range testCerts { + testData.stringSerial = core.SerialToString(testData.Serial) + + rawCert := x509.Certificate{ + NotBefore: testData.NotBefore, + NotAfter: testData.NotAfter, + DNSNames: testData.DNS, + SerialNumber: testData.Serial, + } + // Can't use makeCertificate here because we also care about NotBefore + certDer, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey) + if err != nil { + t.Fatal(err) + } + fqdnStatus := &core.FQDNSet{ + SetHash: core.HashNames(testData.DNS), + Serial: testData.stringSerial, + Issued: testData.NotBefore, + Expires: testData.NotAfter, + } + + err = insertCertificate(certDERWithRegID{DER: certDer, RegID: reg.Id}, time.Time{}) + test.AssertNotError(t, err, fmt.Sprintf("Couldn't add cert %s", testData.stringSerial)) + + err = setupDBMap.Insert(context.Background(), fqdnStatus) + test.AssertNotError(t, err, fmt.Sprintf("Couldn't add fqdnStatus %s", testData.stringSerial)) + } + + for _, testData := range testCerts { + renewed, err := testCtx.m.certIsRenewed(context.Background(), testData.DNS, testData.NotBefore) + if err != nil { + t.Errorf("error checking renewal state for %s: %v", testData.stringSerial, err) + continue + } + if renewed != testData.IsRenewed { + t.Errorf("for %s: got %v, expected %v", testData.stringSerial, renewed, testData.IsRenewed) + } + } +} + +func TestLifetimeOfACert(t *testing.T) { + testCtx := setup(t, []time.Duration{time.Hour * 24, time.Hour * 24 * 4, time.Hour * 24 * 7}) + defer testCtx.cleanUp() + + regA, err := makeRegistration(testCtx.ssa, 1, jsonKeyA, []string{emailA}) + test.AssertNotError(t, err, "Couldn't store regA") + + certA, err := makeCertificate( + regA.Id, + serial1, + []string{"example-a.com"}, + 0, + testCtx.fc) + test.AssertNotError(t, err, "making certificate") + + err = insertCertificate(certA, time.Time{}) + test.AssertNotError(t, err, "unable to insert Certificate") + + type lifeTest struct { + timeLeft time.Duration + numMsgs int + context string + } + tests := []lifeTest{ + { + timeLeft: 9 * 24 * time.Hour, // 9 days before expiration + + numMsgs: 0, + context: "Expected no emails sent because we are more than 7 days out.", + }, + { + (7*24 + 12) * time.Hour, // 7.5 days before + 1, + "Sent 1 for 7 day notice.", + }, + { + 7 * 24 * time.Hour, + 1, + "The 7 day email was already sent.", + }, + { + (4*24 - 1) * time.Hour, // <4 days before, the mailer did not run yesterday + 2, + "Sent 1 for the 7 day notice, and 1 for the 4 day notice.", + }, + { + 36 * time.Hour, // within 1day + nagMargin + 3, + "Sent 1 for the 7 day notice, 1 for the 4 day notice, and 1 for the 1 day notice.", + }, + { + 12 * time.Hour, + 3, + "The 1 day before email was already sent.", + }, + { + -2 * 24 * time.Hour, // 2 days after expiration + 3, + "No expiration warning emails are sent after expiration", + }, + } + + for _, tt := range tests { + testCtx.fc.Add(-tt.timeLeft) + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "error calling findExpiringCertificates") + if len(testCtx.mc.Messages) != tt.numMsgs { + t.Errorf(tt.context+" number of messages: expected %d, got %d", tt.numMsgs, len(testCtx.mc.Messages)) + } + testCtx.fc.Add(tt.timeLeft) + } +} + +func TestDontFindRevokedCert(t *testing.T) { + expiresIn := 24 * time.Hour + testCtx := setup(t, []time.Duration{expiresIn}) + + regA, err := makeRegistration(testCtx.ssa, 1, jsonKeyA, []string{"mailto:one@mail.com"}) + test.AssertNotError(t, err, "Couldn't store regA") + certA, err := makeCertificate( + regA.Id, + serial1, + []string{"example-a.com"}, + expiresIn, + testCtx.fc) + test.AssertNotError(t, err, "making certificate") + + err = insertCertificate(certA, time.Time{}) + test.AssertNotError(t, err, "inserting certificate") + + ctx := context.Background() + + setupDBMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "sa.NewDbMap failed") + _, err = setupDBMap.ExecContext(ctx, "UPDATE certificateStatus SET status = ? WHERE serial = ?", + string(core.OCSPStatusRevoked), core.SerialToString(serial1)) + test.AssertNotError(t, err, "revoking certificate") + + err = testCtx.m.findExpiringCertificates(ctx) + test.AssertNotError(t, err, "err from findExpiringCertificates") + + if len(testCtx.mc.Messages) != 0 { + t.Errorf("no emails should have been sent, but sent %d", len(testCtx.mc.Messages)) + } +} + +func TestDedupOnRegistration(t *testing.T) { + expiresIn := 96 * time.Hour + testCtx := setup(t, []time.Duration{expiresIn}) + + regA, err := makeRegistration(testCtx.ssa, 1, jsonKeyA, []string{emailA}) + test.AssertNotError(t, err, "Couldn't store regA") + certA, err := makeCertificate( + regA.Id, + serial1, + []string{"example-a.com", "shared-example.com"}, + 72*time.Hour, + testCtx.fc) + test.AssertNotError(t, err, "making certificate") + err = insertCertificate(certA, time.Time{}) + test.AssertNotError(t, err, "inserting certificate") + + certB, err := makeCertificate( + regA.Id, + serial2, + []string{"example-b.com", "shared-example.com"}, + 48*time.Hour, + testCtx.fc) + test.AssertNotError(t, err, "making certificate") + err = insertCertificate(certB, time.Time{}) + test.AssertNotError(t, err, "inserting certificate") + + expires := testCtx.fc.Now().Add(48 * time.Hour) + + err = testCtx.m.findExpiringCertificates(context.Background()) + test.AssertNotError(t, err, "error calling findExpiringCertificates") + if len(testCtx.mc.Messages) > 1 { + t.Errorf("num of messages, want %d, got %d", 1, len(testCtx.mc.Messages)) + } + if len(testCtx.mc.Messages) == 0 { + t.Fatalf("no messages sent") + } + domains := "example-a.com\nexample-b.com\nshared-example.com" + test.AssertEquals(t, testCtx.mc.Messages[0], mocks.MailerMessage{ + To: emailARaw, + // A certificate with three domain names should have one in the subject and + // a count of '2 more' at the end + Subject: "Testing: Let's Encrypt certificate expiration notice for domain \"example-a.com\" (and 2 more)", + Body: fmt.Sprintf(`hi, cert for DNS names %s is going to expire in 2 days (%s)`, + domains, + expires.Format(time.DateOnly)), + }) +} + +type testCtx struct { + dbMap *db.WrappedMap + ssa sapb.StorageAuthorityClient + mc *mocks.Mailer + fc clock.FakeClock + m *mailer + log *blog.Mock + cleanUp func() +} + +func setup(t *testing.T, nagTimes []time.Duration) *testCtx { + log := blog.NewMock() + + // We use the test_setup user (which has full permissions to everything) + // because the SA we return is used for inserting data to set up the test. + dbMap, err := sa.DBMapForTestWithLog(vars.DBConnSAFullPerms, log) + if err != nil { + t.Fatalf("Couldn't connect the database: %s", err) + } + + fc := clock.NewFake() + ssa, err := sa.NewSQLStorageAuthority(dbMap, dbMap, nil, 1, 0, fc, log, metrics.NoopRegisterer) + if err != nil { + t.Fatalf("unable to create SQLStorageAuthority: %s", err) + } + cleanUp := test.ResetBoulderTestDatabase(t) + + mc := &mocks.Mailer{} + + offsetNags := make([]time.Duration, len(nagTimes)) + for i, t := range nagTimes { + offsetNags[i] = t + 24*time.Hour + } + + m := &mailer{ + log: log, + mailer: mc, + emailTemplate: tmpl, + subjectTemplate: subjTmpl, + dbMap: dbMap, + rs: isa.SA{Impl: ssa}, + nagTimes: offsetNags, + addressLimiter: &limiter{clk: fc, limit: 4}, + certificatesPerTick: 100, + clk: fc, + stats: initStats(metrics.NoopRegisterer), + } + return &testCtx{ + dbMap: dbMap, + ssa: isa.SA{Impl: ssa}, + mc: mc, + fc: fc, + m: m, + log: log, + cleanUp: cleanUp, + } +} + +func TestLimiter(t *testing.T) { + clk := clock.NewFake() + lim := &limiter{clk: clk, limit: 4} + fooAtExample := "foo@example.com" + lim.inc(fooAtExample) + test.AssertNotError(t, lim.check(fooAtExample), "expected no error") + lim.inc(fooAtExample) + test.AssertNotError(t, lim.check(fooAtExample), "expected no error") + lim.inc(fooAtExample) + test.AssertNotError(t, lim.check(fooAtExample), "expected no error") + lim.inc(fooAtExample) + test.AssertError(t, lim.check(fooAtExample), "expected an error") + + clk.Sleep(time.Hour) + test.AssertError(t, lim.check(fooAtExample), "expected an error") + + // Sleep long enough to reset the limit + clk.Sleep(24 * time.Hour) + test.AssertNotError(t, lim.check(fooAtExample), "expected no error") +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/send_test.go b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/send_test.go new file mode 100644 index 000000000..a95816fea --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/expiration-mailer/send_test.go @@ -0,0 +1,71 @@ +package notmain + +import ( + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "testing" + "time" + + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/test" +) + +var ( + email1 = "mailto:one@shared-example.com" + email2 = "mailto:two@shared-example.com" +) + +func TestSendEarliestCertInfo(t *testing.T) { + expiresIn := 24 * time.Hour + ctx := setup(t, []time.Duration{expiresIn}) + defer ctx.cleanUp() + + rawCertA := newX509Cert("happy A", + ctx.fc.Now().AddDate(0, 0, 5), + []string{"example-A.com", "SHARED-example.com"}, + serial1, + ) + rawCertB := newX509Cert("happy B", + ctx.fc.Now().AddDate(0, 0, 2), + []string{"shared-example.com", "example-b.com"}, + serial2, + ) + + conn, err := ctx.m.mailer.Connect() + test.AssertNotError(t, err, "connecting SMTP") + err = ctx.m.sendNags(conn, []string{email1, email2}, []*x509.Certificate{rawCertA, rawCertB}) + if err != nil { + t.Fatal(err) + } + if len(ctx.mc.Messages) != 2 { + t.Errorf("num of messages, want %d, got %d", 2, len(ctx.mc.Messages)) + } + if len(ctx.mc.Messages) == 0 { + t.Fatalf("no message sent") + } + domains := "example-a.com\nexample-b.com\nshared-example.com" + expected := mocks.MailerMessage{ + Subject: "Testing: Let's Encrypt certificate expiration notice for domain \"example-a.com\" (and 2 more)", + Body: fmt.Sprintf(`hi, cert for DNS names %s is going to expire in 2 days (%s)`, + domains, + rawCertB.NotAfter.Format(time.DateOnly)), + } + expected.To = "one@shared-example.com" + test.AssertEquals(t, expected, ctx.mc.Messages[0]) + expected.To = "two@shared-example.com" + test.AssertEquals(t, expected, ctx.mc.Messages[1]) +} + +func newX509Cert(commonName string, notAfter time.Time, dnsNames []string, serial *big.Int) *x509.Certificate { + return &x509.Certificate{ + Subject: pkix.Name{ + CommonName: commonName, + }, + NotAfter: notAfter, + DNSNames: dnsNames, + SerialNumber: serial, + } + +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main.go b/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main.go new file mode 100644 index 000000000..fa09cc953 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main.go @@ -0,0 +1,304 @@ +package notmain + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/sa" +) + +type idExporter struct { + log blog.Logger + dbMap *db.WrappedMap + clk clock.Clock + grace time.Duration +} + +// resultEntry is a JSON marshalable exporter result entry. +type resultEntry struct { + // ID is exported to support marshaling to JSON. + ID int64 `json:"id"` + + // Hostname is exported to support marshaling to JSON. Not all queries + // will fill this field, so it's JSON field tag marks at as + // omittable. + Hostname string `json:"hostname,omitempty"` +} + +// reverseHostname converts (reversed) names sourced from the +// registrations table to standard hostnames. +func (r *resultEntry) reverseHostname() { + r.Hostname = sa.ReverseName(r.Hostname) +} + +// idExporterResults is passed as a selectable 'holder' for the results +// of id-exporter database queries +type idExporterResults []*resultEntry + +// marshalToJSON returns JSON as bytes for all elements of the inner `id` +// slice. +func (i *idExporterResults) marshalToJSON() ([]byte, error) { + data, err := json.Marshal(i) + if err != nil { + return nil, err + } + data = append(data, '\n') + return data, nil +} + +// writeToFile writes the contents of the inner `ids` slice, as JSON, to +// a file +func (i *idExporterResults) writeToFile(outfile string) error { + data, err := i.marshalToJSON() + if err != nil { + return err + } + return os.WriteFile(outfile, data, 0644) +} + +// findIDs gathers all registration IDs with unexpired certificates. +func (c idExporter) findIDs(ctx context.Context) (idExporterResults, error) { + var holder idExporterResults + _, err := c.dbMap.Select( + ctx, + &holder, + `SELECT DISTINCT r.id + FROM registrations AS r + INNER JOIN certificates AS c on c.registrationID = r.id + WHERE r.contact NOT IN ('[]', 'null') + AND c.expires >= :expireCutoff;`, + map[string]interface{}{ + "expireCutoff": c.clk.Now().Add(-c.grace), + }) + if err != nil { + c.log.AuditErrf("Error finding IDs: %s", err) + return nil, err + } + return holder, nil +} + +// findIDsWithExampleHostnames gathers all registration IDs with +// unexpired certificates and a corresponding example hostname. +func (c idExporter) findIDsWithExampleHostnames(ctx context.Context) (idExporterResults, error) { + var holder idExporterResults + _, err := c.dbMap.Select( + ctx, + &holder, + `SELECT SQL_BIG_RESULT + cert.registrationID AS id, + name.reversedName AS hostname + FROM certificates AS cert + INNER JOIN issuedNames AS name ON name.serial = cert.serial + WHERE cert.expires >= :expireCutoff + GROUP BY cert.registrationID;`, + map[string]interface{}{ + "expireCutoff": c.clk.Now().Add(-c.grace), + }) + if err != nil { + c.log.AuditErrf("Error finding IDs and example hostnames: %s", err) + return nil, err + } + + for _, result := range holder { + result.reverseHostname() + } + return holder, nil +} + +// findIDsForHostnames gathers all registration IDs with unexpired +// certificates for each `hostnames` entry. +func (c idExporter) findIDsForHostnames(ctx context.Context, hostnames []string) (idExporterResults, error) { + var holder idExporterResults + for _, hostname := range hostnames { + // Pass the same list in each time, borp will happily just append to the slice + // instead of overwriting it each time + // https://github.com/letsencrypt/borp/blob/c87bd6443d59746a33aca77db34a60cfc344adb2/select.go#L349-L353 + _, err := c.dbMap.Select( + ctx, + &holder, + `SELECT DISTINCT c.registrationID AS id + FROM certificates AS c + INNER JOIN issuedNames AS n ON c.serial = n.serial + WHERE c.expires >= :expireCutoff + AND n.reversedName = :reversedName;`, + map[string]interface{}{ + "expireCutoff": c.clk.Now().Add(-c.grace), + "reversedName": sa.ReverseName(hostname), + }, + ) + if err != nil { + if db.IsNoRows(err) { + continue + } + return nil, err + } + } + + return holder, nil +} + +const usageIntro = ` +Introduction: + +The ID exporter exists to retrieve the IDs of all registered +users with currently unexpired certificates. This list of registration IDs can +then be given as input to the notification mailer to send bulk notifications. + +The -grace parameter can be used to allow registrations with certificates that +have already expired to be included in the export. The argument is a Go duration +obeying the usual suffix rules (e.g. 24h). + +Registration IDs are favoured over email addresses as the intermediate format in +order to ensure the most up to date contact information is used at the time of +notification. The notification mailer will resolve the ID to email(s) when the +mailing is underway, ensuring we use the correct address if a user has updated +their contact information between the time of export and the time of +notification. + +By default, the ID exporter's output will be JSON of the form: + [ + { "id": 1 }, + ... + { "id": n } + ] + +Operations that return a hostname will be JSON of the form: + [ + { "id": 1, "hostname": "example-1.com" }, + ... + { "id": n, "hostname": "example-n.com" } + ] + +Examples: + Export all registration IDs with unexpired certificates to "regs.json": + + id-exporter -config test/config/id-exporter.json -outfile regs.json + + Export all registration IDs with certificates that are unexpired or expired + within the last two days to "regs.json": + + id-exporter -config test/config/id-exporter.json -grace 48h -outfile + "regs.json" + +Required arguments: +- config +- outfile` + +// unmarshalHostnames unmarshals a hostnames file and ensures that the file +// contained at least one entry. +func unmarshalHostnames(filePath string) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Split(bufio.ScanLines) + + var hostnames []string + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, " ") { + return nil, fmt.Errorf( + "line: %q contains more than one entry, entries must be separated by newlines", line) + } + hostnames = append(hostnames, line) + } + + if len(hostnames) == 0 { + return nil, errors.New("provided file contains 0 hostnames") + } + return hostnames, nil +} + +type Config struct { + ContactExporter struct { + DB cmd.DBConfig + cmd.PasswordConfig + Features features.Config + } +} + +func main() { + outFile := flag.String("outfile", "", "File to output results JSON to.") + grace := flag.Duration("grace", 2*24*time.Hour, "Include results with certificates that expired in < grace ago.") + hostnamesFile := flag.String( + "hostnames", "", "Only include results with unexpired certificates that contain hostnames\nlisted (newline separated) in this file.") + withExampleHostnames := flag.Bool( + "with-example-hostnames", false, "Include an example hostname for each registration ID with an unexpired certificate.") + configFile := flag.String("config", "", "File containing a JSON config.") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s\n\n", usageIntro) + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + + // Parse flags and check required. + flag.Parse() + if *outFile == "" || *configFile == "" { + flag.Usage() + os.Exit(1) + } + + log := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7}) + log.Info(cmd.VersionString()) + + // Load configuration file. + configData, err := os.ReadFile(*configFile) + cmd.FailOnError(err, fmt.Sprintf("Reading %q", *configFile)) + + // Unmarshal JSON config file. + var cfg Config + err = json.Unmarshal(configData, &cfg) + cmd.FailOnError(err, "Unmarshaling config") + + features.Set(cfg.ContactExporter.Features) + + dbMap, err := sa.InitWrappedDb(cfg.ContactExporter.DB, nil, log) + cmd.FailOnError(err, "While initializing dbMap") + + exporter := idExporter{ + log: log, + dbMap: dbMap, + clk: cmd.Clock(), + grace: *grace, + } + + var results idExporterResults + if *hostnamesFile != "" { + hostnames, err := unmarshalHostnames(*hostnamesFile) + cmd.FailOnError(err, "Problem unmarshalling hostnames") + + results, err = exporter.findIDsForHostnames(context.TODO(), hostnames) + cmd.FailOnError(err, "Could not find IDs for hostnames") + + } else if *withExampleHostnames { + results, err = exporter.findIDsWithExampleHostnames(context.TODO()) + cmd.FailOnError(err, "Could not find IDs with hostnames") + + } else { + results, err = exporter.findIDs(context.TODO()) + cmd.FailOnError(err, "Could not find IDs") + } + + err = results.writeToFile(*outFile) + cmd.FailOnError(err, fmt.Sprintf("Could not write result to outfile %q", *outFile)) +} + +func init() { + cmd.RegisterCommand("id-exporter", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main_test.go new file mode 100644 index 000000000..20fdec760 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/id-exporter/main_test.go @@ -0,0 +1,486 @@ +package notmain + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "math/big" + "net" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + isa "github.com/letsencrypt/boulder/test/inmem/sa" + "github.com/letsencrypt/boulder/test/vars" +) + +var ( + regA *corepb.Registration + regB *corepb.Registration + regC *corepb.Registration + regD *corepb.Registration +) + +const ( + emailARaw = "test@example.com" + emailBRaw = "example@example.com" + emailCRaw = "test-example@example.com" + telNum = "666-666-7777" +) + +func TestFindIDs(t *testing.T) { + ctx := context.Background() + + testCtx := setup(t) + defer testCtx.cleanUp() + + // Add some test registrations + testCtx.addRegistrations(t) + + // Run findIDs - since no certificates have been added corresponding to + // the above registrations, no IDs should be found. + results, err := testCtx.c.findIDs(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + test.AssertEquals(t, len(results), 0) + + // Now add some certificates + testCtx.addCertificates(t) + + // Run findIDs - since there are three registrations with unexpired certs + // we should get exactly three IDs back: RegA, RegC and RegD. RegB should + // *not* be present since their certificate has already expired. Unlike + // previous versions of this test RegD is not filtered out for having a `tel:` + // contact field anymore - this is the duty of the notify-mailer. + results, err = testCtx.c.findIDs(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + test.AssertEquals(t, len(results), 3) + for _, entry := range results { + switch entry.ID { + case regA.Id: + case regC.Id: + case regD.Id: + default: + t.Errorf("ID: %d not expected", entry.ID) + } + } + + // Allow a 1 year grace period + testCtx.c.grace = 360 * 24 * time.Hour + results, err = testCtx.c.findIDs(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + // Now all four registration should be returned, including RegB since its + // certificate expired within the grace period + for _, entry := range results { + switch entry.ID { + case regA.Id: + case regB.Id: + case regC.Id: + case regD.Id: + default: + t.Errorf("ID: %d not expected", entry.ID) + } + } +} + +func TestFindIDsWithExampleHostnames(t *testing.T) { + ctx := context.Background() + testCtx := setup(t) + defer testCtx.cleanUp() + + // Add some test registrations + testCtx.addRegistrations(t) + + // Run findIDsWithExampleHostnames - since no certificates have been + // added corresponding to the above registrations, no IDs should be + // found. + results, err := testCtx.c.findIDsWithExampleHostnames(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + test.AssertEquals(t, len(results), 0) + + // Now add some certificates + testCtx.addCertificates(t) + + // Run findIDsWithExampleHostnames - since there are three + // registrations with unexpired certs we should get exactly three + // IDs back: RegA, RegC and RegD. RegB should *not* be present since + // their certificate has already expired. + results, err = testCtx.c.findIDsWithExampleHostnames(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + test.AssertEquals(t, len(results), 3) + for _, entry := range results { + switch entry.ID { + case regA.Id: + test.AssertEquals(t, entry.Hostname, "example-a.com") + case regC.Id: + test.AssertEquals(t, entry.Hostname, "example-c.com") + case regD.Id: + test.AssertEquals(t, entry.Hostname, "example-d.com") + default: + t.Errorf("ID: %d not expected", entry.ID) + } + } + + // Allow a 1 year grace period + testCtx.c.grace = 360 * 24 * time.Hour + results, err = testCtx.c.findIDsWithExampleHostnames(ctx) + test.AssertNotError(t, err, "findIDs() produced error") + + // Now all four registrations should be returned, including RegB + // since it expired within the grace period + test.AssertEquals(t, len(results), 4) + for _, entry := range results { + switch entry.ID { + case regA.Id: + test.AssertEquals(t, entry.Hostname, "example-a.com") + case regB.Id: + test.AssertEquals(t, entry.Hostname, "example-b.com") + case regC.Id: + test.AssertEquals(t, entry.Hostname, "example-c.com") + case regD.Id: + test.AssertEquals(t, entry.Hostname, "example-d.com") + default: + t.Errorf("ID: %d not expected", entry.ID) + } + } +} + +func TestFindIDsForHostnames(t *testing.T) { + ctx := context.Background() + + testCtx := setup(t) + defer testCtx.cleanUp() + + // Add some test registrations + testCtx.addRegistrations(t) + + // Run findIDsForHostnames - since no certificates have been added corresponding to + // the above registrations, no IDs should be found. + results, err := testCtx.c.findIDsForHostnames(ctx, []string{"example-a.com", "example-b.com", "example-c.com", "example-d.com"}) + test.AssertNotError(t, err, "findIDs() produced error") + test.AssertEquals(t, len(results), 0) + + // Now add some certificates + testCtx.addCertificates(t) + + results, err = testCtx.c.findIDsForHostnames(ctx, []string{"example-a.com", "example-b.com", "example-c.com", "example-d.com"}) + test.AssertNotError(t, err, "findIDsForHostnames() failed") + test.AssertEquals(t, len(results), 3) + for _, entry := range results { + switch entry.ID { + case regA.Id: + case regC.Id: + case regD.Id: + default: + t.Errorf("ID: %d not expected", entry.ID) + } + } +} + +func TestWriteToFile(t *testing.T) { + expected := `[{"id":1},{"id":2},{"id":3}]` + mockResults := idExporterResults{{ID: 1}, {ID: 2}, {ID: 3}} + dir := os.TempDir() + + f, err := os.CreateTemp(dir, "ids_test") + test.AssertNotError(t, err, "os.CreateTemp produced an error") + + // Writing the result to an outFile should produce the correct results + err = mockResults.writeToFile(f.Name()) + test.AssertNotError(t, err, fmt.Sprintf("writeIDs produced an error writing to %s", f.Name())) + + contents, err := os.ReadFile(f.Name()) + test.AssertNotError(t, err, fmt.Sprintf("os.ReadFile produced an error reading from %s", f.Name())) + + test.AssertEquals(t, string(contents), expected+"\n") +} + +func Test_unmarshalHostnames(t *testing.T) { + testDir := os.TempDir() + testFile, err := os.CreateTemp(testDir, "ids_test") + test.AssertNotError(t, err, "os.CreateTemp produced an error") + + // Non-existent hostnamesFile + _, err = unmarshalHostnames("file_does_not_exist") + test.AssertError(t, err, "expected error for non-existent file") + + // Empty hostnamesFile + err = os.WriteFile(testFile.Name(), []byte(""), 0644) + test.AssertNotError(t, err, "os.WriteFile produced an error") + _, err = unmarshalHostnames(testFile.Name()) + test.AssertError(t, err, "expected error for file containing 0 entries") + + // One hostname present in the hostnamesFile + err = os.WriteFile(testFile.Name(), []byte("example-a.com"), 0644) + test.AssertNotError(t, err, "os.WriteFile produced an error") + results, err := unmarshalHostnames(testFile.Name()) + test.AssertNotError(t, err, "error when unmarshalling hostnamesFile with a single hostname") + test.AssertEquals(t, len(results), 1) + + // Two hostnames present in the hostnamesFile + err = os.WriteFile(testFile.Name(), []byte("example-a.com\nexample-b.com"), 0644) + test.AssertNotError(t, err, "os.WriteFile produced an error") + results, err = unmarshalHostnames(testFile.Name()) + test.AssertNotError(t, err, "error when unmarshalling hostnamesFile with a two hostnames") + test.AssertEquals(t, len(results), 2) + + // Three hostnames present in the hostnamesFile but two are separated only by a space + err = os.WriteFile(testFile.Name(), []byte("example-a.com\nexample-b.com example-c.com"), 0644) + test.AssertNotError(t, err, "os.WriteFile produced an error") + _, err = unmarshalHostnames(testFile.Name()) + test.AssertError(t, err, "error when unmarshalling hostnamesFile with three space separated domains") +} + +type testCtx struct { + c idExporter + ssa sapb.StorageAuthorityClient + cleanUp func() +} + +func (tc testCtx) addRegistrations(t *testing.T) { + emailA := "mailto:" + emailARaw + emailB := "mailto:" + emailBRaw + emailC := "mailto:" + emailCRaw + tel := "tel:" + telNum + + // Every registration needs a unique JOSE key + jsonKeyA := []byte(`{ + "kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB" +}`) + jsonKeyB := []byte(`{ + "kty":"RSA", + "n":"z8bp-jPtHt4lKBqepeKF28g_QAEOuEsCIou6sZ9ndsQsEjxEOQxQ0xNOQezsKa63eogw8YS3vzjUcPP5BJuVzfPfGd5NVUdT-vSSwxk3wvk_jtNqhrpcoG0elRPQfMVsQWmxCAXCVRz3xbcFI8GTe-syynG3l-g1IzYIIZVNI6jdljCZML1HOMTTW4f7uJJ8mM-08oQCeHbr5ejK7O2yMSSYxW03zY-Tj1iVEebROeMv6IEEJNFSS4yM-hLpNAqVuQxFGetwtwjDMC1Drs1dTWrPuUAAjKGrP151z1_dE74M5evpAhZUmpKv1hY-x85DC6N0hFPgowsanmTNNiV75w", + "e":"AAEAAQ" +}`) + jsonKeyC := []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-sCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" +}`) + jsonKeyD := []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-FCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" +}`) + + initialIP, err := net.ParseIP("127.0.0.1").MarshalText() + test.AssertNotError(t, err, "Couldn't create initialIP") + + // Regs A through C have `mailto:` contact ACME URL's + regA = &corepb.Registration{ + Id: 1, + Contact: []string{emailA}, + Key: jsonKeyA, + InitialIP: initialIP, + } + regB = &corepb.Registration{ + Id: 2, + Contact: []string{emailB}, + Key: jsonKeyB, + InitialIP: initialIP, + } + regC = &corepb.Registration{ + Id: 3, + Contact: []string{emailC}, + Key: jsonKeyC, + InitialIP: initialIP, + } + // Reg D has a `tel:` contact ACME URL + regD = &corepb.Registration{ + Id: 4, + Contact: []string{tel}, + Key: jsonKeyD, + InitialIP: initialIP, + } + + // Add the four test registrations + ctx := context.Background() + regA, err = tc.ssa.NewRegistration(ctx, regA) + test.AssertNotError(t, err, "Couldn't store regA") + regB, err = tc.ssa.NewRegistration(ctx, regB) + test.AssertNotError(t, err, "Couldn't store regB") + regC, err = tc.ssa.NewRegistration(ctx, regC) + test.AssertNotError(t, err, "Couldn't store regC") + regD, err = tc.ssa.NewRegistration(ctx, regD) + test.AssertNotError(t, err, "Couldn't store regD") +} + +func (tc testCtx) addCertificates(t *testing.T) { + ctx := context.Background() + serial1 := big.NewInt(1336) + serial1String := core.SerialToString(serial1) + serial2 := big.NewInt(1337) + serial2String := core.SerialToString(serial2) + serial3 := big.NewInt(1338) + serial3String := core.SerialToString(serial3) + serial4 := big.NewInt(1339) + serial4String := core.SerialToString(serial4) + n := bigIntFromB64("n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw==") + e := intFromB64("AQAB") + d := bigIntFromB64("bWUC9B-EFRIo8kpGfh0ZuyGPvMNKvYWNtB_ikiH9k20eT-O1q_I78eiZkpXxXQ0UTEs2LsNRS-8uJbvQ-A1irkwMSMkK1J3XTGgdrhCku9gRldY7sNA_AKZGh-Q661_42rINLRCe8W-nZ34ui_qOfkLnK9QWDDqpaIsA-bMwWWSDFu2MUBYwkHTMEzLYGqOe04noqeq1hExBTHBOBdkMXiuFhUq1BU6l-DqEiWxqg82sXt2h-LMnT3046AOYJoRioz75tSUQfGCshWTBnP5uDjd18kKhyv07lhfSJdrPdM5Plyl21hsFf4L_mHCuoFau7gdsPfHPxxjVOcOpBrQzwQ==") + p := bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") + q := bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") + + testKey := rsa.PrivateKey{ + PublicKey: rsa.PublicKey{N: n, E: e}, + D: d, + Primes: []*big.Int{p, q}, + } + + fc := clock.NewFake() + + // Add one cert for RegA that expires in 30 days + rawCertA := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "happy A", + }, + NotAfter: fc.Now().Add(30 * 24 * time.Hour), + DNSNames: []string{"example-a.com"}, + SerialNumber: serial1, + } + certDerA, _ := x509.CreateCertificate(rand.Reader, &rawCertA, &rawCertA, &testKey.PublicKey, &testKey) + certA := &core.Certificate{ + RegistrationID: regA.Id, + Serial: serial1String, + Expires: rawCertA.NotAfter, + DER: certDerA, + } + err := tc.c.dbMap.Insert(ctx, certA) + test.AssertNotError(t, err, "Couldn't add certA") + _, err = tc.c.dbMap.ExecContext( + ctx, + "INSERT INTO issuedNames (reversedName, serial, notBefore) VALUES (?,?,0)", + "com.example-a", + serial1String, + ) + test.AssertNotError(t, err, "Couldn't add issued name for certA") + + // Add one cert for RegB that already expired 30 days ago + rawCertB := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "happy B", + }, + NotAfter: fc.Now().Add(-30 * 24 * time.Hour), + DNSNames: []string{"example-b.com"}, + SerialNumber: serial2, + } + certDerB, _ := x509.CreateCertificate(rand.Reader, &rawCertB, &rawCertB, &testKey.PublicKey, &testKey) + certB := &core.Certificate{ + RegistrationID: regB.Id, + Serial: serial2String, + Expires: rawCertB.NotAfter, + DER: certDerB, + } + err = tc.c.dbMap.Insert(ctx, certB) + test.AssertNotError(t, err, "Couldn't add certB") + _, err = tc.c.dbMap.ExecContext( + ctx, + "INSERT INTO issuedNames (reversedName, serial, notBefore) VALUES (?,?,0)", + "com.example-b", + serial2String, + ) + test.AssertNotError(t, err, "Couldn't add issued name for certB") + + // Add one cert for RegC that expires in 30 days + rawCertC := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "happy C", + }, + NotAfter: fc.Now().Add(30 * 24 * time.Hour), + DNSNames: []string{"example-c.com"}, + SerialNumber: serial3, + } + certDerC, _ := x509.CreateCertificate(rand.Reader, &rawCertC, &rawCertC, &testKey.PublicKey, &testKey) + certC := &core.Certificate{ + RegistrationID: regC.Id, + Serial: serial3String, + Expires: rawCertC.NotAfter, + DER: certDerC, + } + err = tc.c.dbMap.Insert(ctx, certC) + test.AssertNotError(t, err, "Couldn't add certC") + _, err = tc.c.dbMap.ExecContext( + ctx, + "INSERT INTO issuedNames (reversedName, serial, notBefore) VALUES (?,?,0)", + "com.example-c", + serial3String, + ) + test.AssertNotError(t, err, "Couldn't add issued name for certC") + + // Add one cert for RegD that expires in 30 days + rawCertD := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "happy D", + }, + NotAfter: fc.Now().Add(30 * 24 * time.Hour), + DNSNames: []string{"example-d.com"}, + SerialNumber: serial4, + } + certDerD, _ := x509.CreateCertificate(rand.Reader, &rawCertD, &rawCertD, &testKey.PublicKey, &testKey) + certD := &core.Certificate{ + RegistrationID: regD.Id, + Serial: serial4String, + Expires: rawCertD.NotAfter, + DER: certDerD, + } + err = tc.c.dbMap.Insert(ctx, certD) + test.AssertNotError(t, err, "Couldn't add certD") + _, err = tc.c.dbMap.ExecContext( + ctx, + "INSERT INTO issuedNames (reversedName, serial, notBefore) VALUES (?,?,0)", + "com.example-d", + serial4String, + ) + test.AssertNotError(t, err, "Couldn't add issued name for certD") +} + +func setup(t *testing.T) testCtx { + log := blog.UseMock() + fc := clock.NewFake() + + // Using DBConnSAFullPerms to be able to insert registrations and certificates + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + if err != nil { + t.Fatalf("Couldn't connect the database: %s", err) + } + cleanUp := test.ResetBoulderTestDatabase(t) + + ssa, err := sa.NewSQLStorageAuthority(dbMap, dbMap, nil, 1, 0, fc, log, metrics.NoopRegisterer) + if err != nil { + t.Fatalf("unable to create SQLStorageAuthority: %s", err) + } + + return testCtx{ + c: idExporter{ + dbMap: dbMap, + log: log, + clk: fc, + }, + ssa: isa.SA{Impl: ssa}, + cleanUp: cleanUp, + } +} + +func bigIntFromB64(b64 string) *big.Int { + bytes, _ := base64.URLEncoding.DecodeString(b64) + x := big.NewInt(0) + x.SetBytes(bytes) + return x +} + +func intFromB64(b64 string) int { + return int(bigIntFromB64(b64).Int64()) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/log-validator/main.go b/third-party/github.com/letsencrypt/boulder/cmd/log-validator/main.go new file mode 100644 index 000000000..2d739cd27 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/log-validator/main.go @@ -0,0 +1,50 @@ +package notmain + +import ( + "context" + "flag" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/log/validator" +) + +type Config struct { + Files []string `validate:"min=1,dive,required"` + DebugAddr string `validate:"omitempty,hostname_port"` + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + checkFile := flag.String("check-file", "", "File path to a file to directly validate, if this argument is provided the config will not be parsed and only this file will be inspected") + flag.Parse() + + if *checkFile != "" { + err := validator.ValidateFile(*checkFile) + cmd.FailOnError(err, "validation failed") + return + } + + var config Config + err := cmd.ReadConfigFile(*configFile, &config) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + if *debugAddr != "" { + config.DebugAddr = *debugAddr + } + + stats, logger, oTelShutdown := cmd.StatsAndLogging(config.Syslog, config.OpenTelemetry, config.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + v := validator.New(config.Files, logger, stats) + defer v.Shutdown() + + cmd.WaitForSignal() +} + +func init() { + cmd.RegisterCommand("log-validator", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/nonce-service/main.go b/third-party/github.com/letsencrypt/boulder/cmd/nonce-service/main.go new file mode 100644 index 000000000..cdc634db7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/nonce-service/main.go @@ -0,0 +1,114 @@ +package notmain + +import ( + "context" + "flag" + "fmt" + "net" + "os" + + "github.com/letsencrypt/boulder/cmd" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/nonce" + noncepb "github.com/letsencrypt/boulder/nonce/proto" +) + +type Config struct { + NonceService struct { + cmd.ServiceConfig + + MaxUsed int + + // UseDerivablePrefix indicates whether to use a nonce prefix derived + // from the gRPC listening address. If this is false, the nonce prefix + // will be the value of the NoncePrefix field. If this is true, the + // NoncePrefixKey field is required. + // TODO(#6610): Remove this. + // + // Deprecated: this value is ignored, and treated as though it is always true. + UseDerivablePrefix bool `validate:"-"` + + // NoncePrefixKey is a secret used for deriving the prefix of each nonce + // instance. It should contain 256 bits (32 bytes) of random data to be + // suitable as an HMAC-SHA256 key (e.g. the output of `openssl rand -hex + // 32`). In a multi-DC deployment this value should be the same across + // all boulder-wfe and nonce-service instances. + NoncePrefixKey cmd.PasswordConfig `validate:"required"` + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig + } +} + +func derivePrefix(key string, grpcAddr string) (string, error) { + host, port, err := net.SplitHostPort(grpcAddr) + if err != nil { + return "", fmt.Errorf("parsing gRPC listen address: %w", err) + } + if host == "" { + return "", fmt.Errorf("nonce service gRPC address must include an IP address: got %q", grpcAddr) + } + if host != "" && port != "" { + hostIP := net.ParseIP(host) + if hostIP == nil { + return "", fmt.Errorf("gRPC address host part was not an IP address") + } + if hostIP.IsUnspecified() { + return "", fmt.Errorf("nonce service gRPC address must be a specific IP address: got %q", grpcAddr) + } + } + return nonce.DerivePrefix(grpcAddr, key), nil +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override. Also used to derive the nonce prefix.") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + if *grpcAddr != "" { + c.NonceService.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.NonceService.DebugAddr = *debugAddr + } + + if c.NonceService.NoncePrefixKey.PasswordFile == "" { + cmd.Fail("NoncePrefixKey PasswordFile must be set") + } + + key, err := c.NonceService.NoncePrefixKey.Pass() + cmd.FailOnError(err, "Failed to load 'noncePrefixKey' file.") + noncePrefix, err := derivePrefix(key, c.NonceService.GRPC.Address) + cmd.FailOnError(err, "Failed to derive nonce prefix") + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.NonceService.Syslog, c.NonceService.OpenTelemetry, c.NonceService.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + ns, err := nonce.NewNonceService(scope, c.NonceService.MaxUsed, noncePrefix) + cmd.FailOnError(err, "Failed to initialize nonce service") + + tlsConfig, err := c.NonceService.TLS.Load(scope) + cmd.FailOnError(err, "tlsConfig config") + + nonceServer := nonce.NewServer(ns) + start, err := bgrpc.NewServer(c.NonceService.GRPC, logger).Add( + &noncepb.NonceService_ServiceDesc, nonceServer).Build(tlsConfig, scope, cmd.Clock()) + cmd.FailOnError(err, "Unable to setup nonce service gRPC server") + + cmd.FailOnError(start(), "Nonce service gRPC server failed") +} + +func init() { + cmd.RegisterCommand("nonce-service", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main.go b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main.go new file mode 100644 index 000000000..6c01efd64 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main.go @@ -0,0 +1,619 @@ +package notmain + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/mail" + "os" + "sort" + "strconv" + "strings" + "sync" + "text/template" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + bmail "github.com/letsencrypt/boulder/mail" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/sa" +) + +type mailer struct { + clk clock.Clock + log blog.Logger + dbMap dbSelector + mailer bmail.Mailer + subject string + emailTemplate *template.Template + recipients []recipient + targetRange interval + sleepInterval time.Duration + parallelSends uint +} + +// interval defines a range of email addresses to send to in alphabetical order. +// The `start` field is inclusive and the `end` field is exclusive. To include +// everything, set `end` to \xFF. +type interval struct { + start string + end string +} + +// contactQueryResult is a receiver for queries to the `registrations` table. +type contactQueryResult struct { + // ID is exported to receive the value of `id`. + ID int64 + + // Contact is exported to receive the value of `contact`. + Contact []byte +} + +func (i *interval) ok() error { + if i.start > i.end { + return fmt.Errorf("interval start value (%s) is greater than end value (%s)", + i.start, i.end) + } + return nil +} + +func (i *interval) includes(s string) bool { + return s >= i.start && s < i.end +} + +// ok ensures that both the `targetRange` and `sleepInterval` are valid. +func (m *mailer) ok() error { + err := m.targetRange.ok() + if err != nil { + return err + } + + if m.sleepInterval < 0 { + return fmt.Errorf( + "sleep interval (%d) is < 0", m.sleepInterval) + } + return nil +} + +func (m *mailer) logStatus(to string, current, total int, start time.Time) { + // Should never happen. + if total <= 0 || current < 1 || current > total { + m.log.AuditErrf("Invalid current (%d) or total (%d)", current, total) + } + completion := (float32(current) / float32(total)) * 100 + now := m.clk.Now() + elapsed := now.Sub(start) + m.log.Infof("Sending message (%d) of (%d) to address (%s) [%.2f%%] time elapsed (%s)", + current, total, to, completion, elapsed) +} + +func sortAddresses(input addressToRecipientMap) []string { + var addresses []string + for address := range input { + addresses = append(addresses, address) + } + sort.Strings(addresses) + return addresses +} + +// makeMessageBody is a helper for mailer.run() that's split out for the +// purposes of testing. +func (m *mailer) makeMessageBody(recipients []recipient) (string, error) { + var messageBody strings.Builder + + err := m.emailTemplate.Execute(&messageBody, recipients) + if err != nil { + return "", err + } + + if messageBody.Len() == 0 { + return "", errors.New("templating resulted in an empty message body") + } + return messageBody.String(), nil +} + +func (m *mailer) run(ctx context.Context) error { + err := m.ok() + if err != nil { + return err + } + + totalRecipients := len(m.recipients) + m.log.Infof("Resolving addresses for (%d) recipients", totalRecipients) + + addressToRecipient, err := m.resolveAddresses(ctx) + if err != nil { + return err + } + + totalAddresses := len(addressToRecipient) + if totalAddresses == 0 { + return errors.New("0 recipients remained after resolving addresses") + } + + m.log.Infof("%d recipients were resolved to %d addresses", totalRecipients, totalAddresses) + + var mostRecipients string + var mostRecipientsLen int + for k, v := range addressToRecipient { + if len(v) > mostRecipientsLen { + mostRecipientsLen = len(v) + mostRecipients = k + } + } + + m.log.Infof("Address %q was associated with the most recipients (%d)", + mostRecipients, mostRecipientsLen) + + type work struct { + index int + address string + } + + var wg sync.WaitGroup + workChan := make(chan work, totalAddresses) + + startTime := m.clk.Now() + sortedAddresses := sortAddresses(addressToRecipient) + + if (m.targetRange.start != "" && m.targetRange.start > sortedAddresses[totalAddresses-1]) || + (m.targetRange.end != "" && m.targetRange.end < sortedAddresses[0]) { + return errors.New("Zero found addresses fall inside target range") + } + + go func(ch chan<- work) { + for i, address := range sortedAddresses { + ch <- work{i, address} + } + close(workChan) + }(workChan) + + if m.parallelSends < 1 { + m.parallelSends = 1 + } + + for senderNum := uint(0); senderNum < m.parallelSends; senderNum++ { + // For politeness' sake, don't open more than 1 new connection per + // second. + if senderNum > 0 { + m.clk.Sleep(time.Second) + } + + conn, err := m.mailer.Connect() + if err != nil { + return fmt.Errorf("connecting parallel sender %d: %w", senderNum, err) + } + + wg.Add(1) + go func(conn bmail.Conn, ch <-chan work) { + defer wg.Done() + for w := range ch { + if !m.targetRange.includes(w.address) { + m.log.Debugf("Address %q is outside of target range, skipping", w.address) + continue + } + + err := policy.ValidEmail(w.address) + if err != nil { + m.log.Infof("Skipping %q due to policy violation: %s", w.address, err) + continue + } + + recipients := addressToRecipient[w.address] + m.logStatus(w.address, w.index+1, totalAddresses, startTime) + + messageBody, err := m.makeMessageBody(recipients) + if err != nil { + m.log.Errf("Skipping %q due to templating error: %s", w.address, err) + continue + } + + err = conn.SendMail([]string{w.address}, m.subject, messageBody) + if err != nil { + var badAddrErr bmail.BadAddressSMTPError + if errors.As(err, &badAddrErr) { + m.log.Errf("address %q was rejected by server: %s", w.address, err) + continue + } + m.log.AuditErrf("while sending mail (%d) of (%d) to address %q: %s", + w.index, len(sortedAddresses), w.address, err) + } + + m.clk.Sleep(m.sleepInterval) + } + conn.Close() + }(conn, workChan) + } + wg.Wait() + + return nil +} + +// resolveAddresses creates a mapping of email addresses to (a list of) +// `recipient`s that resolve to that email address. +func (m *mailer) resolveAddresses(ctx context.Context) (addressToRecipientMap, error) { + result := make(addressToRecipientMap, len(m.recipients)) + for _, recipient := range m.recipients { + addresses, err := getAddressForID(ctx, recipient.id, m.dbMap) + if err != nil { + return nil, err + } + + for _, address := range addresses { + parsed, err := mail.ParseAddress(address) + if err != nil { + m.log.Errf("Unparsable address %q, skipping ID (%d)", address, recipient.id) + continue + } + result[parsed.Address] = append(result[parsed.Address], recipient) + } + } + return result, nil +} + +// dbSelector abstracts over a subset of methods from `borp.DbMap` objects to +// facilitate mocking in unit tests. +type dbSelector interface { + SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error +} + +// getAddressForID queries the database for the email address associated with +// the provided registration ID. +func getAddressForID(ctx context.Context, id int64, dbMap dbSelector) ([]string, error) { + var result contactQueryResult + err := dbMap.SelectOne(ctx, &result, + `SELECT id, + contact + FROM registrations + WHERE contact NOT IN ('[]', 'null') + AND id = :id;`, + map[string]interface{}{"id": id}) + if err != nil { + if db.IsNoRows(err) { + return []string{}, nil + } + return nil, err + } + + var contacts []string + err = json.Unmarshal(result.Contact, &contacts) + if err != nil { + return nil, err + } + + var addresses []string + for _, contact := range contacts { + if strings.HasPrefix(contact, "mailto:") { + addresses = append(addresses, strings.TrimPrefix(contact, "mailto:")) + } + } + return addresses, nil +} + +// recipient represents a single record from the recipient list file. The 'id' +// column is parsed to the 'id' field, all additional data will be parsed to a +// mapping of column name to value in the 'Data' field. Please inform SRE if you +// make any changes to the exported fields of this struct. These fields are +// referenced in operationally critical e-mail templates used to notify +// subscribers during incident response. +type recipient struct { + // id is the subscriber's ID. + id int64 + + // Data is a mapping of column name to value parsed from a single record in + // the provided recipient list file. It's exported so the contents can be + // accessed by the template package. Please inform SRE if you make any + // changes to this field. + Data map[string]string +} + +// addressToRecipientMap maps email addresses to a list of `recipient`s that +// resolve to that email address. +type addressToRecipientMap map[string][]recipient + +// readRecipientsList parses the contents of a recipient list file into a list +// of `recipient` objects. +func readRecipientsList(filename string, delimiter rune) ([]recipient, string, error) { + f, err := os.Open(filename) + if err != nil { + return nil, "", err + } + + reader := csv.NewReader(f) + reader.Comma = delimiter + + // Parse header. + record, err := reader.Read() + if err != nil { + return nil, "", fmt.Errorf("failed to parse header: %w", err) + } + + if record[0] != "id" { + return nil, "", errors.New("header must begin with \"id\"") + } + + // Collect the names of each header column after `id`. + var dataColumns []string + for _, v := range record[1:] { + dataColumns = append(dataColumns, strings.TrimSpace(v)) + if len(v) == 0 { + return nil, "", errors.New("header contains an empty column") + } + } + + var recordsWithEmptyColumns []int64 + var recordsWithDuplicateIDs []int64 + var probsBuff strings.Builder + stringProbs := func() string { + if len(recordsWithEmptyColumns) != 0 { + fmt.Fprintf(&probsBuff, "ID(s) %v contained empty columns and ", + recordsWithEmptyColumns) + } + + if len(recordsWithDuplicateIDs) != 0 { + fmt.Fprintf(&probsBuff, "ID(s) %v were skipped as duplicates", + recordsWithDuplicateIDs) + } + + if probsBuff.Len() == 0 { + return "" + } + return strings.TrimSuffix(probsBuff.String(), " and ") + } + + // Parse records. + recipientIDs := make(map[int64]bool) + var recipients []recipient + for { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + // Finished parsing the file. + if len(recipients) == 0 { + return nil, stringProbs(), errors.New("no records after header") + } + return recipients, stringProbs(), nil + } else if err != nil { + return nil, "", err + } + + // Ensure the first column of each record can be parsed as a valid + // registration ID. + recordID := record[0] + id, err := strconv.ParseInt(recordID, 10, 64) + if err != nil { + return nil, "", fmt.Errorf( + "%q couldn't be parsed as a registration ID due to: %s", recordID, err) + } + + // Skip records that have the same ID as those read previously. + if recipientIDs[id] { + recordsWithDuplicateIDs = append(recordsWithDuplicateIDs, id) + continue + } + recipientIDs[id] = true + + // Collect the columns of data after `id` into a map. + var emptyColumn bool + data := make(map[string]string) + for i, v := range record[1:] { + if len(v) == 0 { + emptyColumn = true + } + data[dataColumns[i]] = v + } + + // Only used for logging. + if emptyColumn { + recordsWithEmptyColumns = append(recordsWithEmptyColumns, id) + } + + recipients = append(recipients, recipient{id, data}) + } +} + +const usageIntro = ` +Introduction: + +The notification mailer exists to send a message to the contact associated +with a list of registration IDs. The attributes of the message (from address, +subject, and message content) are provided by the command line arguments. The +message content is provided as a path to a template file via the -body argument. + +Provide a list of recipient user ids in a CSV file passed with the -recipientList +flag. The CSV file must have "id" as the first column and may have additional +fields to be interpolated into the email template: + + id, lastIssuance + 1234, "from example.com 2018-12-01" + 5678, "from example.net 2018-12-13" + +The additional fields will be interpolated with Golang templating, e.g.: + + Your last issuance on each account was: + {{ range . }} {{ .Data.lastIssuance }} + {{ end }} + +To help the operator gain confidence in the mailing run before committing fully +three safety features are supported: dry runs, intervals and a sleep between emails. + +The -dryRun=true flag will use a mock mailer that prints message content to +stdout instead of performing an SMTP transaction with a real mailserver. This +can be used when the initial parameters are being tweaked to ensure no real +emails are sent. Using -dryRun=false will send real email. + +Intervals supported via the -start and -end arguments. Only email addresses that +are alphabetically between the -start and -end strings will be sent. This can be used +to break up sending into batches, or more likely to resume sending if a batch is killed, +without resending messages that have already been sent. The -start flag is inclusive and +the -end flag is exclusive. + +Notify-mailer de-duplicates email addresses and groups together the resulting recipient +structs, so a person who has multiple accounts using the same address will only receive +one email. + +During mailing the -sleep argument is used to space out individual messages. +This can be used to ensure that the mailing happens at a steady pace with ample +opportunity for the operator to terminate early in the event of error. The +-sleep flag honours durations with a unit suffix (e.g. 1m for 1 minute, 10s for +10 seconds, etc). Using -sleep=0 will disable the sleep and send at full speed. + +Examples: + Send an email with subject "Hello!" from the email "hello@goodbye.com" with + the contents read from "test_msg_body.txt" to every email associated with the + registration IDs listed in "test_reg_recipients.json", sleeping 10 seconds + between each message: + + notify-mailer -config test/config/notify-mailer.json -body + cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com + -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!" + -sleep 10s -dryRun=false + + Do the same, but only to example@example.com: + + notify-mailer -config test/config/notify-mailer.json + -body cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com + -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!" + -start example@example.com -end example@example.comX + + Send the message starting with example@example.com and emailing every address that's + alphabetically higher: + + notify-mailer -config test/config/notify-mailer.json + -body cmd/notify-mailer/testdata/test_msg_body.txt -from hello@goodbye.com + -recipientList cmd/notify-mailer/testdata/test_msg_recipients.csv -subject "Hello!" + -start example@example.com + +Required arguments: +- body +- config +- from +- subject +- recipientList` + +type Config struct { + NotifyMailer struct { + DB cmd.DBConfig + cmd.SMTPConfig + } + Syslog cmd.SyslogConfig +} + +func main() { + from := flag.String("from", "", "From header for emails. Must be a bare email address.") + subject := flag.String("subject", "", "Subject of emails") + recipientListFile := flag.String("recipientList", "", "File containing a CSV list of registration IDs and extra info.") + parseAsTSV := flag.Bool("tsv", false, "Parse the recipient list file as a TSV.") + bodyFile := flag.String("body", "", "File containing the email body in Golang template format.") + dryRun := flag.Bool("dryRun", true, "Whether to do a dry run.") + sleep := flag.Duration("sleep", 500*time.Millisecond, "How long to sleep between emails.") + parallelSends := flag.Uint("parallelSends", 1, "How many parallel goroutines should process emails") + start := flag.String("start", "", "Alphabetically lowest email address to include.") + end := flag.String("end", "\xFF", "Alphabetically highest email address (exclusive).") + reconnBase := flag.Duration("reconnectBase", 1*time.Second, "Base sleep duration between reconnect attempts") + reconnMax := flag.Duration("reconnectMax", 5*60*time.Second, "Max sleep duration between reconnect attempts after exponential backoff") + configFile := flag.String("config", "", "File containing a JSON config.") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s\n\n", usageIntro) + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + + // Validate required args. + flag.Parse() + if *from == "" || *subject == "" || *bodyFile == "" || *configFile == "" || *recipientListFile == "" { + flag.Usage() + os.Exit(1) + } + + configData, err := os.ReadFile(*configFile) + cmd.FailOnError(err, "Couldn't load JSON config file") + + // Parse JSON config. + var cfg Config + err = json.Unmarshal(configData, &cfg) + cmd.FailOnError(err, "Couldn't unmarshal JSON config file") + + log := cmd.NewLogger(cfg.Syslog) + log.Info(cmd.VersionString()) + + dbMap, err := sa.InitWrappedDb(cfg.NotifyMailer.DB, nil, log) + cmd.FailOnError(err, "While initializing dbMap") + + // Load and parse message body. + template, err := template.ParseFiles(*bodyFile) + cmd.FailOnError(err, "Couldn't parse message template") + + // Ensure that in the event of a missing key, an informative error is + // returned. + template.Option("missingkey=error") + + address, err := mail.ParseAddress(*from) + cmd.FailOnError(err, fmt.Sprintf("Couldn't parse %q to address", *from)) + + recipientListDelimiter := ',' + if *parseAsTSV { + recipientListDelimiter = '\t' + } + recipients, probs, err := readRecipientsList(*recipientListFile, recipientListDelimiter) + cmd.FailOnError(err, "Couldn't populate recipients") + + if probs != "" { + log.Infof("While reading the recipient list file %s", probs) + } + + var mailClient bmail.Mailer + if *dryRun { + log.Infof("Starting %s in dry-run mode", cmd.VersionString()) + mailClient = bmail.NewDryRun(*address, log) + } else { + log.Infof("Starting %s", cmd.VersionString()) + smtpPassword, err := cfg.NotifyMailer.PasswordConfig.Pass() + cmd.FailOnError(err, "Couldn't load SMTP password from file") + + mailClient = bmail.New( + cfg.NotifyMailer.Server, + cfg.NotifyMailer.Port, + cfg.NotifyMailer.Username, + smtpPassword, + nil, + *address, + log, + metrics.NoopRegisterer, + *reconnBase, + *reconnMax) + } + + m := mailer{ + clk: cmd.Clock(), + log: log, + dbMap: dbMap, + mailer: mailClient, + subject: *subject, + recipients: recipients, + emailTemplate: template, + targetRange: interval{ + start: *start, + end: *end, + }, + sleepInterval: *sleep, + parallelSends: *parallelSends, + } + + err = m.run(context.TODO()) + cmd.FailOnError(err, "Couldn't complete") + + log.Info("Completed successfully") +} + +func init() { + cmd.RegisterCommand("notify-mailer", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main_test.go new file mode 100644 index 000000000..4f57069f8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/main_test.go @@ -0,0 +1,782 @@ +package notmain + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "os" + "testing" + "text/template" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/test" +) + +func TestIntervalOK(t *testing.T) { + // Test a number of intervals know to be OK, ensure that no error is + // produced when calling `ok()`. + okCases := []struct { + testInterval interval + }{ + {interval{}}, + {interval{start: "aa", end: "\xFF"}}, + {interval{end: "aa"}}, + {interval{start: "aa", end: "bb"}}, + } + for _, testcase := range okCases { + err := testcase.testInterval.ok() + test.AssertNotError(t, err, "valid interval produced ok() error") + } + + badInterval := interval{start: "bb", end: "aa"} + err := badInterval.ok() + test.AssertError(t, err, "bad interval was considered ok") +} + +func setupMakeRecipientList(t *testing.T, contents string) string { + entryFile, err := os.CreateTemp("", "") + test.AssertNotError(t, err, "couldn't create temp file") + + _, err = entryFile.WriteString(contents) + test.AssertNotError(t, err, "couldn't write contents to temp file") + + err = entryFile.Close() + test.AssertNotError(t, err, "couldn't close temp file") + return entryFile.Name() +} + +func TestReadRecipientList(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 +23,example.net,2018-11-22` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + list, _, err := readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") + + expected := []recipient{ + {id: 10, Data: map[string]string{"date": "2018-11-21", "domainName": "example.com"}}, + {id: 23, Data: map[string]string{"date": "2018-11-22", "domainName": "example.net"}}, + } + test.AssertDeepEquals(t, list, expected) + + contents = `id domainName date +10 example.com 2018-11-21 +23 example.net 2018-11-22` + + entryFile = setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + list, _, err = readRecipientsList(entryFile, '\t') + test.AssertNotError(t, err, "received an error for a valid TSV file") + test.AssertDeepEquals(t, list, expected) +} + +func TestReadRecipientListNoExtraColumns(t *testing.T) { + contents := `id +10 +23` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") +} + +func TestReadRecipientsListFileNoExist(t *testing.T) { + _, _, err := readRecipientsList("doesNotExist", ',') + test.AssertError(t, err, "expected error for a file that doesn't exist") +} + +func TestReadRecipientListWithEmptyColumnInHeader(t *testing.T) { + contents := `id, domainName,,date +10,example.com,2018-11-21 +23,example.net` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "failed to error on CSV file with trailing delimiter in header") + test.AssertDeepEquals(t, err, errors.New("header contains an empty column")) +} + +func TestReadRecipientListWithProblems(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 +23,example.net, +10,example.com,2018-11-22 +42,example.net, +24,example.com,2018-11-21 +24,example.com,2018-11-21 +` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + recipients, probs, err := readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") + test.AssertEquals(t, probs, "ID(s) [23 42] contained empty columns and ID(s) [10 24] were skipped as duplicates") + test.AssertEquals(t, len(recipients), 4) + + // Ensure trailing " and " is trimmed from single problem. + contents = `id, domainName, date +23,example.net, +10,example.com,2018-11-21 +42,example.net, +` + + entryFile = setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, probs, err = readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") + test.AssertEquals(t, probs, "ID(s) [23 42] contained empty columns") +} + +func TestReadRecipientListWithEmptyLine(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 + +23,example.net,2018-11-22` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") +} + +func TestReadRecipientListWithMismatchedColumns(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 +23,example.net` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "failed to error on CSV file with mismatched columns") +} + +func TestReadRecipientListWithDuplicateIDs(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 +10,example.net,2018-11-22` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertNotError(t, err, "received an error for a valid CSV file") +} + +func TestReadRecipientListWithUnparsableID(t *testing.T) { + contents := `id, domainName, date +10,example.com,2018-11-21 +twenty,example.net,2018-11-22` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "expected error for CSV file that contains an unparsable registration ID") +} + +func TestReadRecipientListWithoutIDHeader(t *testing.T) { + contents := `notId, domainName, date +10,example.com,2018-11-21 +twenty,example.net,2018-11-22` + + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "expected error for CSV file missing header field `id`") +} + +func TestReadRecipientListWithNoRecords(t *testing.T) { + contents := `id, domainName, date +` + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "expected error for CSV file containing only a header") +} + +func TestReadRecipientListWithNoHeaderOrRecords(t *testing.T) { + contents := `` + entryFile := setupMakeRecipientList(t, contents) + defer os.Remove(entryFile) + + _, _, err := readRecipientsList(entryFile, ',') + test.AssertError(t, err, "expected error for CSV file containing only a header") + test.AssertErrorIs(t, err, io.EOF) +} + +func TestMakeMessageBody(t *testing.T) { + emailTemplate := `{{range . }} +{{ .Data.date }} +{{ .Data.domainName }} +{{end}}` + + m := &mailer{ + log: blog.UseMock(), + mailer: &mocks.Mailer{}, + emailTemplate: template.Must(template.New("email").Parse(emailTemplate)).Option("missingkey=error"), + sleepInterval: 0, + targetRange: interval{end: "\xFF"}, + clk: clock.NewFake(), + recipients: nil, + dbMap: mockEmailResolver{}, + } + + recipients := []recipient{ + {id: 10, Data: map[string]string{"date": "2018-11-21", "domainName": "example.com"}}, + {id: 23, Data: map[string]string{"date": "2018-11-22", "domainName": "example.net"}}, + } + + expectedMessageBody := ` +2018-11-21 +example.com + +2018-11-22 +example.net +` + + // Ensure that a very basic template with 2 recipients can be successfully + // executed. + messageBody, err := m.makeMessageBody(recipients) + test.AssertNotError(t, err, "failed to execute a valid template") + test.AssertEquals(t, messageBody, expectedMessageBody) + + // With no recipients we should get an empty body error. + recipients = []recipient{} + _, err = m.makeMessageBody(recipients) + test.AssertError(t, err, "should have errored on empty body") + + // With a missing key we should get an informative templating error. + recipients = []recipient{{id: 10, Data: map[string]string{"domainName": "example.com"}}} + _, err = m.makeMessageBody(recipients) + test.AssertEquals(t, err.Error(), "template: email:2:8: executing \"email\" at <.Data.date>: map has no entry for key \"date\"") +} + +func TestSleepInterval(t *testing.T) { + const sleepLen = 10 + mc := &mocks.Mailer{} + dbMap := mockEmailResolver{} + tmpl := template.Must(template.New("letter").Parse("an email body")) + recipients := []recipient{{id: 1}, {id: 2}, {id: 3}} + // Set up a mock mailer that sleeps for `sleepLen` seconds and only has one + // goroutine to process results + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + emailTemplate: tmpl, + sleepInterval: sleepLen * time.Second, + parallelSends: 1, + targetRange: interval{start: "", end: "\xFF"}, + clk: clock.NewFake(), + recipients: recipients, + dbMap: dbMap, + } + + // Call run() - this should sleep `sleepLen` per destination address + // After it returns, we expect (sleepLen * number of destinations) seconds has + // elapsed + err := m.run(context.Background()) + test.AssertNotError(t, err, "error calling mailer run()") + expectedEnd := clock.NewFake() + expectedEnd.Add(time.Second * time.Duration(sleepLen*len(recipients))) + test.AssertEquals(t, m.clk.Now(), expectedEnd.Now()) + + // Set up a mock mailer that doesn't sleep at all + m = &mailer{ + log: blog.UseMock(), + mailer: mc, + emailTemplate: tmpl, + sleepInterval: 0, + targetRange: interval{end: "\xFF"}, + clk: clock.NewFake(), + recipients: recipients, + dbMap: dbMap, + } + + // Call run() - this should blast through all destinations without sleep + // After it returns, we expect no clock time to have elapsed on the fake clock + err = m.run(context.Background()) + test.AssertNotError(t, err, "error calling mailer run()") + expectedEnd = clock.NewFake() + test.AssertEquals(t, m.clk.Now(), expectedEnd.Now()) +} + +func TestMailIntervals(t *testing.T) { + const testSubject = "Test Subject" + dbMap := mockEmailResolver{} + + tmpl := template.Must(template.New("letter").Parse("an email body")) + recipients := []recipient{{id: 1}, {id: 2}, {id: 3}} + + mc := &mocks.Mailer{} + + // Create a mailer with a checkpoint interval larger than any of the + // destination email addresses. + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: recipients, + emailTemplate: tmpl, + targetRange: interval{start: "\xFF", end: "\xFF\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer. It should produce an error about the interval start + mc.Clear() + err := m.run(context.Background()) + test.AssertError(t, err, "expected error") + test.AssertEquals(t, len(mc.Messages), 0) + + // Create a mailer with a negative sleep interval + m = &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: recipients, + emailTemplate: tmpl, + targetRange: interval{}, + sleepInterval: -10, + clk: clock.NewFake(), + } + + // Run the mailer. It should produce an error about the sleep interval + mc.Clear() + err = m.run(context.Background()) + test.AssertEquals(t, len(mc.Messages), 0) + test.AssertEquals(t, err.Error(), "sleep interval (-10) is < 0") + + // Create a mailer with an interval starting with a specific email address. + // It should send email to that address and others alphabetically higher. + m = &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: []recipient{{id: 1}, {id: 2}, {id: 3}, {id: 4}}, + emailTemplate: tmpl, + targetRange: interval{start: "test-example-updated@letsencrypt.org", end: "\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer. Two messages should have been produced, one to + // test-example-updated@letsencrypt.org (beginning of the range), + // and one to test-test-test@letsencrypt.org. + mc.Clear() + err = m.run(context.Background()) + test.AssertNotError(t, err, "run() produced an error") + test.AssertEquals(t, len(mc.Messages), 2) + test.AssertEquals(t, mocks.MailerMessage{ + To: "test-example-updated@letsencrypt.org", + Subject: testSubject, + Body: "an email body", + }, mc.Messages[0]) + test.AssertEquals(t, mocks.MailerMessage{ + To: "test-test-test@letsencrypt.org", + Subject: testSubject, + Body: "an email body", + }, mc.Messages[1]) + + // Create a mailer with a checkpoint interval ending before + // "test-example-updated@letsencrypt.org" + m = &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: []recipient{{id: 1}, {id: 2}, {id: 3}, {id: 4}}, + emailTemplate: tmpl, + targetRange: interval{end: "test-example-updated@letsencrypt.org"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer. Two messages should have been produced, one to + // example@letsencrypt.org (ID 1), one to example-example-example@example.com (ID 2) + mc.Clear() + err = m.run(context.Background()) + test.AssertNotError(t, err, "run() produced an error") + test.AssertEquals(t, len(mc.Messages), 2) + test.AssertEquals(t, mocks.MailerMessage{ + To: "example-example-example@letsencrypt.org", + Subject: testSubject, + Body: "an email body", + }, mc.Messages[0]) + test.AssertEquals(t, mocks.MailerMessage{ + To: "example@letsencrypt.org", + Subject: testSubject, + Body: "an email body", + }, mc.Messages[1]) +} + +func TestParallelism(t *testing.T) { + const testSubject = "Test Subject" + dbMap := mockEmailResolver{} + + tmpl := template.Must(template.New("letter").Parse("an email body")) + recipients := []recipient{{id: 1}, {id: 2}, {id: 3}, {id: 4}} + + mc := &mocks.Mailer{} + + // Create a mailer with 10 parallel workers. + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: recipients, + emailTemplate: tmpl, + targetRange: interval{end: "\xFF"}, + sleepInterval: 0, + parallelSends: 10, + clk: clock.NewFake(), + } + + mc.Clear() + err := m.run(context.Background()) + test.AssertNotError(t, err, "run() produced an error") + + // The fake clock should have advanced 9 seconds, one for each parallel + // goroutine after the first doing its polite 1-second sleep at startup. + expectedEnd := clock.NewFake() + expectedEnd.Add(9 * time.Second) + test.AssertEquals(t, m.clk.Now(), expectedEnd.Now()) + + // A message should have been sent to all four addresses. + test.AssertEquals(t, len(mc.Messages), 4) + expectedAddresses := []string{ + "example@letsencrypt.org", + "test-example-updated@letsencrypt.org", + "test-test-test@letsencrypt.org", + "example-example-example@letsencrypt.org", + } + for _, msg := range mc.Messages { + test.AssertSliceContains(t, expectedAddresses, msg.To) + } +} + +func TestMessageContentStatic(t *testing.T) { + // Create a mailer with fixed content + const ( + testSubject = "Test Subject" + ) + dbMap := mockEmailResolver{} + mc := &mocks.Mailer{} + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: testSubject, + recipients: []recipient{{id: 1}}, + emailTemplate: template.Must(template.New("letter").Parse("an email body")), + targetRange: interval{end: "\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer, one message should have been created with the content + // expected + err := m.run(context.Background()) + test.AssertNotError(t, err, "error calling mailer run()") + test.AssertEquals(t, len(mc.Messages), 1) + test.AssertEquals(t, mocks.MailerMessage{ + To: "example@letsencrypt.org", + Subject: testSubject, + Body: "an email body", + }, mc.Messages[0]) +} + +// Send mail with a variable interpolated. +func TestMessageContentInterpolated(t *testing.T) { + recipients := []recipient{ + { + id: 1, + Data: map[string]string{ + "validationMethod": "eyeballing it", + }, + }, + } + dbMap := mockEmailResolver{} + mc := &mocks.Mailer{} + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: "Test Subject", + recipients: recipients, + emailTemplate: template.Must(template.New("letter").Parse( + `issued by {{range .}}{{ .Data.validationMethod }}{{end}}`)), + targetRange: interval{end: "\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer, one message should have been created with the content + // expected + err := m.run(context.Background()) + test.AssertNotError(t, err, "error calling mailer run()") + test.AssertEquals(t, len(mc.Messages), 1) + test.AssertEquals(t, mocks.MailerMessage{ + To: "example@letsencrypt.org", + Subject: "Test Subject", + Body: "issued by eyeballing it", + }, mc.Messages[0]) +} + +// Send mail with a variable interpolated multiple times for accounts that share +// an email address. +func TestMessageContentInterpolatedMultiple(t *testing.T) { + recipients := []recipient{ + { + id: 200, + Data: map[string]string{ + "domain": "blog.example.com", + }, + }, + { + id: 201, + Data: map[string]string{ + "domain": "nas.example.net", + }, + }, + { + id: 202, + Data: map[string]string{ + "domain": "mail.example.org", + }, + }, + { + id: 203, + Data: map[string]string{ + "domain": "panel.example.net", + }, + }, + } + dbMap := mockEmailResolver{} + mc := &mocks.Mailer{} + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: "Test Subject", + recipients: recipients, + emailTemplate: template.Must(template.New("letter").Parse( + `issued for: +{{range .}}{{ .Data.domain }} +{{end}}Thanks`)), + targetRange: interval{end: "\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + // Run the mailer, one message should have been created with the content + // expected + err := m.run(context.Background()) + test.AssertNotError(t, err, "error calling mailer run()") + test.AssertEquals(t, len(mc.Messages), 1) + test.AssertEquals(t, mocks.MailerMessage{ + To: "gotta.lotta.accounts@letsencrypt.org", + Subject: "Test Subject", + Body: `issued for: +blog.example.com +nas.example.net +mail.example.org +panel.example.net +Thanks`, + }, mc.Messages[0]) +} + +// the `mockEmailResolver` implements the `dbSelector` interface from +// `notify-mailer/main.go` to allow unit testing without using a backing +// database +type mockEmailResolver struct{} + +// the `mockEmailResolver` select method treats the requested reg ID as an index +// into a list of anonymous structs +func (bs mockEmailResolver) SelectOne(ctx context.Context, output interface{}, _ string, args ...interface{}) error { + // The "dbList" is just a list of contact records in memory + dbList := []contactQueryResult{ + { + ID: 1, + Contact: []byte(`["mailto:example@letsencrypt.org"]`), + }, + { + ID: 2, + Contact: []byte(`["mailto:test-example-updated@letsencrypt.org"]`), + }, + { + ID: 3, + Contact: []byte(`["mailto:test-test-test@letsencrypt.org"]`), + }, + { + ID: 4, + Contact: []byte(`["mailto:example-example-example@letsencrypt.org"]`), + }, + { + ID: 5, + Contact: []byte(`["mailto:youve.got.mail@letsencrypt.org"]`), + }, + { + ID: 6, + Contact: []byte(`["mailto:mail@letsencrypt.org"]`), + }, + { + ID: 7, + Contact: []byte(`["mailto:***********"]`), + }, + { + ID: 200, + Contact: []byte(`["mailto:gotta.lotta.accounts@letsencrypt.org"]`), + }, + { + ID: 201, + Contact: []byte(`["mailto:gotta.lotta.accounts@letsencrypt.org"]`), + }, + { + ID: 202, + Contact: []byte(`["mailto:gotta.lotta.accounts@letsencrypt.org"]`), + }, + { + ID: 203, + Contact: []byte(`["mailto:gotta.lotta.accounts@letsencrypt.org"]`), + }, + { + ID: 204, + Contact: []byte(`["mailto:gotta.lotta.accounts@letsencrypt.org"]`), + }, + } + + // Play the type cast game so that we can dig into the arguments map and get + // out an int64 `id` parameter. + argsRaw := args[0] + argsMap, ok := argsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("incorrect args type %T", args) + } + idRaw := argsMap["id"] + id, ok := idRaw.(int64) + if !ok { + return fmt.Errorf("incorrect args ID type %T", id) + } + + // Play the type cast game to get a `*contactQueryResult` so we can write + // the result from the db list. + outputPtr, ok := output.(*contactQueryResult) + if !ok { + return fmt.Errorf("incorrect output type %T", output) + } + + for _, v := range dbList { + if v.ID == id { + *outputPtr = v + } + } + if outputPtr.ID == 0 { + return db.ErrDatabaseOp{ + Op: "select one", + Table: "registrations", + Err: sql.ErrNoRows, + } + } + return nil +} + +func TestResolveEmails(t *testing.T) { + // Start with three reg. IDs. Note: the IDs have been matched with fake + // results in the `db` slice in `mockEmailResolver`'s `SelectOne`. If you add + // more test cases here you must also add the corresponding DB result in the + // mock. + recipients := []recipient{ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + // This registration ID deliberately doesn't exist in the mock data to make + // sure this case is handled gracefully + { + id: 999, + }, + // This registration ID deliberately returns an invalid email to make sure any + // invalid contact info that slipped into the DB once upon a time will be ignored + { + id: 7, + }, + { + id: 200, + }, + { + id: 201, + }, + { + id: 202, + }, + { + id: 203, + }, + { + id: 204, + }, + } + + tmpl := template.Must(template.New("letter").Parse("an email body")) + + dbMap := mockEmailResolver{} + mc := &mocks.Mailer{} + m := &mailer{ + log: blog.UseMock(), + mailer: mc, + dbMap: dbMap, + subject: "Test", + recipients: recipients, + emailTemplate: tmpl, + targetRange: interval{end: "\xFF"}, + sleepInterval: 0, + clk: clock.NewFake(), + } + + addressesToRecipients, err := m.resolveAddresses(context.Background()) + test.AssertNotError(t, err, "failed to resolveEmailAddresses") + + expected := []string{ + "example@letsencrypt.org", + "test-example-updated@letsencrypt.org", + "test-test-test@letsencrypt.org", + "gotta.lotta.accounts@letsencrypt.org", + } + + test.AssertEquals(t, len(addressesToRecipients), len(expected)) + for _, address := range expected { + if _, ok := addressesToRecipients[address]; !ok { + t.Errorf("missing entry in addressesToRecipients: %q", address) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_body.txt b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_body.txt new file mode 100644 index 000000000..16417d92c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_body.txt @@ -0,0 +1,3 @@ +This is a test message body regarding these domains: +{{ range . }} {{ .Extra.domainName }} +{{ end }} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_recipients.csv b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_recipients.csv new file mode 100644 index 000000000..ce3b9f86a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/notify-mailer/testdata/test_msg_recipients.csv @@ -0,0 +1,4 @@ +id,domainName +1,one.example.com +2,two.example.net +3,three.example.org diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main.go b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main.go new file mode 100644 index 000000000..4c14ead1e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main.go @@ -0,0 +1,294 @@ +package notmain + +import ( + "context" + "flag" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics/measured_http" + "github.com/letsencrypt/boulder/ocsp/responder" + "github.com/letsencrypt/boulder/ocsp/responder/live" + redis_responder "github.com/letsencrypt/boulder/ocsp/responder/redis" + rapb "github.com/letsencrypt/boulder/ra/proto" + rocsp_config "github.com/letsencrypt/boulder/rocsp/config" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type Config struct { + OCSPResponder struct { + DebugAddr string `validate:"omitempty,hostname_port"` + DB cmd.DBConfig `validate:"required_without_all=Source SAService,structonly"` + + // Source indicates the source of pre-signed OCSP responses to be used. It + // can be a DBConnect string or a file URL. The file URL style is used + // when responding from a static file for intermediates and roots. + // If DBConfig has non-empty fields, it takes precedence over this. + Source string `validate:"required_without_all=DB.DBConnectFile SAService Redis"` + + // The list of issuer certificates, against which OCSP requests/responses + // are checked to ensure we're not responding for anyone else's certs. + IssuerCerts []string `validate:"min=1,dive,required"` + + Path string + + // ListenAddress is the address:port on which to listen for incoming + // OCSP requests. This has a default value of ":80". + ListenAddress string `validate:"omitempty,hostname_port"` + + // When to timeout a request. This should be slightly lower than the + // upstream's timeout when making request to ocsp-responder. + Timeout config.Duration `validate:"-"` + + // How often a response should be signed when using Redis/live-signing + // path. This has a default value of 60h. + LiveSigningPeriod config.Duration `validate:"-"` + + // A limit on how many requests to the RA (and onwards to the CA) will + // be made to sign responses that are not fresh in the cache. This + // should be set to somewhat less than + // (HSM signing capacity) / (number of ocsp-responders). + // Requests that would exceed this limit will block until capacity is + // available and eventually serve an HTTP 500 Internal Server Error. + // This has a default value of 1000. + MaxInflightSignings int `validate:"min=0"` + + // A limit on how many goroutines can be waiting for a signing slot at + // a time. When this limit is exceeded, additional signing requests + // will immediately serve an HTTP 500 Internal Server Error until + // we are back below the limit. This provides load shedding for when + // inbound requests arrive faster than our ability to sign them. + // The default of 0 means "no limit." A good value for this is the + // longest queue we can expect to process before a timeout. For + // instance, if the timeout is 5 seconds, and a signing takes 20ms, + // and we have MaxInflightSignings = 40, we can expect to process + // 40 * 5 / 0.02 = 10,000 requests before the oldest request times out. + MaxSigningWaiters int `validate:"min=0"` + + ShutdownStopTimeout config.Duration + + RequiredSerialPrefixes []string `validate:"omitempty,dive,hexadecimal"` + + Features features.Config + + // Configuration for using Redis as a cache. This configuration should + // allow for both read and write access. + Redis *rocsp_config.RedisConfig `validate:"required_without=Source"` + + // TLS client certificate, private key, and trusted root bundle. + TLS cmd.TLSConfig `validate:"required_without=Source,structonly"` + + // RAService configures how to communicate with the RA when it is necessary + // to generate a fresh OCSP response. + RAService *cmd.GRPCClientConfig + + // SAService configures how to communicate with the SA to look up + // certificate status metadata used to confirm/deny that the response from + // Redis is up-to-date. + SAService *cmd.GRPCClientConfig `validate:"required_without_all=DB.DBConnectFile Source"` + + // LogSampleRate sets how frequently error logs should be emitted. This + // avoids flooding the logs during outages. 1 out of N log lines will be emitted. + // If LogSampleRate is 0, no logs will be emitted. + LogSampleRate int `validate:"min=0"` + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig + + // OpenTelemetryHTTPConfig configures tracing on incoming HTTP requests + OpenTelemetryHTTPConfig cmd.OpenTelemetryHTTPConfig +} + +func main() { + listenAddr := flag.String("addr", "", "OCSP listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + + if *configFile == "" { + fmt.Fprintf(os.Stderr, `Usage of %s: +Config JSON should contain either a DBConnectFile or a Source value containing a file: URL. +If Source is a file: URL, the file should contain a list of OCSP responses in base64-encoded DER, +as generated by Boulder's ceremony command. +`, os.Args[0]) + flag.PrintDefaults() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + features.Set(c.OCSPResponder.Features) + + if *listenAddr != "" { + c.OCSPResponder.ListenAddress = *listenAddr + } + if *debugAddr != "" { + c.OCSPResponder.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.OCSPResponder.DebugAddr) + logger.Info(cmd.VersionString()) + + clk := cmd.Clock() + + var source responder.Source + + if strings.HasPrefix(c.OCSPResponder.Source, "file:") { + url, err := url.Parse(c.OCSPResponder.Source) + cmd.FailOnError(err, "Source was not a URL") + filename := url.Path + // Go interprets cwd-relative file urls (file:test/foo.txt) as having the + // relative part of the path in the 'Opaque' field. + if filename == "" { + filename = url.Opaque + } + source, err = responder.NewMemorySourceFromFile(filename, logger) + cmd.FailOnError(err, fmt.Sprintf("Couldn't read file: %s", url.Path)) + } else { + // Set up the redis source and the combined multiplex source. + rocspRWClient, err := rocsp_config.MakeClient(c.OCSPResponder.Redis, clk, scope) + cmd.FailOnError(err, "Could not make redis client") + + err = rocspRWClient.Ping(context.Background()) + cmd.FailOnError(err, "pinging Redis") + + liveSigningPeriod := c.OCSPResponder.LiveSigningPeriod.Duration + if liveSigningPeriod == 0 { + liveSigningPeriod = 60 * time.Hour + } + + tlsConfig, err := c.OCSPResponder.TLS.Load(scope) + cmd.FailOnError(err, "TLS config") + + raConn, err := bgrpc.ClientSetup(c.OCSPResponder.RAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA") + rac := rapb.NewRegistrationAuthorityClient(raConn) + + maxInflight := c.OCSPResponder.MaxInflightSignings + if maxInflight == 0 { + maxInflight = 1000 + } + liveSource := live.New(rac, int64(maxInflight), c.OCSPResponder.MaxSigningWaiters) + + rocspSource, err := redis_responder.NewRedisSource(rocspRWClient, liveSource, liveSigningPeriod, clk, scope, logger, c.OCSPResponder.LogSampleRate) + cmd.FailOnError(err, "Could not create redis source") + + var dbMap *db.WrappedMap + if c.OCSPResponder.DB != (cmd.DBConfig{}) { + dbMap, err = sa.InitWrappedDb(c.OCSPResponder.DB, scope, logger) + cmd.FailOnError(err, "While initializing dbMap") + } + + var sac sapb.StorageAuthorityReadOnlyClient + if c.OCSPResponder.SAService != nil { + saConn, err := bgrpc.ClientSetup(c.OCSPResponder.SAService, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac = sapb.NewStorageAuthorityReadOnlyClient(saConn) + } + + source, err = redis_responder.NewCheckedRedisSource(rocspSource, dbMap, sac, scope, logger) + cmd.FailOnError(err, "Could not create checkedRedis source") + } + + // Load the certificate from the file path. + issuerCerts := make([]*issuance.Certificate, len(c.OCSPResponder.IssuerCerts)) + for i, issuerFile := range c.OCSPResponder.IssuerCerts { + issuerCert, err := issuance.LoadCertificate(issuerFile) + cmd.FailOnError(err, "Could not load issuer cert") + issuerCerts[i] = issuerCert + } + + source, err = responder.NewFilterSource( + issuerCerts, + c.OCSPResponder.RequiredSerialPrefixes, + source, + scope, + logger, + clk, + ) + cmd.FailOnError(err, "Could not create filtered source") + + m := mux(c.OCSPResponder.Path, source, c.OCSPResponder.Timeout.Duration, scope, c.OpenTelemetryHTTPConfig.Options(), logger, c.OCSPResponder.LogSampleRate) + + if c.OCSPResponder.ListenAddress == "" { + cmd.Fail("HTTP listen address is not configured") + } + + logger.Infof("HTTP server listening on %s", c.OCSPResponder.ListenAddress) + + srv := &http.Server{ + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + Addr: c.OCSPResponder.ListenAddress, + Handler: m, + } + + err = srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running HTTP server") + } + + // When main is ready to exit (because it has received a shutdown signal), + // gracefully shutdown the servers. Calling these shutdown functions causes + // ListenAndServe() to immediately return, cleaning up the server goroutines + // as well, then waits for any lingering connection-handing goroutines to + // finish and clean themselves up. + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), + c.OCSPResponder.ShutdownStopTimeout.Duration) + defer cancel() + _ = srv.Shutdown(ctx) + oTelShutdown(ctx) + }() + + cmd.WaitForSignal() +} + +// ocspMux partially implements the interface defined for http.ServeMux but doesn't implement +// the path cleaning its Handler method does. Notably http.ServeMux will collapse repeated +// slashes into a single slash which breaks the base64 encoding that is used in OCSP GET +// requests. ocsp.Responder explicitly recommends against using http.ServeMux +// for this reason. +type ocspMux struct { + handler http.Handler +} + +func (om *ocspMux) Handler(_ *http.Request) (http.Handler, string) { + return om.handler, "/" +} + +func mux(responderPath string, source responder.Source, timeout time.Duration, stats prometheus.Registerer, oTelHTTPOptions []otelhttp.Option, logger blog.Logger, sampleRate int) http.Handler { + stripPrefix := http.StripPrefix(responderPath, responder.NewResponder(source, timeout, stats, logger, sampleRate)) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/" { + w.Header().Set("Cache-Control", "max-age=43200") // Cache for 12 hours + w.WriteHeader(200) + return + } + stripPrefix.ServeHTTP(w, r) + }) + return measured_http.New(&ocspMux{h}, cmd.Clock(), stats, oTelHTTPOptions...) +} + +func init() { + cmd.RegisterCommand("ocsp-responder", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main_test.go b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main_test.go new file mode 100644 index 000000000..32e90ebd5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/main_test.go @@ -0,0 +1,71 @@ +package notmain + +import ( + "bytes" + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/crypto/ocsp" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/ocsp/responder" + "github.com/letsencrypt/boulder/test" +) + +func TestMux(t *testing.T) { + reqBytes, err := os.ReadFile("./testdata/ocsp.req") + test.AssertNotError(t, err, "failed to read OCSP request") + req, err := ocsp.ParseRequest(reqBytes) + test.AssertNotError(t, err, "failed to parse OCSP request") + + doubleSlashBytes, err := base64.StdEncoding.DecodeString("MFMwUTBPME0wSzAJBgUrDgMCGgUABBR+5mrncpqz/PiiIGRsFqEtYHEIXQQUqEpqYwR93brm0Tm3pkVl7/Oo7KECEgO/AC2R1FW8hePAj4xp//8Jhw==") + test.AssertNotError(t, err, "failed to decode double slash OCSP request") + doubleSlashReq, err := ocsp.ParseRequest(doubleSlashBytes) + test.AssertNotError(t, err, "failed to parse double slash OCSP request") + + respBytes, err := os.ReadFile("./testdata/ocsp.resp") + test.AssertNotError(t, err, "failed to read OCSP response") + resp, err := ocsp.ParseResponse(respBytes, nil) + test.AssertNotError(t, err, "failed to parse OCSP response") + + responses := map[string]*responder.Response{ + req.SerialNumber.String(): {Response: resp, Raw: respBytes}, + doubleSlashReq.SerialNumber.String(): {Response: resp, Raw: respBytes}, + } + src, err := responder.NewMemorySource(responses, blog.NewMock()) + test.AssertNotError(t, err, "failed to create inMemorySource") + + h := mux("/foobar/", src, time.Second, metrics.NoopRegisterer, []otelhttp.Option{}, blog.NewMock(), 1000) + + type muxTest struct { + method string + path string + reqBody []byte + respBody []byte + } + mts := []muxTest{ + {"POST", "/foobar/", reqBytes, respBytes}, + {"GET", "/", nil, nil}, + {"GET", "/foobar/MFMwUTBPME0wSzAJBgUrDgMCGgUABBR+5mrncpqz/PiiIGRsFqEtYHEIXQQUqEpqYwR93brm0Tm3pkVl7/Oo7KECEgO/AC2R1FW8hePAj4xp//8Jhw==", nil, respBytes}, + } + for i, mt := range mts { + w := httptest.NewRecorder() + r, err := http.NewRequest(mt.method, mt.path, bytes.NewReader(mt.reqBody)) + if err != nil { + t.Fatalf("#%d, NewRequest: %s", i, err) + } + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Errorf("Code: want %d, got %d", http.StatusOK, w.Code) + } + if !bytes.Equal(w.Body.Bytes(), mt.respBody) { + t.Errorf("Mismatched body: want %#v, got %#v", mt.respBody, w.Body.Bytes()) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.req b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.req new file mode 100644 index 000000000..587871502 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.req differ diff --git a/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.resp b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.resp new file mode 100644 index 000000000..a35f0bb9f Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/cmd/ocsp-responder/testdata/ocsp.resp differ diff --git a/third-party/github.com/letsencrypt/boulder/cmd/registry.go b/third-party/github.com/letsencrypt/boulder/cmd/registry.go new file mode 100644 index 000000000..2c2240537 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/registry.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + "reflect" + "sort" + "sync" + + "github.com/letsencrypt/validator/v10" +) + +type ConfigValidator struct { + Config interface{} + Validators map[string]validator.Func +} + +var registry struct { + sync.Mutex + commands map[string]func() + configs map[string]*ConfigValidator +} + +// RegisterCommand registers a subcommand and its corresponding config +// validator. The provided func() is called when the subcommand is invoked on +// the command line. The ConfigValidator is optional and used to validate the +// config file for the subcommand. +func RegisterCommand(name string, f func(), cv *ConfigValidator) { + registry.Lock() + defer registry.Unlock() + + if registry.commands == nil { + registry.commands = make(map[string]func()) + } + + if registry.commands[name] != nil { + panic(fmt.Sprintf("command %q was registered twice", name)) + } + registry.commands[name] = f + + if cv == nil { + return + } + + if registry.configs == nil { + registry.configs = make(map[string]*ConfigValidator) + } + + if registry.configs[name] != nil { + panic(fmt.Sprintf("config validator for command %q was registered twice", name)) + } + registry.configs[name] = cv +} + +func LookupCommand(name string) func() { + registry.Lock() + defer registry.Unlock() + return registry.commands[name] +} + +func AvailableCommands() []string { + registry.Lock() + defer registry.Unlock() + var avail []string + for name := range registry.commands { + avail = append(avail, name) + } + sort.Strings(avail) + return avail +} + +// LookupConfigValidator constructs an instance of the *ConfigValidator for the +// given Boulder component name. If no *ConfigValidator was registered, nil is +// returned. +func LookupConfigValidator(name string) *ConfigValidator { + registry.Lock() + defer registry.Unlock() + if registry.configs[name] == nil { + return nil + } + + // Create a new copy of the config struct so that we can validate it + // multiple times without mutating the registry's copy. + copy := reflect.New(reflect.ValueOf( + registry.configs[name].Config).Elem().Type(), + ).Interface() + + return &ConfigValidator{ + Config: copy, + Validators: registry.configs[name].Validators, + } +} + +// AvailableConfigValidators returns a list of Boulder component names for which +// a *ConfigValidator has been registered. +func AvailableConfigValidators() []string { + registry.Lock() + defer registry.Unlock() + var avail []string + for name := range registry.configs { + avail = append(avail, name) + } + sort.Strings(avail) + return avail +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/remoteva/main.go b/third-party/github.com/letsencrypt/boulder/cmd/remoteva/main.go new file mode 100644 index 000000000..9ea068fc0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/remoteva/main.go @@ -0,0 +1,133 @@ +package notmain + +import ( + "context" + "crypto/tls" + "flag" + "os" + "time" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/va" + vaConfig "github.com/letsencrypt/boulder/va/config" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +type Config struct { + RVA struct { + vaConfig.Common + + // SkipGRPCClientCertVerification, when disabled as it should typically + // be, will cause the remoteva server (which receives gRPCs from a + // boulder-va client) to use our default RequireAndVerifyClientCert + // policy. When enabled, the remoteva server will instead use the less + // secure VerifyClientCertIfGiven policy. It should typically be used in + // conjunction with the boulder-va "RVATLSClient" configuration object. + // + // An operator may choose to enable this if the remoteva server is + // logically behind an OSI layer-7 loadbalancer/reverse proxy which + // decrypts traffic and does not/cannot re-encrypt it's own client + // connection to the remoteva server. + // + // Use with caution. + // + // For more information, see: https://pkg.go.dev/crypto/tls#ClientAuthType + SkipGRPCClientCertVerification bool + + Features features.Config + } + + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + grpcAddr := flag.String("addr", "", "gRPC listen address override") + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + err = c.RVA.SetDefaultsAndValidate(grpcAddr, debugAddr) + cmd.FailOnError(err, "Setting and validating default config values") + features.Set(c.RVA.Features) + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.RVA.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + clk := cmd.Clock() + + var servers bdns.ServerProvider + proto := "udp" + if features.Get().DOH { + proto = "tcp" + } + + if len(c.RVA.DNSStaticResolvers) != 0 { + servers, err = bdns.NewStaticProvider(c.RVA.DNSStaticResolvers) + cmd.FailOnError(err, "Couldn't start static DNS server resolver") + } else { + servers, err = bdns.StartDynamicProvider(c.RVA.DNSProvider, 60*time.Second, proto) + cmd.FailOnError(err, "Couldn't start dynamic DNS server resolver") + } + defer servers.Stop() + + tlsConfig, err := c.RVA.TLS.Load(scope) + cmd.FailOnError(err, "tlsConfig config") + + if c.RVA.SkipGRPCClientCertVerification { + tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven + } + + var resolver bdns.Client + if !c.RVA.DNSAllowLoopbackAddresses { + resolver = bdns.New( + c.RVA.DNSTimeout.Duration, + servers, + scope, + clk, + c.RVA.DNSTries, + logger, + tlsConfig) + } else { + resolver = bdns.NewTest( + c.RVA.DNSTimeout.Duration, + servers, + scope, + clk, + c.RVA.DNSTries, + logger, + tlsConfig) + } + + vai, err := va.NewValidationAuthorityImpl( + resolver, + nil, // Our RVAs will never have RVAs of their own. + 0, // Only the VA is concerned with max validation failures + c.RVA.UserAgent, + c.RVA.IssuerDomain, + scope, + clk, + logger, + c.RVA.AccountURIPrefixes) + cmd.FailOnError(err, "Unable to create Remote-VA server") + + start, err := bgrpc.NewServer(c.RVA.GRPC, logger).Add( + &vapb.VA_ServiceDesc, vai).Add( + &vapb.CAA_ServiceDesc, vai).Build(tlsConfig, scope, clk) + cmd.FailOnError(err, "Unable to setup Remote-VA gRPC server") + cmd.FailOnError(start(), "Remote-VA gRPC service failed") +} + +func init() { + cmd.RegisterCommand("remoteva", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/reversed-hostname-checker/main.go b/third-party/github.com/letsencrypt/boulder/cmd/reversed-hostname-checker/main.go new file mode 100644 index 000000000..b0a354d15 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/reversed-hostname-checker/main.go @@ -0,0 +1,62 @@ +// Read a list of reversed hostnames, separated by newlines. Print only those +// that are rejected by the current policy. + +package notmain + +import ( + "bufio" + "flag" + "fmt" + "io" + "log" + "os" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/sa" +) + +func init() { + cmd.RegisterCommand("reversed-hostname-checker", main, nil) +} + +func main() { + inputFilename := flag.String("input", "", "File containing a list of reversed hostnames to check, newline separated. Defaults to stdin") + policyFile := flag.String("policy", "test/hostname-policy.yaml", "File containing a hostname policy in yaml.") + flag.Parse() + + var input io.Reader + var err error + if *inputFilename == "" { + input = os.Stdin + } else { + input, err = os.Open(*inputFilename) + if err != nil { + log.Fatalf("opening %s: %s", *inputFilename, err) + } + } + + scanner := bufio.NewScanner(input) + logger := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7}) + logger.Info(cmd.VersionString()) + pa, err := policy.New(nil, logger) + if err != nil { + log.Fatal(err) + } + err = pa.LoadHostnamePolicyFile(*policyFile) + if err != nil { + log.Fatalf("reading %s: %s", *policyFile, err) + } + var errors bool + for scanner.Scan() { + n := sa.ReverseName(scanner.Text()) + err := pa.WillingToIssue([]string{n}) + if err != nil { + errors = true + fmt.Printf("%s: %s\n", n, err) + } + } + if errors { + os.Exit(1) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client.go b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client.go new file mode 100644 index 000000000..c70fa30aa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client.go @@ -0,0 +1,299 @@ +package notmain + +import ( + "context" + "fmt" + "math/rand" + "os" + "sync/atomic" + "time" + + "github.com/jmhodges/clock" + "golang.org/x/crypto/ocsp" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/rocsp" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +type client struct { + redis *rocsp.RWClient + db *db.WrappedMap // optional + ocspGenerator capb.OCSPGeneratorClient + clk clock.Clock + scanBatchSize int + logger blog.Logger +} + +// processResult represents the result of attempting to sign and store status +// for a single certificateStatus ID. If `err` is non-nil, it indicates the +// attempt failed. +type processResult struct { + id uint64 + err error +} + +func getStartingID(ctx context.Context, clk clock.Clock, db *db.WrappedMap) (int64, error) { + // To scan the DB efficiently, we want to select only currently-valid certificates. There's a + // handy expires index, but for selecting a large set of rows, using the primary key will be + // more efficient. So first we find a good id to start with, then scan from there. Note: since + // AUTO_INCREMENT can skip around a bit, we add padding to ensure we get all currently-valid + // certificates. + startTime := clk.Now().Add(-24 * time.Hour) + var minID *int64 + err := db.QueryRowContext( + ctx, + "SELECT MIN(id) FROM certificateStatus WHERE notAfter >= ?", + startTime, + ).Scan(&minID) + if err != nil { + return 0, fmt.Errorf("selecting minID: %w", err) + } + if minID == nil { + return 0, fmt.Errorf("no entries in certificateStatus (where notAfter >= %s)", startTime) + } + return *minID, nil +} + +func (cl *client) loadFromDB(ctx context.Context, speed ProcessingSpeed, startFromID int64) error { + prevID := startFromID + var err error + if prevID == 0 { + prevID, err = getStartingID(ctx, cl.clk, cl.db) + if err != nil { + return fmt.Errorf("getting starting ID: %w", err) + } + } + + // Find the current maximum id in certificateStatus. We do this because the table is always + // growing. If we scanned until we saw a batch with no rows, we would scan forever. + var maxID *int64 + err = cl.db.QueryRowContext( + ctx, + "SELECT MAX(id) FROM certificateStatus", + ).Scan(&maxID) + if err != nil { + return fmt.Errorf("selecting maxID: %w", err) + } + if maxID == nil { + return fmt.Errorf("no entries in certificateStatus") + } + + // Limit the rate of reading rows. + frequency := time.Duration(float64(time.Second) / float64(time.Duration(speed.RowsPerSecond))) + // a set of all inflight certificate statuses, indexed by their `ID`. + inflightIDs := newInflight() + statusesToSign := cl.scanFromDB(ctx, prevID, *maxID, frequency, inflightIDs) + + results := make(chan processResult, speed.ParallelSigns) + var runningSigners int32 + for range speed.ParallelSigns { + atomic.AddInt32(&runningSigners, 1) + go cl.signAndStoreResponses(ctx, statusesToSign, results, &runningSigners) + } + + var successCount, errorCount int64 + + for result := range results { + inflightIDs.remove(result.id) + if result.err != nil { + errorCount++ + if errorCount < 10 || + (errorCount < 1000 && rand.Intn(1000) < 100) || + (errorCount < 100000 && rand.Intn(1000) < 10) || + (rand.Intn(1000) < 1) { + cl.logger.Errf("error: %s", result.err) + } + } else { + successCount++ + } + + total := successCount + errorCount + if total < 10 || + (total < 1000 && rand.Intn(1000) < 100) || + (total < 100000 && rand.Intn(1000) < 10) || + (rand.Intn(1000) < 1) { + cl.logger.Infof("stored %d responses, %d errors", successCount, errorCount) + } + } + + cl.logger.Infof("done. processed %d successes and %d errors\n", successCount, errorCount) + if inflightIDs.len() != 0 { + return fmt.Errorf("inflightIDs non-empty! has %d items, lowest %d", inflightIDs.len(), inflightIDs.min()) + } + + return nil +} + +// scanFromDB scans certificateStatus rows from the DB, starting with `minID`, and writes them to +// its output channel at a maximum frequency of `frequency`. When it's read all available rows, it +// closes its output channel and exits. +// If there is an error, it logs the error, closes its output channel, and exits. +func (cl *client) scanFromDB(ctx context.Context, prevID int64, maxID int64, frequency time.Duration, inflightIDs *inflight) <-chan *sa.CertStatusMetadata { + statusesToSign := make(chan *sa.CertStatusMetadata) + go func() { + defer close(statusesToSign) + + var err error + currentMin := prevID + for currentMin < maxID { + currentMin, err = cl.scanFromDBOneBatch(ctx, currentMin, frequency, statusesToSign, inflightIDs) + if err != nil { + cl.logger.Infof("error scanning rows: %s", err) + } + } + }() + return statusesToSign +} + +// scanFromDBOneBatch scans up to `cl.scanBatchSize` rows from certificateStatus, in order, and +// writes them to `output`. When done, it returns the highest `id` it saw during the scan. +// We do this in batches because if we tried to scan the whole table in a single query, MariaDB +// would terminate the query after a certain amount of data transferred. +func (cl *client) scanFromDBOneBatch(ctx context.Context, prevID int64, frequency time.Duration, output chan<- *sa.CertStatusMetadata, inflightIDs *inflight) (int64, error) { + rowTicker := time.NewTicker(frequency) + + clauses := "WHERE id > ? ORDER BY id LIMIT ?" + params := []interface{}{prevID, cl.scanBatchSize} + + selector, err := db.NewMappedSelector[sa.CertStatusMetadata](cl.db) + if err != nil { + return -1, fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryContext(ctx, clauses, params...) + if err != nil { + return -1, fmt.Errorf("scanning certificateStatus: %w", err) + } + + var scanned int + var previousID int64 + err = rows.ForEach(func(row *sa.CertStatusMetadata) error { + <-rowTicker.C + + status, err := rows.Get() + if err != nil { + return fmt.Errorf("scanning row %d (previous ID %d): %w", scanned, previousID, err) + } + scanned++ + inflightIDs.add(uint64(status.ID)) + // Emit a log line every 100000 rows. For our current ~215M rows, that + // will emit about 2150 log lines. This probably strikes a good balance + // between too spammy and having a reasonably frequent checkpoint. + if scanned%100000 == 0 { + cl.logger.Infof("scanned %d certificateStatus rows. minimum inflight ID %d", scanned, inflightIDs.min()) + } + output <- status + previousID = status.ID + return nil + }) + if err != nil { + return -1, err + } + + return previousID, nil +} + +// signAndStoreResponses consumes cert statuses on its input channel and writes them to its output +// channel. Before returning, it atomically decrements the provided runningSigners int. If the +// result is 0, indicating this was the last running signer, it closes its output channel. +func (cl *client) signAndStoreResponses(ctx context.Context, input <-chan *sa.CertStatusMetadata, output chan processResult, runningSigners *int32) { + defer func() { + if atomic.AddInt32(runningSigners, -1) <= 0 { + close(output) + } + }() + for status := range input { + ocspReq := &capb.GenerateOCSPRequest{ + Serial: status.Serial, + IssuerID: status.IssuerID, + Status: string(status.Status), + Reason: int32(status.RevokedReason), + RevokedAt: timestamppb.New(status.RevokedDate), + } + result, err := cl.ocspGenerator.GenerateOCSP(ctx, ocspReq) + if err != nil { + output <- processResult{id: uint64(status.ID), err: err} + continue + } + resp, err := ocsp.ParseResponse(result.Response, nil) + if err != nil { + output <- processResult{id: uint64(status.ID), err: err} + continue + } + + err = cl.redis.StoreResponse(ctx, resp) + if err != nil { + output <- processResult{id: uint64(status.ID), err: err} + } else { + output <- processResult{id: uint64(status.ID), err: nil} + } + } +} + +type expiredError struct { + serial string + ago time.Duration +} + +func (e expiredError) Error() string { + return fmt.Sprintf("response for %s expired %s ago", e.serial, e.ago) +} + +func (cl *client) storeResponsesFromFiles(ctx context.Context, files []string) error { + for _, respFile := range files { + respBytes, err := os.ReadFile(respFile) + if err != nil { + return fmt.Errorf("reading response file %q: %w", respFile, err) + } + err = cl.storeResponse(ctx, respBytes) + if err != nil { + return err + } + } + return nil +} + +func (cl *client) storeResponse(ctx context.Context, respBytes []byte) error { + resp, err := ocsp.ParseResponse(respBytes, nil) + if err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + serial := core.SerialToString(resp.SerialNumber) + + if resp.NextUpdate.Before(cl.clk.Now()) { + return expiredError{ + serial: serial, + ago: cl.clk.Now().Sub(resp.NextUpdate), + } + } + + cl.logger.Infof("storing response for %s, generated %s, ttl %g hours", + serial, + resp.ThisUpdate, + time.Until(resp.NextUpdate).Hours(), + ) + + err = cl.redis.StoreResponse(ctx, resp) + if err != nil { + return fmt.Errorf("storing response: %w", err) + } + + retrievedResponse, err := cl.redis.GetResponse(ctx, serial) + if err != nil { + return fmt.Errorf("getting response: %w", err) + } + + parsedRetrievedResponse, err := ocsp.ParseResponse(retrievedResponse, nil) + if err != nil { + return fmt.Errorf("parsing retrieved response: %w", err) + } + cl.logger.Infof("retrieved %s", helper.PrettyResponse(parsedRetrievedResponse)) + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client_test.go b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client_test.go new file mode 100644 index 000000000..ddb11f015 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/client_test.go @@ -0,0 +1,162 @@ +package notmain + +import ( + "context" + "fmt" + "math/big" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/rocsp" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +func makeClient() (*rocsp.RWClient, clock.Clock) { + CACertFile := "../../test/certs/ipki/minica.pem" + CertFile := "../../test/certs/ipki/localhost/cert.pem" + KeyFile := "../../test/certs/ipki/localhost/key.pem" + tlsConfig := cmd.TLSConfig{ + CACertFile: CACertFile, + CertFile: CertFile, + KeyFile: KeyFile, + } + tlsConfig2, err := tlsConfig.Load(metrics.NoopRegisterer) + if err != nil { + panic(err) + } + + rdb := redis.NewRing(&redis.RingOptions{ + Addrs: map[string]string{ + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218", + }, + Username: "unittest-rw", + Password: "824968fa490f4ecec1e52d5e34916bdb60d45f8d", + TLSConfig: tlsConfig2, + }) + clk := clock.NewFake() + return rocsp.NewWritingClient(rdb, 500*time.Millisecond, clk, metrics.NoopRegisterer), clk +} + +func TestGetStartingID(t *testing.T) { + ctx := context.Background() + + clk := clock.NewFake() + dbMap, err := sa.DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "failed setting up db client") + defer test.ResetBoulderTestDatabase(t)() + + cs := core.CertificateStatus{ + Serial: "1337", + NotAfter: clk.Now().Add(12 * time.Hour), + } + err = dbMap.Insert(ctx, &cs) + test.AssertNotError(t, err, "inserting certificate status") + firstID := cs.ID + + cs = core.CertificateStatus{ + Serial: "1338", + NotAfter: clk.Now().Add(36 * time.Hour), + } + err = dbMap.Insert(ctx, &cs) + test.AssertNotError(t, err, "inserting certificate status") + secondID := cs.ID + t.Logf("first ID %d, second ID %d", firstID, secondID) + + clk.Sleep(48 * time.Hour) + + startingID, err := getStartingID(context.Background(), clk, dbMap) + test.AssertNotError(t, err, "getting starting ID") + + test.AssertEquals(t, startingID, secondID) +} + +func TestStoreResponse(t *testing.T) { + redisClient, clk := makeClient() + + issuer, err := core.LoadCert("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "loading int-e1") + + issuerKey, err := test.LoadSigner("../../test/hierarchy/int-e1.key.pem") + test.AssertNotError(t, err, "loading int-e1 key ") + response, err := ocsp.CreateResponse(issuer, issuer, ocsp.Response{ + SerialNumber: big.NewInt(1337), + Status: 0, + ThisUpdate: clk.Now(), + NextUpdate: clk.Now().Add(time.Hour), + }, issuerKey) + test.AssertNotError(t, err, "creating OCSP response") + + cl := client{ + redis: redisClient, + db: nil, + ocspGenerator: nil, + clk: clk, + logger: blog.NewMock(), + } + + err = cl.storeResponse(context.Background(), response) + test.AssertNotError(t, err, "storing response") +} + +type mockOCSPGenerator struct{} + +func (mog mockOCSPGenerator) GenerateOCSP(ctx context.Context, in *capb.GenerateOCSPRequest, opts ...grpc.CallOption) (*capb.OCSPResponse, error) { + return &capb.OCSPResponse{ + Response: []byte("phthpbt"), + }, nil + +} + +func TestLoadFromDB(t *testing.T) { + redisClient, clk := makeClient() + + dbMap, err := sa.DBMapForTest(vars.DBConnSA) + if err != nil { + t.Fatalf("Failed to create dbMap: %s", err) + } + + defer test.ResetBoulderTestDatabase(t) + + for i := range 100 { + err = dbMap.Insert(context.Background(), &core.CertificateStatus{ + Serial: fmt.Sprintf("%036x", i), + NotAfter: clk.Now().Add(200 * time.Hour), + OCSPLastUpdated: clk.Now(), + }) + if err != nil { + t.Fatalf("Failed to insert certificateStatus: %s", err) + } + } + + rocspToolClient := client{ + redis: redisClient, + db: dbMap, + ocspGenerator: mockOCSPGenerator{}, + clk: clk, + scanBatchSize: 10, + logger: blog.NewMock(), + } + + speed := ProcessingSpeed{ + RowsPerSecond: 10000, + ParallelSigns: 100, + } + + err = rocspToolClient.loadFromDB(context.Background(), speed, 0) + if err != nil { + t.Fatalf("loading from DB: %s", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight.go b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight.go new file mode 100644 index 000000000..5a0ca5ba6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight.go @@ -0,0 +1,53 @@ +package notmain + +import "sync" + +type inflight struct { + sync.RWMutex + items map[uint64]struct{} +} + +func newInflight() *inflight { + return &inflight{ + items: make(map[uint64]struct{}), + } +} + +func (i *inflight) add(n uint64) { + i.Lock() + defer i.Unlock() + i.items[n] = struct{}{} +} + +func (i *inflight) remove(n uint64) { + i.Lock() + defer i.Unlock() + delete(i.items, n) +} + +func (i *inflight) len() int { + i.RLock() + defer i.RUnlock() + return len(i.items) +} + +// min returns the numerically smallest key inflight. If nothing is inflight, +// it returns 0. Note: this takes O(n) time in the number of keys and should +// be called rarely. +func (i *inflight) min() uint64 { + i.RLock() + defer i.RUnlock() + if len(i.items) == 0 { + return 0 + } + var min uint64 + for k := range i.items { + if min == 0 { + min = k + } + if k < min { + min = k + } + } + return min +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight_test.go b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight_test.go new file mode 100644 index 000000000..9ce52ee03 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/inflight_test.go @@ -0,0 +1,33 @@ +package notmain + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestInflight(t *testing.T) { + ifl := newInflight() + test.AssertEquals(t, ifl.len(), 0) + test.AssertEquals(t, ifl.min(), uint64(0)) + + ifl.add(1337) + test.AssertEquals(t, ifl.len(), 1) + test.AssertEquals(t, ifl.min(), uint64(1337)) + + ifl.remove(1337) + test.AssertEquals(t, ifl.len(), 0) + test.AssertEquals(t, ifl.min(), uint64(0)) + + ifl.add(7341) + ifl.add(3317) + ifl.add(1337) + test.AssertEquals(t, ifl.len(), 3) + test.AssertEquals(t, ifl.min(), uint64(1337)) + + ifl.remove(3317) + ifl.remove(1337) + ifl.remove(7341) + test.AssertEquals(t, ifl.len(), 0) + test.AssertEquals(t, ifl.min(), uint64(0)) +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/main.go b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/main.go new file mode 100644 index 000000000..f02fd9ef9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/main.go @@ -0,0 +1,268 @@ +package notmain + +import ( + "context" + "encoding/base64" + "encoding/pem" + "flag" + "fmt" + "os" + "strings" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/db" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/metrics" + rocsp_config "github.com/letsencrypt/boulder/rocsp/config" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +type Config struct { + ROCSPTool struct { + DebugAddr string `validate:"omitempty,hostname_port"` + Redis rocsp_config.RedisConfig + + // If using load-from-db, this provides credentials to connect to the DB + // and the CA. Otherwise, it's optional. + LoadFromDB *LoadFromDBConfig + } + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +// LoadFromDBConfig provides the credentials and configuration needed to load +// data from the certificateStatuses table in the DB and get it signed. +type LoadFromDBConfig struct { + // Credentials to connect to the DB. + DB cmd.DBConfig + // Credentials to request OCSP signatures from the CA. + GRPCTLS cmd.TLSConfig + // Timeouts and hostnames for the CA. + OCSPGeneratorService cmd.GRPCClientConfig + // How fast to process rows. + Speed ProcessingSpeed +} + +type ProcessingSpeed struct { + // If using load-from-db, this limits how many items per second we + // scan from the DB. We might go slower than this depending on how fast + // we read rows from the DB, but we won't go faster. Defaults to 2000. + RowsPerSecond int `validate:"min=0"` + // If using load-from-db, this controls how many parallel requests to + // boulder-ca for OCSP signing we can make. Defaults to 100. + ParallelSigns int `validate:"min=0"` + // If using load-from-db, the LIMIT on our scanning queries. We have to + // apply a limit because MariaDB will cut off our response at some + // threshold of total bytes transferred (1 GB by default). Defaults to 10000. + ScanBatchSize int `validate:"min=0"` +} + +func init() { + cmd.RegisterCommand("rocsp-tool", main, &cmd.ConfigValidator{Config: &Config{}}) +} + +func main() { + err := main2() + if err != nil { + cmd.FailOnError(err, "") + } +} + +var startFromID *int64 + +func main2() error { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + startFromID = flag.Int64("start-from-id", 0, "For load-from-db, the first ID in the certificateStatus table to scan") + flag.Usage = helpExit + flag.Parse() + if *configFile == "" || len(flag.Args()) < 1 { + helpExit() + } + + var conf Config + err := cmd.ReadConfigFile(*configFile, &conf) + if err != nil { + return fmt.Errorf("reading JSON config file: %w", err) + } + + if *debugAddr != "" { + conf.ROCSPTool.DebugAddr = *debugAddr + } + + _, logger, oTelShutdown := cmd.StatsAndLogging(conf.Syslog, conf.OpenTelemetry, conf.ROCSPTool.DebugAddr) + defer oTelShutdown(context.Background()) + logger.Info(cmd.VersionString()) + + clk := cmd.Clock() + redisClient, err := rocsp_config.MakeClient(&conf.ROCSPTool.Redis, clk, metrics.NoopRegisterer) + if err != nil { + return fmt.Errorf("making client: %w", err) + } + + var db *db.WrappedMap + var ocspGenerator capb.OCSPGeneratorClient + var scanBatchSize int + if conf.ROCSPTool.LoadFromDB != nil { + lfd := conf.ROCSPTool.LoadFromDB + db, err = sa.InitWrappedDb(lfd.DB, nil, logger) + if err != nil { + return fmt.Errorf("connecting to DB: %w", err) + } + + ocspGenerator, err = configureOCSPGenerator(lfd.GRPCTLS, + lfd.OCSPGeneratorService, clk, metrics.NoopRegisterer) + if err != nil { + return fmt.Errorf("configuring gRPC to CA: %w", err) + } + setDefault(&lfd.Speed.RowsPerSecond, 2000) + setDefault(&lfd.Speed.ParallelSigns, 100) + setDefault(&lfd.Speed.ScanBatchSize, 10000) + scanBatchSize = lfd.Speed.ScanBatchSize + } + + ctx := context.Background() + cl := client{ + redis: redisClient, + db: db, + ocspGenerator: ocspGenerator, + clk: clk, + scanBatchSize: scanBatchSize, + logger: logger, + } + + for _, sc := range subCommands { + if flag.Arg(0) == sc.name { + return sc.cmd(ctx, cl, conf, flag.Args()[1:]) + } + } + fmt.Fprintf(os.Stderr, "unrecognized subcommand %q\n", flag.Arg(0)) + helpExit() + return nil +} + +// subCommand represents a single subcommand. `name` is the name used to invoke it, and `help` is +// its help text. +type subCommand struct { + name string + help string + cmd func(context.Context, client, Config, []string) error +} + +var ( + Store = subCommand{"store", "for each filename on command line, read the file as an OCSP response and store it in Redis", + func(ctx context.Context, cl client, _ Config, args []string) error { + err := cl.storeResponsesFromFiles(ctx, flag.Args()[1:]) + if err != nil { + return err + } + return nil + }, + } + Get = subCommand{ + "get", + "for each serial on command line, fetch that serial's response and pretty-print it", + func(ctx context.Context, cl client, _ Config, args []string) error { + for _, serial := range flag.Args()[1:] { + resp, err := cl.redis.GetResponse(ctx, serial) + if err != nil { + return err + } + parsed, err := ocsp.ParseResponse(resp, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "parsing error on %x: %s", resp, err) + continue + } else { + fmt.Printf("%s\n", helper.PrettyResponse(parsed)) + } + } + return nil + }, + } + GetPEM = subCommand{"get-pem", "for each serial on command line, fetch that serial's response and print it PEM-encoded", + func(ctx context.Context, cl client, _ Config, args []string) error { + for _, serial := range flag.Args()[1:] { + resp, err := cl.redis.GetResponse(ctx, serial) + if err != nil { + return err + } + block := pem.Block{ + Bytes: resp, + Type: "OCSP RESPONSE", + } + err = pem.Encode(os.Stdout, &block) + if err != nil { + return err + } + } + return nil + }, + } + LoadFromDB = subCommand{"load-from-db", "scan the database for all OCSP entries for unexpired certificates, and store in Redis", + func(ctx context.Context, cl client, c Config, args []string) error { + if c.ROCSPTool.LoadFromDB == nil { + return fmt.Errorf("config field LoadFromDB was missing") + } + err := cl.loadFromDB(ctx, c.ROCSPTool.LoadFromDB.Speed, *startFromID) + if err != nil { + return fmt.Errorf("loading OCSP responses from DB: %w", err) + } + return nil + }, + } + ScanResponses = subCommand{"scan-responses", "scan Redis for OCSP response entries. For each entry, print the serial and base64-encoded response", + func(ctx context.Context, cl client, _ Config, args []string) error { + results := cl.redis.ScanResponses(ctx, "*") + for r := range results { + if r.Err != nil { + return r.Err + } + fmt.Printf("%s: %s\n", r.Serial, base64.StdEncoding.EncodeToString(r.Body)) + } + return nil + }, + } +) + +var subCommands = []subCommand{ + Store, Get, GetPEM, LoadFromDB, ScanResponses, +} + +func helpExit() { + var names []string + var helpStrings []string + for _, s := range subCommands { + names = append(names, s.name) + helpStrings = append(helpStrings, fmt.Sprintf(" %s -- %s", s.name, s.help)) + } + fmt.Fprintf(os.Stderr, "Usage: %s [%s] --config path/to/config.json\n", os.Args[0], strings.Join(names, "|")) + os.Stderr.Write([]byte(strings.Join(helpStrings, "\n"))) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr) + flag.PrintDefaults() + os.Exit(1) +} + +func configureOCSPGenerator(tlsConf cmd.TLSConfig, grpcConf cmd.GRPCClientConfig, clk clock.Clock, scope prometheus.Registerer) (capb.OCSPGeneratorClient, error) { + tlsConfig, err := tlsConf.Load(scope) + if err != nil { + return nil, fmt.Errorf("loading TLS config: %w", err) + } + + caConn, err := bgrpc.ClientSetup(&grpcConf, tlsConfig, scope, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to CA") + return capb.NewOCSPGeneratorClient(caConn), nil +} + +// setDefault sets the target to a default value, if it is zero. +func setDefault(target *int, def int) { + if *target == 0 { + *target = def + } +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/testdata/ocsp.response b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/testdata/ocsp.response new file mode 100644 index 000000000..c52cbbc1e Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/cmd/rocsp-tool/testdata/ocsp.response differ diff --git a/third-party/github.com/letsencrypt/boulder/cmd/shell.go b/third-party/github.com/letsencrypt/boulder/cmd/shell.go new file mode 100644 index 000000000..0934614a3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/shell.go @@ -0,0 +1,553 @@ +// Package cmd provides utilities that underlie the specific commands. +package cmd + +import ( + "context" + "encoding/json" + "errors" + "expvar" + "fmt" + "io" + "log" + "log/syslog" + "net/http" + "net/http/pprof" + "os" + "os/signal" + "runtime" + "runtime/debug" + "strings" + "syscall" + "time" + + "github.com/go-logr/stdr" + "github.com/go-sql-driver/mysql" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.25.0" + "google.golang.org/grpc/grpclog" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/letsencrypt/validator/v10" +) + +// Because we don't know when this init will be called with respect to +// flag.Parse() and other flag definitions, we can't rely on the regular +// flag mechanism. But this one is fine. +func init() { + for _, v := range os.Args { + if v == "--version" || v == "-version" { + fmt.Println(VersionString()) + os.Exit(0) + } + } +} + +// mysqlLogger implements the mysql.Logger interface. +type mysqlLogger struct { + blog.Logger +} + +func (m mysqlLogger) Print(v ...interface{}) { + m.AuditErrf("[mysql] %s", fmt.Sprint(v...)) +} + +// grpcLogger implements the grpclog.LoggerV2 interface. +type grpcLogger struct { + blog.Logger +} + +// Ensure that fatal logs exit, because we use neither the gRPC default logger +// nor the stdlib default logger, both of which would call os.Exit(1) for us. +func (log grpcLogger) Fatal(args ...interface{}) { + log.Error(args...) + os.Exit(1) +} +func (log grpcLogger) Fatalf(format string, args ...interface{}) { + log.Errorf(format, args...) + os.Exit(1) +} +func (log grpcLogger) Fatalln(args ...interface{}) { + log.Errorln(args...) + os.Exit(1) +} + +// Treat all gRPC error logs as potential audit events. +func (log grpcLogger) Error(args ...interface{}) { + log.Logger.AuditErr(fmt.Sprint(args...)) +} +func (log grpcLogger) Errorf(format string, args ...interface{}) { + log.Logger.AuditErrf(format, args...) +} +func (log grpcLogger) Errorln(args ...interface{}) { + log.Logger.AuditErr(fmt.Sprintln(args...)) +} + +// Pass through most Warnings, but filter out a few noisy ones. +func (log grpcLogger) Warning(args ...interface{}) { + log.Logger.Warning(fmt.Sprint(args...)) +} +func (log grpcLogger) Warningf(format string, args ...interface{}) { + log.Logger.Warningf(format, args...) +} +func (log grpcLogger) Warningln(args ...interface{}) { + msg := fmt.Sprintln(args...) + // See https://github.com/letsencrypt/boulder/issues/4628 + if strings.Contains(msg, `ccResolverWrapper: error parsing service config: no JSON service config provided`) { + return + } + // See https://github.com/letsencrypt/boulder/issues/4379 + if strings.Contains(msg, `Server.processUnaryRPC failed to write status: connection error: desc = "transport is closing"`) { + return + } + // Since we've already formatted the message, just pass through to .Warning() + log.Logger.Warning(msg) +} + +// Don't log any INFO-level gRPC stuff. In practice this is all noise, like +// failed TXT lookups for service discovery (we only use A records). +func (log grpcLogger) Info(args ...interface{}) {} +func (log grpcLogger) Infof(format string, args ...interface{}) {} +func (log grpcLogger) Infoln(args ...interface{}) {} + +// V returns true if the verbosity level l is less than the verbosity we want to +// log at. +func (log grpcLogger) V(l int) bool { + // We always return false. This causes gRPC to not log some things which are + // only logged conditionally if the logLevel is set below a certain value. + // TODO: Use the wrapped log.Logger.stdoutLevel and log.Logger.syslogLevel + // to determine a correct return value here. + return false +} + +// promLogger implements the promhttp.Logger interface. +type promLogger struct { + blog.Logger +} + +func (log promLogger) Println(args ...interface{}) { + log.AuditErr(fmt.Sprint(args...)) +} + +type redisLogger struct { + blog.Logger +} + +func (rl redisLogger) Printf(ctx context.Context, format string, v ...interface{}) { + rl.Infof(format, v...) +} + +// logWriter implements the io.Writer interface. +type logWriter struct { + blog.Logger +} + +func (lw logWriter) Write(p []byte) (n int, err error) { + // Lines received by logWriter will always have a trailing newline. + lw.Logger.Info(strings.Trim(string(p), "\n")) + return +} + +// logOutput implements the log.Logger interface's Output method for use with logr +type logOutput struct { + blog.Logger +} + +func (l logOutput) Output(calldepth int, logline string) error { + l.Logger.Info(logline) + return nil +} + +// StatsAndLogging sets up an AuditLogger, Prometheus Registerer, and +// OpenTelemetry tracing. It returns the Registerer and AuditLogger, along +// with a graceful shutdown function to be deferred. +// +// It spawns off an HTTP server on the provided port to report the stats and +// provide pprof profiling handlers. +// +// The constructed AuditLogger as the default logger, and configures the mysql +// and grpc packages to use our logger. This must be called before any gRPC code +// is called, because gRPC's SetLogger doesn't use any locking. +// +// This function does not return an error, and will panic on problems. +func StatsAndLogging(logConf SyslogConfig, otConf OpenTelemetryConfig, addr string) (prometheus.Registerer, blog.Logger, func(context.Context)) { + logger := NewLogger(logConf) + + shutdown := NewOpenTelemetry(otConf, logger) + + return newStatsRegistry(addr, logger), logger, shutdown +} + +// NewLogger creates a logger object with the provided settings, sets it as +// the global logger, and returns it. +// +// It also sets the logging systems for various packages we use to go through +// the created logger, and sets up a periodic log event for the current timestamp. +func NewLogger(logConf SyslogConfig) blog.Logger { + var logger blog.Logger + if logConf.SyslogLevel >= 0 { + syslogger, err := syslog.Dial( + "", + "", + syslog.LOG_INFO, // default, not actually used + core.Command()) + FailOnError(err, "Could not connect to Syslog") + syslogLevel := int(syslog.LOG_INFO) + if logConf.SyslogLevel != 0 { + syslogLevel = logConf.SyslogLevel + } + logger, err = blog.New(syslogger, logConf.StdoutLevel, syslogLevel) + FailOnError(err, "Could not connect to Syslog") + } else { + logger = blog.StdoutLogger(logConf.StdoutLevel) + } + + _ = blog.Set(logger) + _ = mysql.SetLogger(mysqlLogger{logger}) + grpclog.SetLoggerV2(grpcLogger{logger}) + log.SetOutput(logWriter{logger}) + redis.SetLogger(redisLogger{logger}) + + // Periodically log the current timestamp, to ensure syslog timestamps match + // Boulder's conception of time. + go func() { + for { + time.Sleep(time.Minute) + logger.Info(fmt.Sprintf("time=%s", time.Now().Format(time.RFC3339Nano))) + } + }() + return logger +} + +func newVersionCollector() prometheus.Collector { + buildTime := core.Unspecified + if core.GetBuildTime() != core.Unspecified { + // core.BuildTime is set by our Makefile using the shell command 'date + // -u' which outputs in a consistent format across all POSIX systems. + bt, err := time.Parse(time.UnixDate, core.BuildTime) + if err != nil { + // Should never happen unless the Makefile is changed. + buildTime = "Unparsable" + } else { + buildTime = bt.Format(time.RFC3339) + } + } + return prometheus.NewGaugeFunc( + prometheus.GaugeOpts{ + Name: "version", + Help: fmt.Sprintf( + "A metric with a constant value of '1' labeled by the short commit-id (buildId), build timestamp in RFC3339 format (buildTime), and Go release tag like 'go1.3' (goVersion) from which %s was built.", + core.Command(), + ), + ConstLabels: prometheus.Labels{ + "buildId": core.GetBuildID(), + "buildTime": buildTime, + "goVersion": runtime.Version(), + }, + }, + func() float64 { return 1 }, + ) +} + +func newStatsRegistry(addr string, logger blog.Logger) prometheus.Registerer { + registry := prometheus.NewRegistry() + registry.MustRegister(collectors.NewGoCollector()) + registry.MustRegister(collectors.NewProcessCollector( + collectors.ProcessCollectorOpts{})) + registry.MustRegister(newVersionCollector()) + + mux := http.NewServeMux() + // Register the available pprof handlers. These are all registered on + // DefaultServeMux just by importing pprof, but since we eschew + // DefaultServeMux, we need to explicitly register them on our own mux. + mux.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) + mux.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) + mux.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) + mux.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) + // These handlers are defined in runtime/pprof instead of net/http/pprof, and + // have to be accessed through net/http/pprof's Handler func. + mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + mux.Handle("/debug/pprof/block", pprof.Handler("block")) + mux.Handle("/debug/pprof/heap", pprof.Handler("heap")) + mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex")) + mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + + mux.Handle("/debug/vars", expvar.Handler()) + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{ + ErrorLog: promLogger{logger}, + })) + + if addr == "" { + logger.Err("Debug listen address is not configured") + os.Exit(1) + } + logger.Infof("Debug server listening on %s", addr) + + server := http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: time.Minute, + } + go func() { + err := server.ListenAndServe() + if err != nil { + logger.Errf("unable to boot debug server on %s: %v", addr, err) + os.Exit(1) + } + }() + return registry +} + +// NewOpenTelemetry sets up our OpenTelemetry tracing +// It returns a graceful shutdown function to be deferred. +func NewOpenTelemetry(config OpenTelemetryConfig, logger blog.Logger) func(ctx context.Context) { + otel.SetLogger(stdr.New(logOutput{logger})) + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { logger.Errf("OpenTelemetry error: %v", err) })) + + r, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String(core.Command()), + semconv.ServiceVersionKey.String(core.GetBuildID()), + ), + ) + if err != nil { + FailOnError(err, "Could not create OpenTelemetry resource") + } + + opts := []trace.TracerProviderOption{ + trace.WithResource(r), + // Use a ParentBased sampler to respect the sample decisions on incoming + // traces, and TraceIDRatioBased to randomly sample new traces. + trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(config.SampleRatio))), + } + + if config.Endpoint != "" { + exporter, err := otlptracegrpc.New( + context.Background(), + otlptracegrpc.WithInsecure(), + otlptracegrpc.WithEndpoint(config.Endpoint)) + if err != nil { + FailOnError(err, "Could not create OpenTelemetry OTLP exporter") + } + + opts = append(opts, trace.WithBatcher(exporter)) + } + + tracerProvider := trace.NewTracerProvider(opts...) + otel.SetTracerProvider(tracerProvider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + + return func(ctx context.Context) { + err := tracerProvider.Shutdown(ctx) + if err != nil { + logger.Errf("Error while shutting down OpenTelemetry: %v", err) + } + } +} + +// AuditPanic catches and logs panics, then exits with exit code 1. +// This method should be called in a defer statement as early as possible. +func AuditPanic() { + err := recover() + // No panic, no problem + if err == nil { + return + } + // Get the global logger if it's initialized, or create a default one if not. + // We could wind up creating a default logger if we panic so early in a process' + // lifetime that we haven't yet parsed the config and created a logger. + log := blog.Get() + // For the special type `failure`, audit log the message and exit quietly + fail, ok := err.(failure) + if ok { + log.AuditErr(fail.msg) + } else { + // For all other values passed to `panic`, log them and a stack trace + log.AuditErrf("Panic caused by err: %s", err) + + log.AuditErrf("Stack Trace (Current goroutine) %s", debug.Stack()) + } + // Because this function is deferred as early as possible, there's no further defers to run after this one + // So it is safe to os.Exit to set the exit code and exit without losing any defers we haven't executed. + os.Exit(1) +} + +// failure is a sentinel type that `Fail` passes to `panic` so `AuditPanic` can exit +// quietly and print the msg. +type failure struct { + msg string +} + +func (f failure) String() string { + return f.msg +} + +// Fail raises a panic with a special type that causes `AuditPanic` to audit log the provided message +// and then exit nonzero (without printing a stack trace). +func Fail(msg string) { + panic(failure{msg}) +} + +// FailOnError calls Fail if the provided error is non-nil. +// This is useful for one-line error handling in top-level executables, +// but should generally be avoided in libraries. The message argument is optional. +func FailOnError(err error, msg string) { + if err == nil { + return + } + if msg == "" { + Fail(err.Error()) + } else { + Fail(fmt.Sprintf("%s: %s", msg, err)) + } +} + +func decodeJSONStrict(in io.Reader, out interface{}) error { + decoder := json.NewDecoder(in) + decoder.DisallowUnknownFields() + + return decoder.Decode(out) +} + +// ReadConfigFile takes a file path as an argument and attempts to +// unmarshal the content of the file into a struct containing a +// configuration of a boulder component. Any config keys in the JSON +// file which do not correspond to expected keys in the config struct +// will result in errors. +func ReadConfigFile(filename string, out interface{}) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + return decodeJSONStrict(file, out) +} + +// ValidateJSONConfig takes a *ConfigValidator and an io.Reader containing a +// JSON representation of a config. The JSON data is unmarshaled into the +// *ConfigValidator's inner Config and then validated according to the +// 'validate' tags for on each field. Callers can use cmd.LookupConfigValidator +// to get a *ConfigValidator for a given Boulder component. This is exported for +// use in SRE CI tooling. +func ValidateJSONConfig(cv *ConfigValidator, in io.Reader) error { + if cv == nil { + return errors.New("config validator cannot be nil") + } + + // Initialize the validator and load any custom tags. + validate := validator.New() + for tag, v := range cv.Validators { + err := validate.RegisterValidation(tag, v) + if err != nil { + return err + } + } + + err := decodeJSONStrict(in, cv.Config) + if err != nil { + return err + } + err = validate.Struct(cv.Config) + if err != nil { + errs, ok := err.(validator.ValidationErrors) + if !ok { + // This should never happen. + return err + } + if len(errs) > 0 { + allErrs := []string{} + for _, e := range errs { + allErrs = append(allErrs, e.Error()) + } + return errors.New(strings.Join(allErrs, ", ")) + } + } + return nil +} + +// ValidateYAMLConfig takes a *ConfigValidator and an io.Reader containing a +// YAML representation of a config. The YAML data is unmarshaled into the +// *ConfigValidator's inner Config and then validated according to the +// 'validate' tags for on each field. Callers can use cmd.LookupConfigValidator +// to get a *ConfigValidator for a given Boulder component. This is exported for +// use in SRE CI tooling. +func ValidateYAMLConfig(cv *ConfigValidator, in io.Reader) error { + if cv == nil { + return errors.New("config validator cannot be nil") + } + + // Initialize the validator and load any custom tags. + validate := validator.New() + for tag, v := range cv.Validators { + err := validate.RegisterValidation(tag, v) + if err != nil { + return err + } + } + + inBytes, err := io.ReadAll(in) + if err != nil { + return err + } + err = strictyaml.Unmarshal(inBytes, cv.Config) + if err != nil { + return err + } + err = validate.Struct(cv.Config) + if err != nil { + errs, ok := err.(validator.ValidationErrors) + if !ok { + // This should never happen. + return err + } + if len(errs) > 0 { + allErrs := []string{} + for _, e := range errs { + allErrs = append(allErrs, e.Error()) + } + return errors.New(strings.Join(allErrs, ", ")) + } + } + return nil +} + +// VersionString produces a friendly Application version string. +func VersionString() string { + return fmt.Sprintf("Versions: %s=(%s %s) Golang=(%s) BuildHost=(%s)", core.Command(), core.GetBuildID(), core.GetBuildTime(), runtime.Version(), core.GetBuildHost()) +} + +// CatchSignals blocks until a SIGTERM, SIGINT, or SIGHUP is received, then +// executes the given callback. The callback should not block, it should simply +// signal other goroutines (particularly the main goroutine) to clean themselves +// up and exit. This function is intended to be called in its own goroutine, +// while the main goroutine waits for an indication that the other goroutines +// have exited cleanly. +func CatchSignals(callback func()) { + WaitForSignal() + callback() +} + +// WaitForSignal blocks until a SIGTERM, SIGINT, or SIGHUP is received. It then +// returns, allowing execution to resume, generally allowing a main() function +// to return and trigger and deferred cleanup functions. This function is +// intended to be called directly from the main goroutine, while a gRPC or HTTP +// server runs in a background goroutine. +func WaitForSignal() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM) + signal.Notify(sigChan, syscall.SIGINT) + signal.Notify(sigChan, syscall.SIGHUP) + <-sigChan +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/shell_test.go b/third-party/github.com/letsencrypt/boulder/cmd/shell_test.go new file mode 100644 index 000000000..debafd54e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/shell_test.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "time" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + validPAConfig = []byte(`{ + "dbConnect": "dummyDBConnect", + "enforcePolicyWhitelist": false, + "challenges": { "http-01": true } +}`) + invalidPAConfig = []byte(`{ + "dbConnect": "dummyDBConnect", + "enforcePolicyWhitelist": false, + "challenges": { "nonsense": true } +}`) + noChallengesPAConfig = []byte(`{ + "dbConnect": "dummyDBConnect", + "enforcePolicyWhitelist": false +}`) + + emptyChallengesPAConfig = []byte(`{ + "dbConnect": "dummyDBConnect", + "enforcePolicyWhitelist": false, + "challenges": {} +}`) +) + +func TestPAConfigUnmarshal(t *testing.T) { + var pc1 PAConfig + err := json.Unmarshal(validPAConfig, &pc1) + test.AssertNotError(t, err, "Failed to unmarshal PAConfig") + test.AssertNotError(t, pc1.CheckChallenges(), "Flagged valid challenges as bad") + + var pc2 PAConfig + err = json.Unmarshal(invalidPAConfig, &pc2) + test.AssertNotError(t, err, "Failed to unmarshal PAConfig") + test.AssertError(t, pc2.CheckChallenges(), "Considered invalid challenges as good") + + var pc3 PAConfig + err = json.Unmarshal(noChallengesPAConfig, &pc3) + test.AssertNotError(t, err, "Failed to unmarshal PAConfig") + test.AssertError(t, pc3.CheckChallenges(), "Disallow empty challenges map") + + var pc4 PAConfig + err = json.Unmarshal(emptyChallengesPAConfig, &pc4) + test.AssertNotError(t, err, "Failed to unmarshal PAConfig") + test.AssertError(t, pc4.CheckChallenges(), "Disallow empty challenges map") +} + +func TestMysqlLogger(t *testing.T) { + log := blog.UseMock() + mLog := mysqlLogger{log} + + testCases := []struct { + args []interface{} + expected string + }{ + { + []interface{}{nil}, + `ERR: [AUDIT] [mysql] `, + }, + { + []interface{}{""}, + `ERR: [AUDIT] [mysql] `, + }, + { + []interface{}{"Sup ", 12345, " Sup sup"}, + `ERR: [AUDIT] [mysql] Sup 12345 Sup sup`, + }, + } + + for _, tc := range testCases { + // mysqlLogger proxies blog.AuditLogger to provide a Print() method + mLog.Print(tc.args...) + logged := log.GetAll() + // Calling Print should produce the expected output + test.AssertEquals(t, len(logged), 1) + test.AssertEquals(t, logged[0], tc.expected) + log.Clear() + } +} + +func TestCaptureStdlibLog(t *testing.T) { + logger := blog.UseMock() + oldDest := log.Writer() + defer func() { + log.SetOutput(oldDest) + }() + log.SetOutput(logWriter{logger}) + log.Print("thisisatest") + results := logger.GetAllMatching("thisisatest") + if len(results) != 1 { + t.Fatalf("Expected logger to receive 'thisisatest', got: %s", + strings.Join(logger.GetAllMatching(".*"), "\n")) + } +} + +func TestVersionString(t *testing.T) { + core.BuildID = "TestBuildID" + core.BuildTime = "RightNow!" + core.BuildHost = "Localhost" + + versionStr := VersionString() + expected := fmt.Sprintf("Versions: cmd.test=(TestBuildID RightNow!) Golang=(%s) BuildHost=(Localhost)", runtime.Version()) + test.AssertEquals(t, versionStr, expected) +} + +func TestReadConfigFile(t *testing.T) { + err := ReadConfigFile("", nil) + test.AssertError(t, err, "ReadConfigFile('') did not error") + + type config struct { + NotifyMailer struct { + DB DBConfig + SMTPConfig + } + Syslog SyslogConfig + } + var c config + err = ReadConfigFile("../test/config/notify-mailer.json", &c) + test.AssertNotError(t, err, "ReadConfigFile(../test/config/notify-mailer.json) errored") + test.AssertEquals(t, c.NotifyMailer.SMTPConfig.Server, "localhost") +} + +func TestLogWriter(t *testing.T) { + mock := blog.UseMock() + lw := logWriter{mock} + _, _ = lw.Write([]byte("hi\n")) + lines := mock.GetAllMatching(".*") + test.AssertEquals(t, len(lines), 1) + test.AssertEquals(t, lines[0], "INFO: hi") +} + +func TestGRPCLoggerWarningFilter(t *testing.T) { + m := blog.NewMock() + l := grpcLogger{m} + l.Warningln("asdf", "qwer") + lines := m.GetAllMatching(".*") + test.AssertEquals(t, len(lines), 1) + + m = blog.NewMock() + l = grpcLogger{m} + l.Warningln("Server.processUnaryRPC failed to write status: connection error: desc = \"transport is closing\"") + lines = m.GetAllMatching(".*") + test.AssertEquals(t, len(lines), 0) +} + +func Test_newVersionCollector(t *testing.T) { + // 'buildTime' + core.BuildTime = core.Unspecified + version := newVersionCollector() + // Default 'Unspecified' should emit 'Unspecified'. + test.AssertMetricWithLabelsEquals(t, version, prometheus.Labels{"buildTime": core.Unspecified}, 1) + // Parsable UnixDate should emit UnixTime. + now := time.Now().UTC() + core.BuildTime = now.Format(time.UnixDate) + version = newVersionCollector() + test.AssertMetricWithLabelsEquals(t, version, prometheus.Labels{"buildTime": now.Format(time.RFC3339)}, 1) + // Unparsable timestamp should emit 'Unsparsable'. + core.BuildTime = "outta time" + version = newVersionCollector() + test.AssertMetricWithLabelsEquals(t, version, prometheus.Labels{"buildTime": "Unparsable"}, 1) + + // 'buildId' + expectedBuildID := "TestBuildId" + core.BuildID = expectedBuildID + version = newVersionCollector() + test.AssertMetricWithLabelsEquals(t, version, prometheus.Labels{"buildId": expectedBuildID}, 1) + + // 'goVersion' + test.AssertMetricWithLabelsEquals(t, version, prometheus.Labels{"goVersion": runtime.Version()}, 1) +} + +func loadConfigFile(t *testing.T, path string) *os.File { + cf, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + return cf +} + +func TestFailedConfigValidation(t *testing.T) { + type FooConfig struct { + VitalValue string `yaml:"vitalValue" validate:"required"` + VoluntarilyVoid string `yaml:"voluntarilyVoid"` + VisciouslyVetted string `yaml:"visciouslyVetted" validate:"omitempty,endswith=baz"` + } + + // Violates 'endswith' tag JSON. + cf := loadConfigFile(t, "testdata/1_missing_endswith.json") + defer cf.Close() + err := ValidateJSONConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'endswith'") + + // Violates 'endswith' tag YAML. + cf = loadConfigFile(t, "testdata/1_missing_endswith.yaml") + defer cf.Close() + err = ValidateYAMLConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'endswith'") + + // Violates 'required' tag JSON. + cf = loadConfigFile(t, "testdata/2_missing_required.json") + defer cf.Close() + err = ValidateJSONConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'required'") + + // Violates 'required' tag YAML. + cf = loadConfigFile(t, "testdata/2_missing_required.yaml") + defer cf.Close() + err = ValidateYAMLConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'required'") +} + +func TestFailExit(t *testing.T) { + // Test that when Fail is called with a `defer AuditPanic()`, + // the program exits with a non-zero exit code and logs + // the result (but not stack trace). + // Inspired by https://go.dev/talks/2014/testing.slide#23 + if os.Getenv("TIME_TO_DIE") == "1" { + defer AuditPanic() + Fail("tears in the rain") + return + } + + // gosec points out that os.Args[0] is tainted, but we only run this as a test + // so we are not worried about it containing an untrusted value. + //nolint:gosec + cmd := exec.Command(os.Args[0], "-test.run=TestFailExit") + cmd.Env = append(os.Environ(), "TIME_TO_DIE=1") + output, err := cmd.CombinedOutput() + test.AssertError(t, err, "running a failing program") + test.AssertContains(t, string(output), "[AUDIT] tears in the rain") + // "goroutine" usually shows up in stack traces, so we check it + // to make sure we didn't print a stack trace. + test.AssertNotContains(t, string(output), "goroutine") +} + +func testPanicStackTraceHelper() { + var x *int + *x = 1 //nolint:govet +} + +func TestPanicStackTrace(t *testing.T) { + // Test that when a nil pointer dereference is hit after a + // `defer AuditPanic()`, the program exits with a non-zero + // exit code and prints the result (but not stack trace). + // Inspired by https://go.dev/talks/2014/testing.slide#23 + if os.Getenv("AT_THE_DISCO") == "1" { + defer AuditPanic() + testPanicStackTraceHelper() + return + } + + // gosec points out that os.Args[0] is tainted, but we only run this as a test + // so we are not worried about it containing an untrusted value. + //nolint:gosec + cmd := exec.Command(os.Args[0], "-test.run=TestPanicStackTrace") + cmd.Env = append(os.Environ(), "AT_THE_DISCO=1") + output, err := cmd.CombinedOutput() + test.AssertError(t, err, "running a failing program") + test.AssertContains(t, string(output), "nil pointer dereference") + test.AssertContains(t, string(output), "Stack Trace") + test.AssertContains(t, string(output), "cmd/shell_test.go:") +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.json b/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.json new file mode 100644 index 000000000..af9286b63 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.json @@ -0,0 +1,5 @@ +{ + "vitalValue": "Gotcha", + "voluntarilyVoid": "Not used", + "visciouslyVetted": "Whatever" +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.yaml b/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.yaml new file mode 100644 index 000000000..f101121ec --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/1_missing_endswith.yaml @@ -0,0 +1,3 @@ +vitalValue: "Gotcha" +voluntarilyVoid: "Not used" +visciouslyVetted: "Whatever" diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.json b/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.json new file mode 100644 index 000000000..7fd2fe293 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.json @@ -0,0 +1,4 @@ +{ + "voluntarilyVoid": "Not used", + "visciouslyVetted": "barbaz" +} diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.yaml b/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.yaml new file mode 100644 index 000000000..10a918d4c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/2_missing_required.yaml @@ -0,0 +1,2 @@ +voluntarilyVoid: "Not used" +visciouslyVetted: "barbaz" diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl new file mode 100644 index 000000000..c43b16c5d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl @@ -0,0 +1 @@ +test@tcp(testhost:3306)/testDB?readTimeout=800ms&writeTimeout=800ms diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl_newline b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl_newline new file mode 100644 index 000000000..f2395d918 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_dburl_newline @@ -0,0 +1,2 @@ +test@tcp(testhost:3306)/testDB?readTimeout=800ms&writeTimeout=800ms + diff --git a/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_secret b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_secret new file mode 100644 index 000000000..d97c5eada --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/cmd/testdata/test_secret @@ -0,0 +1 @@ +secret diff --git a/third-party/github.com/letsencrypt/boulder/config/duration.go b/third-party/github.com/letsencrypt/boulder/config/duration.go new file mode 100644 index 000000000..c97eeb486 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/config/duration.go @@ -0,0 +1,57 @@ +package config + +import ( + "encoding/json" + "errors" + "time" +) + +// Duration is just an alias for time.Duration that allows +// serialization to YAML as well as JSON. +type Duration struct { + time.Duration `validate:"required"` +} + +// ErrDurationMustBeString is returned when a non-string value is +// presented to be deserialized as a ConfigDuration +var ErrDurationMustBeString = errors.New("cannot JSON unmarshal something other than a string into a ConfigDuration") + +// UnmarshalJSON parses a string into a ConfigDuration using +// time.ParseDuration. If the input does not unmarshal as a +// string, then UnmarshalJSON returns ErrDurationMustBeString. +func (d *Duration) UnmarshalJSON(b []byte) error { + s := "" + err := json.Unmarshal(b, &s) + if err != nil { + var jsonUnmarshalTypeErr *json.UnmarshalTypeError + if errors.As(err, &jsonUnmarshalTypeErr) { + return ErrDurationMustBeString + } + return err + } + dd, err := time.ParseDuration(s) + d.Duration = dd + return err +} + +// MarshalJSON returns the string form of the duration, as a byte array. +func (d Duration) MarshalJSON() ([]byte, error) { + return []byte(d.Duration.String()), nil +} + +// UnmarshalYAML uses the same format as JSON, but is called by the YAML +// parser (vs. the JSON parser). +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + err := unmarshal(&s) + if err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return err + } + + d.Duration = dur + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/core/challenges.go b/third-party/github.com/letsencrypt/boulder/core/challenges.go new file mode 100644 index 000000000..d5e7a8729 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/challenges.go @@ -0,0 +1,41 @@ +package core + +import "fmt" + +func newChallenge(challengeType AcmeChallenge, token string) Challenge { + return Challenge{ + Type: challengeType, + Status: StatusPending, + Token: token, + } +} + +// HTTPChallenge01 constructs a http-01 challenge. +func HTTPChallenge01(token string) Challenge { + return newChallenge(ChallengeTypeHTTP01, token) +} + +// DNSChallenge01 constructs a dns-01 challenge. +func DNSChallenge01(token string) Challenge { + return newChallenge(ChallengeTypeDNS01, token) +} + +// TLSALPNChallenge01 constructs a tls-alpn-01 challenge. +func TLSALPNChallenge01(token string) Challenge { + return newChallenge(ChallengeTypeTLSALPN01, token) +} + +// NewChallenge constructs a challenge of the given kind. It returns an +// error if the challenge type is unrecognized. +func NewChallenge(kind AcmeChallenge, token string) (Challenge, error) { + switch kind { + case ChallengeTypeHTTP01: + return HTTPChallenge01(token), nil + case ChallengeTypeDNS01: + return DNSChallenge01(token), nil + case ChallengeTypeTLSALPN01: + return TLSALPNChallenge01(token), nil + default: + return Challenge{}, fmt.Errorf("unrecognized challenge type %q", kind) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/core/challenges_test.go b/third-party/github.com/letsencrypt/boulder/core/challenges_test.go new file mode 100644 index 000000000..c598a1ae0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/challenges_test.go @@ -0,0 +1,12 @@ +package core + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestNewChallenge(t *testing.T) { + challenge := newChallenge(ChallengeTypeDNS01, "asd") + test.Assert(t, challenge.Token == "asd", "token is not set") +} diff --git a/third-party/github.com/letsencrypt/boulder/core/core_test.go b/third-party/github.com/letsencrypt/boulder/core/core_test.go new file mode 100644 index 000000000..889f9c9fe --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/core_test.go @@ -0,0 +1,74 @@ +package core + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/go-jose/go-jose/v4" + + "github.com/letsencrypt/boulder/test" +) + +// challenges.go + +var accountKeyJSON = `{ + "kty":"RSA", + "n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e":"AQAB" +}` + +func TestChallenges(t *testing.T) { + var accountKey *jose.JSONWebKey + err := json.Unmarshal([]byte(accountKeyJSON), &accountKey) + if err != nil { + t.Errorf("Error unmarshaling JWK: %v", err) + } + + token := NewToken() + http01 := HTTPChallenge01(token) + test.AssertNotError(t, http01.CheckPending(), "CheckConsistencyForClientOffer returned an error") + + dns01 := DNSChallenge01(token) + test.AssertNotError(t, dns01.CheckPending(), "CheckConsistencyForClientOffer returned an error") + + tlsalpn01 := TLSALPNChallenge01(token) + test.AssertNotError(t, tlsalpn01.CheckPending(), "CheckConsistencyForClientOffer returned an error") + + test.Assert(t, ChallengeTypeHTTP01.IsValid(), "Refused valid challenge") + test.Assert(t, ChallengeTypeDNS01.IsValid(), "Refused valid challenge") + test.Assert(t, ChallengeTypeTLSALPN01.IsValid(), "Refused valid challenge") + test.Assert(t, !AcmeChallenge("nonsense-71").IsValid(), "Accepted invalid challenge") +} + +// util.go + +func TestRandomString(t *testing.T) { + byteLength := 256 + b64 := RandomString(byteLength) + bin, err := base64.RawURLEncoding.DecodeString(b64) + if err != nil { + t.Errorf("Error in base64 decode: %v", err) + } + if len(bin) != byteLength { + t.Errorf("Improper length: %v", len(bin)) + } + + token := NewToken() + if len(token) != 43 { + t.Errorf("Improper length for token: %v %v", len(token), token) + } +} + +func TestFingerprint(t *testing.T) { + in := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + out := []byte{55, 71, 8, 255, 247, 113, 157, 213, + 151, 158, 200, 117, 213, 108, 210, 40, + 111, 109, 60, 247, 236, 49, 122, 59, + 37, 99, 42, 171, 40, 236, 55, 187} + + digest := Fingerprint256(in) + if digest != base64.RawURLEncoding.EncodeToString(out) { + t.Errorf("Incorrect SHA-256 fingerprint: %v", digest) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/core/interfaces.go b/third-party/github.com/letsencrypt/boulder/core/interfaces.go new file mode 100644 index 000000000..59b55a3f4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/interfaces.go @@ -0,0 +1,14 @@ +package core + +import ( + "github.com/letsencrypt/boulder/identifier" +) + +// PolicyAuthority defines the public interface for the Boulder PA +// TODO(#5891): Move this interface to a more appropriate location. +type PolicyAuthority interface { + WillingToIssue([]string) error + ChallengesFor(identifier.ACMEIdentifier) ([]Challenge, error) + ChallengeTypeEnabled(AcmeChallenge) bool + CheckAuthz(*Authorization) error +} diff --git a/third-party/github.com/letsencrypt/boulder/core/objects.go b/third-party/github.com/letsencrypt/boulder/core/objects.go new file mode 100644 index 000000000..c01f551ab --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/objects.go @@ -0,0 +1,505 @@ +package core + +import ( + "crypto" + "encoding/base64" + "encoding/json" + "fmt" + "hash/fnv" + "net" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/revocation" +) + +// AcmeStatus defines the state of a given authorization +type AcmeStatus string + +// These statuses are the states of authorizations, challenges, and registrations +const ( + StatusUnknown = AcmeStatus("unknown") // Unknown status; the default + StatusPending = AcmeStatus("pending") // In process; client has next action + StatusProcessing = AcmeStatus("processing") // In process; server has next action + StatusReady = AcmeStatus("ready") // Order is ready for finalization + StatusValid = AcmeStatus("valid") // Object is valid + StatusInvalid = AcmeStatus("invalid") // Validation failed + StatusRevoked = AcmeStatus("revoked") // Object no longer valid + StatusDeactivated = AcmeStatus("deactivated") // Object has been deactivated +) + +// AcmeResource values identify different types of ACME resources +type AcmeResource string + +// The types of ACME resources +const ( + ResourceNewReg = AcmeResource("new-reg") + ResourceNewAuthz = AcmeResource("new-authz") + ResourceNewCert = AcmeResource("new-cert") + ResourceRevokeCert = AcmeResource("revoke-cert") + ResourceRegistration = AcmeResource("reg") + ResourceChallenge = AcmeResource("challenge") + ResourceAuthz = AcmeResource("authz") + ResourceKeyChange = AcmeResource("key-change") +) + +// AcmeChallenge values identify different types of ACME challenges +type AcmeChallenge string + +// These types are the available challenges +const ( + ChallengeTypeHTTP01 = AcmeChallenge("http-01") + ChallengeTypeDNS01 = AcmeChallenge("dns-01") + ChallengeTypeTLSALPN01 = AcmeChallenge("tls-alpn-01") +) + +// IsValid tests whether the challenge is a known challenge +func (c AcmeChallenge) IsValid() bool { + switch c { + case ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01: + return true + default: + return false + } +} + +// OCSPStatus defines the state of OCSP for a domain +type OCSPStatus string + +// These status are the states of OCSP +const ( + OCSPStatusGood = OCSPStatus("good") + OCSPStatusRevoked = OCSPStatus("revoked") + // Not a real OCSP status. This is a placeholder we write before the + // actual precertificate is issued, to ensure we never return "good" before + // issuance succeeds, for BR compliance reasons. + OCSPStatusNotReady = OCSPStatus("wait") +) + +var OCSPStatusToInt = map[OCSPStatus]int{ + OCSPStatusGood: ocsp.Good, + OCSPStatusRevoked: ocsp.Revoked, + OCSPStatusNotReady: -1, +} + +// DNSPrefix is attached to DNS names in DNS challenges +const DNSPrefix = "_acme-challenge" + +type RawCertificateRequest struct { + CSR JSONBuffer `json:"csr"` // The encoded CSR +} + +// Registration objects represent non-public metadata attached +// to account keys. +type Registration struct { + // Unique identifier + ID int64 `json:"id,omitempty" db:"id"` + + // Account key to which the details are attached + Key *jose.JSONWebKey `json:"key"` + + // Contact URIs + Contact *[]string `json:"contact,omitempty"` + + // Agreement with terms of service + Agreement string `json:"agreement,omitempty"` + + // InitialIP is the IP address from which the registration was created + InitialIP net.IP `json:"initialIp"` + + // CreatedAt is the time the registration was created. + CreatedAt *time.Time `json:"createdAt,omitempty"` + + Status AcmeStatus `json:"status"` +} + +// ValidationRecord represents a validation attempt against a specific URL/hostname +// and the IP addresses that were resolved and used. +type ValidationRecord struct { + // SimpleHTTP only + URL string `json:"url,omitempty"` + + // Shared + Hostname string `json:"hostname,omitempty"` + Port string `json:"port,omitempty"` + AddressesResolved []net.IP `json:"addressesResolved,omitempty"` + AddressUsed net.IP `json:"addressUsed,omitempty"` + // AddressesTried contains a list of addresses tried before the `AddressUsed`. + // Presently this will only ever be one IP from `AddressesResolved` since the + // only retry is in the case of a v6 failure with one v4 fallback. E.g. if + // a record with `AddressesResolved: { 127.0.0.1, ::1 }` were processed for + // a challenge validation with the IPv6 first flag on and the ::1 address + // failed but the 127.0.0.1 retry succeeded then the record would end up + // being: + // { + // ... + // AddressesResolved: [ 127.0.0.1, ::1 ], + // AddressUsed: 127.0.0.1 + // AddressesTried: [ ::1 ], + // ... + // } + AddressesTried []net.IP `json:"addressesTried,omitempty"` + // ResolverAddrs is the host:port of the DNS resolver(s) that fulfilled the + // lookup for AddressUsed. During recursive A and AAAA lookups, a record may + // instead look like A:host:port or AAAA:host:port + ResolverAddrs []string `json:"resolverAddrs,omitempty"` + // UsedRSAKEX is a *temporary* addition to the validation record, so we can + // see how many servers that we reach out to during HTTP-01 and TLS-ALPN-01 + // validation are only willing to negotiate RSA key exchange mechanisms. The + // field is not included in the serialized json to avoid cluttering the + // database and log lines. + // TODO(#7321): Remove this when we have collected sufficient data. + UsedRSAKEX bool `json:"-"` +} + +// Challenge is an aggregate of all data needed for any challenges. +// +// Rather than define individual types for different types of +// challenge, we just throw all the elements into one bucket, +// together with the common metadata elements. +type Challenge struct { + // Type is the type of challenge encoded in this object. + Type AcmeChallenge `json:"type"` + + // URL is the URL to which a response can be posted. Required for all types. + URL string `json:"url,omitempty"` + + // Status is the status of this challenge. Required for all types. + Status AcmeStatus `json:"status,omitempty"` + + // Validated is the time at which the server validated the challenge. Required + // if status is valid. + Validated *time.Time `json:"validated,omitempty"` + + // Error contains the error that occurred during challenge validation, if any. + // If set, the Status must be "invalid". + Error *probs.ProblemDetails `json:"error,omitempty"` + + // Token is a random value that uniquely identifies the challenge. It is used + // by all current challenges (http-01, tls-alpn-01, and dns-01). + Token string `json:"token,omitempty"` + + // ProvidedKeyAuthorization used to carry the expected key authorization from + // the RA to the VA. However, since this field is never presented to the user + // via the ACME API, it should not be on this type. + // + // Deprecated: use vapb.PerformValidationRequest.ExpectedKeyAuthorization instead. + // TODO(#7514): Remove this. + ProvidedKeyAuthorization string `json:"keyAuthorization,omitempty"` + + // Contains information about URLs used or redirected to and IPs resolved and + // used + ValidationRecord []ValidationRecord `json:"validationRecord,omitempty"` +} + +// ExpectedKeyAuthorization computes the expected KeyAuthorization value for +// the challenge. +func (ch Challenge) ExpectedKeyAuthorization(key *jose.JSONWebKey) (string, error) { + if key == nil { + return "", fmt.Errorf("Cannot authorize a nil key") + } + + thumbprint, err := key.Thumbprint(crypto.SHA256) + if err != nil { + return "", err + } + + return ch.Token + "." + base64.RawURLEncoding.EncodeToString(thumbprint), nil +} + +// RecordsSane checks the sanity of a ValidationRecord object before sending it +// back to the RA to be stored. +func (ch Challenge) RecordsSane() bool { + if ch.ValidationRecord == nil || len(ch.ValidationRecord) == 0 { + return false + } + + switch ch.Type { + case ChallengeTypeHTTP01: + for _, rec := range ch.ValidationRecord { + // TODO(#7140): Add a check for ResolverAddress == "" only after the + // core.proto change has been deployed. + if rec.URL == "" || rec.Hostname == "" || rec.Port == "" || rec.AddressUsed == nil || + len(rec.AddressesResolved) == 0 { + return false + } + } + case ChallengeTypeTLSALPN01: + if len(ch.ValidationRecord) > 1 { + return false + } + if ch.ValidationRecord[0].URL != "" { + return false + } + // TODO(#7140): Add a check for ResolverAddress == "" only after the + // core.proto change has been deployed. + if ch.ValidationRecord[0].Hostname == "" || ch.ValidationRecord[0].Port == "" || + ch.ValidationRecord[0].AddressUsed == nil || len(ch.ValidationRecord[0].AddressesResolved) == 0 { + return false + } + case ChallengeTypeDNS01: + if len(ch.ValidationRecord) > 1 { + return false + } + // TODO(#7140): Add a check for ResolverAddress == "" only after the + // core.proto change has been deployed. + if ch.ValidationRecord[0].Hostname == "" { + return false + } + return true + default: // Unsupported challenge type + return false + } + + return true +} + +// CheckPending ensures that a challenge object is pending and has a token. +// This is used before offering the challenge to the client, and before actually +// validating a challenge. +func (ch Challenge) CheckPending() error { + if ch.Status != StatusPending { + return fmt.Errorf("challenge is not pending") + } + + if !looksLikeAToken(ch.Token) { + return fmt.Errorf("token is missing or malformed") + } + + return nil +} + +// StringID is used to generate a ID for challenges associated with new style authorizations. +// This is necessary as these challenges no longer have a unique non-sequential identifier +// in the new storage scheme. This identifier is generated by constructing a fnv hash over the +// challenge token and type and encoding the first 4 bytes of it using the base64 URL encoding. +func (ch Challenge) StringID() string { + h := fnv.New128a() + h.Write([]byte(ch.Token)) + h.Write([]byte(ch.Type)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)[0:4]) +} + +// Authorization represents the authorization of an account key holder +// to act on behalf of a domain. This struct is intended to be used both +// internally and for JSON marshaling on the wire. Any fields that should be +// suppressed on the wire (e.g., ID, regID) must be made empty before marshaling. +type Authorization struct { + // An identifier for this authorization, unique across + // authorizations and certificates within this instance. + ID string `json:"id,omitempty" db:"id"` + + // The identifier for which authorization is being given + Identifier identifier.ACMEIdentifier `json:"identifier,omitempty" db:"identifier"` + + // The registration ID associated with the authorization + RegistrationID int64 `json:"regId,omitempty" db:"registrationID"` + + // The status of the validation of this authorization + Status AcmeStatus `json:"status,omitempty" db:"status"` + + // The date after which this authorization will be no + // longer be considered valid. Note: a certificate may be issued even on the + // last day of an authorization's lifetime. The last day for which someone can + // hold a valid certificate based on an authorization is authorization + // lifetime + certificate lifetime. + Expires *time.Time `json:"expires,omitempty" db:"expires"` + + // An array of challenges objects used to validate the + // applicant's control of the identifier. For authorizations + // in process, these are challenges to be fulfilled; for + // final authorizations, they describe the evidence that + // the server used in support of granting the authorization. + // + // There should only ever be one challenge of each type in this + // slice and the order of these challenges may not be predictable. + Challenges []Challenge `json:"challenges,omitempty" db:"-"` + + // https://datatracker.ietf.org/doc/html/rfc8555#page-29 + // + // wildcard (optional, boolean): This field MUST be present and true + // for authorizations created as a result of a newOrder request + // containing a DNS identifier with a value that was a wildcard + // domain name. For other authorizations, it MUST be absent. + // Wildcard domain names are described in Section 7.1.3. + // + // This is not represented in the database because we calculate it from + // the identifier stored in the database. Unlike the identifier returned + // as part of the authorization, the identifier we store in the database + // can contain an asterisk. + Wildcard bool `json:"wildcard,omitempty" db:"-"` +} + +// FindChallengeByStringID will look for a challenge matching the given ID inside +// this authorization. If found, it will return the index of that challenge within +// the Authorization's Challenges array. Otherwise it will return -1. +func (authz *Authorization) FindChallengeByStringID(id string) int { + for i, c := range authz.Challenges { + if c.StringID() == id { + return i + } + } + return -1 +} + +// SolvedBy will look through the Authorizations challenges, returning the type +// of the *first* challenge it finds with Status: valid, or an error if no +// challenge is valid. +func (authz *Authorization) SolvedBy() (AcmeChallenge, error) { + if len(authz.Challenges) == 0 { + return "", fmt.Errorf("Authorization has no challenges") + } + for _, chal := range authz.Challenges { + if chal.Status == StatusValid { + return chal.Type, nil + } + } + return "", fmt.Errorf("Authorization not solved by any challenge") +} + +// JSONBuffer fields get encoded and decoded JOSE-style, in base64url encoding +// with stripped padding. +type JSONBuffer []byte + +// MarshalJSON encodes a JSONBuffer for transmission. +func (jb JSONBuffer) MarshalJSON() (result []byte, err error) { + return json.Marshal(base64.RawURLEncoding.EncodeToString(jb)) +} + +// UnmarshalJSON decodes a JSONBuffer to an object. +func (jb *JSONBuffer) UnmarshalJSON(data []byte) (err error) { + var str string + err = json.Unmarshal(data, &str) + if err != nil { + return err + } + *jb, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(str, "=")) + return +} + +// Certificate objects are entirely internal to the server. The only +// thing exposed on the wire is the certificate itself. +type Certificate struct { + ID int64 `db:"id"` + RegistrationID int64 `db:"registrationID"` + + Serial string `db:"serial"` + Digest string `db:"digest"` + DER []byte `db:"der"` + Issued time.Time `db:"issued"` + Expires time.Time `db:"expires"` +} + +// CertificateStatus structs are internal to the server. They represent the +// latest data about the status of the certificate, required for generating new +// OCSP responses and determining if a certificate has been revoked. +type CertificateStatus struct { + ID int64 `db:"id"` + + Serial string `db:"serial"` + + // status: 'good' or 'revoked'. Note that good, expired certificates remain + // with status 'good' but don't necessarily get fresh OCSP responses. + Status OCSPStatus `db:"status"` + + // ocspLastUpdated: The date and time of the last time we generated an OCSP + // response. If we have never generated one, this has the zero value of + // time.Time, i.e. Jan 1 1970. + OCSPLastUpdated time.Time `db:"ocspLastUpdated"` + + // revokedDate: If status is 'revoked', this is the date and time it was + // revoked. Otherwise it has the zero value of time.Time, i.e. Jan 1 1970. + RevokedDate time.Time `db:"revokedDate"` + + // revokedReason: If status is 'revoked', this is the reason code for the + // revocation. Otherwise it is zero (which happens to be the reason + // code for 'unspecified'). + RevokedReason revocation.Reason `db:"revokedReason"` + + LastExpirationNagSent time.Time `db:"lastExpirationNagSent"` + + // NotAfter and IsExpired are convenience columns which allow expensive + // queries to quickly filter out certificates that we don't need to care about + // anymore. These are particularly useful for the expiration mailer and CRL + // updater. See https://github.com/letsencrypt/boulder/issues/1864. + NotAfter time.Time `db:"notAfter"` + IsExpired bool `db:"isExpired"` + + // Note: this is not an issuance.IssuerNameID because that would create an + // import cycle between core and issuance. + // Note2: This field used to be called `issuerID`. We keep the old name in + // the DB, but update the Go field name to be clear which type of ID this + // is. + IssuerNameID int64 `db:"issuerID"` +} + +// FQDNSet contains the SHA256 hash of the lowercased, comma joined dNSNames +// contained in a certificate. +type FQDNSet struct { + ID int64 + SetHash []byte + Serial string + Issued time.Time + Expires time.Time +} + +// SCTDERs is a convenience type +type SCTDERs [][]byte + +// CertDER is a convenience type that helps differentiate what the +// underlying byte slice contains +type CertDER []byte + +// SuggestedWindow is a type exposed inside the RenewalInfo resource. +type SuggestedWindow struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +// IsWithin returns true if the given time is within the suggested window, +// inclusive of the start time and exclusive of the end time. +func (window SuggestedWindow) IsWithin(now time.Time) bool { + return !now.Before(window.Start) && now.Before(window.End) +} + +// RenewalInfo is a type which is exposed to clients which query the renewalInfo +// endpoint specified in draft-aaron-ari. +type RenewalInfo struct { + SuggestedWindow SuggestedWindow `json:"suggestedWindow"` +} + +// RenewalInfoSimple constructs a `RenewalInfo` object and suggested window +// using a very simple renewal calculation: calculate a point 2/3rds of the way +// through the validity period, then give a 2-day window around that. Both the +// `issued` and `expires` timestamps are expected to be UTC. +func RenewalInfoSimple(issued time.Time, expires time.Time) RenewalInfo { + validity := expires.Add(time.Second).Sub(issued) + renewalOffset := validity / time.Duration(3) + idealRenewal := expires.Add(-renewalOffset) + return RenewalInfo{ + SuggestedWindow: SuggestedWindow{ + Start: idealRenewal.Add(-24 * time.Hour), + End: idealRenewal.Add(24 * time.Hour), + }, + } +} + +// RenewalInfoImmediate constructs a `RenewalInfo` object with a suggested +// window in the past. Per the draft-ietf-acme-ari-01 spec, clients should +// attempt to renew immediately if the suggested window is in the past. The +// passed `now` is assumed to be a timestamp representing the current moment in +// time. +func RenewalInfoImmediate(now time.Time) RenewalInfo { + oneHourAgo := now.Add(-1 * time.Hour) + return RenewalInfo{ + SuggestedWindow: SuggestedWindow{ + Start: oneHourAgo, + End: oneHourAgo.Add(time.Minute * 30), + }, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/core/objects_test.go b/third-party/github.com/letsencrypt/boulder/core/objects_test.go new file mode 100644 index 000000000..9aba3b2fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/objects_test.go @@ -0,0 +1,190 @@ +package core + +import ( + "crypto/rsa" + "encoding/json" + "math/big" + "net" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + + "github.com/letsencrypt/boulder/test" +) + +func TestExpectedKeyAuthorization(t *testing.T) { + ch := Challenge{Token: "hi"} + jwk1 := &jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1234), E: 1234}} + jwk2 := &jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(5678), E: 5678}} + + ka1, err := ch.ExpectedKeyAuthorization(jwk1) + test.AssertNotError(t, err, "Failed to calculate expected key authorization 1") + ka2, err := ch.ExpectedKeyAuthorization(jwk2) + test.AssertNotError(t, err, "Failed to calculate expected key authorization 2") + + expected1 := "hi.sIMEyhkWCCSYqDqZqPM1bKkvb5T9jpBOb7_w5ZNorF4" + expected2 := "hi.FPoiyqWPod2T0fKqkPI1uXPYUsRK1DSyzsQsv0oMuGg" + if ka1 != expected1 { + t.Errorf("Incorrect ka1. Expected [%s], got [%s]", expected1, ka1) + } + if ka2 != expected2 { + t.Errorf("Incorrect ka2. Expected [%s], got [%s]", expected2, ka2) + } +} + +func TestRecordSanityCheckOnUnsupportedChallengeType(t *testing.T) { + rec := []ValidationRecord{ + { + URL: "http://localhost/test", + Hostname: "localhost", + Port: "80", + AddressesResolved: []net.IP{{127, 0, 0, 1}}, + AddressUsed: net.IP{127, 0, 0, 1}, + ResolverAddrs: []string{"eastUnboundAndDown"}, + }, + } + + chall := Challenge{Type: "obsoletedChallenge", ValidationRecord: rec} + test.Assert(t, !chall.RecordsSane(), "Record with unsupported challenge type should not be sane") +} + +func TestChallengeSanityCheck(t *testing.T) { + // Make a temporary account key + var accountKey *jose.JSONWebKey + err := json.Unmarshal([]byte(`{ + "kty":"RSA", + "n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e":"AQAB" + }`), &accountKey) + test.AssertNotError(t, err, "Error unmarshaling JWK") + + types := []AcmeChallenge{ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01} + for _, challengeType := range types { + chall := Challenge{ + Type: challengeType, + Status: StatusInvalid, + } + test.AssertError(t, chall.CheckPending(), "CheckConsistencyForClientOffer didn't return an error") + + chall.Status = StatusPending + test.AssertError(t, chall.CheckPending(), "CheckConsistencyForClientOffer didn't return an error") + + chall.Token = "KQqLsiS5j0CONR_eUXTUSUDNVaHODtc-0pD6ACif7U4" + test.AssertNotError(t, chall.CheckPending(), "CheckConsistencyForClientOffer returned an error") + } +} + +func TestJSONBufferUnmarshal(t *testing.T) { + testStruct := struct { + Buffer JSONBuffer + }{} + + notValidBase64 := []byte(`{"Buffer":"!!!!"}`) + err := json.Unmarshal(notValidBase64, &testStruct) + test.Assert(t, err != nil, "Should have choked on invalid base64") +} + +func TestAuthorizationSolvedBy(t *testing.T) { + validHTTP01 := HTTPChallenge01("") + validHTTP01.Status = StatusValid + validDNS01 := DNSChallenge01("") + validDNS01.Status = StatusValid + testCases := []struct { + Name string + Authz Authorization + ExpectedResult AcmeChallenge + ExpectedError string + }{ + // An authz with no challenges should return nil + { + Name: "No challenges", + Authz: Authorization{}, + ExpectedError: "Authorization has no challenges", + }, + // An authz with all non-valid challenges should return nil + { + Name: "All non-valid challenges", + Authz: Authorization{ + Challenges: []Challenge{HTTPChallenge01(""), DNSChallenge01("")}, + }, + ExpectedError: "Authorization not solved by any challenge", + }, + // An authz with one valid HTTP01 challenge amongst other challenges should + // return the HTTP01 challenge + { + Name: "Valid HTTP01 challenge", + Authz: Authorization{ + Challenges: []Challenge{HTTPChallenge01(""), validHTTP01, DNSChallenge01("")}, + }, + ExpectedResult: ChallengeTypeHTTP01, + }, + // An authz with both a valid HTTP01 challenge and a valid DNS01 challenge + // among other challenges should return whichever valid challenge is first + // (in this case DNS01) + { + Name: "Valid HTTP01 and DNS01 challenge", + Authz: Authorization{ + Challenges: []Challenge{validDNS01, HTTPChallenge01(""), validHTTP01, DNSChallenge01("")}, + }, + ExpectedResult: ChallengeTypeDNS01, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := tc.Authz.SolvedBy() + if tc.ExpectedError != "" { + test.AssertEquals(t, err.Error(), tc.ExpectedError) + } + if tc.ExpectedResult != "" { + test.AssertEquals(t, result, tc.ExpectedResult) + } + }) + } +} + +func TestChallengeStringID(t *testing.T) { + ch := Challenge{ + Token: "asd", + Type: ChallengeTypeDNS01, + } + test.AssertEquals(t, ch.StringID(), "iFVMwA") + ch.Type = ChallengeTypeHTTP01 + test.AssertEquals(t, ch.StringID(), "0Gexug") +} + +func TestFindChallengeByType(t *testing.T) { + authz := Authorization{ + Challenges: []Challenge{ + {Token: "woo", Type: ChallengeTypeDNS01}, + {Token: "woo", Type: ChallengeTypeHTTP01}, + }, + } + test.AssertEquals(t, 0, authz.FindChallengeByStringID(authz.Challenges[0].StringID())) + test.AssertEquals(t, 1, authz.FindChallengeByStringID(authz.Challenges[1].StringID())) + test.AssertEquals(t, -1, authz.FindChallengeByStringID("hello")) +} + +func TestRenewalInfoSuggestedWindowIsWithin(t *testing.T) { + now := time.Now().UTC() + window := SuggestedWindow{ + Start: now, + End: now.Add(time.Hour), + } + + // Exactly the beginning, inclusive of the first nanosecond. + test.Assert(t, window.IsWithin(now), "Start of window should be within the window") + + // Exactly the middle. + test.Assert(t, window.IsWithin(now.Add(time.Minute*30)), "Middle of window should be within the window") + + // Exactly the end time. + test.Assert(t, !window.IsWithin(now.Add(time.Hour)), "End of window should be outside the window") + + // Exactly the end of the window. + test.Assert(t, window.IsWithin(now.Add(time.Hour-time.Nanosecond)), "Should be just inside the window") + + // Just before the first nanosecond. + test.Assert(t, !window.IsWithin(now.Add(-time.Nanosecond)), "Before the window should not be within the window") +} diff --git a/third-party/github.com/letsencrypt/boulder/core/proto/core.pb.go b/third-party/github.com/letsencrypt/boulder/core/proto/core.pb.go new file mode 100644 index 000000000..1f926178e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/proto/core.pb.go @@ -0,0 +1,1245 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: core.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Challenge struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 13 + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + Uri string `protobuf:"bytes,9,opt,name=uri,proto3" json:"uri,omitempty"` + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + // TODO(#7514): Remove this. + KeyAuthorization string `protobuf:"bytes,5,opt,name=keyAuthorization,proto3" json:"keyAuthorization,omitempty"` + Validationrecords []*ValidationRecord `protobuf:"bytes,10,rep,name=validationrecords,proto3" json:"validationrecords,omitempty"` + Error *ProblemDetails `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + Validated *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=validated,proto3" json:"validated,omitempty"` +} + +func (x *Challenge) Reset() { + *x = Challenge{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Challenge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Challenge) ProtoMessage() {} + +func (x *Challenge) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Challenge.ProtoReflect.Descriptor instead. +func (*Challenge) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{0} +} + +func (x *Challenge) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Challenge) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Challenge) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Challenge) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *Challenge) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *Challenge) GetKeyAuthorization() string { + if x != nil { + return x.KeyAuthorization + } + return "" +} + +func (x *Challenge) GetValidationrecords() []*ValidationRecord { + if x != nil { + return x.Validationrecords + } + return nil +} + +func (x *Challenge) GetError() *ProblemDetails { + if x != nil { + return x.Error + } + return nil +} + +func (x *Challenge) GetValidated() *timestamppb.Timestamp { + if x != nil { + return x.Validated + } + return nil +} + +type ValidationRecord struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 9 + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + Port string `protobuf:"bytes,2,opt,name=port,proto3" json:"port,omitempty"` + AddressesResolved [][]byte `protobuf:"bytes,3,rep,name=addressesResolved,proto3" json:"addressesResolved,omitempty"` // net.IP.MarshalText() + AddressUsed []byte `protobuf:"bytes,4,opt,name=addressUsed,proto3" json:"addressUsed,omitempty"` // net.IP.MarshalText() + Authorities []string `protobuf:"bytes,5,rep,name=authorities,proto3" json:"authorities,omitempty"` + Url string `protobuf:"bytes,6,opt,name=url,proto3" json:"url,omitempty"` + // A list of addresses tried before the address used (see + // core/objects.go and the comment on the ValidationRecord structure + // definition for more information. + AddressesTried [][]byte `protobuf:"bytes,7,rep,name=addressesTried,proto3" json:"addressesTried,omitempty"` // net.IP.MarshalText() + ResolverAddrs []string `protobuf:"bytes,8,rep,name=resolverAddrs,proto3" json:"resolverAddrs,omitempty"` +} + +func (x *ValidationRecord) Reset() { + *x = ValidationRecord{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidationRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidationRecord) ProtoMessage() {} + +func (x *ValidationRecord) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidationRecord.ProtoReflect.Descriptor instead. +func (*ValidationRecord) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{1} +} + +func (x *ValidationRecord) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *ValidationRecord) GetPort() string { + if x != nil { + return x.Port + } + return "" +} + +func (x *ValidationRecord) GetAddressesResolved() [][]byte { + if x != nil { + return x.AddressesResolved + } + return nil +} + +func (x *ValidationRecord) GetAddressUsed() []byte { + if x != nil { + return x.AddressUsed + } + return nil +} + +func (x *ValidationRecord) GetAuthorities() []string { + if x != nil { + return x.Authorities + } + return nil +} + +func (x *ValidationRecord) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ValidationRecord) GetAddressesTried() [][]byte { + if x != nil { + return x.AddressesTried + } + return nil +} + +func (x *ValidationRecord) GetResolverAddrs() []string { + if x != nil { + return x.ResolverAddrs + } + return nil +} + +type ProblemDetails struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ProblemType string `protobuf:"bytes,1,opt,name=problemType,proto3" json:"problemType,omitempty"` + Detail string `protobuf:"bytes,2,opt,name=detail,proto3" json:"detail,omitempty"` + HttpStatus int32 `protobuf:"varint,3,opt,name=httpStatus,proto3" json:"httpStatus,omitempty"` +} + +func (x *ProblemDetails) Reset() { + *x = ProblemDetails{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProblemDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProblemDetails) ProtoMessage() {} + +func (x *ProblemDetails) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProblemDetails.ProtoReflect.Descriptor instead. +func (*ProblemDetails) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{2} +} + +func (x *ProblemDetails) GetProblemType() string { + if x != nil { + return x.ProblemType + } + return "" +} + +func (x *ProblemDetails) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *ProblemDetails) GetHttpStatus() int32 { + if x != nil { + return x.HttpStatus + } + return 0 +} + +type Certificate struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 9 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Serial string `protobuf:"bytes,2,opt,name=serial,proto3" json:"serial,omitempty"` + Digest string `protobuf:"bytes,3,opt,name=digest,proto3" json:"digest,omitempty"` + Der []byte `protobuf:"bytes,4,opt,name=der,proto3" json:"der,omitempty"` + Issued *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=issued,proto3" json:"issued,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires,proto3" json:"expires,omitempty"` +} + +func (x *Certificate) Reset() { + *x = Certificate{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Certificate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Certificate) ProtoMessage() {} + +func (x *Certificate) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Certificate.ProtoReflect.Descriptor instead. +func (*Certificate) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{3} +} + +func (x *Certificate) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *Certificate) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *Certificate) GetDigest() string { + if x != nil { + return x.Digest + } + return "" +} + +func (x *Certificate) GetDer() []byte { + if x != nil { + return x.Der + } + return nil +} + +func (x *Certificate) GetIssued() *timestamppb.Timestamp { + if x != nil { + return x.Issued + } + return nil +} + +func (x *Certificate) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +type CertificateStatus struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 16 + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + OcspLastUpdated *timestamppb.Timestamp `protobuf:"bytes,15,opt,name=ocspLastUpdated,proto3" json:"ocspLastUpdated,omitempty"` + RevokedDate *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=revokedDate,proto3" json:"revokedDate,omitempty"` + RevokedReason int64 `protobuf:"varint,6,opt,name=revokedReason,proto3" json:"revokedReason,omitempty"` + LastExpirationNagSent *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=lastExpirationNagSent,proto3" json:"lastExpirationNagSent,omitempty"` + NotAfter *timestamppb.Timestamp `protobuf:"bytes,14,opt,name=notAfter,proto3" json:"notAfter,omitempty"` + IsExpired bool `protobuf:"varint,10,opt,name=isExpired,proto3" json:"isExpired,omitempty"` + IssuerID int64 `protobuf:"varint,11,opt,name=issuerID,proto3" json:"issuerID,omitempty"` +} + +func (x *CertificateStatus) Reset() { + *x = CertificateStatus{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CertificateStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CertificateStatus) ProtoMessage() {} + +func (x *CertificateStatus) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CertificateStatus.ProtoReflect.Descriptor instead. +func (*CertificateStatus) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{4} +} + +func (x *CertificateStatus) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *CertificateStatus) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *CertificateStatus) GetOcspLastUpdated() *timestamppb.Timestamp { + if x != nil { + return x.OcspLastUpdated + } + return nil +} + +func (x *CertificateStatus) GetRevokedDate() *timestamppb.Timestamp { + if x != nil { + return x.RevokedDate + } + return nil +} + +func (x *CertificateStatus) GetRevokedReason() int64 { + if x != nil { + return x.RevokedReason + } + return 0 +} + +func (x *CertificateStatus) GetLastExpirationNagSent() *timestamppb.Timestamp { + if x != nil { + return x.LastExpirationNagSent + } + return nil +} + +func (x *CertificateStatus) GetNotAfter() *timestamppb.Timestamp { + if x != nil { + return x.NotAfter + } + return nil +} + +func (x *CertificateStatus) GetIsExpired() bool { + if x != nil { + return x.IsExpired + } + return false +} + +func (x *CertificateStatus) GetIssuerID() int64 { + if x != nil { + return x.IssuerID + } + return 0 +} + +type Registration struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 10 + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Key []byte `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Contact []string `protobuf:"bytes,3,rep,name=contact,proto3" json:"contact,omitempty"` + ContactsPresent bool `protobuf:"varint,4,opt,name=contactsPresent,proto3" json:"contactsPresent,omitempty"` + Agreement string `protobuf:"bytes,5,opt,name=agreement,proto3" json:"agreement,omitempty"` + InitialIP []byte `protobuf:"bytes,6,opt,name=initialIP,proto3" json:"initialIP,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=createdAt,proto3" json:"createdAt,omitempty"` + Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` +} + +func (x *Registration) Reset() { + *x = Registration{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Registration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Registration) ProtoMessage() {} + +func (x *Registration) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Registration.ProtoReflect.Descriptor instead. +func (*Registration) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{5} +} + +func (x *Registration) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Registration) GetKey() []byte { + if x != nil { + return x.Key + } + return nil +} + +func (x *Registration) GetContact() []string { + if x != nil { + return x.Contact + } + return nil +} + +func (x *Registration) GetContactsPresent() bool { + if x != nil { + return x.ContactsPresent + } + return false +} + +func (x *Registration) GetAgreement() string { + if x != nil { + return x.Agreement + } + return "" +} + +func (x *Registration) GetInitialIP() []byte { + if x != nil { + return x.InitialIP + } + return nil +} + +func (x *Registration) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Registration) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +type Authorization struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 10 + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Identifier string `protobuf:"bytes,2,opt,name=identifier,proto3" json:"identifier,omitempty"` + RegistrationID int64 `protobuf:"varint,3,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=expires,proto3" json:"expires,omitempty"` + Challenges []*Challenge `protobuf:"bytes,6,rep,name=challenges,proto3" json:"challenges,omitempty"` +} + +func (x *Authorization) Reset() { + *x = Authorization{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Authorization) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Authorization) ProtoMessage() {} + +func (x *Authorization) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Authorization.ProtoReflect.Descriptor instead. +func (*Authorization) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{6} +} + +func (x *Authorization) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Authorization) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +func (x *Authorization) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *Authorization) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Authorization) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +func (x *Authorization) GetChallenges() []*Challenge { + if x != nil { + return x.Challenges + } + return nil +} + +type Order struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 15 + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=expires,proto3" json:"expires,omitempty"` + Error *ProblemDetails `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + CertificateSerial string `protobuf:"bytes,5,opt,name=certificateSerial,proto3" json:"certificateSerial,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + Names []string `protobuf:"bytes,8,rep,name=names,proto3" json:"names,omitempty"` + BeganProcessing bool `protobuf:"varint,9,opt,name=beganProcessing,proto3" json:"beganProcessing,omitempty"` + Created *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=created,proto3" json:"created,omitempty"` + V2Authorizations []int64 `protobuf:"varint,11,rep,packed,name=v2Authorizations,proto3" json:"v2Authorizations,omitempty"` + CertificateProfileName string `protobuf:"bytes,14,opt,name=certificateProfileName,proto3" json:"certificateProfileName,omitempty"` +} + +func (x *Order) Reset() { + *x = Order{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Order) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Order) ProtoMessage() {} + +func (x *Order) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Order.ProtoReflect.Descriptor instead. +func (*Order) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{7} +} + +func (x *Order) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Order) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *Order) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +func (x *Order) GetError() *ProblemDetails { + if x != nil { + return x.Error + } + return nil +} + +func (x *Order) GetCertificateSerial() string { + if x != nil { + return x.CertificateSerial + } + return "" +} + +func (x *Order) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Order) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +func (x *Order) GetBeganProcessing() bool { + if x != nil { + return x.BeganProcessing + } + return false +} + +func (x *Order) GetCreated() *timestamppb.Timestamp { + if x != nil { + return x.Created + } + return nil +} + +func (x *Order) GetV2Authorizations() []int64 { + if x != nil { + return x.V2Authorizations + } + return nil +} + +func (x *Order) GetCertificateProfileName() string { + if x != nil { + return x.CertificateProfileName + } + return "" +} + +type CRLEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 5 + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + Reason int32 `protobuf:"varint,2,opt,name=reason,proto3" json:"reason,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=revokedAt,proto3" json:"revokedAt,omitempty"` +} + +func (x *CRLEntry) Reset() { + *x = CRLEntry{} + if protoimpl.UnsafeEnabled { + mi := &file_core_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CRLEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CRLEntry) ProtoMessage() {} + +func (x *CRLEntry) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CRLEntry.ProtoReflect.Descriptor instead. +func (*CRLEntry) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{8} +} + +func (x *CRLEntry) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *CRLEntry) GetReason() int32 { + if x != nil { + return x.Reason + } + return 0 +} + +func (x *CRLEntry) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +var File_core_proto protoreflect.FileDescriptor + +var file_core_proto_rawDesc = []byte{ + 0x0a, 0x0a, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x63, 0x6f, + 0x72, 0x65, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0xd9, 0x02, 0x0a, 0x09, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, + 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x10, 0x0a, + 0x03, 0x75, 0x72, 0x69, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, + 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2a, 0x0a, 0x10, 0x6b, 0x65, 0x79, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x6b, 0x65, 0x79, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x44, 0x0a, 0x11, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x72, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, + 0x6f, 0x72, 0x65, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x52, 0x11, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x2a, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x50, 0x72, + 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x12, 0x38, 0x0a, 0x09, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x64, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x64, 0x4a, 0x04, 0x08, + 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x4a, 0x04, 0x08, 0x0b, 0x10, 0x0c, 0x22, + 0x94, 0x02, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x70, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, + 0x11, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, + 0x65, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x55, 0x73, 0x65, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x55, 0x73, 0x65, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, + 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x65, 0x73, 0x54, 0x72, 0x69, 0x65, 0x64, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0c, + 0x52, 0x0e, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x54, 0x72, 0x69, 0x65, 0x64, + 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, + 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, + 0x72, 0x41, 0x64, 0x64, 0x72, 0x73, 0x22, 0x6a, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, + 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x62, + 0x6c, 0x65, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, + 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x12, 0x1e, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x22, 0xed, 0x01, 0x0a, 0x0b, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x65, + 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x64, 0x65, 0x72, 0x12, 0x32, 0x0a, 0x06, + 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, + 0x12, 0x34, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, + 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x4a, 0x04, 0x08, 0x06, + 0x10, 0x07, 0x22, 0xd5, 0x03, 0x0a, 0x11, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x44, 0x0a, 0x0f, 0x6f, 0x63, 0x73, 0x70, + 0x4c, 0x61, 0x73, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0f, 0x6f, + 0x63, 0x73, 0x70, 0x4c, 0x61, 0x73, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x3c, + 0x0a, 0x0b, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x44, 0x61, 0x74, 0x65, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x0b, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x44, 0x61, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, + 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0d, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x12, 0x50, 0x0a, 0x15, 0x6c, 0x61, 0x73, 0x74, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x67, 0x53, 0x65, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x15, 0x6c, + 0x61, 0x73, 0x74, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x67, + 0x53, 0x65, 0x6e, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x41, 0x66, 0x74, 0x65, 0x72, + 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, + 0x69, 0x73, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x09, 0x69, 0x73, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x73, + 0x73, 0x75, 0x65, 0x72, 0x49, 0x44, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, 0x73, + 0x73, 0x75, 0x65, 0x72, 0x49, 0x44, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x4a, 0x04, 0x08, 0x04, + 0x10, 0x05, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x4a, 0x04, + 0x08, 0x08, 0x10, 0x09, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x88, 0x02, 0x0a, 0x0c, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x18, 0x0a, + 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x63, 0x74, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, + 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x63, 0x74, 0x12, 0x28, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x61, + 0x63, 0x74, 0x73, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x63, 0x74, 0x73, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, + 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x67, 0x72, 0x65, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x72, 0x65, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, + 0x1c, 0x0a, 0x09, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x49, 0x50, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x09, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x49, 0x50, 0x12, 0x38, 0x0a, + 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4a, + 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0xf8, 0x01, 0x0a, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, + 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x34, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x65, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x12, 0x2f, 0x0a, + 0x0a, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, + 0x67, 0x65, 0x52, 0x0a, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x73, 0x4a, 0x04, + 0x08, 0x05, 0x10, 0x06, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, + 0x22, 0xd3, 0x03, 0x0a, 0x05, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x49, 0x44, 0x12, 0x34, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x50, + 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2c, 0x0a, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x12, 0x28, 0x0a, 0x0f, 0x62, 0x65, 0x67, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, + 0x69, 0x6e, 0x67, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x62, 0x65, 0x67, 0x61, 0x6e, + 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x63, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x12, 0x2a, 0x0a, 0x10, 0x76, 0x32, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x03, 0x52, 0x10, 0x76, 0x32, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x36, 0x0a, 0x16, + 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x63, 0x65, + 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, + 0x4e, 0x61, 0x6d, 0x65, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x07, + 0x4a, 0x04, 0x08, 0x0a, 0x10, 0x0b, 0x22, 0x7a, 0x0a, 0x08, 0x43, 0x52, 0x4c, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x41, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x41, 0x74, 0x4a, 0x04, 0x08, 0x03, + 0x10, 0x04, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, + 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_core_proto_rawDescOnce sync.Once + file_core_proto_rawDescData = file_core_proto_rawDesc +) + +func file_core_proto_rawDescGZIP() []byte { + file_core_proto_rawDescOnce.Do(func() { + file_core_proto_rawDescData = protoimpl.X.CompressGZIP(file_core_proto_rawDescData) + }) + return file_core_proto_rawDescData +} + +var file_core_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_core_proto_goTypes = []interface{}{ + (*Challenge)(nil), // 0: core.Challenge + (*ValidationRecord)(nil), // 1: core.ValidationRecord + (*ProblemDetails)(nil), // 2: core.ProblemDetails + (*Certificate)(nil), // 3: core.Certificate + (*CertificateStatus)(nil), // 4: core.CertificateStatus + (*Registration)(nil), // 5: core.Registration + (*Authorization)(nil), // 6: core.Authorization + (*Order)(nil), // 7: core.Order + (*CRLEntry)(nil), // 8: core.CRLEntry + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp +} +var file_core_proto_depIdxs = []int32{ + 1, // 0: core.Challenge.validationrecords:type_name -> core.ValidationRecord + 2, // 1: core.Challenge.error:type_name -> core.ProblemDetails + 9, // 2: core.Challenge.validated:type_name -> google.protobuf.Timestamp + 9, // 3: core.Certificate.issued:type_name -> google.protobuf.Timestamp + 9, // 4: core.Certificate.expires:type_name -> google.protobuf.Timestamp + 9, // 5: core.CertificateStatus.ocspLastUpdated:type_name -> google.protobuf.Timestamp + 9, // 6: core.CertificateStatus.revokedDate:type_name -> google.protobuf.Timestamp + 9, // 7: core.CertificateStatus.lastExpirationNagSent:type_name -> google.protobuf.Timestamp + 9, // 8: core.CertificateStatus.notAfter:type_name -> google.protobuf.Timestamp + 9, // 9: core.Registration.createdAt:type_name -> google.protobuf.Timestamp + 9, // 10: core.Authorization.expires:type_name -> google.protobuf.Timestamp + 0, // 11: core.Authorization.challenges:type_name -> core.Challenge + 9, // 12: core.Order.expires:type_name -> google.protobuf.Timestamp + 2, // 13: core.Order.error:type_name -> core.ProblemDetails + 9, // 14: core.Order.created:type_name -> google.protobuf.Timestamp + 9, // 15: core.CRLEntry.revokedAt:type_name -> google.protobuf.Timestamp + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name +} + +func init() { file_core_proto_init() } +func file_core_proto_init() { + if File_core_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_core_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Challenge); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidationRecord); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProblemDetails); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Certificate); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CertificateStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Registration); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Authorization); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Order); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_core_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CRLEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_core_proto_rawDesc, + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_core_proto_goTypes, + DependencyIndexes: file_core_proto_depIdxs, + MessageInfos: file_core_proto_msgTypes, + }.Build() + File_core_proto = out.File + file_core_proto_rawDesc = nil + file_core_proto_goTypes = nil + file_core_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/core/proto/core.proto b/third-party/github.com/letsencrypt/boulder/core/proto/core.proto new file mode 100644 index 000000000..3a13afa97 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/proto/core.proto @@ -0,0 +1,128 @@ +syntax = "proto3"; + +package core; +option go_package = "github.com/letsencrypt/boulder/core/proto"; + +import "google/protobuf/timestamp.proto"; + +message Challenge { + // Next unused field number: 13 + int64 id = 1; + string type = 2; + string status = 6; + string uri = 9; + string token = 3; + reserved 4; // Previously accountKey + // TODO(#7514): Remove this. + string keyAuthorization = 5; + repeated ValidationRecord validationrecords = 10; + ProblemDetails error = 7; + reserved 8; // Unused and accidentally skipped during initial commit. + reserved 11; // Previously validatedNS + google.protobuf.Timestamp validated = 12; +} + +message ValidationRecord { + // Next unused field number: 9 + string hostname = 1; + string port = 2; + repeated bytes addressesResolved = 3; // net.IP.MarshalText() + bytes addressUsed = 4; // net.IP.MarshalText() + + repeated string authorities = 5; + string url = 6; + // A list of addresses tried before the address used (see + // core/objects.go and the comment on the ValidationRecord structure + // definition for more information. + repeated bytes addressesTried = 7; // net.IP.MarshalText() + repeated string resolverAddrs = 8; +} + +message ProblemDetails { + string problemType = 1; + string detail = 2; + int32 httpStatus = 3; +} + +message Certificate { + // Next unused field number: 9 + int64 registrationID = 1; + string serial = 2; + string digest = 3; + bytes der = 4; + reserved 5; // Previously issuedNS + google.protobuf.Timestamp issued = 7; + reserved 6; // Previously expiresNS + google.protobuf.Timestamp expires = 8; +} + +message CertificateStatus { + // Next unused field number: 16 + string serial = 1; + reserved 2; // previously subscriberApproved + string status = 3; + reserved 4; // Previously ocspLastUpdatedNS + google.protobuf.Timestamp ocspLastUpdated = 15; + reserved 5; // Previously revokedDateNS + google.protobuf.Timestamp revokedDate = 12; + int64 revokedReason = 6; + reserved 7; // Previously lastExpirationNagSentNS + reserved 8; // previously ocspResponse + google.protobuf.Timestamp lastExpirationNagSent = 13; + reserved 9; // Previously notAfterNS + google.protobuf.Timestamp notAfter = 14; + bool isExpired = 10; + int64 issuerID = 11; +} + +message Registration { + // Next unused field number: 10 + int64 id = 1; + bytes key = 2; + repeated string contact = 3; + bool contactsPresent = 4; + string agreement = 5; + bytes initialIP = 6; + reserved 7; // Previously createdAtNS + google.protobuf.Timestamp createdAt = 9; + string status = 8; +} + +message Authorization { + // Next unused field number: 10 + string id = 1; + string identifier = 2; + int64 registrationID = 3; + string status = 4; + reserved 5; // Previously expiresNS + google.protobuf.Timestamp expires = 9; + repeated core.Challenge challenges = 6; + reserved 7; // previously ACMEv1 combinations + reserved 8; // previously v2 +} + +message Order { + // Next unused field number: 15 + int64 id = 1; + int64 registrationID = 2; + reserved 3; // Previously expiresNS + google.protobuf.Timestamp expires = 12; + ProblemDetails error = 4; + string certificateSerial = 5; + reserved 6; // previously authorizations, deprecated in favor of v2Authorizations + string status = 7; + repeated string names = 8; + bool beganProcessing = 9; + reserved 10; // Previously createdNS + google.protobuf.Timestamp created = 13; + repeated int64 v2Authorizations = 11; + string certificateProfileName = 14; +} + +message CRLEntry { + // Next unused field number: 5 + string serial = 1; + int32 reason = 2; + reserved 3; // Previously revokedAtNS + google.protobuf.Timestamp revokedAt = 4; +} diff --git a/third-party/github.com/letsencrypt/boulder/core/util.go b/third-party/github.com/letsencrypt/boulder/core/util.go new file mode 100644 index 000000000..641521f16 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/util.go @@ -0,0 +1,383 @@ +package core + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "expvar" + "fmt" + "io" + "math/big" + mrand "math/rand" + "os" + "path" + "reflect" + "regexp" + "sort" + "strings" + "time" + "unicode" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const Unspecified = "Unspecified" + +// Package Variables Variables + +// BuildID is set by the compiler (using -ldflags "-X core.BuildID $(git rev-parse --short HEAD)") +// and is used by GetBuildID +var BuildID string + +// BuildHost is set by the compiler and is used by GetBuildHost +var BuildHost string + +// BuildTime is set by the compiler and is used by GetBuildTime +var BuildTime string + +func init() { + expvar.NewString("BuildID").Set(BuildID) + expvar.NewString("BuildTime").Set(BuildTime) +} + +// Random stuff + +type randSource interface { + Read(p []byte) (n int, err error) +} + +// RandReader is used so that it can be replaced in tests that require +// deterministic output +var RandReader randSource = rand.Reader + +// RandomString returns a randomly generated string of the requested length. +func RandomString(byteLength int) string { + b := make([]byte, byteLength) + _, err := io.ReadFull(RandReader, b) + if err != nil { + panic(fmt.Sprintf("Error reading random bytes: %s", err)) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// NewToken produces a random string for Challenges, etc. +func NewToken() string { + return RandomString(32) +} + +var tokenFormat = regexp.MustCompile(`^[\w-]{43}$`) + +// looksLikeAToken checks whether a string represents a 32-octet value in +// the URL-safe base64 alphabet. +func looksLikeAToken(token string) bool { + return tokenFormat.MatchString(token) +} + +// Fingerprints + +// Fingerprint256 produces an unpadded, URL-safe Base64-encoded SHA256 digest +// of the data. +func Fingerprint256(data []byte) string { + d := sha256.New() + _, _ = d.Write(data) // Never returns an error + return base64.RawURLEncoding.EncodeToString(d.Sum(nil)) +} + +type Sha256Digest [sha256.Size]byte + +// KeyDigest produces the SHA256 digest of a provided public key. +func KeyDigest(key crypto.PublicKey) (Sha256Digest, error) { + switch t := key.(type) { + case *jose.JSONWebKey: + if t == nil { + return Sha256Digest{}, errors.New("cannot compute digest of nil key") + } + return KeyDigest(t.Key) + case jose.JSONWebKey: + return KeyDigest(t.Key) + default: + keyDER, err := x509.MarshalPKIXPublicKey(key) + if err != nil { + return Sha256Digest{}, err + } + return sha256.Sum256(keyDER), nil + } +} + +// KeyDigestB64 produces a padded, standard Base64-encoded SHA256 digest of a +// provided public key. +func KeyDigestB64(key crypto.PublicKey) (string, error) { + digest, err := KeyDigest(key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(digest[:]), nil +} + +// KeyDigestEquals determines whether two public keys have the same digest. +func KeyDigestEquals(j, k crypto.PublicKey) bool { + digestJ, errJ := KeyDigestB64(j) + digestK, errK := KeyDigestB64(k) + // Keys that don't have a valid digest (due to marshalling problems) + // are never equal. So, e.g. nil keys are not equal. + if errJ != nil || errK != nil { + return false + } + return digestJ == digestK +} + +// PublicKeysEqual determines whether two public keys are identical. +func PublicKeysEqual(a, b crypto.PublicKey) (bool, error) { + switch ak := a.(type) { + case *rsa.PublicKey: + return ak.Equal(b), nil + case *ecdsa.PublicKey: + return ak.Equal(b), nil + default: + return false, fmt.Errorf("unsupported public key type %T", ak) + } +} + +// SerialToString converts a certificate serial number (big.Int) to a String +// consistently. +func SerialToString(serial *big.Int) string { + return fmt.Sprintf("%036x", serial) +} + +// StringToSerial converts a string into a certificate serial number (big.Int) +// consistently. +func StringToSerial(serial string) (*big.Int, error) { + var serialNum big.Int + if !ValidSerial(serial) { + return &serialNum, fmt.Errorf("invalid serial number %q", serial) + } + _, err := fmt.Sscanf(serial, "%036x", &serialNum) + return &serialNum, err +} + +// ValidSerial tests whether the input string represents a syntactically +// valid serial number, i.e., that it is a valid hex string between 32 +// and 36 characters long. +func ValidSerial(serial string) bool { + // Originally, serial numbers were 32 hex characters long. We later increased + // them to 36, but we allow the shorter ones because they exist in some + // production databases. + if len(serial) != 32 && len(serial) != 36 { + return false + } + _, err := hex.DecodeString(serial) + return err == nil +} + +// GetBuildID identifies what build is running. +func GetBuildID() (retID string) { + retID = BuildID + if retID == "" { + retID = Unspecified + } + return +} + +// GetBuildTime identifies when this build was made +func GetBuildTime() (retID string) { + retID = BuildTime + if retID == "" { + retID = Unspecified + } + return +} + +// GetBuildHost identifies the building host +func GetBuildHost() (retID string) { + retID = BuildHost + if retID == "" { + retID = Unspecified + } + return +} + +// IsAnyNilOrZero returns whether any of the supplied values are nil, or (if not) +// if any of them is its type's zero-value. This is useful for validating that +// all required fields on a proto message are present. +func IsAnyNilOrZero(vals ...interface{}) bool { + for _, val := range vals { + switch v := val.(type) { + case nil: + return true + case bool: + if !v { + return true + } + case string: + if v == "" { + return true + } + case []string: + if len(v) == 0 { + return true + } + case byte: + // Byte is an alias for uint8 and will cover that case. + if v == 0 { + return true + } + case []byte: + if len(v) == 0 { + return true + } + case int: + if v == 0 { + return true + } + case int8: + if v == 0 { + return true + } + case int16: + if v == 0 { + return true + } + case int32: + if v == 0 { + return true + } + case int64: + if v == 0 { + return true + } + case uint: + if v == 0 { + return true + } + case uint16: + if v == 0 { + return true + } + case uint32: + if v == 0 { + return true + } + case uint64: + if v == 0 { + return true + } + case float32: + if v == 0 { + return true + } + case float64: + if v == 0 { + return true + } + case time.Time: + if v.IsZero() { + return true + } + case *timestamppb.Timestamp: + if v == nil || v.AsTime().IsZero() { + return true + } + case *durationpb.Duration: + if v == nil || v.AsDuration() == time.Duration(0) { + return true + } + default: + if reflect.ValueOf(v).IsZero() { + return true + } + } + } + return false +} + +// UniqueLowerNames returns the set of all unique names in the input after all +// of them are lowercased. The returned names will be in their lowercased form +// and sorted alphabetically. +func UniqueLowerNames(names []string) (unique []string) { + nameMap := make(map[string]int, len(names)) + for _, name := range names { + nameMap[strings.ToLower(name)] = 1 + } + + unique = make([]string, 0, len(nameMap)) + for name := range nameMap { + unique = append(unique, name) + } + sort.Strings(unique) + return +} + +// HashNames returns a hash of the names requested. This is intended for use +// when interacting with the orderFqdnSets table and rate limiting. +func HashNames(names []string) []byte { + names = UniqueLowerNames(names) + hash := sha256.Sum256([]byte(strings.Join(names, ","))) + return hash[:] +} + +// LoadCert loads a PEM certificate specified by filename or returns an error +func LoadCert(filename string) (*x509.Certificate, error) { + certPEM, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, fmt.Errorf("no data in cert PEM file %q", filename) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + return cert, nil +} + +// retryJitter is used to prevent bunched retried queries from falling into lockstep +const retryJitter = 0.2 + +// RetryBackoff calculates a backoff time based on number of retries, will always +// add jitter so requests that start in unison won't fall into lockstep. Because of +// this the returned duration can always be larger than the maximum by a factor of +// retryJitter. Adapted from +// https://github.com/grpc/grpc-go/blob/v1.11.3/backoff.go#L77-L96 +func RetryBackoff(retries int, base, max time.Duration, factor float64) time.Duration { + if retries == 0 { + return 0 + } + backoff, fMax := float64(base), float64(max) + for backoff < fMax && retries > 1 { + backoff *= factor + retries-- + } + if backoff > fMax { + backoff = fMax + } + // Randomize backoff delays so that if a cluster of requests start at + // the same time, they won't operate in lockstep. + backoff *= (1 - retryJitter) + 2*retryJitter*mrand.Float64() + return time.Duration(backoff) +} + +// IsASCII determines if every character in a string is encoded in +// the ASCII character set. +func IsASCII(str string) bool { + for _, r := range str { + if r > unicode.MaxASCII { + return false + } + } + return true +} + +func Command() string { + return path.Base(os.Args[0]) +} diff --git a/third-party/github.com/letsencrypt/boulder/core/util_test.go b/third-party/github.com/letsencrypt/boulder/core/util_test.go new file mode 100644 index 000000000..294f555a3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/core/util_test.go @@ -0,0 +1,343 @@ +package core + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "math/big" + "os" + "sort" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/test" +) + +// challenges.go +func TestNewToken(t *testing.T) { + token := NewToken() + fmt.Println(token) + tokenLength := int(math.Ceil(32 * 8 / 6.0)) // 32 bytes, b64 encoded + if len(token) != tokenLength { + t.Fatalf("Expected token of length %d, got %d", tokenLength, len(token)) + } + collider := map[string]bool{} + // Test for very blatant RNG failures: + // Try 2^20 birthdays in a 2^72 search space... + // our naive collision probability here is 2^-32... + for range 1000000 { + token = NewToken()[:12] // just sample a portion + test.Assert(t, !collider[token], "Token collision!") + collider[token] = true + } +} + +func TestLooksLikeAToken(t *testing.T) { + test.Assert(t, !looksLikeAToken("R-UL_7MrV3tUUjO9v5ym2srK3dGGCwlxbVyKBdwLOS"), "Accepted short token") + test.Assert(t, !looksLikeAToken("R-UL_7MrV3tUUjO9v5ym2srK3dGGCwlxbVyKBdwLOS%"), "Accepted invalid token") + test.Assert(t, looksLikeAToken("R-UL_7MrV3tUUjO9v5ym2srK3dGGCwlxbVyKBdwLOSU"), "Rejected valid token") +} + +func TestSerialUtils(t *testing.T) { + serial := SerialToString(big.NewInt(100000000000000000)) + test.AssertEquals(t, serial, "00000000000000000000016345785d8a0000") + + serialNum, err := StringToSerial("00000000000000000000016345785d8a0000") + test.AssertNotError(t, err, "Couldn't convert serial number to *big.Int") + if serialNum.Cmp(big.NewInt(100000000000000000)) != 0 { + t.Fatalf("Incorrect conversion, got %d", serialNum) + } + + badSerial, err := StringToSerial("doop!!!!000") + test.AssertContains(t, err.Error(), "invalid serial number") + fmt.Println(badSerial) +} + +func TestBuildID(t *testing.T) { + test.AssertEquals(t, Unspecified, GetBuildID()) +} + +const JWK1JSON = `{ + "kty": "RSA", + "n": "vuc785P8lBj3fUxyZchF_uZw6WtbxcorqgTyq-qapF5lrO1U82Tp93rpXlmctj6fyFHBVVB5aXnUHJ7LZeVPod7Wnfl8p5OyhlHQHC8BnzdzCqCMKmWZNX5DtETDId0qzU7dPzh0LP0idt5buU7L9QNaabChw3nnaL47iu_1Di5Wp264p2TwACeedv2hfRDjDlJmaQXuS8Rtv9GnRWyC9JBu7XmGvGDziumnJH7Hyzh3VNu-kSPQD3vuAFgMZS6uUzOztCkT0fpOalZI6hqxtWLvXUMj-crXrn-Maavz8qRhpAyp5kcYk3jiHGgQIi7QSK2JIdRJ8APyX9HlmTN5AQ", + "e": "AQAB" +}` +const JWK1Digest = `ul04Iq07ulKnnrebv2hv3yxCGgVvoHs8hjq2tVKx3mc=` +const JWK2JSON = `{ + "kty":"RSA", + "n":"yTsLkI8n4lg9UuSKNRC0UPHsVjNdCYk8rGXIqeb_rRYaEev3D9-kxXY8HrYfGkVt5CiIVJ-n2t50BKT8oBEMuilmypSQqJw0pCgtUm-e6Z0Eg3Ly6DMXFlycyikegiZ0b-rVX7i5OCEZRDkENAYwFNX4G7NNCwEZcH7HUMUmty9dchAqDS9YWzPh_dde1A9oy9JMH07nRGDcOzIh1rCPwc71nwfPPYeeS4tTvkjanjeigOYBFkBLQuv7iBB4LPozsGF1XdoKiIIi-8ye44McdhOTPDcQp3xKxj89aO02pQhBECv61rmbPinvjMG9DYxJmZvjsKF4bN2oy0DxdC1jDw", + "e":"AQAB" +}` + +func TestKeyDigest(t *testing.T) { + // Test with JWK (value, reference, and direct) + var jwk jose.JSONWebKey + err := json.Unmarshal([]byte(JWK1JSON), &jwk) + if err != nil { + t.Fatal(err) + } + digest, err := KeyDigestB64(jwk) + test.Assert(t, err == nil && digest == JWK1Digest, "Failed to digest JWK by value") + digest, err = KeyDigestB64(&jwk) + test.Assert(t, err == nil && digest == JWK1Digest, "Failed to digest JWK by reference") + digest, err = KeyDigestB64(jwk.Key) + test.Assert(t, err == nil && digest == JWK1Digest, "Failed to digest bare key") + + // Test with unknown key type + _, err = KeyDigestB64(struct{}{}) + test.Assert(t, err != nil, "Should have rejected unknown key type") +} + +func TestKeyDigestEquals(t *testing.T) { + var jwk1, jwk2 jose.JSONWebKey + err := json.Unmarshal([]byte(JWK1JSON), &jwk1) + if err != nil { + t.Fatal(err) + } + err = json.Unmarshal([]byte(JWK2JSON), &jwk2) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, KeyDigestEquals(jwk1, jwk1), "Key digests for same key should match") + test.Assert(t, !KeyDigestEquals(jwk1, jwk2), "Key digests for different keys should not match") + test.Assert(t, !KeyDigestEquals(jwk1, struct{}{}), "Unknown key types should not match anything") + test.Assert(t, !KeyDigestEquals(struct{}{}, struct{}{}), "Unknown key types should not match anything") +} + +func TestIsAnyNilOrZero(t *testing.T) { + test.Assert(t, IsAnyNilOrZero(nil), "Nil seen as non-zero") + + test.Assert(t, IsAnyNilOrZero(false), "False bool seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(true), "True bool seen as zero") + + test.Assert(t, IsAnyNilOrZero(0), "Untyped constant zero seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(1), "Untyped constant 1 seen as zero") + test.Assert(t, IsAnyNilOrZero(int(0)), "int(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(int(1)), "int(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(int8(0)), "int8(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(int8(1)), "int8(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(int16(0)), "int16(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(int16(1)), "int16(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(int32(0)), "int32(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(int32(1)), "int32(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(int64(0)), "int64(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(int64(1)), "int64(1) seen as zero") + + test.Assert(t, IsAnyNilOrZero(uint(0)), "uint(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(uint(1)), "uint(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(uint8(0)), "uint8(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(uint8(1)), "uint8(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(uint16(0)), "uint16(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(uint16(1)), "uint16(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(uint32(0)), "uint32(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(uint32(1)), "uint32(1) seen as zero") + test.Assert(t, IsAnyNilOrZero(uint64(0)), "uint64(0) seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(uint64(1)), "uint64(1) seen as zero") + + test.Assert(t, !IsAnyNilOrZero(-12.345), "Untyped float32 seen as zero") + test.Assert(t, !IsAnyNilOrZero(float32(6.66)), "Non-empty float32 seen as zero") + test.Assert(t, IsAnyNilOrZero(float32(0)), "Empty float32 seen as non-zero") + + test.Assert(t, !IsAnyNilOrZero(float64(7.77)), "Non-empty float64 seen as zero") + test.Assert(t, IsAnyNilOrZero(float64(0)), "Empty float64 seen as non-zero") + + test.Assert(t, IsAnyNilOrZero(""), "Empty string seen as non-zero") + test.Assert(t, !IsAnyNilOrZero("string"), "Non-empty string seen as zero") + + test.Assert(t, IsAnyNilOrZero([]string{}), "Empty string slice seen as non-zero") + test.Assert(t, !IsAnyNilOrZero([]string{"barncats"}), "Non-empty string slice seen as zero") + + test.Assert(t, IsAnyNilOrZero([]byte{}), "Empty byte slice seen as non-zero") + test.Assert(t, !IsAnyNilOrZero([]byte("byte")), "Non-empty byte slice seen as zero") + + test.Assert(t, IsAnyNilOrZero(time.Time{}), "No specified time value seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(time.Now()), "Current time seen as zero") + + type Foo struct { + foo int + } + test.Assert(t, IsAnyNilOrZero(Foo{}), "Empty struct seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(Foo{5}), "Non-empty struct seen as zero") + var f *Foo + test.Assert(t, IsAnyNilOrZero(f), "Pointer to uninitialized struct seen as non-zero") + + test.Assert(t, IsAnyNilOrZero(1, ""), "Mixed values seen as non-zero") + test.Assert(t, IsAnyNilOrZero("", 1), "Mixed values seen as non-zero") + + var p *timestamppb.Timestamp + test.Assert(t, IsAnyNilOrZero(p), "Pointer to uninitialized timestamppb.Timestamp seen as non-zero") + test.Assert(t, IsAnyNilOrZero(timestamppb.New(time.Time{})), "*timestamppb.Timestamp containing an uninitialized inner time.Time{} is seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(timestamppb.Now()), "A *timestamppb.Timestamp with valid inner time is seen as zero") + + var d *durationpb.Duration + var zeroDuration time.Duration + test.Assert(t, IsAnyNilOrZero(d), "Pointer to uninitialized durationpb.Duration seen as non-zero") + test.Assert(t, IsAnyNilOrZero(durationpb.New(zeroDuration)), "*durationpb.Duration containing an zero value time.Duration is seen as non-zero") + test.Assert(t, !IsAnyNilOrZero(durationpb.New(666)), "A *durationpb.Duration with valid inner duration is seen as zero") +} + +func BenchmarkIsAnyNilOrZero(b *testing.B) { + var thyme *time.Time + var sage *time.Duration + var table = []struct { + input interface{} + }{ + {input: int(0)}, + {input: int(1)}, + {input: int8(0)}, + {input: int8(1)}, + {input: int16(0)}, + {input: int16(1)}, + {input: int32(0)}, + {input: int32(1)}, + {input: int64(0)}, + {input: int64(1)}, + {input: uint(0)}, + {input: uint(1)}, + {input: uint8(0)}, + {input: uint8(1)}, + {input: uint16(0)}, + {input: uint16(1)}, + {input: uint32(0)}, + {input: uint32(1)}, + {input: uint64(0)}, + {input: uint64(1)}, + {input: float32(0)}, + {input: float32(0.1)}, + {input: float64(0)}, + {input: float64(0.1)}, + {input: ""}, + {input: "ahoyhoy"}, + {input: []string{}}, + {input: []string{""}}, + {input: []string{"oodley_doodley"}}, + {input: []byte{}}, + {input: []byte{0}}, + {input: []byte{1}}, + {input: []rune{}}, + {input: []rune{2}}, + {input: []rune{3}}, + {input: nil}, + {input: false}, + {input: true}, + {input: thyme}, + {input: time.Time{}}, + {input: time.Date(2015, time.June, 04, 11, 04, 38, 0, time.UTC)}, + {input: sage}, + {input: time.Duration(1)}, + {input: time.Duration(0)}, + } + + for _, v := range table { + b.Run(fmt.Sprintf("input_%T_%v", v.input, v.input), func(b *testing.B) { + for range b.N { + _ = IsAnyNilOrZero(v.input) + } + }) + } +} + +func TestUniqueLowerNames(t *testing.T) { + u := UniqueLowerNames([]string{"foobar.com", "fooBAR.com", "baz.com", "foobar.com", "bar.com", "bar.com", "a.com"}) + sort.Strings(u) + test.AssertDeepEquals(t, []string{"a.com", "bar.com", "baz.com", "foobar.com"}, u) +} + +func TestValidSerial(t *testing.T) { + notLength32Or36 := "A" + length32 := strings.Repeat("A", 32) + length36 := strings.Repeat("A", 36) + isValidSerial := ValidSerial(notLength32Or36) + test.AssertEquals(t, isValidSerial, false) + isValidSerial = ValidSerial(length32) + test.AssertEquals(t, isValidSerial, true) + isValidSerial = ValidSerial(length36) + test.AssertEquals(t, isValidSerial, true) +} + +func TestLoadCert(t *testing.T) { + var osPathErr *os.PathError + _, err := LoadCert("") + test.AssertError(t, err, "Loading empty path did not error") + test.AssertErrorWraps(t, err, &osPathErr) + + _, err = LoadCert("totally/fake/path") + test.AssertError(t, err, "Loading nonexistent path did not error") + test.AssertErrorWraps(t, err, &osPathErr) + + _, err = LoadCert("../test/hierarchy/README.md") + test.AssertError(t, err, "Loading non-PEM file did not error") + test.AssertContains(t, err.Error(), "no data in cert PEM file") + + _, err = LoadCert("../test/hierarchy/int-e1.key.pem") + test.AssertError(t, err, "Loading non-cert PEM file did not error") + test.AssertContains(t, err.Error(), "x509: malformed tbs certificate") + + cert, err := LoadCert("../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "Failed to load cert PEM file") + test.AssertEquals(t, cert.Subject.CommonName, "(TEST) Radical Rhino R3") +} + +func TestRetryBackoff(t *testing.T) { + assertBetween := func(a, b, c float64) { + t.Helper() + if a < b || a > c { + t.Fatalf("%f is not between %f and %f", a, b, c) + } + } + + factor := 1.5 + base := time.Minute + max := 10 * time.Minute + + backoff := RetryBackoff(0, base, max, factor) + assertBetween(float64(backoff), 0, 0) + + expected := base + backoff = RetryBackoff(1, base, max, factor) + assertBetween(float64(backoff), float64(expected)*0.8, float64(expected)*1.2) + + expected = time.Second * 90 + backoff = RetryBackoff(2, base, max, factor) + assertBetween(float64(backoff), float64(expected)*0.8, float64(expected)*1.2) + + expected = time.Minute * 10 + // should be truncated + backoff = RetryBackoff(7, base, max, factor) + assertBetween(float64(backoff), float64(expected)*0.8, float64(expected)*1.2) + +} + +func TestHashNames(t *testing.T) { + // Test that it is deterministic + h1 := HashNames([]string{"a"}) + h2 := HashNames([]string{"a"}) + test.AssertByteEquals(t, h1, h2) + + // Test that it differentiates + h1 = HashNames([]string{"a"}) + h2 = HashNames([]string{"b"}) + test.Assert(t, !bytes.Equal(h1, h2), "Should have been different") + + // Test that it is not subject to ordering + h1 = HashNames([]string{"a", "b"}) + h2 = HashNames([]string{"b", "a"}) + test.AssertByteEquals(t, h1, h2) + + // Test that it is not subject to case + h1 = HashNames([]string{"a", "b"}) + h2 = HashNames([]string{"A", "B"}) + test.AssertByteEquals(t, h1, h2) + + // Test that it is not subject to duplication + h1 = HashNames([]string{"a", "a"}) + h2 = HashNames([]string{"a"}) + test.AssertByteEquals(t, h1, h2) +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/checker/checker.go b/third-party/github.com/letsencrypt/boulder/crl/checker/checker.go new file mode 100644 index 000000000..9bceb308f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/checker/checker.go @@ -0,0 +1,116 @@ +package checker + +import ( + "bytes" + "crypto/x509" + "fmt" + "math/big" + "sort" + "time" + + zlint_x509 "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3" + + "github.com/letsencrypt/boulder/linter" +) + +// Validate runs the given CRL through our set of lints, ensures its signature +// validates (if supplied with a non-nil issuer), and checks that the CRL is +// less than ageLimit old. It returns an error if any of these conditions are +// not met. +func Validate(crl *x509.RevocationList, issuer *x509.Certificate, ageLimit time.Duration) error { + zcrl, err := zlint_x509.ParseRevocationList(crl.Raw) + if err != nil { + return fmt.Errorf("parsing CRL: %w", err) + } + + err = linter.ProcessResultSet(zlint.LintRevocationList(zcrl)) + if err != nil { + return fmt.Errorf("linting CRL: %w", err) + } + + if issuer != nil { + err = crl.CheckSignatureFrom(issuer) + if err != nil { + return fmt.Errorf("checking CRL signature: %w", err) + } + } + + if time.Since(crl.ThisUpdate) >= ageLimit { + return fmt.Errorf("thisUpdate more than %s in the past: %v", ageLimit, crl.ThisUpdate) + } + + return nil +} + +type diffResult struct { + Added []*big.Int + Removed []*big.Int + // TODO: consider adding a "changed" field, for entries whose revocation time + // or revocation reason changes. +} + +// Diff returns the sets of serials that were added and removed between two +// CRLs. In order to be comparable, the CRLs must come from the same issuer, and +// be given in the correct order (the "old" CRL's Number and ThisUpdate must +// both precede the "new" CRL's). +func Diff(old, new *x509.RevocationList) (*diffResult, error) { + if !bytes.Equal(old.AuthorityKeyId, new.AuthorityKeyId) { + return nil, fmt.Errorf("CRLs were not issued by same issuer") + } + + if !old.ThisUpdate.Before(new.ThisUpdate) { + return nil, fmt.Errorf("old CRL does not precede new CRL") + } + + if old.Number.Cmp(new.Number) >= 0 { + return nil, fmt.Errorf("old CRL does not precede new CRL") + } + + // Sort both sets of serials so we can march through them in order. + oldSerials := make([]*big.Int, len(old.RevokedCertificateEntries)) + for i, rc := range old.RevokedCertificateEntries { + oldSerials[i] = rc.SerialNumber + } + sort.Slice(oldSerials, func(i, j int) bool { + return oldSerials[i].Cmp(oldSerials[j]) < 0 + }) + + newSerials := make([]*big.Int, len(new.RevokedCertificateEntries)) + for j, rc := range new.RevokedCertificateEntries { + newSerials[j] = rc.SerialNumber + } + sort.Slice(newSerials, func(i, j int) bool { + return newSerials[i].Cmp(newSerials[j]) < 0 + }) + + // Work our way through both lists of sorted serials. If the old list skips + // past a serial seen in the new list, then that serial was added. If the new + // list skips past a serial seen in the old list, then it was removed. + i, j := 0, 0 + added := make([]*big.Int, 0) + removed := make([]*big.Int, 0) + for { + if i >= len(oldSerials) { + added = append(added, newSerials[j:]...) + break + } + if j >= len(newSerials) { + removed = append(removed, oldSerials[i:]...) + break + } + cmp := oldSerials[i].Cmp(newSerials[j]) + if cmp < 0 { + removed = append(removed, oldSerials[i]) + i++ + } else if cmp > 0 { + added = append(added, newSerials[j]) + j++ + } else { + i++ + j++ + } + } + + return &diffResult{added, removed}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/checker/checker_test.go b/third-party/github.com/letsencrypt/boulder/crl/checker/checker_test.go new file mode 100644 index 000000000..346e2aef0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/checker/checker_test.go @@ -0,0 +1,117 @@ +package checker + +import ( + "crypto/rand" + "crypto/x509" + "encoding/pem" + "io" + "math/big" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + "github.com/letsencrypt/boulder/test" +) + +func TestValidate(t *testing.T) { + crlFile, err := os.Open("../../test/hierarchy/int-e1.crl.pem") + test.AssertNotError(t, err, "opening test crl file") + crlPEM, err := io.ReadAll(crlFile) + test.AssertNotError(t, err, "reading test crl file") + crlDER, _ := pem.Decode(crlPEM) + crl, err := x509.ParseRevocationList(crlDER.Bytes) + test.AssertNotError(t, err, "parsing test crl") + issuer, err := core.LoadCert("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + + err = Validate(crl, issuer, 100*365*24*time.Hour) + test.AssertNotError(t, err, "validating good crl") + + err = Validate(crl, issuer, 0) + test.AssertError(t, err, "validating too-old crl") + test.AssertContains(t, err.Error(), "in the past") + + issuer2, err := core.LoadCert("../../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + err = Validate(crl, issuer2, 100*365*24*time.Hour) + test.AssertError(t, err, "validating crl from wrong issuer") + test.AssertContains(t, err.Error(), "signature") + + crlFile, err = os.Open("../../linter/lints/cabf_br/testdata/crl_long_validity.pem") + test.AssertNotError(t, err, "opening test crl file") + crlPEM, err = io.ReadAll(crlFile) + test.AssertNotError(t, err, "reading test crl file") + crlDER, _ = pem.Decode(crlPEM) + crl, err = x509.ParseRevocationList(crlDER.Bytes) + test.AssertNotError(t, err, "parsing test crl") + err = Validate(crl, issuer, 100*365*24*time.Hour) + test.AssertError(t, err, "validating crl with lint error") + test.AssertContains(t, err.Error(), "linting") +} + +func TestDiff(t *testing.T) { + issuer, err := issuance.LoadIssuer( + issuance.IssuerConfig{ + Location: issuance.IssuerLoc{ + File: "../../test/hierarchy/int-e1.key.pem", + CertFile: "../../test/hierarchy/int-e1.cert.pem", + }, + IssuerURL: "http://not-example.com/issuer-url", + OCSPURL: "http://not-example.com/ocsp", + CRLURLBase: "http://not-example.com/crl/", + }, clock.NewFake()) + test.AssertNotError(t, err, "loading test issuer") + + now := time.Now() + template := x509.RevocationList{ + ThisUpdate: now, + NextUpdate: now.Add(24 * time.Hour), + Number: big.NewInt(1), + RevokedCertificateEntries: []x509.RevocationListEntry{ + { + SerialNumber: big.NewInt(1), + RevocationTime: now.Add(-time.Hour), + }, + { + SerialNumber: big.NewInt(2), + RevocationTime: now.Add(-time.Hour), + }, + }, + } + + oldCRLDER, err := x509.CreateRevocationList(rand.Reader, &template, issuer.Cert.Certificate, issuer.Signer) + test.AssertNotError(t, err, "creating old crl") + oldCRL, err := x509.ParseRevocationList(oldCRLDER) + test.AssertNotError(t, err, "parsing old crl") + + now = now.Add(time.Hour) + template = x509.RevocationList{ + ThisUpdate: now, + NextUpdate: now.Add(24 * time.Hour), + Number: big.NewInt(2), + RevokedCertificateEntries: []x509.RevocationListEntry{ + { + SerialNumber: big.NewInt(1), + RevocationTime: now.Add(-2 * time.Hour), + }, + { + SerialNumber: big.NewInt(3), + RevocationTime: now.Add(-time.Hour), + }, + }, + } + + newCRLDER, err := x509.CreateRevocationList(rand.Reader, &template, issuer.Cert.Certificate, issuer.Signer) + test.AssertNotError(t, err, "creating old crl") + newCRL, err := x509.ParseRevocationList(newCRLDER) + test.AssertNotError(t, err, "parsing old crl") + + res, err := Diff(oldCRL, newCRL) + test.AssertNotError(t, err, "diffing crls") + test.AssertEquals(t, len(res.Added), 1) + test.AssertEquals(t, len(res.Removed), 1) +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/crl.go b/third-party/github.com/letsencrypt/boulder/crl/crl.go new file mode 100644 index 000000000..7e128d6a7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/crl.go @@ -0,0 +1,44 @@ +package crl + +import ( + "encoding/json" + "math/big" + "time" + + "github.com/letsencrypt/boulder/issuance" +) + +// number represents the 'crlNumber' field of a CRL. It must be constructed by +// calling `Number()`. +type number *big.Int + +// Number derives the 'CRLNumber' field for a CRL from the value of the +// 'thisUpdate' field provided as a `time.Time`. +func Number(thisUpdate time.Time) number { + // Per RFC 5280 Section 5.2.3, 'CRLNumber' is a monotonically increasing + // sequence number for a given CRL scope and CRL that MUST be at most 20 + // octets. A 64-bit (8-byte) integer will never exceed that requirement, but + // lets us guarantee that the CRL Number is always increasing without having + // to store or look up additional state. + return number(big.NewInt(thisUpdate.UnixNano())) +} + +// id is a unique identifier for a CRL which is primarily used for logging. This +// identifier is composed of the 'Issuer', 'CRLNumber', and the shard index +// (e.g. {"issuerID": 123, "crlNum": 456, "shardIdx": 78}). It must be constructed +// by calling `Id()`. +type id string + +// Id is a utility function which constructs a new `id`. +func Id(issuerID issuance.NameID, shardIdx int, crlNumber number) id { + type info struct { + IssuerID issuance.NameID `json:"issuerID"` + ShardIdx int `json:"shardIdx"` + CRLNumber number `json:"crlNumber"` + } + jsonBytes, err := json.Marshal(info{issuerID, shardIdx, crlNumber}) + if err != nil { + panic(err) + } + return id(jsonBytes) +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/crl_test.go b/third-party/github.com/letsencrypt/boulder/crl/crl_test.go new file mode 100644 index 000000000..5a26b25ed --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/crl_test.go @@ -0,0 +1,17 @@ +package crl + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/letsencrypt/boulder/test" +) + +func TestId(t *testing.T) { + thisUpdate := time.Now() + out := Id(1337, 1, Number(thisUpdate)) + expectCRLId := fmt.Sprintf("{\"issuerID\":1337,\"shardIdx\":1,\"crlNumber\":%d}", big.NewInt(thisUpdate.UnixNano())) + test.AssertEquals(t, string(out), expectCRLId) +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/idp/idp.go b/third-party/github.com/letsencrypt/boulder/crl/idp/idp.go new file mode 100644 index 000000000..b329d4383 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/idp/idp.go @@ -0,0 +1,102 @@ +package idp + +import ( + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" +) + +var idpOID = asn1.ObjectIdentifier{2, 5, 29, 28} // id-ce-issuingDistributionPoint + +// issuingDistributionPoint represents the ASN.1 IssuingDistributionPoint +// SEQUENCE as defined in RFC 5280 Section 5.2.5. We only use three of the +// fields, so the others are omitted. +type issuingDistributionPoint struct { + DistributionPoint distributionPointName `asn1:"optional,tag:0"` + OnlyContainsUserCerts bool `asn1:"optional,tag:1"` + OnlyContainsCACerts bool `asn1:"optional,tag:2"` +} + +// distributionPointName represents the ASN.1 DistributionPointName CHOICE as +// defined in RFC 5280 Section 4.2.1.13. We only use one of the fields, so the +// others are omitted. +type distributionPointName struct { + // Technically, FullName is of type GeneralNames, which is of type SEQUENCE OF + // GeneralName. But GeneralName itself is of type CHOICE, and the asn1.Marhsal + // function doesn't support marshalling structs to CHOICEs, so we have to use + // asn1.RawValue and encode the GeneralName ourselves. + FullName []asn1.RawValue `asn1:"optional,tag:0"` +} + +// MakeUserCertsExt returns a critical IssuingDistributionPoint extension +// containing the given URLs and with the OnlyContainsUserCerts boolean set to +// true. +func MakeUserCertsExt(urls []string) (pkix.Extension, error) { + var gns []asn1.RawValue + for _, url := range urls { + gns = append(gns, asn1.RawValue{ // GeneralName + Class: 2, // context-specific + Tag: 6, // uniformResourceIdentifier, IA5String + Bytes: []byte(url), + }) + } + + val := issuingDistributionPoint{ + DistributionPoint: distributionPointName{FullName: gns}, + OnlyContainsUserCerts: true, + } + + valBytes, err := asn1.Marshal(val) + if err != nil { + return pkix.Extension{}, err + } + + return pkix.Extension{ + Id: idpOID, + Value: valBytes, + Critical: true, + }, nil +} + +// MakeCACertsExt returns a critical IssuingDistributionPoint extension +// asserting the OnlyContainsCACerts boolean. +func MakeCACertsExt() (*pkix.Extension, error) { + val := issuingDistributionPoint{ + OnlyContainsCACerts: true, + } + + valBytes, err := asn1.Marshal(val) + if err != nil { + return nil, err + } + + return &pkix.Extension{ + Id: idpOID, + Value: valBytes, + Critical: true, + }, nil +} + +// GetIDPURIs returns the URIs contained within the issuingDistributionPoint +// extension, if present, or an error otherwise. +func GetIDPURIs(exts []pkix.Extension) ([]string, error) { + for _, ext := range exts { + if ext.Id.Equal(idpOID) { + val := issuingDistributionPoint{} + rest, err := asn1.Unmarshal(ext.Value, &val) + if err != nil { + return nil, fmt.Errorf("parsing IssuingDistributionPoint extension: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("parsing IssuingDistributionPoint extension: got %d unexpected trailing bytes", len(rest)) + } + var uris []string + for _, generalName := range val.DistributionPoint.FullName { + uris = append(uris, string(generalName.Bytes)) + } + return uris, nil + } + } + return nil, errors.New("no IssuingDistributionPoint extension found") +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/idp/idp_test.go b/third-party/github.com/letsencrypt/boulder/crl/idp/idp_test.go new file mode 100644 index 000000000..a142a5913 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/idp/idp_test.go @@ -0,0 +1,40 @@ +package idp + +import ( + "encoding/hex" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestMakeUserCertsExt(t *testing.T) { + t.Parallel() + dehex := func(s string) []byte { r, _ := hex.DecodeString(s); return r } + tests := []struct { + name string + urls []string + want []byte + }{ + { + name: "one (real) url", + urls: []string{"http://prod.c.lencr.org/20506757847264211/126.crl"}, + want: dehex("303AA035A0338631687474703A2F2F70726F642E632E6C656E63722E6F72672F32303530363735373834373236343231312F3132362E63726C8101FF"), + }, + { + name: "two urls", + urls: []string{"http://old.style/12345678/90.crl", "http://new.style/90.crl"}, + want: dehex("3042A03DA03B8620687474703A2F2F6F6C642E7374796C652F31323334353637382F39302E63726C8617687474703A2F2F6E65772E7374796C652F39302E63726C8101FF"), + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := MakeUserCertsExt(tc.urls) + test.AssertNotError(t, err, "should never fail to marshal asn1 to bytes") + test.AssertDeepEquals(t, got.Id, idpOID) + test.AssertEquals(t, got.Critical, true) + test.AssertDeepEquals(t, got.Value, tc.want) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.pb.go b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.pb.go new file mode 100644 index 000000000..ba95c8ab1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.pb.go @@ -0,0 +1,281 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: storer.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type UploadCRLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *UploadCRLRequest_Metadata + // *UploadCRLRequest_CrlChunk + Payload isUploadCRLRequest_Payload `protobuf_oneof:"payload"` +} + +func (x *UploadCRLRequest) Reset() { + *x = UploadCRLRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_storer_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UploadCRLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadCRLRequest) ProtoMessage() {} + +func (x *UploadCRLRequest) ProtoReflect() protoreflect.Message { + mi := &file_storer_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadCRLRequest.ProtoReflect.Descriptor instead. +func (*UploadCRLRequest) Descriptor() ([]byte, []int) { + return file_storer_proto_rawDescGZIP(), []int{0} +} + +func (m *UploadCRLRequest) GetPayload() isUploadCRLRequest_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *UploadCRLRequest) GetMetadata() *CRLMetadata { + if x, ok := x.GetPayload().(*UploadCRLRequest_Metadata); ok { + return x.Metadata + } + return nil +} + +func (x *UploadCRLRequest) GetCrlChunk() []byte { + if x, ok := x.GetPayload().(*UploadCRLRequest_CrlChunk); ok { + return x.CrlChunk + } + return nil +} + +type isUploadCRLRequest_Payload interface { + isUploadCRLRequest_Payload() +} + +type UploadCRLRequest_Metadata struct { + Metadata *CRLMetadata `protobuf:"bytes,1,opt,name=metadata,proto3,oneof"` +} + +type UploadCRLRequest_CrlChunk struct { + CrlChunk []byte `protobuf:"bytes,2,opt,name=crlChunk,proto3,oneof"` +} + +func (*UploadCRLRequest_Metadata) isUploadCRLRequest_Payload() {} + +func (*UploadCRLRequest_CrlChunk) isUploadCRLRequest_Payload() {} + +type CRLMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + Number int64 `protobuf:"varint,2,opt,name=number,proto3" json:"number,omitempty"` + ShardIdx int64 `protobuf:"varint,3,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` +} + +func (x *CRLMetadata) Reset() { + *x = CRLMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_storer_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CRLMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CRLMetadata) ProtoMessage() {} + +func (x *CRLMetadata) ProtoReflect() protoreflect.Message { + mi := &file_storer_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CRLMetadata.ProtoReflect.Descriptor instead. +func (*CRLMetadata) Descriptor() ([]byte, []int) { + return file_storer_proto_rawDescGZIP(), []int{1} +} + +func (x *CRLMetadata) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *CRLMetadata) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +func (x *CRLMetadata) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +var File_storer_proto protoreflect.FileDescriptor + +var file_storer_proto_rawDesc = []byte{ + 0x0a, 0x0c, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, + 0x73, 0x74, 0x6f, 0x72, 0x65, 0x72, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0x6e, 0x0a, 0x10, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x52, 0x4c, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x6f, 0x72, + 0x65, 0x72, 0x2e, 0x43, 0x52, 0x4c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x48, 0x00, + 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1c, 0x0a, 0x08, 0x63, 0x72, + 0x6c, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x08, + 0x63, 0x72, 0x6c, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x22, 0x65, 0x0a, 0x0b, 0x43, 0x52, 0x4c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, + 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x06, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x1a, + 0x0a, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x32, 0x4e, 0x0a, 0x09, 0x43, 0x52, + 0x4c, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x72, 0x12, 0x41, 0x0a, 0x09, 0x55, 0x70, 0x6c, 0x6f, 0x61, + 0x64, 0x43, 0x52, 0x4c, 0x12, 0x18, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x72, 0x2e, 0x55, 0x70, + 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x28, 0x01, 0x42, 0x31, 0x5a, 0x2f, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x72, 0x6c, + 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_storer_proto_rawDescOnce sync.Once + file_storer_proto_rawDescData = file_storer_proto_rawDesc +) + +func file_storer_proto_rawDescGZIP() []byte { + file_storer_proto_rawDescOnce.Do(func() { + file_storer_proto_rawDescData = protoimpl.X.CompressGZIP(file_storer_proto_rawDescData) + }) + return file_storer_proto_rawDescData +} + +var file_storer_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_storer_proto_goTypes = []interface{}{ + (*UploadCRLRequest)(nil), // 0: storer.UploadCRLRequest + (*CRLMetadata)(nil), // 1: storer.CRLMetadata + (*emptypb.Empty)(nil), // 2: google.protobuf.Empty +} +var file_storer_proto_depIdxs = []int32{ + 1, // 0: storer.UploadCRLRequest.metadata:type_name -> storer.CRLMetadata + 0, // 1: storer.CRLStorer.UploadCRL:input_type -> storer.UploadCRLRequest + 2, // 2: storer.CRLStorer.UploadCRL:output_type -> google.protobuf.Empty + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_storer_proto_init() } +func file_storer_proto_init() { + if File_storer_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_storer_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UploadCRLRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_storer_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CRLMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_storer_proto_msgTypes[0].OneofWrappers = []interface{}{ + (*UploadCRLRequest_Metadata)(nil), + (*UploadCRLRequest_CrlChunk)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_storer_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_storer_proto_goTypes, + DependencyIndexes: file_storer_proto_depIdxs, + MessageInfos: file_storer_proto_msgTypes, + }.Build() + File_storer_proto = out.File + file_storer_proto_rawDesc = nil + file_storer_proto_goTypes = nil + file_storer_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.proto b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.proto new file mode 100644 index 000000000..451d61165 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package storer; +option go_package = "github.com/letsencrypt/boulder/crl/storer/proto"; + +import "google/protobuf/empty.proto"; + +service CRLStorer { + rpc UploadCRL(stream UploadCRLRequest) returns (google.protobuf.Empty) {} +} + +message UploadCRLRequest { + oneof payload { + CRLMetadata metadata = 1; + bytes crlChunk = 2; + } +} + +message CRLMetadata { + int64 issuerNameID = 1; + int64 number = 2; + int64 shardIdx = 3; +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer_grpc.pb.go new file mode 100644 index 000000000..06e8b0c7d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/storer/proto/storer_grpc.pb.go @@ -0,0 +1,104 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: storer.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CRLStorer_UploadCRL_FullMethodName = "/storer.CRLStorer/UploadCRL" +) + +// CRLStorerClient is the client API for CRLStorer service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type CRLStorerClient interface { + UploadCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[UploadCRLRequest, emptypb.Empty], error) +} + +type cRLStorerClient struct { + cc grpc.ClientConnInterface +} + +func NewCRLStorerClient(cc grpc.ClientConnInterface) CRLStorerClient { + return &cRLStorerClient{cc} +} + +func (c *cRLStorerClient) UploadCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[UploadCRLRequest, emptypb.Empty], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &CRLStorer_ServiceDesc.Streams[0], CRLStorer_UploadCRL_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[UploadCRLRequest, emptypb.Empty]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type CRLStorer_UploadCRLClient = grpc.ClientStreamingClient[UploadCRLRequest, emptypb.Empty] + +// CRLStorerServer is the server API for CRLStorer service. +// All implementations must embed UnimplementedCRLStorerServer +// for forward compatibility +type CRLStorerServer interface { + UploadCRL(grpc.ClientStreamingServer[UploadCRLRequest, emptypb.Empty]) error + mustEmbedUnimplementedCRLStorerServer() +} + +// UnimplementedCRLStorerServer must be embedded to have forward compatible implementations. +type UnimplementedCRLStorerServer struct { +} + +func (UnimplementedCRLStorerServer) UploadCRL(grpc.ClientStreamingServer[UploadCRLRequest, emptypb.Empty]) error { + return status.Errorf(codes.Unimplemented, "method UploadCRL not implemented") +} +func (UnimplementedCRLStorerServer) mustEmbedUnimplementedCRLStorerServer() {} + +// UnsafeCRLStorerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CRLStorerServer will +// result in compilation errors. +type UnsafeCRLStorerServer interface { + mustEmbedUnimplementedCRLStorerServer() +} + +func RegisterCRLStorerServer(s grpc.ServiceRegistrar, srv CRLStorerServer) { + s.RegisterService(&CRLStorer_ServiceDesc, srv) +} + +func _CRLStorer_UploadCRL_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(CRLStorerServer).UploadCRL(&grpc.GenericServerStream[UploadCRLRequest, emptypb.Empty]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type CRLStorer_UploadCRLServer = grpc.ClientStreamingServer[UploadCRLRequest, emptypb.Empty] + +// CRLStorer_ServiceDesc is the grpc.ServiceDesc for CRLStorer service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CRLStorer_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "storer.CRLStorer", + HandlerType: (*CRLStorerServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "UploadCRL", + Handler: _CRLStorer_UploadCRL_Handler, + ClientStreams: true, + }, + }, + Metadata: "storer.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/storer/storer.go b/third-party/github.com/letsencrypt/boulder/crl/storer/storer.go new file mode 100644 index 000000000..9b41f560f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/storer/storer.go @@ -0,0 +1,250 @@ +package storer + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "math/big" + "slices" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/crl" + "github.com/letsencrypt/boulder/crl/idp" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" +) + +// simpleS3 matches the subset of the s3.Client interface which we use, to allow +// simpler mocking in tests. +type simpleS3 interface { + PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) +} + +type crlStorer struct { + cspb.UnsafeCRLStorerServer + s3Client simpleS3 + s3Bucket string + issuers map[issuance.NameID]*issuance.Certificate + uploadCount *prometheus.CounterVec + sizeHistogram *prometheus.HistogramVec + latencyHistogram *prometheus.HistogramVec + log blog.Logger + clk clock.Clock +} + +var _ cspb.CRLStorerServer = (*crlStorer)(nil) + +func New( + issuers []*issuance.Certificate, + s3Client simpleS3, + s3Bucket string, + stats prometheus.Registerer, + log blog.Logger, + clk clock.Clock, +) (*crlStorer, error) { + issuersByNameID := make(map[issuance.NameID]*issuance.Certificate, len(issuers)) + for _, issuer := range issuers { + issuersByNameID[issuer.NameID()] = issuer + } + + uploadCount := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "crl_storer_uploads", + Help: "A counter of the number of CRLs uploaded by crl-storer", + }, []string{"issuer", "result"}) + stats.MustRegister(uploadCount) + + sizeHistogram := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "crl_storer_sizes", + Help: "A histogram of the sizes (in bytes) of CRLs uploaded by crl-storer", + Buckets: []float64{0, 256, 1024, 4096, 16384, 65536}, + }, []string{"issuer"}) + stats.MustRegister(sizeHistogram) + + latencyHistogram := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "crl_storer_upload_times", + Help: "A histogram of the time (in seconds) it took crl-storer to upload CRLs", + Buckets: []float64{0.01, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000}, + }, []string{"issuer"}) + stats.MustRegister(latencyHistogram) + + return &crlStorer{ + issuers: issuersByNameID, + s3Client: s3Client, + s3Bucket: s3Bucket, + uploadCount: uploadCount, + sizeHistogram: sizeHistogram, + latencyHistogram: latencyHistogram, + log: log, + clk: clk, + }, nil +} + +// TODO(#6261): Unify all error messages to identify the shard they're working +// on as a JSON object including issuer, crl number, and shard number. + +// UploadCRL implements the gRPC method of the same name. It takes a stream of +// bytes as its input, parses and runs some sanity checks on the CRL, and then +// uploads it to S3. +func (cs *crlStorer) UploadCRL(stream grpc.ClientStreamingServer[cspb.UploadCRLRequest, emptypb.Empty]) error { + var issuer *issuance.Certificate + var shardIdx int64 + var crlNumber *big.Int + crlBytes := make([]byte, 0) + + // Read all of the messages from the input stream. + for { + in, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return err + } + + switch payload := in.Payload.(type) { + case *cspb.UploadCRLRequest_Metadata: + if crlNumber != nil || issuer != nil { + return errors.New("got more than one metadata message") + } + if payload.Metadata.IssuerNameID == 0 || payload.Metadata.Number == 0 { + return errors.New("got incomplete metadata message") + } + + shardIdx = payload.Metadata.ShardIdx + crlNumber = crl.Number(time.Unix(0, payload.Metadata.Number)) + + var ok bool + issuer, ok = cs.issuers[issuance.NameID(payload.Metadata.IssuerNameID)] + if !ok { + return fmt.Errorf("got unrecognized IssuerID: %d", payload.Metadata.IssuerNameID) + } + + case *cspb.UploadCRLRequest_CrlChunk: + crlBytes = append(crlBytes, payload.CrlChunk...) + } + } + + // Do some basic sanity checks on the received metadata and CRL. + if issuer == nil || crlNumber == nil { + return errors.New("got no metadata message") + } + + crlId := crl.Id(issuer.NameID(), int(shardIdx), crlNumber) + + cs.sizeHistogram.WithLabelValues(issuer.Subject.CommonName).Observe(float64(len(crlBytes))) + + crl, err := x509.ParseRevocationList(crlBytes) + if err != nil { + return fmt.Errorf("parsing CRL for %s: %w", crlId, err) + } + + if crl.Number.Cmp(crlNumber) != 0 { + return errors.New("got mismatched CRL Number") + } + + err = crl.CheckSignatureFrom(issuer.Certificate) + if err != nil { + return fmt.Errorf("validating signature for %s: %w", crlId, err) + } + + // Before uploading this CRL, we want to compare it against the previous CRL + // to ensure that the CRL Number field is not going backwards. This is an + // additional safety check against clock skew and potential races, if multiple + // crl-updaters are working on the same shard at the same time. We only run + // these checks if we found a CRL, so we don't block uploading brand new CRLs. + filename := fmt.Sprintf("%d/%d.crl", issuer.NameID(), shardIdx) + prevObj, err := cs.s3Client.GetObject(stream.Context(), &s3.GetObjectInput{ + Bucket: &cs.s3Bucket, + Key: &filename, + }) + if err != nil { + var smithyErr *smithyhttp.ResponseError + if !errors.As(err, &smithyErr) || smithyErr.HTTPStatusCode() != 404 { + return fmt.Errorf("getting previous CRL for %s: %w", crlId, err) + } + cs.log.Infof("No previous CRL found for %s, proceeding", crlId) + } else { + prevBytes, err := io.ReadAll(prevObj.Body) + if err != nil { + return fmt.Errorf("downloading previous CRL for %s: %w", crlId, err) + } + + prevCRL, err := x509.ParseRevocationList(prevBytes) + if err != nil { + return fmt.Errorf("parsing previous CRL for %s: %w", crlId, err) + } + + if crl.Number.Cmp(prevCRL.Number) <= 0 { + return fmt.Errorf("crlNumber not strictly increasing: %d <= %d", crl.Number, prevCRL.Number) + } + + idpURIs, err := idp.GetIDPURIs(crl.Extensions) + if err != nil { + return fmt.Errorf("getting IDP for %s: %w", crlId, err) + } + + prevURIs, err := idp.GetIDPURIs(prevCRL.Extensions) + if err != nil { + return fmt.Errorf("getting previous IDP for %s: %w", crlId, err) + } + + uriMatch := false + for _, uri := range idpURIs { + if slices.Contains(prevURIs, uri) { + uriMatch = true + break + } + } + if !uriMatch { + return fmt.Errorf("IDP does not match previous: %v !∩ %v", idpURIs, prevURIs) + } + } + + // Finally actually upload the new CRL. + start := cs.clk.Now() + + checksum := sha256.Sum256(crlBytes) + checksumb64 := base64.StdEncoding.EncodeToString(checksum[:]) + crlContentType := "application/pkix-crl" + _, err = cs.s3Client.PutObject(stream.Context(), &s3.PutObjectInput{ + Bucket: &cs.s3Bucket, + Key: &filename, + Body: bytes.NewReader(crlBytes), + ChecksumAlgorithm: types.ChecksumAlgorithmSha256, + ChecksumSHA256: &checksumb64, + ContentType: &crlContentType, + Metadata: map[string]string{"crlNumber": crlNumber.String()}, + }) + + latency := cs.clk.Now().Sub(start) + cs.latencyHistogram.WithLabelValues(issuer.Subject.CommonName).Observe(latency.Seconds()) + + if err != nil { + cs.uploadCount.WithLabelValues(issuer.Subject.CommonName, "failed").Inc() + cs.log.AuditErrf("CRL upload failed: id=[%s] err=[%s]", crlId, err) + return fmt.Errorf("uploading to S3: %w", err) + } + + cs.uploadCount.WithLabelValues(issuer.Subject.CommonName, "success").Inc() + cs.log.AuditInfof( + "CRL uploaded: id=[%s] issuerCN=[%s] thisUpdate=[%s] nextUpdate=[%s] numEntries=[%d]", + crlId, issuer.Subject.CommonName, crl.ThisUpdate, crl.NextUpdate, len(crl.RevokedCertificateEntries), + ) + + return stream.SendAndClose(&emptypb.Empty{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/storer/storer_test.go b/third-party/github.com/letsencrypt/boulder/crl/storer/storer_test.go new file mode 100644 index 000000000..a26589d74 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/storer/storer_test.go @@ -0,0 +1,528 @@ +package storer + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "io" + "math/big" + "net/http" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/jmhodges/clock" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/crl/idp" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +type fakeUploadCRLServerStream struct { + grpc.ServerStream + input <-chan *cspb.UploadCRLRequest +} + +func (s *fakeUploadCRLServerStream) Recv() (*cspb.UploadCRLRequest, error) { + next, ok := <-s.input + if !ok { + return nil, io.EOF + } + return next, nil +} + +func (s *fakeUploadCRLServerStream) SendAndClose(*emptypb.Empty) error { + return nil +} + +func (s *fakeUploadCRLServerStream) Context() context.Context { + return context.Background() +} + +func setupTestUploadCRL(t *testing.T) (*crlStorer, *issuance.Issuer) { + t.Helper() + + r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "loading fake RSA issuer cert") + issuerE1, err := issuance.LoadIssuer( + issuance.IssuerConfig{ + Location: issuance.IssuerLoc{ + File: "../../test/hierarchy/int-e1.key.pem", + CertFile: "../../test/hierarchy/int-e1.cert.pem", + }, + IssuerURL: "http://not-example.com/issuer-url", + OCSPURL: "http://not-example.com/ocsp", + CRLURLBase: "http://not-example.com/crl/", + }, clock.NewFake()) + test.AssertNotError(t, err, "loading fake ECDSA issuer cert") + + storer, err := New( + []*issuance.Certificate{r3, issuerE1.Cert}, + nil, "le-crl.s3.us-west.amazonaws.com", + metrics.NoopRegisterer, blog.NewMock(), clock.NewFake(), + ) + test.AssertNotError(t, err, "creating test crl-storer") + + return storer, issuerE1 +} + +// Test that we get an error when no metadata is sent. +func TestUploadCRLNoMetadata(t *testing.T) { + storer, _ := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + close(ins) + err := <-errs + test.AssertError(t, err, "can't upload CRL with no metadata") + test.AssertContains(t, err.Error(), "no metadata") +} + +// Test that we get an error when incomplete metadata is sent. +func TestUploadCRLIncompleteMetadata(t *testing.T) { + storer, _ := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{}, + }, + } + close(ins) + err := <-errs + test.AssertError(t, err, "can't upload CRL with incomplete metadata") + test.AssertContains(t, err.Error(), "incomplete metadata") +} + +// Test that we get an error when a bad issuer is sent. +func TestUploadCRLUnrecognizedIssuer(t *testing.T) { + storer, _ := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: 1, + Number: 1, + }, + }, + } + close(ins) + err := <-errs + test.AssertError(t, err, "can't upload CRL with unrecognized issuer") + test.AssertContains(t, err.Error(), "unrecognized") +} + +// Test that we get an error when two metadata are sent. +func TestUploadCRLMultipleMetadata(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + close(ins) + err := <-errs + test.AssertError(t, err, "can't upload CRL with multiple metadata") + test.AssertContains(t, err.Error(), "more than one") +} + +// Test that we get an error when a malformed CRL is sent. +func TestUploadCRLMalformedBytes(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: []byte("this is not a valid crl"), + }, + } + close(ins) + err := <-errs + test.AssertError(t, err, "can't upload unparsable CRL") + test.AssertContains(t, err.Error(), "parsing CRL") +} + +// Test that we get an error when an invalid CRL (signed by a throwaway +// private key but tagged as being from a "real" issuer) is sent. +func TestUploadCRLInvalidSignature(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + fakeSigner, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating throwaway signer") + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(time.Hour), + Number: big.NewInt(1), + }, + iss.Cert.Certificate, + fakeSigner, + ) + test.AssertNotError(t, err, "creating test CRL") + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't upload unverifiable CRL") + test.AssertContains(t, err.Error(), "validating signature") +} + +// Test that we get an error if the CRL Numbers mismatch. +func TestUploadCRLMismatchedNumbers(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(time.Hour), + Number: big.NewInt(2), + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "can't upload CRL with mismatched number") + test.AssertContains(t, err.Error(), "mismatched") +} + +// fakeSimpleS3 implements the simpleS3 interface, provides prevBytes for +// downloads, and checks that uploads match the expectBytes. +type fakeSimpleS3 struct { + prevBytes []byte + expectBytes []byte +} + +func (p *fakeSimpleS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + recvBytes, err := io.ReadAll(params.Body) + if err != nil { + return nil, err + } + if !bytes.Equal(p.expectBytes, recvBytes) { + return nil, errors.New("received bytes did not match expectation") + } + return &s3.PutObjectOutput{}, nil +} + +func (p *fakeSimpleS3) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + if p.prevBytes != nil { + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(p.prevBytes))}, nil + } + return nil, &smithyhttp.ResponseError{Response: &smithyhttp.Response{Response: &http.Response{StatusCode: 404}}} +} + +// Test that the correct bytes get propagated to S3. +func TestUploadCRLSuccess(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + idpExt, err := idp.MakeUserCertsExt([]string{"http://c.ex.org"}) + test.AssertNotError(t, err, "creating test IDP extension") + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 2, + }, + }, + } + + prevCRLBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: storer.clk.Now(), + NextUpdate: storer.clk.Now().Add(time.Hour), + Number: big.NewInt(1), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + ExtraExtensions: []pkix.Extension{idpExt}, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + + storer.clk.Sleep(time.Minute) + + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: storer.clk.Now(), + NextUpdate: storer.clk.Now().Add(time.Hour), + Number: big.NewInt(2), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + ExtraExtensions: []pkix.Extension{idpExt}, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + + storer.s3Client = &fakeSimpleS3{prevBytes: prevCRLBytes, expectBytes: crlBytes} + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertNotError(t, err, "uploading valid CRL should work") +} + +// Test that the correct bytes get propagated to S3 for a CRL with to predecessor. +func TestUploadNewCRLSuccess(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(time.Hour), + Number: big.NewInt(1), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + + storer.s3Client = &fakeSimpleS3{expectBytes: crlBytes} + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertNotError(t, err, "uploading valid CRL should work") +} + +// Test that we get an error when the previous CRL has a higher CRL number. +func TestUploadCRLBackwardsNumber(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + + prevCRLBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: storer.clk.Now(), + NextUpdate: storer.clk.Now().Add(time.Hour), + Number: big.NewInt(2), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + + storer.clk.Sleep(time.Minute) + + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: storer.clk.Now(), + NextUpdate: storer.clk.Now().Add(time.Hour), + Number: big.NewInt(1), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + + storer.s3Client = &fakeSimpleS3{prevBytes: prevCRLBytes, expectBytes: crlBytes} + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "uploading out-of-order numbers should fail") + test.AssertContains(t, err.Error(), "crlNumber not strictly increasing") +} + +// brokenSimpleS3 implements the simpleS3 interface. It returns errors for all +// uploads and downloads. +type brokenSimpleS3 struct{} + +func (p *brokenSimpleS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + return nil, errors.New("sorry") +} + +func (p *brokenSimpleS3) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + return nil, errors.New("oops") +} + +// Test that we get an error when S3 falls over. +func TestUploadCRLBrokenS3(t *testing.T) { + storer, iss := setupTestUploadCRL(t) + errs := make(chan error, 1) + + ins := make(chan *cspb.UploadCRLRequest) + go func() { + errs <- storer.UploadCRL(&fakeUploadCRLServerStream{input: ins}) + }() + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(iss.Cert.NameID()), + Number: 1, + }, + }, + } + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + &x509.RevocationList{ + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(time.Hour), + Number: big.NewInt(1), + RevokedCertificateEntries: []x509.RevocationListEntry{ + {SerialNumber: big.NewInt(123), RevocationTime: time.Now().Add(-time.Hour)}, + }, + }, + iss.Cert.Certificate, + iss.Signer, + ) + test.AssertNotError(t, err, "creating test CRL") + storer.s3Client = &brokenSimpleS3{} + ins <- &cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: crlBytes, + }, + } + close(ins) + err = <-errs + test.AssertError(t, err, "uploading to broken S3 should fail") + test.AssertContains(t, err.Error(), "getting previous CRL") +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/updater/batch.go b/third-party/github.com/letsencrypt/boulder/crl/updater/batch.go new file mode 100644 index 000000000..fb61d8d38 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/updater/batch.go @@ -0,0 +1,73 @@ +package updater + +import ( + "context" + "errors" + "sync" + + "github.com/letsencrypt/boulder/crl" + "github.com/letsencrypt/boulder/issuance" +) + +// RunOnce causes the crlUpdater to update every shard immediately, then exit. +// It will run as many simultaneous goroutines as the configured maxParallelism. +func (cu *crlUpdater) RunOnce(ctx context.Context) error { + var wg sync.WaitGroup + atTime := cu.clk.Now() + + type workItem struct { + issuerNameID issuance.NameID + shardIdx int + } + + var anyErr bool + var once sync.Once + + shardWorker := func(in <-chan workItem) { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + return + case work, ok := <-in: + if !ok { + return + } + err := cu.updateShardWithRetry(ctx, atTime, work.issuerNameID, work.shardIdx, nil) + if err != nil { + cu.log.AuditErrf( + "Generating CRL failed: id=[%s] err=[%s]", + crl.Id(work.issuerNameID, work.shardIdx, crl.Number(atTime)), err) + once.Do(func() { anyErr = true }) + } + } + } + } + + inputs := make(chan workItem) + + for range cu.maxParallelism { + wg.Add(1) + go shardWorker(inputs) + } + + for _, issuer := range cu.issuers { + for i := range cu.numShards { + select { + case <-ctx.Done(): + close(inputs) + wg.Wait() + return ctx.Err() + case inputs <- workItem{issuerNameID: issuer.NameID(), shardIdx: i + 1}: + } + } + } + close(inputs) + + wg.Wait() + if anyErr { + return errors.New("one or more errors encountered, see logs") + } + return ctx.Err() +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/updater/batch_test.go b/third-party/github.com/letsencrypt/boulder/crl/updater/batch_test.go new file mode 100644 index 000000000..26907ecc0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/updater/batch_test.go @@ -0,0 +1,43 @@ +package updater + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func TestRunOnce(t *testing.T) { + e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + + mockLog := blog.NewMock() + clk := clock.NewFake() + clk.Set(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) + cu, err := NewUpdater( + []*issuance.Certificate{e1, r3}, + 2, 18*time.Hour, 24*time.Hour, + 6*time.Hour, time.Minute, 1, 1, + &fakeSAC{grcc: fakeGRCC{err: errors.New("db no worky")}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)}, + &fakeCGC{gcc: fakeGCC{}}, + &fakeCSC{ucc: fakeUCC{}}, + metrics.NoopRegisterer, mockLog, clk, + ) + test.AssertNotError(t, err, "building test crlUpdater") + + // An error that affects all issuers should have every issuer reflected in the + // combined error message. + err = cu.RunOnce(context.Background()) + test.AssertError(t, err, "database error") + test.AssertContains(t, err.Error(), "one or more errors") + test.AssertEquals(t, len(mockLog.GetAllMatching("Generating CRL failed:")), 4) + cu.tickHistogram.Reset() +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/updater/continuous.go b/third-party/github.com/letsencrypt/boulder/crl/updater/continuous.go new file mode 100644 index 000000000..e4552f68f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/updater/continuous.go @@ -0,0 +1,74 @@ +package updater + +import ( + "context" + "math/rand" + "sync" + "time" + + "github.com/letsencrypt/boulder/crl" + "github.com/letsencrypt/boulder/issuance" +) + +// Run causes the crlUpdater to enter its processing loop. It starts one +// goroutine for every shard it intends to update, each of which will wake at +// the appropriate interval. +func (cu *crlUpdater) Run(ctx context.Context) error { + var wg sync.WaitGroup + + shardWorker := func(issuerNameID issuance.NameID, shardIdx int) { + defer wg.Done() + + // Wait for a random number of nanoseconds less than the updatePeriod, so + // that process restarts do not skip or delay shards deterministically. + waitTimer := time.NewTimer(time.Duration(rand.Int63n(cu.updatePeriod.Nanoseconds()))) + defer waitTimer.Stop() + select { + case <-waitTimer.C: + // Continue to ticker loop + case <-ctx.Done(): + return + } + + // Do work, then sleep for updatePeriod. Rinse, and repeat. + ticker := time.NewTicker(cu.updatePeriod) + defer ticker.Stop() + for { + // Check for context cancellation before we do any real work, in case we + // overran the last tick and both cases were selectable at the same time. + if ctx.Err() != nil { + return + } + + atTime := cu.clk.Now() + err := cu.updateShardWithRetry(ctx, atTime, issuerNameID, shardIdx, nil) + if err != nil { + // We only log, rather than return, so that the long-lived process can + // continue and try again at the next tick. + cu.log.AuditErrf( + "Generating CRL failed: id=[%s] err=[%s]", + crl.Id(issuerNameID, shardIdx, crl.Number(atTime)), err) + } + + select { + case <-ticker.C: + continue + case <-ctx.Done(): + return + } + } + } + + // Start one shard worker per shard this updater is responsible for. + for _, issuer := range cu.issuers { + for i := 1; i <= cu.numShards; i++ { + wg.Add(1) + go shardWorker(issuer.NameID(), i) + } + } + + // Wait for all of the shard workers to exit, which will happen when their + // contexts are cancelled, probably by a SIGTERM. + wg.Wait() + return ctx.Err() +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/updater/updater.go b/third-party/github.com/letsencrypt/boulder/crl/updater/updater.go new file mode 100644 index 000000000..c5790b72b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/updater/updater.go @@ -0,0 +1,456 @@ +package updater + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "math" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/crl" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +type crlUpdater struct { + issuers map[issuance.NameID]*issuance.Certificate + numShards int + shardWidth time.Duration + lookbackPeriod time.Duration + updatePeriod time.Duration + updateTimeout time.Duration + maxParallelism int + maxAttempts int + + sa sapb.StorageAuthorityClient + ca capb.CRLGeneratorClient + cs cspb.CRLStorerClient + + tickHistogram *prometheus.HistogramVec + updatedCounter *prometheus.CounterVec + + log blog.Logger + clk clock.Clock +} + +func NewUpdater( + issuers []*issuance.Certificate, + numShards int, + shardWidth time.Duration, + lookbackPeriod time.Duration, + updatePeriod time.Duration, + updateTimeout time.Duration, + maxParallelism int, + maxAttempts int, + sa sapb.StorageAuthorityClient, + ca capb.CRLGeneratorClient, + cs cspb.CRLStorerClient, + stats prometheus.Registerer, + log blog.Logger, + clk clock.Clock, +) (*crlUpdater, error) { + issuersByNameID := make(map[issuance.NameID]*issuance.Certificate, len(issuers)) + for _, issuer := range issuers { + issuersByNameID[issuer.NameID()] = issuer + } + + if numShards < 1 { + return nil, fmt.Errorf("must have positive number of shards, got: %d", numShards) + } + + if updatePeriod >= 7*24*time.Hour { + return nil, fmt.Errorf("must update CRLs at least every 7 days, got: %s", updatePeriod) + } + + if updateTimeout >= updatePeriod { + return nil, fmt.Errorf("update timeout must be less than period: %s !< %s", updateTimeout, updatePeriod) + } + + if lookbackPeriod < 2*updatePeriod { + return nil, fmt.Errorf("lookbackPeriod must be at least 2x updatePeriod: %s !< 2 * %s", lookbackPeriod, updatePeriod) + } + + if maxParallelism <= 0 { + maxParallelism = 1 + } + + if maxAttempts <= 0 { + maxAttempts = 1 + } + + tickHistogram := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "crl_updater_ticks", + Help: "A histogram of crl-updater tick latencies labeled by issuer and result", + Buckets: []float64{0.01, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000}, + }, []string{"issuer", "result"}) + stats.MustRegister(tickHistogram) + + updatedCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "crl_updater_generated", + Help: "A counter of CRL generation calls labeled by result", + }, []string{"issuer", "result"}) + stats.MustRegister(updatedCounter) + + return &crlUpdater{ + issuersByNameID, + numShards, + shardWidth, + lookbackPeriod, + updatePeriod, + updateTimeout, + maxParallelism, + maxAttempts, + sa, + ca, + cs, + tickHistogram, + updatedCounter, + log, + clk, + }, nil +} + +// updateShardWithRetry calls updateShard repeatedly (with exponential backoff +// between attempts) until it succeeds or the max number of attempts is reached. +func (cu *crlUpdater) updateShardWithRetry(ctx context.Context, atTime time.Time, issuerNameID issuance.NameID, shardIdx int, chunks []chunk) error { + ctx, cancel := context.WithTimeout(ctx, cu.updateTimeout) + defer cancel() + deadline, _ := ctx.Deadline() + + if chunks == nil { + // Compute the shard map and relevant chunk boundaries, if not supplied. + // Batch mode supplies this to avoid duplicate computation. + shardMap, err := cu.getShardMappings(ctx, atTime) + if err != nil { + return fmt.Errorf("computing shardmap: %w", err) + } + chunks = shardMap[shardIdx%cu.numShards] + } + + _, err := cu.sa.LeaseCRLShard(ctx, &sapb.LeaseCRLShardRequest{ + IssuerNameID: int64(issuerNameID), + MinShardIdx: int64(shardIdx), + MaxShardIdx: int64(shardIdx), + Until: timestamppb.New(deadline.Add(time.Minute)), + }) + if err != nil { + return fmt.Errorf("leasing shard: %w", err) + } + + crlID := crl.Id(issuerNameID, shardIdx, crl.Number(atTime)) + + for i := range cu.maxAttempts { + // core.RetryBackoff always returns 0 when its first argument is zero. + sleepTime := core.RetryBackoff(i, time.Second, time.Minute, 2) + if i != 0 { + cu.log.Errf( + "Generating CRL failed, will retry in %vs: id=[%s] err=[%s]", + sleepTime.Seconds(), crlID, err) + } + cu.clk.Sleep(sleepTime) + + err = cu.updateShard(ctx, atTime, issuerNameID, shardIdx, chunks) + if err == nil { + break + } + } + if err != nil { + return err + } + + // Notify the database that that we're done. + _, err = cu.sa.UpdateCRLShard(ctx, &sapb.UpdateCRLShardRequest{ + IssuerNameID: int64(issuerNameID), + ShardIdx: int64(shardIdx), + ThisUpdate: timestamppb.New(atTime), + }) + if err != nil { + return fmt.Errorf("updating db metadata: %w", err) + } + + return nil +} + +// updateShard processes a single shard. It computes the shard's boundaries, gets +// the list of revoked certs in that shard from the SA, gets the CA to sign the +// resulting CRL, and gets the crl-storer to upload it. It returns an error if +// any of these operations fail. +func (cu *crlUpdater) updateShard(ctx context.Context, atTime time.Time, issuerNameID issuance.NameID, shardIdx int, chunks []chunk) (err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + crlID := crl.Id(issuerNameID, shardIdx, crl.Number(atTime)) + + start := cu.clk.Now() + defer func() { + // This func closes over the named return value `err`, so can reference it. + result := "success" + if err != nil { + result = "failed" + } + cu.tickHistogram.WithLabelValues(cu.issuers[issuerNameID].Subject.CommonName, result).Observe(cu.clk.Since(start).Seconds()) + cu.updatedCounter.WithLabelValues(cu.issuers[issuerNameID].Subject.CommonName, result).Inc() + }() + + cu.log.Infof( + "Generating CRL shard: id=[%s] numChunks=[%d]", crlID, len(chunks)) + + // Get the full list of CRL Entries for this shard from the SA. + var crlEntries []*proto.CRLEntry + for _, chunk := range chunks { + saStream, err := cu.sa.GetRevokedCerts(ctx, &sapb.GetRevokedCertsRequest{ + IssuerNameID: int64(issuerNameID), + ExpiresAfter: timestamppb.New(chunk.start), + ExpiresBefore: timestamppb.New(chunk.end), + RevokedBefore: timestamppb.New(atTime), + }) + if err != nil { + return fmt.Errorf("connecting to SA: %w", err) + } + + for { + entry, err := saStream.Recv() + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("retrieving entry from SA: %w", err) + } + crlEntries = append(crlEntries, entry) + } + + cu.log.Infof( + "Queried SA for CRL shard: id=[%s] expiresAfter=[%s] expiresBefore=[%s] numEntries=[%d]", + crlID, chunk.start, chunk.end, len(crlEntries)) + } + + // Send the full list of CRL Entries to the CA. + caStream, err := cu.ca.GenerateCRL(ctx) + if err != nil { + return fmt.Errorf("connecting to CA: %w", err) + } + + err = caStream.Send(&capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Metadata{ + Metadata: &capb.CRLMetadata{ + IssuerNameID: int64(issuerNameID), + ThisUpdate: timestamppb.New(atTime), + ShardIdx: int64(shardIdx), + }, + }, + }) + if err != nil { + return fmt.Errorf("sending CA metadata: %w", err) + } + + for _, entry := range crlEntries { + err = caStream.Send(&capb.GenerateCRLRequest{ + Payload: &capb.GenerateCRLRequest_Entry{ + Entry: entry, + }, + }) + if err != nil { + return fmt.Errorf("sending entry to CA: %w", err) + } + } + + err = caStream.CloseSend() + if err != nil { + return fmt.Errorf("closing CA request stream: %w", err) + } + + // Receive the full bytes of the signed CRL from the CA. + crlLen := 0 + crlHash := sha256.New() + var crlChunks [][]byte + for { + out, err := caStream.Recv() + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("receiving CRL bytes: %w", err) + } + + crlLen += len(out.Chunk) + crlHash.Write(out.Chunk) + crlChunks = append(crlChunks, out.Chunk) + } + + // Send the full bytes of the signed CRL to the Storer. + csStream, err := cu.cs.UploadCRL(ctx) + if err != nil { + return fmt.Errorf("connecting to CRLStorer: %w", err) + } + + err = csStream.Send(&cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_Metadata{ + Metadata: &cspb.CRLMetadata{ + IssuerNameID: int64(issuerNameID), + Number: atTime.UnixNano(), + ShardIdx: int64(shardIdx), + }, + }, + }) + if err != nil { + return fmt.Errorf("sending CRLStorer metadata: %w", err) + } + + for _, chunk := range crlChunks { + err = csStream.Send(&cspb.UploadCRLRequest{ + Payload: &cspb.UploadCRLRequest_CrlChunk{ + CrlChunk: chunk, + }, + }) + if err != nil { + return fmt.Errorf("uploading CRL bytes: %w", err) + } + } + + _, err = csStream.CloseAndRecv() + if err != nil { + return fmt.Errorf("closing CRLStorer upload stream: %w", err) + } + + cu.log.Infof( + "Generated CRL shard: id=[%s] size=[%d] hash=[%x]", + crlID, crlLen, crlHash.Sum(nil)) + + return nil +} + +// anchorTime is used as a universal starting point against which other times +// can be compared. This time must be less than 290 years (2^63-1 nanoseconds) +// in the past, to ensure that Go's time.Duration can represent that difference. +// The significance of 2015-06-04 11:04:38 UTC is left as an exercise to the +// reader. +func anchorTime() time.Time { + return time.Date(2015, time.June, 04, 11, 04, 38, 0, time.UTC) +} + +// chunk represents a fixed slice of time during which some certificates +// presumably expired or will expire. Its non-unique index indicates which shard +// it will be mapped to. The start boundary is inclusive, the end boundary is +// exclusive. +type chunk struct { + start time.Time + end time.Time + Idx int +} + +// shardMap is a mapping of shard indices to the set of chunks which should be +// included in that shard. Under most circumstances there is a one-to-one +// mapping, but certain configuration (such as having very narrow shards, or +// having a very long lookback period) can result in more than one chunk being +// mapped to a single shard. +type shardMap [][]chunk + +// getShardMappings determines which chunks are currently relevant, based on +// the current time, the configured lookbackPeriod, and the farthest-future +// certificate expiration in the database. It then maps all of those chunks to +// their corresponding shards, and returns that mapping. +// +// The idea here is that shards should be stable. Picture a timeline, divided +// into chunks. Number those chunks from 0 (starting at the anchor time) up to +// numShards, then repeat the cycle when you run out of numbers: +// +// chunk: 0 1 2 3 4 0 1 2 3 4 0 +// |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----... +// ^-anchorTime +// +// The total time window we care about goes from atTime-lookbackPeriod, forward +// through the time of the farthest-future notAfter date found in the database. +// The lookbackPeriod must be larger than the updatePeriod, to ensure that any +// certificates which were both revoked *and* expired since the last time we +// issued CRLs get included in this generation. Because these times are likely +// to fall in the middle of chunks, we include the whole chunks surrounding +// those times in our output CRLs: +// +// included chunk: 4 0 1 2 3 4 0 1 +// ...--|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----... +// atTime-lookbackPeriod-^ ^-atTime lastExpiry-^ +// +// Because this total period of time may include multiple chunks with the same +// number, we then coalesce these chunks into a single shard. Ideally, this +// will never happen: it should only happen if the lookbackPeriod is very +// large, or if the shardWidth is small compared to the lastExpiry (such that +// numShards * shardWidth is less than lastExpiry - atTime). In this example, +// shards 0, 1, and 4 all get the contents of two chunks mapped to them, while +// shards 2 and 3 get only one chunk each. +// +// included chunk: 4 0 1 2 3 4 0 1 +// ...--|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----... +// │ │ │ │ │ │ │ │ +// shard 0: <────────────────┘─────────────────────────────┘ │ +// shard 1: <──────────────────────┘─────────────────────────────┘ +// shard 2: <────────────────────────────┘ │ │ +// shard 3: <──────────────────────────────────┘ │ +// shard 4: <──────────┘─────────────────────────────┘ +// +// Under this scheme, the shard to which any given certificate will be mapped is +// a function of only three things: that certificate's notAfter timestamp, the +// chunk width, and the number of shards. +func (cu *crlUpdater) getShardMappings(ctx context.Context, atTime time.Time) (shardMap, error) { + res := make(shardMap, cu.numShards) + + // Get the farthest-future expiration timestamp to ensure we cover everything. + lastExpiry, err := cu.sa.GetMaxExpiration(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + + // Find the id number and boundaries of the earliest chunk we care about. + first := atTime.Add(-cu.lookbackPeriod) + c, err := GetChunkAtTime(cu.shardWidth, cu.numShards, first) + if err != nil { + return nil, err + } + + // Iterate over chunks until we get completely beyond the farthest-future + // expiration. + for c.start.Before(lastExpiry.AsTime()) { + res[c.Idx] = append(res[c.Idx], c) + c = chunk{ + start: c.end, + end: c.end.Add(cu.shardWidth), + Idx: (c.Idx + 1) % cu.numShards, + } + } + + return res, nil +} + +// GetChunkAtTime returns the chunk whose boundaries contain the given time. +// It is exported so that it can be used by both the crl-updater and the RA +// as we transition from dynamic to static shard mappings. +func GetChunkAtTime(shardWidth time.Duration, numShards int, atTime time.Time) (chunk, error) { + // Compute the amount of time between the current time and the anchor time. + timeSinceAnchor := atTime.Sub(anchorTime()) + if timeSinceAnchor == time.Duration(math.MaxInt64) || timeSinceAnchor < 0 { + return chunk{}, errors.New("shard boundary math broken: anchor time too far away") + } + + // Determine how many full chunks fit within that time, and from that the + // index number of the desired chunk. + chunksSinceAnchor := timeSinceAnchor.Nanoseconds() / shardWidth.Nanoseconds() + chunkIdx := int(chunksSinceAnchor) % numShards + + // Determine the boundaries of the chunk. + timeSinceChunk := time.Duration(timeSinceAnchor.Nanoseconds() % shardWidth.Nanoseconds()) + left := atTime.Add(-timeSinceChunk) + right := left.Add(shardWidth) + + return chunk{left, right, chunkIdx}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/crl/updater/updater_test.go b/third-party/github.com/letsencrypt/boulder/crl/updater/updater_test.go new file mode 100644 index 000000000..9b2b16108 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/crl/updater/updater_test.go @@ -0,0 +1,401 @@ +package updater + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + capb "github.com/letsencrypt/boulder/ca/proto" + corepb "github.com/letsencrypt/boulder/core/proto" + cspb "github.com/letsencrypt/boulder/crl/storer/proto" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +// fakeGRCC is a fake grpc.ClientStreamingClient which can be +// populated with some CRL entries or an error for use as the return value of +// a faked GetRevokedCerts call. +type fakeGRCC struct { + grpc.ClientStream + entries []*corepb.CRLEntry + nextIdx int + err error +} + +func (f *fakeGRCC) Recv() (*corepb.CRLEntry, error) { + if f.err != nil { + return nil, f.err + } + if f.nextIdx < len(f.entries) { + res := f.entries[f.nextIdx] + f.nextIdx++ + return res, nil + } + return nil, io.EOF +} + +// fakeSAC is a fake sapb.StorageAuthorityClient which can be populated with a +// fakeGRCC to be used as the return value for calls to GetRevokedCerts, and a +// fake timestamp to serve as the database's maximum notAfter value. +type fakeSAC struct { + sapb.StorageAuthorityClient + grcc fakeGRCC + maxNotAfter time.Time + leaseError error +} + +func (f *fakeSAC) GetRevokedCerts(ctx context.Context, _ *sapb.GetRevokedCertsRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[corepb.CRLEntry], error) { + return &f.grcc, nil +} + +func (f *fakeSAC) GetMaxExpiration(_ context.Context, req *emptypb.Empty, _ ...grpc.CallOption) (*timestamppb.Timestamp, error) { + return timestamppb.New(f.maxNotAfter), nil +} + +func (f *fakeSAC) LeaseCRLShard(_ context.Context, req *sapb.LeaseCRLShardRequest, _ ...grpc.CallOption) (*sapb.LeaseCRLShardResponse, error) { + if f.leaseError != nil { + return nil, f.leaseError + } + return &sapb.LeaseCRLShardResponse{IssuerNameID: req.IssuerNameID, ShardIdx: req.MinShardIdx}, nil +} + +// fakeGCC is a fake grpc.BidiStreamingClient which can be +// populated with some CRL entries or an error for use as the return value of +// a faked GenerateCRL call. +type fakeGCC struct { + grpc.ClientStream + chunks [][]byte + nextIdx int + sendErr error + recvErr error +} + +func (f *fakeGCC) Send(*capb.GenerateCRLRequest) error { + return f.sendErr +} + +func (f *fakeGCC) CloseSend() error { + return nil +} + +func (f *fakeGCC) Recv() (*capb.GenerateCRLResponse, error) { + if f.recvErr != nil { + return nil, f.recvErr + } + if f.nextIdx < len(f.chunks) { + res := f.chunks[f.nextIdx] + f.nextIdx++ + return &capb.GenerateCRLResponse{Chunk: res}, nil + } + return nil, io.EOF +} + +// fakeCGC is a fake capb.CRLGeneratorClient which can be populated with a +// fakeGCC to be used as the return value for calls to GenerateCRL. +type fakeCGC struct { + gcc fakeGCC +} + +func (f *fakeCGC) GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[capb.GenerateCRLRequest, capb.GenerateCRLResponse], error) { + return &f.gcc, nil +} + +// fakeUCC is a fake grpc.ClientStreamingClient which can be populated with +// an error for use as the return value of a faked UploadCRL call. +type fakeUCC struct { + grpc.ClientStream + sendErr error + recvErr error +} + +func (f *fakeUCC) Send(*cspb.UploadCRLRequest) error { + return f.sendErr +} + +func (f *fakeUCC) CloseAndRecv() (*emptypb.Empty, error) { + if f.recvErr != nil { + return nil, f.recvErr + } + return &emptypb.Empty{}, nil +} + +// fakeCSC is a fake cspb.CRLStorerClient which can be populated with a +// fakeUCC for use as the return value for calls to UploadCRL. +type fakeCSC struct { + ucc fakeUCC +} + +func (f *fakeCSC) UploadCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[cspb.UploadCRLRequest, emptypb.Empty], error) { + return &f.ucc, nil +} + +func TestUpdateShard(t *testing.T) { + e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + + sentinelErr := errors.New("oops") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + clk := clock.NewFake() + clk.Set(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) + cu, err := NewUpdater( + []*issuance.Certificate{e1, r3}, + 2, 18*time.Hour, 24*time.Hour, + 6*time.Hour, time.Minute, 1, 1, + &fakeSAC{grcc: fakeGRCC{}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)}, + &fakeCGC{gcc: fakeGCC{}}, + &fakeCSC{ucc: fakeUCC{}}, + metrics.NoopRegisterer, blog.NewMock(), clk, + ) + test.AssertNotError(t, err, "building test crlUpdater") + + testChunks := []chunk{ + {clk.Now(), clk.Now().Add(18 * time.Hour), 0}, + } + + // Ensure that getting no results from the SA still works. + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertNotError(t, err, "empty CRL") + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "success", + }, 1) + cu.updatedCounter.Reset() + + // Errors closing the Storer upload stream should bubble up. + cu.cs = &fakeCSC{ucc: fakeUCC{recvErr: sentinelErr}} + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "storer error") + test.AssertContains(t, err.Error(), "closing CRLStorer upload stream") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "failed", + }, 1) + cu.updatedCounter.Reset() + + // Errors sending to the Storer should bubble up sooner. + cu.cs = &fakeCSC{ucc: fakeUCC{sendErr: sentinelErr}} + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "storer error") + test.AssertContains(t, err.Error(), "sending CRLStorer metadata") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "failed", + }, 1) + cu.updatedCounter.Reset() + + // Errors reading from the CA should bubble up sooner. + cu.ca = &fakeCGC{gcc: fakeGCC{recvErr: sentinelErr}} + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "CA error") + test.AssertContains(t, err.Error(), "receiving CRL bytes") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "failed", + }, 1) + cu.updatedCounter.Reset() + + // Errors sending to the CA should bubble up sooner. + cu.ca = &fakeCGC{gcc: fakeGCC{sendErr: sentinelErr}} + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "CA error") + test.AssertContains(t, err.Error(), "sending CA metadata") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "failed", + }, 1) + cu.updatedCounter.Reset() + + // Errors reading from the SA should bubble up soonest. + cu.sa = &fakeSAC{grcc: fakeGRCC{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)} + err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "database error") + test.AssertContains(t, err.Error(), "retrieving entry from SA") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ + "issuer": "(TEST) Elegant Elephant E1", "result": "failed", + }, 1) + cu.updatedCounter.Reset() +} + +func TestUpdateShardWithRetry(t *testing.T) { + e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "loading test issuer") + + sentinelErr := errors.New("oops") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + clk := clock.NewFake() + clk.Set(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) + + // Build an updater that will always fail when it talks to the SA. + cu, err := NewUpdater( + []*issuance.Certificate{e1, r3}, + 2, 18*time.Hour, 24*time.Hour, + 6*time.Hour, time.Minute, 1, 1, + &fakeSAC{grcc: fakeGRCC{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)}, + &fakeCGC{gcc: fakeGCC{}}, + &fakeCSC{ucc: fakeUCC{}}, + metrics.NoopRegisterer, blog.NewMock(), clk, + ) + test.AssertNotError(t, err, "building test crlUpdater") + + testChunks := []chunk{ + {clk.Now(), clk.Now().Add(18 * time.Hour), 0}, + } + + // Ensure that having MaxAttempts set to 1 results in the clock not moving + // forward at all. + startTime := cu.clk.Now() + err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "database error") + test.AssertErrorIs(t, err, sentinelErr) + test.AssertEquals(t, cu.clk.Now(), startTime) + + // Ensure that having MaxAttempts set to 5 results in the clock moving forward + // by 1+2+4+8=15 seconds. The core.RetryBackoff system has 20% jitter built + // in, so we have to be approximate. + cu.maxAttempts = 5 + startTime = cu.clk.Now() + err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 0, testChunks) + test.AssertError(t, err, "database error") + test.AssertErrorIs(t, err, sentinelErr) + t.Logf("start: %v", startTime) + t.Logf("now: %v", cu.clk.Now()) + test.Assert(t, startTime.Add(15*0.8*time.Second).Before(cu.clk.Now()), "retries didn't sleep enough") + test.Assert(t, startTime.Add(15*1.2*time.Second).After(cu.clk.Now()), "retries slept too much") +} + +func TestGetShardMappings(t *testing.T) { + // We set atTime to be exactly one day (numShards * shardWidth) after the + // anchorTime for these tests, so that we know that the index of the first + // chunk we would normally (i.e. not taking lookback or overshoot into + // account) care about is 0. + atTime := anchorTime().Add(24 * time.Hour) + + // When there is no lookback, and the maxNotAfter is exactly as far in the + // future as the numShards * shardWidth looks, every shard should be mapped to + // exactly one chunk. + tcu := crlUpdater{ + numShards: 24, + shardWidth: 1 * time.Hour, + sa: &fakeSAC{maxNotAfter: atTime.Add(23*time.Hour + 30*time.Minute)}, + lookbackPeriod: 0, + } + m, err := tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting aligned shards") + test.AssertEquals(t, len(m), 24) + for _, s := range m { + test.AssertEquals(t, len(s), 1) + } + + // When there is 1.5 hours each of lookback and maxNotAfter overshoot, then + // there should be four shards which each get two chunks mapped to them. + tcu = crlUpdater{ + numShards: 24, + shardWidth: 1 * time.Hour, + sa: &fakeSAC{maxNotAfter: atTime.Add(24*time.Hour + 90*time.Minute)}, + lookbackPeriod: 90 * time.Minute, + } + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting overshoot shards") + test.AssertEquals(t, len(m), 24) + for i, s := range m { + if i == 0 || i == 1 || i == 22 || i == 23 { + test.AssertEquals(t, len(s), 2) + } else { + test.AssertEquals(t, len(s), 1) + } + } + + // When there is a massive amount of overshoot, many chunks should be mapped + // to each shard. + tcu = crlUpdater{ + numShards: 24, + shardWidth: 1 * time.Hour, + sa: &fakeSAC{maxNotAfter: atTime.Add(90 * 24 * time.Hour)}, + lookbackPeriod: time.Minute, + } + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting overshoot shards") + test.AssertEquals(t, len(m), 24) + for i, s := range m { + if i == 23 { + test.AssertEquals(t, len(s), 91) + } else { + test.AssertEquals(t, len(s), 90) + } + } + + // An arbitrarily-chosen chunk should always end up in the same shard no + // matter what the current time, lookback, and overshoot are, as long as the + // number of shards and the shard width remains constant. + tcu = crlUpdater{ + numShards: 24, + shardWidth: 1 * time.Hour, + sa: &fakeSAC{maxNotAfter: atTime.Add(24 * time.Hour)}, + lookbackPeriod: time.Hour, + } + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting consistency shards") + test.AssertEquals(t, m[10][0].start, anchorTime().Add(34*time.Hour)) + tcu.lookbackPeriod = 4 * time.Hour + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting consistency shards") + test.AssertEquals(t, m[10][0].start, anchorTime().Add(34*time.Hour)) + tcu.sa = &fakeSAC{maxNotAfter: atTime.Add(300 * 24 * time.Hour)} + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting consistency shards") + test.AssertEquals(t, m[10][0].start, anchorTime().Add(34*time.Hour)) + atTime = atTime.Add(6 * time.Hour) + m, err = tcu.getShardMappings(context.Background(), atTime) + test.AssertNotError(t, err, "getting consistency shards") + test.AssertEquals(t, m[10][0].start, anchorTime().Add(34*time.Hour)) +} + +func TestGetChunkAtTime(t *testing.T) { + // Our test updater divides time into chunks 1 day wide, numbered 0 through 9. + numShards := 10 + shardWidth := 24 * time.Hour + + // The chunk right at the anchor time should have index 0 and start at the + // anchor time. This also tests behavior when atTime is on a chunk boundary. + atTime := anchorTime() + c, err := GetChunkAtTime(shardWidth, numShards, atTime) + test.AssertNotError(t, err, "getting chunk at anchor") + test.AssertEquals(t, c.Idx, 0) + test.Assert(t, c.start.Equal(atTime), "getting chunk at anchor") + test.Assert(t, c.end.Equal(atTime.Add(24*time.Hour)), "getting chunk at anchor") + + // The chunk a bit over a year in the future should have index 5. + atTime = anchorTime().Add(365 * 24 * time.Hour) + c, err = GetChunkAtTime(shardWidth, numShards, atTime.Add(time.Minute)) + test.AssertNotError(t, err, "getting chunk") + test.AssertEquals(t, c.Idx, 5) + test.Assert(t, c.start.Equal(atTime), "getting chunk") + test.Assert(t, c.end.Equal(atTime.Add(24*time.Hour)), "getting chunk") + + // A chunk very far in the future should break the math. We have to add to + // the time twice, since the whole point of "very far in the future" is that + // it isn't representable by a time.Duration. + atTime = anchorTime().Add(200 * 365 * 24 * time.Hour).Add(200 * 365 * 24 * time.Hour) + c, err = GetChunkAtTime(shardWidth, numShards, atTime) + test.AssertError(t, err, "getting far-future chunk") +} diff --git a/third-party/github.com/letsencrypt/boulder/csr/csr.go b/third-party/github.com/letsencrypt/boulder/csr/csr.go new file mode 100644 index 000000000..1f343ba9b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/csr/csr.go @@ -0,0 +1,121 @@ +package csr + +import ( + "context" + "crypto" + "crypto/x509" + "errors" + "strings" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/goodkey" +) + +// maxCNLength is the maximum length allowed for the common name as specified in RFC 5280 +const maxCNLength = 64 + +// This map is used to decide which CSR signing algorithms we consider +// strong enough to use. Significantly the missing algorithms are: +// * No algorithms using MD2, MD5, or SHA-1 +// * No DSA algorithms +var goodSignatureAlgorithms = map[x509.SignatureAlgorithm]bool{ + x509.SHA256WithRSA: true, + x509.SHA384WithRSA: true, + x509.SHA512WithRSA: true, + x509.ECDSAWithSHA256: true, + x509.ECDSAWithSHA384: true, + x509.ECDSAWithSHA512: true, +} + +var ( + invalidPubKey = berrors.BadCSRError("invalid public key in CSR") + unsupportedSigAlg = berrors.BadCSRError("signature algorithm not supported") + invalidSig = berrors.BadCSRError("invalid signature on CSR") + invalidEmailPresent = berrors.BadCSRError("CSR contains one or more email address fields") + invalidIPPresent = berrors.BadCSRError("CSR contains one or more IP address fields") + invalidNoDNS = berrors.BadCSRError("at least one DNS name is required") +) + +// VerifyCSR checks the validity of a x509.CertificateRequest. Before doing checks it normalizes +// the CSR which lowers the case of DNS names and subject CN, and hoist a DNS name into the CN +// if it is empty. +func VerifyCSR(ctx context.Context, csr *x509.CertificateRequest, maxNames int, keyPolicy *goodkey.KeyPolicy, pa core.PolicyAuthority) error { + key, ok := csr.PublicKey.(crypto.PublicKey) + if !ok { + return invalidPubKey + } + err := keyPolicy.GoodKey(ctx, key) + if err != nil { + if errors.Is(err, goodkey.ErrBadKey) { + return berrors.BadCSRError("invalid public key in CSR: %s", err) + } + return berrors.InternalServerError("error checking key validity: %s", err) + } + if !goodSignatureAlgorithms[csr.SignatureAlgorithm] { + return unsupportedSigAlg + } + + err = csr.CheckSignature() + if err != nil { + return invalidSig + } + if len(csr.EmailAddresses) > 0 { + return invalidEmailPresent + } + if len(csr.IPAddresses) > 0 { + return invalidIPPresent + } + + names := NamesFromCSR(csr) + + if len(names.SANs) == 0 && names.CN == "" { + return invalidNoDNS + } + if len(names.CN) > maxCNLength { + return berrors.BadCSRError("CN was longer than %d bytes", maxCNLength) + } + if len(names.SANs) > maxNames { + return berrors.BadCSRError("CSR contains more than %d DNS names", maxNames) + } + + err = pa.WillingToIssue(names.SANs) + if err != nil { + return err + } + return nil +} + +type names struct { + SANs []string + CN string +} + +// NamesFromCSR deduplicates and lower-cases the Subject Common Name and Subject +// Alternative Names from the CSR. If the CSR contains a CN, then it preserves +// it and guarantees that the SANs also include it. If the CSR does not contain +// a CN, then it also attempts to promote a SAN to the CN (if any is short +// enough to fit). +func NamesFromCSR(csr *x509.CertificateRequest) names { + // Produce a new "sans" slice with the same memory address as csr.DNSNames + // but force a new allocation if an append happens so that we don't + // accidentally mutate the underlying csr.DNSNames array. + sans := csr.DNSNames[0:len(csr.DNSNames):len(csr.DNSNames)] + if csr.Subject.CommonName != "" { + sans = append(sans, csr.Subject.CommonName) + } + + if csr.Subject.CommonName != "" { + return names{SANs: core.UniqueLowerNames(sans), CN: strings.ToLower(csr.Subject.CommonName)} + } + + // If there's no CN already, but we want to set one, promote the first SAN + // which is shorter than the maximum acceptable CN length (if any). + for _, name := range sans { + if len(name) <= maxCNLength { + return names{SANs: core.UniqueLowerNames(sans), CN: strings.ToLower(name)} + } + } + + return names{SANs: core.UniqueLowerNames(sans)} +} diff --git a/third-party/github.com/letsencrypt/boulder/csr/csr_test.go b/third-party/github.com/letsencrypt/boulder/csr/csr_test.go new file mode 100644 index 000000000..90884906a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/csr/csr_test.go @@ -0,0 +1,274 @@ +package csr + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "net" + "strings" + "testing" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/test" +) + +type mockPA struct{} + +func (pa *mockPA) ChallengesFor(identifier identifier.ACMEIdentifier) (challenges []core.Challenge, err error) { + return +} + +func (pa *mockPA) WillingToIssue(domains []string) error { + for _, domain := range domains { + if domain == "bad-name.com" || domain == "other-bad-name.com" { + return errors.New("policy forbids issuing for identifier") + } + } + return nil +} + +func (pa *mockPA) ChallengeTypeEnabled(t core.AcmeChallenge) bool { + return true +} + +func (pa *mockPA) CheckAuthz(a *core.Authorization) error { + return nil +} + +func TestVerifyCSR(t *testing.T) { + private, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + signedReqBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{PublicKey: private.PublicKey, SignatureAlgorithm: x509.SHA256WithRSA}, private) + test.AssertNotError(t, err, "error generating test CSR") + signedReq, err := x509.ParseCertificateRequest(signedReqBytes) + test.AssertNotError(t, err, "error parsing test CSR") + brokenSignedReq := new(x509.CertificateRequest) + *brokenSignedReq = *signedReq + brokenSignedReq.Signature = []byte{1, 1, 1, 1} + signedReqWithHosts := new(x509.CertificateRequest) + *signedReqWithHosts = *signedReq + signedReqWithHosts.DNSNames = []string{"a.com", "b.com"} + signedReqWithLongCN := new(x509.CertificateRequest) + *signedReqWithLongCN = *signedReq + signedReqWithLongCN.Subject.CommonName = strings.Repeat("a", maxCNLength+1) + signedReqWithBadNames := new(x509.CertificateRequest) + *signedReqWithBadNames = *signedReq + signedReqWithBadNames.DNSNames = []string{"bad-name.com", "other-bad-name.com"} + signedReqWithEmailAddress := new(x509.CertificateRequest) + *signedReqWithEmailAddress = *signedReq + signedReqWithEmailAddress.EmailAddresses = []string{"foo@bar.com"} + signedReqWithIPAddress := new(x509.CertificateRequest) + *signedReqWithIPAddress = *signedReq + signedReqWithIPAddress.IPAddresses = []net.IP{net.IPv4(1, 2, 3, 4)} + signedReqWithAllLongSANs := new(x509.CertificateRequest) + *signedReqWithAllLongSANs = *signedReq + signedReqWithAllLongSANs.DNSNames = []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com"} + + keyPolicy, err := goodkey.NewPolicy(nil, nil) + test.AssertNotError(t, err, "creating test keypolicy") + + cases := []struct { + csr *x509.CertificateRequest + maxNames int + pa core.PolicyAuthority + expectedError error + }{ + { + &x509.CertificateRequest{}, + 100, + &mockPA{}, + invalidPubKey, + }, + { + &x509.CertificateRequest{PublicKey: &private.PublicKey}, + 100, + &mockPA{}, + unsupportedSigAlg, + }, + { + brokenSignedReq, + 100, + &mockPA{}, + invalidSig, + }, + { + signedReq, + 100, + &mockPA{}, + invalidNoDNS, + }, + { + signedReqWithLongCN, + 100, + &mockPA{}, + berrors.BadCSRError("CN was longer than %d bytes", maxCNLength), + }, + { + signedReqWithHosts, + 1, + &mockPA{}, + berrors.BadCSRError("CSR contains more than 1 DNS names"), + }, + { + signedReqWithBadNames, + 100, + &mockPA{}, + errors.New("policy forbids issuing for identifier"), + }, + { + signedReqWithEmailAddress, + 100, + &mockPA{}, + invalidEmailPresent, + }, + { + signedReqWithIPAddress, + 100, + &mockPA{}, + invalidIPPresent, + }, + { + signedReqWithAllLongSANs, + 100, + &mockPA{}, + nil, + }, + } + + for _, c := range cases { + err := VerifyCSR(context.Background(), c.csr, c.maxNames, &keyPolicy, c.pa) + test.AssertDeepEquals(t, c.expectedError, err) + } +} + +func TestNamesFromCSR(t *testing.T) { + tooLongString := strings.Repeat("a", maxCNLength+1) + + cases := []struct { + name string + csr *x509.CertificateRequest + expectedCN string + expectedNames []string + }{ + { + "no explicit CN", + &x509.CertificateRequest{DNSNames: []string{"a.com"}}, + "a.com", + []string{"a.com"}, + }, + { + "explicit uppercase CN", + &x509.CertificateRequest{Subject: pkix.Name{CommonName: "A.com"}, DNSNames: []string{"a.com"}}, + "a.com", + []string{"a.com"}, + }, + { + "no explicit CN, uppercase SAN", + &x509.CertificateRequest{DNSNames: []string{"A.com"}}, + "a.com", + []string{"a.com"}, + }, + { + "duplicate SANs", + &x509.CertificateRequest{DNSNames: []string{"b.com", "b.com", "a.com", "a.com"}}, + "b.com", + []string{"a.com", "b.com"}, + }, + { + "explicit CN not found in SANs", + &x509.CertificateRequest{Subject: pkix.Name{CommonName: "a.com"}, DNSNames: []string{"b.com"}}, + "a.com", + []string{"a.com", "b.com"}, + }, + { + "no explicit CN, too long leading SANs", + &x509.CertificateRequest{DNSNames: []string{ + tooLongString + ".a.com", + tooLongString + ".b.com", + "a.com", + "b.com", + }}, + "a.com", + []string{"a.com", tooLongString + ".a.com", tooLongString + ".b.com", "b.com"}, + }, + { + "explicit CN, too long leading SANs", + &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "A.com"}, + DNSNames: []string{ + tooLongString + ".a.com", + tooLongString + ".b.com", + "a.com", + "b.com", + }}, + "a.com", + []string{"a.com", tooLongString + ".a.com", tooLongString + ".b.com", "b.com"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + names := NamesFromCSR(tc.csr) + test.AssertEquals(t, names.CN, tc.expectedCN) + test.AssertDeepEquals(t, names.SANs, tc.expectedNames) + }) + } +} + +func TestSHA1Deprecation(t *testing.T) { + features.Reset() + + keyPolicy, err := goodkey.NewPolicy(nil, nil) + test.AssertNotError(t, err, "creating test keypolicy") + + private, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + + makeAndVerifyCsr := func(alg x509.SignatureAlgorithm) error { + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, + &x509.CertificateRequest{ + DNSNames: []string{"example.com"}, + SignatureAlgorithm: alg, + PublicKey: &private.PublicKey, + }, private) + test.AssertNotError(t, err, "creating test CSR") + + csr, err := x509.ParseCertificateRequest(csrBytes) + test.AssertNotError(t, err, "parsing test CSR") + + return VerifyCSR(context.Background(), csr, 100, &keyPolicy, &mockPA{}) + } + + err = makeAndVerifyCsr(x509.SHA256WithRSA) + test.AssertNotError(t, err, "SHA256 CSR should verify") + + err = makeAndVerifyCsr(x509.SHA1WithRSA) + test.AssertError(t, err, "SHA1 CSR should not verify") +} + +func TestDuplicateExtensionRejection(t *testing.T) { + private, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, + &x509.CertificateRequest{ + DNSNames: []string{"example.com"}, + SignatureAlgorithm: x509.SHA256WithRSA, + PublicKey: &private.PublicKey, + ExtraExtensions: []pkix.Extension{ + {Id: asn1.ObjectIdentifier{2, 5, 29, 1}, Value: []byte("hello")}, + {Id: asn1.ObjectIdentifier{2, 5, 29, 1}, Value: []byte("world")}, + }, + }, private) + test.AssertNotError(t, err, "creating test CSR") + + _, err = x509.ParseCertificateRequest(csrBytes) + test.AssertError(t, err, "CSR with duplicate extension OID should fail to parse") +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig.go new file mode 100644 index 000000000..8adab4adb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig.go @@ -0,0 +1,121 @@ +package ctconfig + +import ( + "errors" + "fmt" + "time" + + "github.com/letsencrypt/boulder/config" +) + +// LogShard describes a single shard of a temporally sharded +// CT log +type LogShard struct { + URI string + Key string + WindowStart time.Time + WindowEnd time.Time +} + +// TemporalSet contains a set of temporal shards of a single log +type TemporalSet struct { + Name string + Shards []LogShard +} + +// Setup initializes the TemporalSet by parsing the start and end dates +// and verifying WindowEnd > WindowStart +func (ts *TemporalSet) Setup() error { + if ts.Name == "" { + return errors.New("Name cannot be empty") + } + if len(ts.Shards) == 0 { + return errors.New("temporal set contains no shards") + } + for i := range ts.Shards { + if !ts.Shards[i].WindowEnd.After(ts.Shards[i].WindowStart) { + return errors.New("WindowStart must be before WindowEnd") + } + } + return nil +} + +// pick chooses the correct shard from a TemporalSet to use for the given +// expiration time. In the case where two shards have overlapping windows +// the earlier of the two shards will be chosen. +func (ts *TemporalSet) pick(exp time.Time) (*LogShard, error) { + for _, shard := range ts.Shards { + if exp.Before(shard.WindowStart) { + continue + } + if !exp.Before(shard.WindowEnd) { + continue + } + return &shard, nil + } + return nil, fmt.Errorf("no valid shard available for temporal set %q for expiration date %q", ts.Name, exp) +} + +// LogDescription contains the information needed to submit certificates +// to a CT log and verify returned receipts. If TemporalSet is non-nil then +// URI and Key should be empty. +type LogDescription struct { + URI string + Key string + SubmitFinalCert bool + + *TemporalSet +} + +// Info returns the URI and key of the log, either from a plain log description +// or from the earliest valid shard from a temporal log set +func (ld LogDescription) Info(exp time.Time) (string, string, error) { + if ld.TemporalSet == nil { + return ld.URI, ld.Key, nil + } + shard, err := ld.TemporalSet.pick(exp) + if err != nil { + return "", "", err + } + return shard.URI, shard.Key, nil +} + +// CTGroup represents a group of CT Logs. Although capable of holding logs +// grouped by any arbitrary feature, is today primarily used to hold logs which +// are all operated by the same legal entity. +type CTGroup struct { + Name string + Logs []LogDescription +} + +// CTConfig is the top-level config object expected to be embedded in an +// executable's JSON config struct. +type CTConfig struct { + // Stagger is duration (e.g. "200ms") indicating how long to wait for a log + // from one operator group to accept a certificate before attempting + // submission to a log run by a different operator instead. + Stagger config.Duration + // LogListFile is a path to a JSON log list file. The file must match Chrome's + // schema: https://www.gstatic.com/ct/log_list/v3/log_list_schema.json + LogListFile string `validate:"required"` + // SCTLogs is a list of CT log names to submit precerts to in order to get SCTs. + SCTLogs []string `validate:"min=1,dive,required"` + // InfoLogs is a list of CT log names to submit precerts to on a best-effort + // basis. Logs are included here for the sake of wider distribution of our + // precerts, and to exercise logs that in the qualification process. + InfoLogs []string + // FinalLogs is a list of CT log names to submit final certificates to. + // This may include duplicates from the lists above, to submit both precerts + // and final certs to the same log. + FinalLogs []string +} + +// LogID holds enough information to uniquely identify a CT Log: its log_id +// (the base64-encoding of the SHA-256 hash of its public key) and its human- +// readable name/description. This is used to extract other log parameters +// (such as its URL and public key) from the Chrome Log List. +type LogID struct { + Name string + ID string + SubmitFinal bool +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig_test.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig_test.go new file mode 100644 index 000000000..d8d710f39 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctconfig/ctconfig_test.go @@ -0,0 +1,116 @@ +package ctconfig + +import ( + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/test" +) + +func TestTemporalSetup(t *testing.T) { + for _, tc := range []struct { + ts TemporalSet + err string + }{ + { + ts: TemporalSet{}, + err: "Name cannot be empty", + }, + { + ts: TemporalSet{ + Name: "temporal set", + }, + err: "temporal set contains no shards", + }, + { + ts: TemporalSet{ + Name: "temporal set", + Shards: []LogShard{ + { + WindowStart: time.Time{}, + WindowEnd: time.Time{}, + }, + }, + }, + err: "WindowStart must be before WindowEnd", + }, + { + ts: TemporalSet{ + Name: "temporal set", + Shards: []LogShard{ + { + WindowStart: time.Time{}.Add(time.Hour), + WindowEnd: time.Time{}, + }, + }, + }, + err: "WindowStart must be before WindowEnd", + }, + { + ts: TemporalSet{ + Name: "temporal set", + Shards: []LogShard{ + { + WindowStart: time.Time{}, + WindowEnd: time.Time{}.Add(time.Hour), + }, + }, + }, + err: "", + }, + } { + err := tc.ts.Setup() + if err != nil && tc.err != err.Error() { + t.Errorf("got error %q, wanted %q", err, tc.err) + } else if err == nil && tc.err != "" { + t.Errorf("unexpected error %q", err) + } + } +} + +func TestLogInfo(t *testing.T) { + ld := LogDescription{ + URI: "basic-uri", + Key: "basic-key", + } + uri, key, err := ld.Info(time.Time{}) + test.AssertNotError(t, err, "Info failed") + test.AssertEquals(t, uri, ld.URI) + test.AssertEquals(t, key, ld.Key) + + fc := clock.NewFake() + ld.TemporalSet = &TemporalSet{} + _, _, err = ld.Info(fc.Now()) + test.AssertError(t, err, "Info should fail with a TemporalSet with no viable shards") + ld.TemporalSet.Shards = []LogShard{{WindowStart: fc.Now().Add(time.Hour), WindowEnd: fc.Now().Add(time.Hour * 2)}} + _, _, err = ld.Info(fc.Now()) + test.AssertError(t, err, "Info should fail with a TemporalSet with no viable shards") + + fc.Add(time.Hour * 4) + now := fc.Now() + ld.TemporalSet.Shards = []LogShard{ + { + WindowStart: now.Add(time.Hour * -4), + WindowEnd: now.Add(time.Hour * -2), + URI: "a", + Key: "a", + }, + { + WindowStart: now.Add(time.Hour * -2), + WindowEnd: now.Add(time.Hour * 2), + URI: "b", + Key: "b", + }, + { + WindowStart: now.Add(time.Hour * 2), + WindowEnd: now.Add(time.Hour * 4), + URI: "c", + Key: "c", + }, + } + uri, key, err = ld.Info(now) + test.AssertNotError(t, err, "Info failed") + test.AssertEquals(t, uri, "b") + test.AssertEquals(t, key, "b") +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy.go new file mode 100644 index 000000000..de713f1e4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy.go @@ -0,0 +1,243 @@ +package ctpolicy + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + pubpb "github.com/letsencrypt/boulder/publisher/proto" +) + +const ( + succeeded = "succeeded" + failed = "failed" +) + +// CTPolicy is used to hold information about SCTs required from various +// groupings +type CTPolicy struct { + pub pubpb.PublisherClient + sctLogs loglist.List + infoLogs loglist.List + finalLogs loglist.List + stagger time.Duration + log blog.Logger + winnerCounter *prometheus.CounterVec + operatorGroupsGauge *prometheus.GaugeVec + shardExpiryGauge *prometheus.GaugeVec +} + +// New creates a new CTPolicy struct +func New(pub pubpb.PublisherClient, sctLogs loglist.List, infoLogs loglist.List, finalLogs loglist.List, stagger time.Duration, log blog.Logger, stats prometheus.Registerer) *CTPolicy { + winnerCounter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "sct_winner", + Help: "Counter of logs which are selected for sct submission, by log URL and result (succeeded or failed).", + }, + []string{"url", "result"}, + ) + stats.MustRegister(winnerCounter) + + operatorGroupsGauge := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ct_operator_group_size_gauge", + Help: "Gauge for CT operators group size, by operator and log source (capable of providing SCT, informational logs, logs we submit final certs to).", + }, + []string{"operator", "source"}, + ) + stats.MustRegister(operatorGroupsGauge) + + shardExpiryGauge := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ct_shard_expiration_seconds", + Help: "CT shard end_exclusive field expressed as Unix epoch time, by operator and logID.", + }, + []string{"operator", "logID"}, + ) + stats.MustRegister(shardExpiryGauge) + + for op, group := range sctLogs { + operatorGroupsGauge.WithLabelValues(op, "sctLogs").Set(float64(len(group))) + + for _, log := range group { + if log.EndExclusive.IsZero() { + // Handles the case for non-temporally sharded logs too. + shardExpiryGauge.WithLabelValues(op, log.Name).Set(float64(0)) + } else { + shardExpiryGauge.WithLabelValues(op, log.Name).Set(float64(log.EndExclusive.Unix())) + } + } + } + + for op, group := range infoLogs { + operatorGroupsGauge.WithLabelValues(op, "infoLogs").Set(float64(len(group))) + } + + for op, group := range finalLogs { + operatorGroupsGauge.WithLabelValues(op, "finalLogs").Set(float64(len(group))) + } + + return &CTPolicy{ + pub: pub, + sctLogs: sctLogs, + infoLogs: infoLogs, + finalLogs: finalLogs, + stagger: stagger, + log: log, + winnerCounter: winnerCounter, + operatorGroupsGauge: operatorGroupsGauge, + shardExpiryGauge: shardExpiryGauge, + } +} + +type result struct { + sct []byte + url string + err error +} + +// GetSCTs retrieves exactly two SCTs from the total collection of configured +// log groups, with at most one SCT coming from each group. It expects that all +// logs run by a single operator (e.g. Google) are in the same group, to +// guarantee that SCTs from logs in different groups do not end up coming from +// the same operator. As such, it enforces Google's current CT Policy, which +// requires that certs have two SCTs from logs run by different operators. +func (ctp *CTPolicy) GetSCTs(ctx context.Context, cert core.CertDER, expiration time.Time) (core.SCTDERs, error) { + // We'll cancel this sub-context when we have the two SCTs we need, to cause + // any other ongoing submission attempts to quit. + subCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // This closure will be called in parallel once for each operator group. + getOne := func(i int, g string) ([]byte, string, error) { + // Sleep a little bit to stagger our requests to the later groups. Use `i-1` + // to compute the stagger duration so that the first two groups (indices 0 + // and 1) get negative or zero (i.e. instant) sleep durations. If the + // context gets cancelled (most likely because two logs from other operator + // groups returned SCTs already) before the sleep is complete, quit instead. + select { + case <-subCtx.Done(): + return nil, "", subCtx.Err() + case <-time.After(time.Duration(i-1) * ctp.stagger): + } + + // Pick a random log from among those in the group. In practice, very few + // operator groups have more than one log, so this loses little flexibility. + url, key, err := ctp.sctLogs.PickOne(g, expiration) + if err != nil { + return nil, "", fmt.Errorf("unable to get log info: %w", err) + } + + sct, err := ctp.pub.SubmitToSingleCTWithResult(ctx, &pubpb.Request{ + LogURL: url, + LogPublicKey: key, + Der: cert, + Kind: pubpb.SubmissionType_sct, + }) + if err != nil { + return nil, url, fmt.Errorf("ct submission to %q (%q) failed: %w", g, url, err) + } + + return sct.Sct, url, nil + } + + // Ensure that this channel has a buffer equal to the number of goroutines + // we're kicking off, so that they're all guaranteed to be able to write to + // it and exit without blocking and leaking. + results := make(chan result, len(ctp.sctLogs)) + + // Kick off a collection of goroutines to try to submit the precert to each + // log operator group. Randomize the order of the groups so that we're not + // always trying to submit to the same two operators. + for i, group := range ctp.sctLogs.Permute() { + go func(i int, g string) { + sctDER, url, err := getOne(i, g) + results <- result{sct: sctDER, url: url, err: err} + }(i, group) + } + + go ctp.submitPrecertInformational(cert, expiration) + + // Finally, collect SCTs and/or errors from our results channel. We know that + // we will collect len(ctp.sctLogs) results from the channel because every + // goroutine is guaranteed to write one result to the channel. + scts := make(core.SCTDERs, 0) + errs := make([]string, 0) + for range len(ctp.sctLogs) { + res := <-results + if res.err != nil { + errs = append(errs, res.err.Error()) + if res.url != "" { + ctp.winnerCounter.WithLabelValues(res.url, failed).Inc() + } + continue + } + scts = append(scts, res.sct) + ctp.winnerCounter.WithLabelValues(res.url, succeeded).Inc() + if len(scts) >= 2 { + return scts, nil + } + } + + // If we made it to the end of that loop, that means we never got two SCTs + // to return. Error out instead. + if ctx.Err() != nil { + // We timed out (the calling function returned and canceled our context), + // thereby causing all of our getOne sub-goroutines to be cancelled. + return nil, berrors.MissingSCTsError("failed to get 2 SCTs before ctx finished: %s", ctx.Err()) + } + return nil, berrors.MissingSCTsError("failed to get 2 SCTs, got %d error(s): %s", len(errs), strings.Join(errs, "; ")) +} + +// submitAllBestEffort submits the given certificate or precertificate to every +// log ("informational" for precerts, "final" for certs) configured in the policy. +// It neither waits for these submission to complete, nor tracks their success. +func (ctp *CTPolicy) submitAllBestEffort(blob core.CertDER, kind pubpb.SubmissionType, expiry time.Time) { + logs := ctp.finalLogs + if kind == pubpb.SubmissionType_info { + logs = ctp.infoLogs + } + + for _, group := range logs { + for _, log := range group { + if log.StartInclusive.After(expiry) || log.EndExclusive.Equal(expiry) || log.EndExclusive.Before(expiry) { + continue + } + + go func(log loglist.Log) { + _, err := ctp.pub.SubmitToSingleCTWithResult( + context.Background(), + &pubpb.Request{ + LogURL: log.Url, + LogPublicKey: log.Key, + Der: blob, + Kind: kind, + }, + ) + if err != nil { + ctp.log.Warningf("ct submission of cert to log %q failed: %s", log.Url, err) + } + }(log) + } + } + +} + +// submitPrecertInformational submits precertificates to any configured +// "informational" logs, but does not care about success or returned SCTs. +func (ctp *CTPolicy) submitPrecertInformational(cert core.CertDER, expiration time.Time) { + ctp.submitAllBestEffort(cert, pubpb.SubmissionType_info, expiration) +} + +// SubmitFinalCert submits finalized certificates created from precertificates +// to any configured "final" logs, but does not care about success. +func (ctp *CTPolicy) SubmitFinalCert(cert core.CertDER, expiration time.Time) { + ctp.submitAllBestEffort(cert, pubpb.SubmissionType_final, expiration) +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy_test.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy_test.go new file mode 100644 index 000000000..b7619761a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/ctpolicy_test.go @@ -0,0 +1,262 @@ +package ctpolicy + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + pubpb "github.com/letsencrypt/boulder/publisher/proto" + "github.com/letsencrypt/boulder/test" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" +) + +type mockPub struct{} + +func (mp *mockPub) SubmitToSingleCTWithResult(_ context.Context, _ *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + return &pubpb.Result{Sct: []byte{0}}, nil +} + +type mockFailPub struct{} + +func (mp *mockFailPub) SubmitToSingleCTWithResult(_ context.Context, _ *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + return nil, errors.New("BAD") +} + +type mockSlowPub struct{} + +func (mp *mockSlowPub) SubmitToSingleCTWithResult(ctx context.Context, _ *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + <-ctx.Done() + return nil, errors.New("timed out") +} + +func TestGetSCTs(t *testing.T) { + expired, cancel := context.WithDeadline(context.Background(), time.Now()) + defer cancel() + missingSCTErr := berrors.MissingSCTs + testCases := []struct { + name string + mock pubpb.PublisherClient + groups loglist.List + ctx context.Context + result core.SCTDERs + expectErr string + berrorType *berrors.ErrorType + }{ + { + name: "basic success case", + mock: &mockPub{}, + groups: loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + "LogA2": {Url: "UrlA2", Key: "KeyA2"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, + ctx: context.Background(), + result: core.SCTDERs{[]byte{0}, []byte{0}}, + }, + { + name: "basic failure case", + mock: &mockFailPub{}, + groups: loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + "LogA2": {Url: "UrlA2", Key: "KeyA2"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, + ctx: context.Background(), + expectErr: "failed to get 2 SCTs, got 3 error(s)", + berrorType: &missingSCTErr, + }, + { + name: "parent context timeout failure case", + mock: &mockSlowPub{}, + groups: loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + "LogA2": {Url: "UrlA2", Key: "KeyA2"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, + ctx: expired, + expectErr: "failed to get 2 SCTs before ctx finished", + berrorType: &missingSCTErr, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctp := New(tc.mock, tc.groups, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + ret, err := ctp.GetSCTs(tc.ctx, []byte{0}, time.Time{}) + if tc.result != nil { + test.AssertDeepEquals(t, ret, tc.result) + } else if tc.expectErr != "" { + if !strings.Contains(err.Error(), tc.expectErr) { + t.Errorf("Error %q did not match expected %q", err, tc.expectErr) + } + if tc.berrorType != nil { + test.AssertErrorIs(t, err, *tc.berrorType) + } + } + }) + } +} + +type mockFailOnePub struct { + badURL string +} + +func (mp *mockFailOnePub) SubmitToSingleCTWithResult(_ context.Context, req *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + if req.LogURL == mp.badURL { + return nil, errors.New("BAD") + } + return &pubpb.Result{Sct: []byte{0}}, nil +} + +func TestGetSCTsMetrics(t *testing.T) { + ctp := New(&mockFailOnePub{badURL: "UrlA1"}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + _, err := ctp.GetSCTs(context.Background(), []byte{0}, time.Time{}) + test.AssertNotError(t, err, "GetSCTs failed") + test.AssertMetricWithLabelsEquals(t, ctp.winnerCounter, prometheus.Labels{"url": "UrlB1", "result": succeeded}, 1) + test.AssertMetricWithLabelsEquals(t, ctp.winnerCounter, prometheus.Labels{"url": "UrlC1", "result": succeeded}, 1) +} + +func TestGetSCTsFailMetrics(t *testing.T) { + // Ensure the proper metrics are incremented when GetSCTs fails. + ctp := New(&mockFailOnePub{badURL: "UrlA1"}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + _, err := ctp.GetSCTs(context.Background(), []byte{0}, time.Time{}) + test.AssertError(t, err, "GetSCTs should have failed") + test.AssertErrorIs(t, err, berrors.MissingSCTs) + test.AssertMetricWithLabelsEquals(t, ctp.winnerCounter, prometheus.Labels{"url": "UrlA1", "result": failed}, 1) + + // Ensure the proper metrics are incremented when GetSCTs times out. + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + ctp = New(&mockSlowPub{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + _, err = ctp.GetSCTs(ctx, []byte{0}, time.Time{}) + test.AssertError(t, err, "GetSCTs should have timed out") + test.AssertErrorIs(t, err, berrors.MissingSCTs) + test.AssertContains(t, err.Error(), context.DeadlineExceeded.Error()) + test.AssertMetricWithLabelsEquals(t, ctp.winnerCounter, prometheus.Labels{"url": "UrlA1", "result": failed}, 1) +} + +func TestLogListMetrics(t *testing.T) { + // Multiple operator groups with configured logs. + ctp := New(&mockPub{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + "LogA2": {Url: "UrlA2", Key: "KeyA2"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperA", "source": "sctLogs"}, 2) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperB", "source": "sctLogs"}, 1) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperC", "source": "sctLogs"}, 1) + + // Multiple operator groups, no configured logs in one group + ctp = New(&mockPub{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + "LogA2": {Url: "UrlA2", Key: "KeyA2"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + "OperC": {}, + }, nil, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + "OperB": {}, + "OperC": { + "LogC1": {Url: "UrlC1", Key: "KeyC1"}, + }, + }, 0, blog.NewMock(), metrics.NoopRegisterer) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperA", "source": "sctLogs"}, 2) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperB", "source": "sctLogs"}, 1) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperC", "source": "sctLogs"}, 0) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperA", "source": "finalLogs"}, 1) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperB", "source": "finalLogs"}, 0) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperC", "source": "finalLogs"}, 1) + + // Multiple operator groups with no configured logs. + ctp = New(&mockPub{}, loglist.List{ + "OperA": {}, + "OperB": {}, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperA", "source": "sctLogs"}, 0) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperB", "source": "sctLogs"}, 0) + + // Single operator group with no configured logs. + ctp = New(&mockPub{}, loglist.List{ + "OperA": {}, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + test.AssertMetricWithLabelsEquals(t, ctp.operatorGroupsGauge, prometheus.Labels{"operator": "OperA", "source": "allLogs"}, 0) + + fc := clock.NewFake() + Tomorrow := fc.Now().Add(24 * time.Hour) + NextWeek := fc.Now().Add(7 * 24 * time.Hour) + + // Multiple operator groups with configured logs. + ctp = New(&mockPub{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1", Name: "LogA1", EndExclusive: Tomorrow}, + "LogA2": {Url: "UrlA2", Key: "KeyA2", Name: "LogA2", EndExclusive: NextWeek}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1", Name: "LogB1", EndExclusive: Tomorrow}, + }, + }, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer) + test.AssertMetricWithLabelsEquals(t, ctp.shardExpiryGauge, prometheus.Labels{"operator": "OperA", "logID": "LogA1"}, 86400) + test.AssertMetricWithLabelsEquals(t, ctp.shardExpiryGauge, prometheus.Labels{"operator": "OperA", "logID": "LogA2"}, 604800) + test.AssertMetricWithLabelsEquals(t, ctp.shardExpiryGauge, prometheus.Labels{"operator": "OperB", "logID": "LogB1"}, 86400) +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/lintlist.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/lintlist.go new file mode 100644 index 000000000..f9ee04940 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/lintlist.go @@ -0,0 +1,42 @@ +package loglist + +import "sync" + +var lintlist struct { + sync.Once + list List + err error +} + +// InitLintList creates and stores a loglist intended for linting (i.e. with +// purpose Validation). We have to store this in a global because the zlint +// framework doesn't (yet) support configuration, so the e_scts_from_same_operator +// lint cannot load a log list on its own. Instead, we have the CA call this +// initialization function at startup, and have the lint call the getter below +// to get access to the cached list. +func InitLintList(path string) error { + lintlist.Do(func() { + l, err := New(path) + if err != nil { + lintlist.err = err + return + } + + l, err = l.forPurpose(Validation) + if err != nil { + lintlist.err = err + return + } + + lintlist.list = l + }) + + return lintlist.err +} + +// GetLintList returns the log list initialized by InitLintList. This must +// only be called after InitLintList has been called on the same (or parent) +// goroutine. +func GetLintList() List { + return lintlist.list +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist.go new file mode 100644 index 000000000..8722b65c8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist.go @@ -0,0 +1,319 @@ +package loglist + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "math/rand" + "os" + "strings" + "time" + + "github.com/letsencrypt/boulder/ctpolicy/loglist/schema" +) + +// purpose is the use to which a log list will be put. This type exists to allow +// the following consts to be declared for use by LogList consumers. +type purpose string + +// Issuance means that the new log list should only contain Usable logs, which +// can issue SCTs that will be trusted by all Chrome clients. +const Issuance purpose = "scts" + +// Informational means that the new log list can contain Usable, Qualified, and +// Pending logs, which will all accept submissions but not necessarily be +// trusted by Chrome clients. +const Informational purpose = "info" + +// Validation means that the new log list should only contain Usable and +// Readonly logs, whose SCTs will be trusted by all Chrome clients but aren't +// necessarily still issuing SCTs today. +const Validation purpose = "lint" + +// List represents a list of logs, grouped by their operator, arranged by +// the "v3" schema as published by Chrome: +// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json +// It exports no fields so that consumers don't have to deal with the terrible +// autogenerated names of the structs it wraps. +type List map[string]OperatorGroup + +// OperatorGroup represents a group of logs which are all run by the same +// operator organization. It provides constant-time lookup of logs within the +// group by their unique ID. +type OperatorGroup map[string]Log + +// Log represents a single log run by an operator. It contains just the info +// necessary to contact a log, and to determine whether that log will accept +// the submission of a certificate with a given expiration. +type Log struct { + Name string + Url string + Key string + StartInclusive time.Time + EndExclusive time.Time + State state +} + +// State is an enum representing the various states a CT log can be in. Only +// pending, qualified, and usable logs can be submitted to. Only usable and +// readonly logs are trusted by Chrome. +type state int + +const ( + unknown state = iota + pending + qualified + usable + readonly + retired + rejected +) + +func stateFromState(s *schema.LogListSchemaJsonOperatorsElemLogsElemState) state { + if s == nil { + return unknown + } else if s.Rejected != nil { + return rejected + } else if s.Retired != nil { + return retired + } else if s.Readonly != nil { + return readonly + } else if s.Pending != nil { + return pending + } else if s.Qualified != nil { + return qualified + } else if s.Usable != nil { + return usable + } + return unknown +} + +// usableForPurpose returns true if the log state is acceptable for the given +// log list purpose, and false otherwise. +func usableForPurpose(s state, p purpose) bool { + switch p { + case Issuance: + return s == usable + case Informational: + return s == usable || s == qualified || s == pending + case Validation: + return s == usable || s == readonly + } + return false +} + +// New returns a LogList of all operators and all logs parsed from the file at +// the given path. The file must conform to the JSON Schema published by Google: +// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json +func New(path string) (List, error) { + file, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read CT Log List: %w", err) + } + + return newHelper(file) +} + +// newHelper is a helper to allow the core logic of `New()` to be unit tested +// without having to write files to disk. +func newHelper(file []byte) (List, error) { + var parsed schema.LogListSchemaJson + err := json.Unmarshal(file, &parsed) + if err != nil { + return nil, fmt.Errorf("failed to parse CT Log List: %w", err) + } + + result := make(List) + for _, op := range parsed.Operators { + group := make(OperatorGroup) + for _, log := range op.Logs { + var name string + if log.Description != nil { + name = *log.Description + } + + info := Log{ + Name: name, + Url: log.Url, + Key: log.Key, + State: stateFromState(log.State), + } + + if log.TemporalInterval != nil { + startInclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.StartInclusive) + if err != nil { + return nil, fmt.Errorf("failed to parse log %q start timestamp: %w", log.Url, err) + } + + endExclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.EndExclusive) + if err != nil { + return nil, fmt.Errorf("failed to parse log %q end timestamp: %w", log.Url, err) + } + + info.StartInclusive = startInclusive + info.EndExclusive = endExclusive + } + + group[log.LogId] = info + } + result[op.Name] = group + } + + return result, nil +} + +// SubsetForPurpose returns a new log list containing only those logs whose +// names match those in the given list, and whose state is acceptable for the +// given purpose. It returns an error if any of the given names are not found +// in the starting list, or if the resulting list is too small to satisfy the +// Chrome "two operators" policy. +func (ll List) SubsetForPurpose(names []string, p purpose) (List, error) { + sub, err := ll.subset(names) + if err != nil { + return nil, err + } + + res, err := sub.forPurpose(p) + if err != nil { + return nil, err + } + + return res, nil +} + +// subset returns a new log list containing only those logs whose names match +// those in the given list. It returns an error if any of the given names are +// not found. +func (ll List) subset(names []string) (List, error) { + remaining := make(map[string]struct{}, len(names)) + for _, name := range names { + remaining[name] = struct{}{} + } + + newList := make(List) + for operator, group := range ll { + newGroup := make(OperatorGroup) + for id, log := range group { + if _, found := remaining[log.Name]; !found { + continue + } + + newLog := Log{ + Name: log.Name, + Url: log.Url, + Key: log.Key, + State: log.State, + StartInclusive: log.StartInclusive, + EndExclusive: log.EndExclusive, + } + + newGroup[id] = newLog + delete(remaining, newLog.Name) + } + if len(newGroup) > 0 { + newList[operator] = newGroup + } + } + + if len(remaining) > 0 { + missed := make([]string, len(remaining)) + for name := range remaining { + missed = append(missed, fmt.Sprintf("%q", name)) + } + return nil, fmt.Errorf("failed to find logs matching name(s): %s", strings.Join(missed, ", ")) + } + + return newList, nil +} + +// forPurpose returns a new log list containing only those logs whose states are +// acceptable for the given purpose. It returns an error if the purpose is +// Issuance or Validation and the set of remaining logs is too small to satisfy +// the Google "two operators" log policy. +func (ll List) forPurpose(p purpose) (List, error) { + newList := make(List) + for operator, group := range ll { + newGroup := make(OperatorGroup) + for id, log := range group { + if !usableForPurpose(log.State, p) { + continue + } + + newLog := Log{ + Name: log.Name, + Url: log.Url, + Key: log.Key, + State: log.State, + StartInclusive: log.StartInclusive, + EndExclusive: log.EndExclusive, + } + + newGroup[id] = newLog + } + if len(newGroup) > 0 { + newList[operator] = newGroup + } + } + + if len(newList) < 2 && p != Informational { + return nil, errors.New("log list does not have enough groups to satisfy Chrome policy") + } + + return newList, nil +} + +// OperatorForLogID returns the Name of the Group containing the Log with the +// given ID, or an error if no such log/group can be found. +func (ll List) OperatorForLogID(logID string) (string, error) { + for op, group := range ll { + if _, found := group[logID]; found { + return op, nil + } + } + return "", fmt.Errorf("no log with ID %q found", logID) +} + +// Permute returns the list of operator group names in a randomized order. +func (ll List) Permute() []string { + keys := make([]string, 0, len(ll)) + for k := range ll { + keys = append(keys, k) + } + + result := make([]string, len(ll)) + for i, j := range rand.Perm(len(ll)) { + result[i] = keys[j] + } + return result +} + +// PickOne returns the URI and Public Key of a single randomly-selected log +// which is run by the given operator and whose temporal interval includes the +// given expiry time. It returns an error if no such log can be found. +func (ll List) PickOne(operator string, expiry time.Time) (string, string, error) { + group, ok := ll[operator] + if !ok { + return "", "", fmt.Errorf("no log operator group named %q", operator) + } + + candidates := make([]Log, 0) + for _, log := range group { + if log.StartInclusive.IsZero() || log.EndExclusive.IsZero() { + candidates = append(candidates, log) + continue + } + + if (log.StartInclusive.Equal(expiry) || log.StartInclusive.Before(expiry)) && log.EndExclusive.After(expiry) { + candidates = append(candidates, log) + } + } + + // Ensure rand.Intn below won't panic. + if len(candidates) < 1 { + return "", "", fmt.Errorf("no log found for group %q and expiry %s", operator, expiry) + } + + log := candidates[rand.Intn(len(candidates))] + return log.Url, log.Key, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist_test.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist_test.go new file mode 100644 index 000000000..5646809d5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist_test.go @@ -0,0 +1,208 @@ +package loglist + +import ( + "testing" + "time" + + "github.com/letsencrypt/boulder/test" +) + +func TestNew(t *testing.T) { + +} + +func TestSubset(t *testing.T) { + input := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1"}, + "ID A2": Log{Name: "Log A2"}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1"}, + "ID B2": Log{Name: "Log B2"}, + }, + "Operator C": { + "ID C1": Log{Name: "Log C1"}, + "ID C2": Log{Name: "Log C2"}, + }, + } + + actual, err := input.subset(nil) + test.AssertNotError(t, err, "nil names should not error") + test.AssertEquals(t, len(actual), 0) + + actual, err = input.subset([]string{}) + test.AssertNotError(t, err, "empty names should not error") + test.AssertEquals(t, len(actual), 0) + + actual, err = input.subset([]string{"Other Log"}) + test.AssertError(t, err, "wrong name should result in error") + test.AssertEquals(t, len(actual), 0) + + expected := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1"}, + "ID A2": Log{Name: "Log A2"}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1"}, + }, + } + actual, err = input.subset([]string{"Log B1", "Log A1", "Log A2"}) + test.AssertNotError(t, err, "normal usage should not error") + test.AssertDeepEquals(t, actual, expected) +} + +func TestForPurpose(t *testing.T) { + input := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + "ID A2": Log{Name: "Log A2", State: rejected}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: usable}, + "ID B2": Log{Name: "Log B2", State: retired}, + }, + "Operator C": { + "ID C1": Log{Name: "Log C1", State: pending}, + "ID C2": Log{Name: "Log C2", State: readonly}, + }, + } + expected := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: usable}, + }, + } + actual, err := input.forPurpose(Issuance) + test.AssertNotError(t, err, "should have two acceptable logs") + test.AssertDeepEquals(t, actual, expected) + + input = List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + "ID A2": Log{Name: "Log A2", State: rejected}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: qualified}, + "ID B2": Log{Name: "Log B2", State: retired}, + }, + "Operator C": { + "ID C1": Log{Name: "Log C1", State: pending}, + "ID C2": Log{Name: "Log C2", State: readonly}, + }, + } + _, err = input.forPurpose(Issuance) + test.AssertError(t, err, "should only have one acceptable log") + + expected = List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + }, + "Operator C": { + "ID C2": Log{Name: "Log C2", State: readonly}, + }, + } + actual, err = input.forPurpose(Validation) + test.AssertNotError(t, err, "should have two acceptable logs") + test.AssertDeepEquals(t, actual, expected) + + expected = List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: qualified}, + }, + "Operator C": { + "ID C1": Log{Name: "Log C1", State: pending}, + }, + } + actual, err = input.forPurpose(Informational) + test.AssertNotError(t, err, "should have three acceptable logs") + test.AssertDeepEquals(t, actual, expected) +} + +func TestOperatorForLogID(t *testing.T) { + input := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: qualified}, + }, + } + + actual, err := input.OperatorForLogID("ID B1") + test.AssertNotError(t, err, "should have found log") + test.AssertEquals(t, actual, "Operator B") + + _, err = input.OperatorForLogID("Other ID") + test.AssertError(t, err, "should not have found log") +} + +func TestPermute(t *testing.T) { + input := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", State: usable}, + "ID A2": Log{Name: "Log A2", State: rejected}, + }, + "Operator B": { + "ID B1": Log{Name: "Log B1", State: qualified}, + "ID B2": Log{Name: "Log B2", State: retired}, + }, + "Operator C": { + "ID C1": Log{Name: "Log C1", State: pending}, + "ID C2": Log{Name: "Log C2", State: readonly}, + }, + } + + actual := input.Permute() + test.AssertEquals(t, len(actual), 3) + test.AssertSliceContains(t, actual, "Operator A") + test.AssertSliceContains(t, actual, "Operator B") + test.AssertSliceContains(t, actual, "Operator C") +} + +func TestPickOne(t *testing.T) { + date0 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + date1 := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + date2 := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + + input := List{ + "Operator A": { + "ID A1": Log{Name: "Log A1"}, + }, + } + _, _, err := input.PickOne("Operator B", date0) + test.AssertError(t, err, "should have failed to find operator") + + input = List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", StartInclusive: date0, EndExclusive: date1}, + }, + } + _, _, err = input.PickOne("Operator A", date2) + test.AssertError(t, err, "should have failed to find log") + _, _, err = input.PickOne("Operator A", date1) + test.AssertError(t, err, "should have failed to find log") + _, _, err = input.PickOne("Operator A", date0) + test.AssertNotError(t, err, "should have found a log") + _, _, err = input.PickOne("Operator A", date0.Add(time.Hour)) + test.AssertNotError(t, err, "should have found a log") + + input = List{ + "Operator A": { + "ID A1": Log{Name: "Log A1", StartInclusive: date0, EndExclusive: date1, Key: "KA1", Url: "UA1"}, + "ID A2": Log{Name: "Log A2", StartInclusive: date1, EndExclusive: date2, Key: "KA2", Url: "UA2"}, + "ID B1": Log{Name: "Log B1", StartInclusive: date0, EndExclusive: date1, Key: "KB1", Url: "UB1"}, + "ID B2": Log{Name: "Log B2", StartInclusive: date1, EndExclusive: date2, Key: "KB2", Url: "UB2"}, + }, + } + url, key, err := input.PickOne("Operator A", date0.Add(time.Hour)) + test.AssertNotError(t, err, "should have found a log") + test.AssertSliceContains(t, []string{"UA1", "UB1"}, url) + test.AssertSliceContains(t, []string{"KA1", "KB1"}, key) +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/log_list_schema.json b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/log_list_schema.json new file mode 100644 index 000000000..e0dac92df --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/log_list_schema.json @@ -0,0 +1,280 @@ +{ + "type": "object", + "id": "https://www.gstatic.com/ct/log_list/v3/log_list_schema.json", + "$schema": "http://json-schema.org/draft-07/schema", + "required": [ + "operators" + ], + "definitions": { + "state": { + "type": "object", + "properties": { + "timestamp": { + "description": "The time at which the log entered this state.", + "type": "string", + "format": "date-time", + "examples": [ + "2018-01-01T00:00:00Z" + ] + } + }, + "required": [ + "timestamp" + ] + } + }, + "properties": { + "version": { + "type": "string", + "title": "Version of this log list", + "description": "The version will change whenever a change is made to any part of this log list.", + "examples": [ + "1", + "1.0.0", + "1.0.0b" + ] + }, + "log_list_timestamp": { + "description": "The time at which this version of the log list was published.", + "type": "string", + "format": "date-time", + "examples": [ + "2018-01-01T00:00:00Z" + ] + }, + "operators": { + "title": "CT log operators", + "description": "People/organizations that run Certificate Transparency logs.", + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "email", + "logs" + ], + "properties": { + "name": { + "title": "Name of this log operator", + "type": "string" + }, + "email": { + "title": "CT log operator email addresses", + "description": "The log operator can be contacted using any of these email addresses.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "format": "email" + } + }, + "logs": { + "description": "Details of Certificate Transparency logs run by this operator.", + "type": "array", + "items": { + "type": "object", + "required": [ + "key", + "log_id", + "mmd", + "url" + ], + "properties": { + "description": { + "title": "Description of the CT log", + "description": "A human-readable description that can be used to identify this log.", + "type": "string" + }, + "key": { + "title": "The public key of the CT log", + "description": "The log's public key as a DER-encoded ASN.1 SubjectPublicKeyInfo structure, then encoded as base64 (https://tools.ietf.org/html/rfc5280#section-4.1.2.7).", + "type": "string" + }, + "log_id": { + "title": "The SHA-256 hash of the CT log's public key, base64-encoded", + "description": "This is the LogID found in SCTs issued by this log (https://tools.ietf.org/html/rfc6962#section-3.2).", + "type": "string", + "minLength": 44, + "maxLength": 44 + }, + "mmd": { + "title": "The Maximum Merge Delay, in seconds", + "description": "The CT log should not take longer than this to incorporate a certificate (https://tools.ietf.org/html/rfc6962#section-3).", + "type": "number", + "minimum": 1, + "default": 86400 + }, + "url": { + "title": "The base URL of the CT log's HTTP API", + "description": "The API endpoints are defined in https://tools.ietf.org/html/rfc6962#section-4.", + "type": "string", + "format": "uri", + "examples": [ + "https://ct.googleapis.com/pilot/" + ] + }, + "dns": { + "title": "The domain name of the CT log's DNS API", + "description": "The API endpoints are defined in https://github.com/google/certificate-transparency-rfcs/blob/master/dns/draft-ct-over-dns.md.", + "type": "string", + "format": "hostname", + "examples": [ + "pilot.ct.googleapis.com" + ] + }, + "temporal_interval": { + "description": "The log will only accept certificates that expire (have a NotAfter date) between these dates.", + "type": "object", + "required": [ + "start_inclusive", + "end_exclusive" + ], + "properties": { + "start_inclusive": { + "description": "All certificates must expire on this date or later.", + "type": "string", + "format": "date-time", + "examples": [ + "2018-01-01T00:00:00Z" + ] + }, + "end_exclusive": { + "description": "All certificates must expire before this date.", + "type": "string", + "format": "date-time", + "examples": [ + "2019-01-01T00:00:00Z" + ] + } + } + }, + "log_type": { + "description": "The purpose of this log, e.g. test.", + "type": "string", + "enum": [ + "prod", + "test" + ] + }, + "state": { + "title": "The state of the log from the log list distributor's perspective.", + "type": "object", + "properties": { + "pending": { + "$ref": "#/definitions/state" + }, + "qualified": { + "$ref": "#/definitions/state" + }, + "usable": { + "$ref": "#/definitions/state" + }, + "readonly": { + "allOf": [ + { + "$ref": "#/definitions/state" + }, + { + "required": [ + "final_tree_head" + ], + "properties": { + "final_tree_head": { + "description": "The tree head (tree size and root hash) at which the log was made read-only.", + "type": "object", + "required": [ + "tree_size", + "sha256_root_hash" + ], + "properties": { + "tree_size": { + "type": "number", + "minimum": 0 + }, + "sha256_root_hash": { + "type": "string", + "minLength": 44, + "maxLength": 44 + } + } + } + } + } + ] + }, + "retired": { + "$ref": "#/definitions/state" + }, + "rejected": { + "$ref": "#/definitions/state" + } + }, + "oneOf": [ + { + "required": [ + "pending" + ] + }, + { + "required": [ + "qualified" + ] + }, + { + "required": [ + "usable" + ] + }, + { + "required": [ + "readonly" + ] + }, + { + "required": [ + "retired" + ] + }, + { + "required": [ + "rejected" + ] + } + ] + }, + "previous_operators": { + "title": "Previous operators that ran this log in the past, if any.", + "description": "If the log has changed operators, this will contain a list of the previous operators, along with the timestamp when they stopped operating the log.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": [ + "name", + "end_time" + ], + "properties": { + "name": { + "title": "Name of the log operator", + "type": "string" + }, + "end_time": { + "description": "The time at which this operator stopped operating this log.", + "type": "string", + "format": "date-time", + "examples": [ + "2018-01-01T00:00:00Z" + ] + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/schema.go b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/schema.go new file mode 100644 index 000000000..79a1957b0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/schema.go @@ -0,0 +1,269 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package schema + +import "fmt" +import "encoding/json" +import "reflect" + +type LogListSchemaJson struct { + // The time at which this version of the log list was published. + LogListTimestamp *string `json:"log_list_timestamp,omitempty"` + + // People/organizations that run Certificate Transparency logs. + Operators []LogListSchemaJsonOperatorsElem `json:"operators"` + + // The version will change whenever a change is made to any part of this log list. + Version *string `json:"version,omitempty"` +} + +type LogListSchemaJsonOperatorsElem struct { + // The log operator can be contacted using any of these email addresses. + Email []string `json:"email"` + + // Details of Certificate Transparency logs run by this operator. + Logs []LogListSchemaJsonOperatorsElemLogsElem `json:"logs"` + + // Name corresponds to the JSON schema field "name". + Name string `json:"name"` +} + +type LogListSchemaJsonOperatorsElemLogsElem struct { + // A human-readable description that can be used to identify this log. + Description *string `json:"description,omitempty"` + + // The API endpoints are defined in + // https://github.com/google/certificate-transparency-rfcs/blob/master/dns/draft-ct-over-dns.md. + Dns *string `json:"dns,omitempty"` + + // The log's public key as a DER-encoded ASN.1 SubjectPublicKeyInfo structure, + // then encoded as base64 (https://tools.ietf.org/html/rfc5280#section-4.1.2.7). + Key string `json:"key"` + + // This is the LogID found in SCTs issued by this log + // (https://tools.ietf.org/html/rfc6962#section-3.2). + LogId string `json:"log_id"` + + // The purpose of this log, e.g. test. + LogType *LogListSchemaJsonOperatorsElemLogsElemLogType `json:"log_type,omitempty"` + + // The CT log should not take longer than this to incorporate a certificate + // (https://tools.ietf.org/html/rfc6962#section-3). + Mmd float64 `json:"mmd"` + + // If the log has changed operators, this will contain a list of the previous + // operators, along with the timestamp when they stopped operating the log. + PreviousOperators []LogListSchemaJsonOperatorsElemLogsElemPreviousOperatorsElem `json:"previous_operators,omitempty"` + + // State corresponds to the JSON schema field "state". + State *LogListSchemaJsonOperatorsElemLogsElemState `json:"state,omitempty"` + + // The log will only accept certificates that expire (have a NotAfter date) + // between these dates. + TemporalInterval *LogListSchemaJsonOperatorsElemLogsElemTemporalInterval `json:"temporal_interval,omitempty"` + + // The API endpoints are defined in https://tools.ietf.org/html/rfc6962#section-4. + Url string `json:"url"` +} + +type LogListSchemaJsonOperatorsElemLogsElemLogType string + +const LogListSchemaJsonOperatorsElemLogsElemLogTypeProd LogListSchemaJsonOperatorsElemLogsElemLogType = "prod" +const LogListSchemaJsonOperatorsElemLogsElemLogTypeTest LogListSchemaJsonOperatorsElemLogsElemLogType = "test" + +type LogListSchemaJsonOperatorsElemLogsElemPreviousOperatorsElem struct { + // The time at which this operator stopped operating this log. + EndTime string `json:"end_time"` + + // Name corresponds to the JSON schema field "name". + Name string `json:"name"` +} + +type LogListSchemaJsonOperatorsElemLogsElemState struct { + // Pending corresponds to the JSON schema field "pending". + Pending *State `json:"pending,omitempty"` + + // Qualified corresponds to the JSON schema field "qualified". + Qualified *State `json:"qualified,omitempty"` + + // Readonly corresponds to the JSON schema field "readonly". + Readonly interface{} `json:"readonly,omitempty"` + + // Rejected corresponds to the JSON schema field "rejected". + Rejected *State `json:"rejected,omitempty"` + + // Retired corresponds to the JSON schema field "retired". + Retired *State `json:"retired,omitempty"` + + // Usable corresponds to the JSON schema field "usable". + Usable *State `json:"usable,omitempty"` +} + +// The log will only accept certificates that expire (have a NotAfter date) between +// these dates. +type LogListSchemaJsonOperatorsElemLogsElemTemporalInterval struct { + // All certificates must expire before this date. + EndExclusive string `json:"end_exclusive"` + + // All certificates must expire on this date or later. + StartInclusive string `json:"start_inclusive"` +} + +type State struct { + // The time at which the log entered this state. + Timestamp string `json:"timestamp"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJsonOperatorsElemLogsElemPreviousOperatorsElem) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["end_time"]; !ok || v == nil { + return fmt.Errorf("field end_time: required") + } + if v, ok := raw["name"]; !ok || v == nil { + return fmt.Errorf("field name: required") + } + type Plain LogListSchemaJsonOperatorsElemLogsElemPreviousOperatorsElem + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = LogListSchemaJsonOperatorsElemLogsElemPreviousOperatorsElem(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJsonOperatorsElemLogsElemTemporalInterval) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["end_exclusive"]; !ok || v == nil { + return fmt.Errorf("field end_exclusive: required") + } + if v, ok := raw["start_inclusive"]; !ok || v == nil { + return fmt.Errorf("field start_inclusive: required") + } + type Plain LogListSchemaJsonOperatorsElemLogsElemTemporalInterval + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = LogListSchemaJsonOperatorsElemLogsElemTemporalInterval(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJsonOperatorsElemLogsElemLogType) UnmarshalJSON(b []byte) error { + var v string + if err := json.Unmarshal(b, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_LogListSchemaJsonOperatorsElemLogsElemLogType { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_LogListSchemaJsonOperatorsElemLogsElemLogType, v) + } + *j = LogListSchemaJsonOperatorsElemLogsElemLogType(v) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJsonOperatorsElemLogsElem) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["key"]; !ok || v == nil { + return fmt.Errorf("field key: required") + } + if v, ok := raw["log_id"]; !ok || v == nil { + return fmt.Errorf("field log_id: required") + } + if v, ok := raw["url"]; !ok || v == nil { + return fmt.Errorf("field url: required") + } + type Plain LogListSchemaJsonOperatorsElemLogsElem + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + if v, ok := raw["mmd"]; !ok || v == nil { + plain.Mmd = 86400 + } + *j = LogListSchemaJsonOperatorsElemLogsElem(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *State) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["timestamp"]; !ok || v == nil { + return fmt.Errorf("field timestamp: required") + } + type Plain State + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = State(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJsonOperatorsElem) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["email"]; !ok || v == nil { + return fmt.Errorf("field email: required") + } + if v, ok := raw["logs"]; !ok || v == nil { + return fmt.Errorf("field logs: required") + } + if v, ok := raw["name"]; !ok || v == nil { + return fmt.Errorf("field name: required") + } + type Plain LogListSchemaJsonOperatorsElem + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = LogListSchemaJsonOperatorsElem(plain) + return nil +} + +var enumValues_LogListSchemaJsonOperatorsElemLogsElemLogType = []interface{}{ + "prod", + "test", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *LogListSchemaJson) UnmarshalJSON(b []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if v, ok := raw["operators"]; !ok || v == nil { + return fmt.Errorf("field operators: required") + } + type Plain LogListSchemaJson + var plain Plain + if err := json.Unmarshal(b, &plain); err != nil { + return err + } + *j = LogListSchemaJson(plain) + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/update.sh b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/update.sh new file mode 100644 index 000000000..b5a6c8c8d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ctpolicy/loglist/schema/update.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -e + +# This script updates the log list JSON Schema and the Go structs generated +# from that schema. + +# It is not intended to be run on a regular basis; we do not expect the JSON +# Schema to change. It is retained here for historical purposes, so that if/when +# the schema does change, or the ecosystem moves to a v4 version of the schema, +# regenerating these files will be quick and easy. + +# This script expects github.com/atombender/go-jsonschema to be installed: +if ! command -v gojsonschema +then + echo "Install gojsonschema, then re-run this script:" + echo "go install github.com/atombender/go-jsonschema/cmd/gojsonschema@latest" +fi + +this_dir=$(dirname $(readlink -f "${0}")) + +curl https://www.gstatic.com/ct/log_list/v3/log_list_schema.json >| "${this_dir}"/log_list_schema.json + +gojsonschema -p schema "${this_dir}"/log_list_schema.json >| "${this_dir}"/schema.go diff --git a/third-party/github.com/letsencrypt/boulder/data/production-email.template b/third-party/github.com/letsencrypt/boulder/data/production-email.template new file mode 100644 index 000000000..b3d3dc4a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/data/production-email.template @@ -0,0 +1,24 @@ +Hello, + +Your certificate (or certificates) for the names listed below will expire in +{{.DaysToExpiration}} days (on {{.ExpirationDate}}). Please make sure to renew +your certificate before then, or visitors to your website will encounter errors. + +{{.DNSNames}} + +For any questions or support, please visit https://community.letsencrypt.org/. +Unfortunately, we can't provide support by email. + +For details about when we send these emails, please visit +https://letsencrypt.org/docs/expiration-emails/. In particular, note +that this reminder email is still sent if you've obtained a slightly +different certificate by adding or removing names. If you've replaced +this certificate with a newer one that covers more or fewer names than +the list above, you may be able to ignore this message. + +If you want to stop receiving all email from this address, click +*|UNSUB:https://mandrillapp.com/unsub|* +(Warning: this is a one-click action that cannot be undone) + +Regards, +The Let's Encrypt Team diff --git a/third-party/github.com/letsencrypt/boulder/data/staging-email.template b/third-party/github.com/letsencrypt/boulder/data/staging-email.template new file mode 100644 index 000000000..f4fdf9be5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/data/staging-email.template @@ -0,0 +1,27 @@ +Hello, + +[ Note: This message is from the Let's Encrypt staging environment. It +likely is not relevant to any live web site. ] + +You issued a testing cert (not a live one) from Let's Encrypt staging +environment. This mail takes the place of what would normally be a renewal +reminder, but instead is demonstrating delivery of renewal notices. Have a nice +day! + +Details: +DNS Names: {{.DNSNames}} +Expiration Date: {{.ExpirationDate}}) +Days to Expiration: {{.DaysToExpiration}} + +For any questions or support, please visit https://community.letsencrypt.org/. +Unfortunately, we can't provide support by email. + +For details about when we send these emails, please visit +https://letsencrypt.org/docs/expiration-emails/. + +If you want to stop receiving all email from this address, click +*|UNSUB:https://mandrillapp.com/unsub|* +(Warning: this is a one-click action that cannot be undone) + +Regards, +The Let's Encrypt Team diff --git a/third-party/github.com/letsencrypt/boulder/db/gorm.go b/third-party/github.com/letsencrypt/boulder/db/gorm.go new file mode 100644 index 000000000..6dfe82ff8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/gorm.go @@ -0,0 +1,224 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "regexp" + "strings" +) + +// Characters allowed in an unquoted identifier by MariaDB. +// https://mariadb.com/kb/en/identifier-names/#unquoted +var mariaDBUnquotedIdentifierRE = regexp.MustCompile("^[0-9a-zA-Z$_]+$") + +func validMariaDBUnquotedIdentifier(s string) error { + if !mariaDBUnquotedIdentifierRE.MatchString(s) { + return fmt.Errorf("invalid MariaDB identifier %q", s) + } + + allNumeric := true + startsNumeric := false + for i, c := range []byte(s) { + if c < '0' || c > '9' { + if startsNumeric && len(s) > i && s[i] == 'e' { + return fmt.Errorf("MariaDB identifier looks like floating point: %q", s) + } + allNumeric = false + break + } + startsNumeric = true + } + if allNumeric { + return fmt.Errorf("MariaDB identifier contains only numerals: %q", s) + } + return nil +} + +// NewMappedSelector returns an object which can be used to automagically query +// the provided type-mapped database for rows of the parameterized type. +func NewMappedSelector[T any](executor MappedExecutor) (MappedSelector[T], error) { + var throwaway T + t := reflect.TypeOf(throwaway) + + // We use a very strict mapping of struct fields to table columns here: + // - The struct must not have any embedded structs, only named fields. + // - The struct field names must be case-insensitively identical to the + // column names (no struct tags necessary). + // - The struct field names must be case-insensitively unique. + // - Every field of the struct must correspond to a database column. + // - Note that the reverse is not true: it's perfectly okay for there to be + // database columns which do not correspond to fields in the struct; those + // columns will be ignored. + // TODO: In the future, when we replace borp's TableMap with our own, this + // check should be performed at the time the mapping is declared. + columns := make([]string, 0) + seen := make(map[string]struct{}) + for i := range t.NumField() { + field := t.Field(i) + if field.Anonymous { + return nil, fmt.Errorf("struct contains anonymous embedded struct %q", field.Name) + } + column := strings.ToLower(t.Field(i).Name) + err := validMariaDBUnquotedIdentifier(column) + if err != nil { + return nil, fmt.Errorf("struct field maps to unsafe db column name %q", column) + } + if _, found := seen[column]; found { + return nil, fmt.Errorf("struct fields map to duplicate column name %q", column) + } + seen[column] = struct{}{} + columns = append(columns, column) + } + + return &mappedSelector[T]{wrapped: executor, columns: columns}, nil +} + +type mappedSelector[T any] struct { + wrapped MappedExecutor + columns []string +} + +// QueryContext performs a SELECT on the appropriate table for T. It combines the best +// features of borp, the go stdlib, and generics, using the type parameter of +// the typeSelector object to automatically look up the proper table name and +// columns to select. It returns an iterable which yields fully-populated +// objects of the parameterized type directly. The given clauses MUST be only +// the bits of a sql query from "WHERE ..." onwards; if they contain any of the +// "SELECT ... FROM ..." portion of the query it will result in an error. The +// args take the same kinds of values as borp's SELECT: either one argument per +// positional placeholder, or a map of placeholder names to their arguments +// (see https://pkg.go.dev/github.com/letsencrypt/borp#readme-ad-hoc-sql). +// +// The caller is responsible for calling `Rows.Close()` when they are done with +// the query. The caller is also responsible for ensuring that the clauses +// argument does not contain any user-influenced input. +func (ts mappedSelector[T]) QueryContext(ctx context.Context, clauses string, args ...interface{}) (Rows[T], error) { + // Look up the table to use based on the type of this TypeSelector. + var throwaway T + tableMap, err := ts.wrapped.TableFor(reflect.TypeOf(throwaway), false) + if err != nil { + return nil, fmt.Errorf("database model type not mapped to table name: %w", err) + } + + return ts.QueryFrom(ctx, tableMap.TableName, clauses, args...) +} + +// QueryFrom is the same as Query, but it additionally takes a table name to +// select from, rather than automatically computing the table name from borp's +// DbMap. +// +// The caller is responsible for calling `Rows.Close()` when they are done with +// the query. The caller is also responsible for ensuring that the clauses +// argument does not contain any user-influenced input. +func (ts mappedSelector[T]) QueryFrom(ctx context.Context, tablename string, clauses string, args ...interface{}) (Rows[T], error) { + err := validMariaDBUnquotedIdentifier(tablename) + if err != nil { + return nil, err + } + + // Construct the query from the column names, table name, and given clauses. + // Note that the column names here are in the order given by + query := fmt.Sprintf( + "SELECT %s FROM %s %s", + strings.Join(ts.columns, ", "), + tablename, + clauses, + ) + + r, err := ts.wrapped.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("reading db: %w", err) + } + + return &rows[T]{wrapped: r, numCols: len(ts.columns)}, nil +} + +// rows is a wrapper around the stdlib's sql.rows, but with a more +// type-safe method to get actual row content. +type rows[T any] struct { + wrapped *sql.Rows + numCols int +} + +// ForEach calls the given function with each model object retrieved by +// repeatedly calling .Get(). It closes the rows object when it hits an error +// or finishes iterating over the rows, so it can only be called once. This is +// the intended way to use the result of QueryContext or QueryFrom; the other +// methods on this type are lower-level and intended for advanced use only. +func (r rows[T]) ForEach(do func(*T) error) (err error) { + defer func() { + // Close the row reader when we exit. Use the named error return to combine + // any error from normal execution with any error from closing. + closeErr := r.Close() + if closeErr != nil && err != nil { + err = fmt.Errorf("%w; also while closing the row reader: %w", err, closeErr) + } else if closeErr != nil { + err = closeErr + } + // If closeErr is nil, then just leaving the existing named return alone + // will do the right thing. + }() + + for r.Next() { + row, err := r.Get() + if err != nil { + return fmt.Errorf("reading row: %w", err) + } + + err = do(row) + if err != nil { + return err + } + } + + err = r.Err() + if err != nil { + return fmt.Errorf("iterating over row reader: %w", err) + } + + return nil +} + +// Next is a wrapper around sql.Rows.Next(). It must be called before every call +// to Get(), including the first. +func (r rows[T]) Next() bool { + return r.wrapped.Next() +} + +// Get is a wrapper around sql.Rows.Scan(). Rather than populating an arbitrary +// number of &interface{} arguments, it returns a populated object of the +// parameterized type. +func (r rows[T]) Get() (*T, error) { + result := new(T) + v := reflect.ValueOf(result) + + // Because sql.Rows.Scan(...) takes a variadic number of individual targets to + // read values into, build a slice that can be splatted into the call. Use the + // pre-computed list of in-order column names to populate it. + scanTargets := make([]interface{}, r.numCols) + for i := range scanTargets { + field := v.Elem().Field(i) + scanTargets[i] = field.Addr().Interface() + } + + err := r.wrapped.Scan(scanTargets...) + if err != nil { + return nil, fmt.Errorf("reading db row: %w", err) + } + + return result, nil +} + +// Err is a wrapper around sql.Rows.Err(). It should be checked immediately +// after Next() returns false for any reason. +func (r rows[T]) Err() error { + return r.wrapped.Err() +} + +// Close is a wrapper around sql.Rows.Close(). It must be called when the caller +// is done reading rows, regardless of success or error. +func (r rows[T]) Close() error { + return r.wrapped.Close() +} diff --git a/third-party/github.com/letsencrypt/boulder/db/gorm_test.go b/third-party/github.com/letsencrypt/boulder/db/gorm_test.go new file mode 100644 index 000000000..c0a179bbc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/gorm_test.go @@ -0,0 +1,16 @@ +package db + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestValidMariaDBUnquotedIdentifier(t *testing.T) { + test.AssertError(t, validMariaDBUnquotedIdentifier("12345"), "expected error for 12345") + test.AssertError(t, validMariaDBUnquotedIdentifier("12345e"), "expected error for 12345e") + test.AssertError(t, validMariaDBUnquotedIdentifier("1e10"), "expected error for 1e10") + test.AssertError(t, validMariaDBUnquotedIdentifier("foo\\bar"), "expected error for foo\\bar") + test.AssertError(t, validMariaDBUnquotedIdentifier("zoom "), "expected error for identifier ending in space") + test.AssertNotError(t, validMariaDBUnquotedIdentifier("hi"), "expected no error for 'hi'") +} diff --git a/third-party/github.com/letsencrypt/boulder/db/interfaces.go b/third-party/github.com/letsencrypt/boulder/db/interfaces.go new file mode 100644 index 000000000..f08e25888 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/interfaces.go @@ -0,0 +1,160 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "reflect" + + "github.com/letsencrypt/borp" +) + +// These interfaces exist to aid in mocking database operations for unit tests. +// +// By convention, any function that takes a OneSelector, Selector, +// Inserter, Execer, or SelectExecer as as an argument expects +// that a context has already been applied to the relevant DbMap or +// Transaction object. + +// A OneSelector is anything that provides a `SelectOne` function. +type OneSelector interface { + SelectOne(context.Context, interface{}, string, ...interface{}) error +} + +// A Selector is anything that provides a `Select` function. +type Selector interface { + Select(context.Context, interface{}, string, ...interface{}) ([]interface{}, error) +} + +// A Inserter is anything that provides an `Insert` function +type Inserter interface { + Insert(context.Context, ...interface{}) error +} + +// A Execer is anything that provides an `ExecContext` function +type Execer interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) +} + +// SelectExecer offers a subset of borp.SqlExecutor's methods: Select and +// ExecContext. +type SelectExecer interface { + Selector + Execer +} + +// DatabaseMap offers the full combination of OneSelector, Inserter, +// SelectExecer, and a Begin function for creating a Transaction. +type DatabaseMap interface { + OneSelector + Inserter + SelectExecer + BeginTx(context.Context) (Transaction, error) +} + +// Executor offers the full combination of OneSelector, Inserter, SelectExecer +// and adds a handful of other high level borp methods we use in Boulder. +type Executor interface { + OneSelector + Inserter + SelectExecer + Queryer + Delete(context.Context, ...interface{}) (int64, error) + Get(context.Context, interface{}, ...interface{}) (interface{}, error) + Update(context.Context, ...interface{}) (int64, error) +} + +// Queryer offers the QueryContext method. Note that this is not read-only (i.e. not +// Selector), since a QueryContext can be `INSERT`, `UPDATE`, etc. The difference +// between QueryContext and ExecContext is that QueryContext can return rows. So for instance it is +// suitable for inserting rows and getting back ids. +type Queryer interface { + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) +} + +// Transaction extends an Executor and adds Rollback and Commit +type Transaction interface { + Executor + Rollback() error + Commit() error +} + +// MappedExecutor is anything that can map types to tables +type MappedExecutor interface { + TableFor(reflect.Type, bool) (*borp.TableMap, error) + QueryContext(ctx context.Context, clauses string, args ...interface{}) (*sql.Rows, error) +} + +// MappedSelector is anything that can execute various kinds of SQL statements +// against a table automatically determined from the parameterized type. +type MappedSelector[T any] interface { + QueryContext(ctx context.Context, clauses string, args ...interface{}) (Rows[T], error) + QueryFrom(ctx context.Context, tablename string, clauses string, args ...interface{}) (Rows[T], error) +} + +// Rows is anything which lets you iterate over the result rows of a SELECT +// query. It is similar to sql.Rows, but generic. +type Rows[T any] interface { + ForEach(func(*T) error) error + Next() bool + Get() (*T, error) + Err() error + Close() error +} + +// MockSqlExecutor implement SqlExecutor by returning errors from every call. +// +// TODO: To mock out WithContext, we needed to be able to return objects that satisfy +// borp.SqlExecutor. That's a pretty big interface, so we specify one no-op mock +// that we can embed everywhere we need to satisfy it. +// Note: MockSqlExecutor does *not* implement WithContext. The expectation is +// that structs that embed MockSqlExecutor will define their own WithContext +// that returns a reference to themselves. That makes it easy for those structs +// to override the specific methods they need to implement (e.g. SelectOne). +type MockSqlExecutor struct{} + +func (mse MockSqlExecutor) Get(ctx context.Context, i interface{}, keys ...interface{}) (interface{}, error) { + return nil, errors.New("unimplemented") +} +func (mse MockSqlExecutor) Insert(ctx context.Context, list ...interface{}) error { + return errors.New("unimplemented") +} +func (mse MockSqlExecutor) Update(ctx context.Context, list ...interface{}) (int64, error) { + return 0, errors.New("unimplemented") +} +func (mse MockSqlExecutor) Delete(ctx context.Context, list ...interface{}) (int64, error) { + return 0, errors.New("unimplemented") +} +func (mse MockSqlExecutor) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return nil, errors.New("unimplemented") +} +func (mse MockSqlExecutor) Select(ctx context.Context, i interface{}, query string, args ...interface{}) ([]interface{}, error) { + return nil, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectInt(ctx context.Context, query string, args ...interface{}) (int64, error) { + return 0, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectNullInt(ctx context.Context, query string, args ...interface{}) (sql.NullInt64, error) { + return sql.NullInt64{}, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectFloat(ctx context.Context, query string, args ...interface{}) (float64, error) { + return 0, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectNullFloat(ctx context.Context, query string, args ...interface{}) (sql.NullFloat64, error) { + return sql.NullFloat64{}, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectStr(ctx context.Context, query string, args ...interface{}) (string, error) { + return "", errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectNullStr(ctx context.Context, query string, args ...interface{}) (sql.NullString, error) { + return sql.NullString{}, errors.New("unimplemented") +} +func (mse MockSqlExecutor) SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error { + return errors.New("unimplemented") +} +func (mse MockSqlExecutor) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + return nil, errors.New("unimplemented") +} +func (mse MockSqlExecutor) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/db/map.go b/third-party/github.com/letsencrypt/boulder/db/map.go new file mode 100644 index 000000000..4abd2dce5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/map.go @@ -0,0 +1,339 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "reflect" + "regexp" + + "github.com/go-sql-driver/mysql" + "github.com/letsencrypt/borp" +) + +// ErrDatabaseOp wraps an underlying err with a description of the operation +// that was being performed when the error occurred (insert, select, select +// one, exec, etc) and the table that the operation was being performed on. +type ErrDatabaseOp struct { + Op string + Table string + Err error +} + +// Error for an ErrDatabaseOp composes a message with context about the +// operation and table as well as the underlying Err's error message. +func (e ErrDatabaseOp) Error() string { + // If there is a table, include it in the context + if e.Table != "" { + return fmt.Sprintf( + "failed to %s %s: %s", + e.Op, + e.Table, + e.Err) + } + return fmt.Sprintf( + "failed to %s: %s", + e.Op, + e.Err) +} + +// Unwrap returns the inner error to allow inspection of error chains. +func (e ErrDatabaseOp) Unwrap() error { + return e.Err +} + +// IsNoRows is a utility function for determining if an error wraps the go sql +// package's ErrNoRows, which is returned when a Scan operation has no more +// results to return, and as such is returned by many borp methods. +func IsNoRows(err error) bool { + return errors.Is(err, sql.ErrNoRows) +} + +// IsDuplicate is a utility function for determining if an error wrap MySQL's +// Error 1062: Duplicate entry. This error is returned when inserting a row +// would violate a unique key constraint. +func IsDuplicate(err error) bool { + var dbErr *mysql.MySQLError + return errors.As(err, &dbErr) && dbErr.Number == 1062 +} + +// WrappedMap wraps a *borp.DbMap such that its major functions wrap error +// results in ErrDatabaseOp instances before returning them to the caller. +type WrappedMap struct { + dbMap *borp.DbMap +} + +func NewWrappedMap(dbMap *borp.DbMap) *WrappedMap { + return &WrappedMap{dbMap: dbMap} +} + +func (m *WrappedMap) TableFor(t reflect.Type, checkPK bool) (*borp.TableMap, error) { + return m.dbMap.TableFor(t, checkPK) +} + +func (m *WrappedMap) Get(ctx context.Context, holder interface{}, keys ...interface{}) (interface{}, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.Get(ctx, holder, keys...) +} + +func (m *WrappedMap) Insert(ctx context.Context, list ...interface{}) error { + return WrappedExecutor{sqlExecutor: m.dbMap}.Insert(ctx, list...) +} + +func (m *WrappedMap) Update(ctx context.Context, list ...interface{}) (int64, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.Update(ctx, list...) +} + +func (m *WrappedMap) Delete(ctx context.Context, list ...interface{}) (int64, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.Delete(ctx, list...) +} + +func (m *WrappedMap) Select(ctx context.Context, holder interface{}, query string, args ...interface{}) ([]interface{}, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.Select(ctx, holder, query, args...) +} + +func (m *WrappedMap) SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error { + return WrappedExecutor{sqlExecutor: m.dbMap}.SelectOne(ctx, holder, query, args...) +} + +func (m *WrappedMap) SelectNullInt(ctx context.Context, query string, args ...interface{}) (sql.NullInt64, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.SelectNullInt(ctx, query, args...) +} + +func (m *WrappedMap) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.QueryContext(ctx, query, args...) +} + +func (m *WrappedMap) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { + return WrappedExecutor{sqlExecutor: m.dbMap}.QueryRowContext(ctx, query, args...) +} + +func (m *WrappedMap) SelectStr(ctx context.Context, query string, args ...interface{}) (string, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.SelectStr(ctx, query, args...) +} + +func (m *WrappedMap) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return WrappedExecutor{sqlExecutor: m.dbMap}.ExecContext(ctx, query, args...) +} + +func (m *WrappedMap) BeginTx(ctx context.Context) (Transaction, error) { + tx, err := m.dbMap.BeginTx(ctx) + if err != nil { + return tx, ErrDatabaseOp{ + Op: "begin transaction", + Err: err, + } + } + return WrappedTransaction{ + transaction: tx, + }, err +} + +// WrappedTransaction wraps a *borp.Transaction such that its major functions +// wrap error results in ErrDatabaseOp instances before returning them to the +// caller. +type WrappedTransaction struct { + transaction *borp.Transaction +} + +func (tx WrappedTransaction) Commit() error { + return tx.transaction.Commit() +} + +func (tx WrappedTransaction) Rollback() error { + return tx.transaction.Rollback() +} + +func (tx WrappedTransaction) Get(ctx context.Context, holder interface{}, keys ...interface{}) (interface{}, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).Get(ctx, holder, keys...) +} + +func (tx WrappedTransaction) Insert(ctx context.Context, list ...interface{}) error { + return (WrappedExecutor{sqlExecutor: tx.transaction}).Insert(ctx, list...) +} + +func (tx WrappedTransaction) Update(ctx context.Context, list ...interface{}) (int64, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).Update(ctx, list...) +} + +func (tx WrappedTransaction) Delete(ctx context.Context, list ...interface{}) (int64, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).Delete(ctx, list...) +} + +func (tx WrappedTransaction) Select(ctx context.Context, holder interface{}, query string, args ...interface{}) ([]interface{}, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).Select(ctx, holder, query, args...) +} + +func (tx WrappedTransaction) SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error { + return (WrappedExecutor{sqlExecutor: tx.transaction}).SelectOne(ctx, holder, query, args...) +} + +func (tx WrappedTransaction) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).QueryContext(ctx, query, args...) +} + +func (tx WrappedTransaction) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return (WrappedExecutor{sqlExecutor: tx.transaction}).ExecContext(ctx, query, args...) +} + +// WrappedExecutor wraps a borp.SqlExecutor such that its major functions +// wrap error results in ErrDatabaseOp instances before returning them to the +// caller. +type WrappedExecutor struct { + sqlExecutor borp.SqlExecutor +} + +func errForOp(operation string, err error, list []interface{}) ErrDatabaseOp { + table := "unknown" + if len(list) > 0 { + table = fmt.Sprintf("%T", list[0]) + } + return ErrDatabaseOp{ + Op: operation, + Table: table, + Err: err, + } +} + +func errForQuery(query, operation string, err error, list []interface{}) ErrDatabaseOp { + // Extract the table from the query + table := tableFromQuery(query) + if table == "" && len(list) > 0 { + // If there's no table from the query but there was a list of holder types, + // use the type from the first element of the list and indicate we failed to + // extract a table from the query. + table = fmt.Sprintf("%T (unknown table)", list[0]) + } else if table == "" { + // If there's no table from the query and no list of holders then all we can + // say is that the table is unknown. + table = "unknown table" + } + + return ErrDatabaseOp{ + Op: operation, + Table: table, + Err: err, + } +} + +func (we WrappedExecutor) Get(ctx context.Context, holder interface{}, keys ...interface{}) (interface{}, error) { + res, err := we.sqlExecutor.Get(ctx, holder, keys...) + if err != nil { + return res, errForOp("get", err, []interface{}{holder}) + } + return res, err +} + +func (we WrappedExecutor) Insert(ctx context.Context, list ...interface{}) error { + err := we.sqlExecutor.Insert(ctx, list...) + if err != nil { + return errForOp("insert", err, list) + } + return nil +} + +func (we WrappedExecutor) Update(ctx context.Context, list ...interface{}) (int64, error) { + updatedRows, err := we.sqlExecutor.Update(ctx, list...) + if err != nil { + return updatedRows, errForOp("update", err, list) + } + return updatedRows, err +} + +func (we WrappedExecutor) Delete(ctx context.Context, list ...interface{}) (int64, error) { + deletedRows, err := we.sqlExecutor.Delete(ctx, list...) + if err != nil { + return deletedRows, errForOp("delete", err, list) + } + return deletedRows, err +} + +func (we WrappedExecutor) Select(ctx context.Context, holder interface{}, query string, args ...interface{}) ([]interface{}, error) { + result, err := we.sqlExecutor.Select(ctx, holder, query, args...) + if err != nil { + return result, errForQuery(query, "select", err, []interface{}{holder}) + } + return result, err +} + +func (we WrappedExecutor) SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error { + err := we.sqlExecutor.SelectOne(ctx, holder, query, args...) + if err != nil { + return errForQuery(query, "select one", err, []interface{}{holder}) + } + return nil +} + +func (we WrappedExecutor) SelectNullInt(ctx context.Context, query string, args ...interface{}) (sql.NullInt64, error) { + rows, err := we.sqlExecutor.SelectNullInt(ctx, query, args...) + if err != nil { + return sql.NullInt64{}, errForQuery(query, "select", err, nil) + } + return rows, nil +} + +func (we WrappedExecutor) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { + // Note: we can't do error wrapping here because the error is passed via the `*sql.Row` + // object, and we can't produce a `*sql.Row` object with a custom error because it is unexported. + return we.sqlExecutor.QueryRowContext(ctx, query, args...) +} + +func (we WrappedExecutor) SelectStr(ctx context.Context, query string, args ...interface{}) (string, error) { + str, err := we.sqlExecutor.SelectStr(ctx, query, args...) + if err != nil { + return "", errForQuery(query, "select", err, nil) + } + return str, nil +} + +func (we WrappedExecutor) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + rows, err := we.sqlExecutor.QueryContext(ctx, query, args...) + if err != nil { + return nil, errForQuery(query, "select", err, nil) + } + return rows, nil +} + +var ( + // selectTableRegexp matches the table name from an SQL select statement + selectTableRegexp = regexp.MustCompile(`(?i)^\s*select\s+[a-z\d:\.\(\), \_\*` + "`" + `]+\s+from\s+([a-z\d\_,` + "`" + `]+)`) + // insertTableRegexp matches the table name from an SQL insert statement + insertTableRegexp = regexp.MustCompile(`(?i)^\s*insert\s+into\s+([a-z\d \_,` + "`" + `]+)\s+(?:set|\()`) + // updateTableRegexp matches the table name from an SQL update statement + updateTableRegexp = regexp.MustCompile(`(?i)^\s*update\s+([a-z\d \_,` + "`" + `]+)\s+set`) + // deleteTableRegexp matches the table name from an SQL delete statement + deleteTableRegexp = regexp.MustCompile(`(?i)^\s*delete\s+from\s+([a-z\d \_,` + "`" + `]+)\s+where`) + + // tableRegexps is a list of regexps that tableFromQuery will try to use in + // succession to find the table name for an SQL query. While tableFromQuery + // isn't used by the higher level borp Insert/Update/Select/etc functions we + // include regexps for matching inserts, updates, selects, etc because we want + // to match the correct table when these types of queries are run through + // ExecContext(). + tableRegexps = []*regexp.Regexp{ + selectTableRegexp, + insertTableRegexp, + updateTableRegexp, + deleteTableRegexp, + } +) + +// tableFromQuery uses the tableRegexps on the provided query to return the +// associated table name or an empty string if it can't be determined from the +// query. +func tableFromQuery(query string) string { + for _, r := range tableRegexps { + if matches := r.FindStringSubmatch(query); len(matches) >= 2 { + return matches[1] + } + } + return "" +} + +func (we WrappedExecutor) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + res, err := we.sqlExecutor.ExecContext(ctx, query, args...) + if err != nil { + return res, errForQuery(query, "exec", err, args) + } + return res, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/db/map_test.go b/third-party/github.com/letsencrypt/boulder/db/map_test.go new file mode 100644 index 000000000..19fdd7fe4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/map_test.go @@ -0,0 +1,341 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + + "github.com/letsencrypt/borp" + + "github.com/go-sql-driver/mysql" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +func TestErrDatabaseOpError(t *testing.T) { + testErr := errors.New("computers are cancelled") + testCases := []struct { + name string + err error + expected string + }{ + { + name: "error with table", + err: ErrDatabaseOp{ + Op: "test", + Table: "testTable", + Err: testErr, + }, + expected: fmt.Sprintf("failed to test testTable: %s", testErr), + }, + { + name: "error with no table", + err: ErrDatabaseOp{ + Op: "test", + Err: testErr, + }, + expected: fmt.Sprintf("failed to test: %s", testErr), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test.AssertEquals(t, tc.err.Error(), tc.expected) + }) + } +} + +func TestIsNoRows(t *testing.T) { + testCases := []struct { + name string + err ErrDatabaseOp + expectedNoRows bool + }{ + { + name: "underlying err is sql.ErrNoRows", + err: ErrDatabaseOp{ + Op: "test", + Table: "testTable", + Err: fmt.Errorf("some wrapper around %w", sql.ErrNoRows), + }, + expectedNoRows: true, + }, + { + name: "underlying err is not sql.ErrNoRows", + err: ErrDatabaseOp{ + Op: "test", + Table: "testTable", + Err: fmt.Errorf("some wrapper around %w", errors.New("lots of rows. too many rows.")), + }, + expectedNoRows: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test.AssertEquals(t, IsNoRows(tc.err), tc.expectedNoRows) + }) + } +} + +func TestIsDuplicate(t *testing.T) { + testCases := []struct { + name string + err ErrDatabaseOp + expectDuplicate bool + }{ + { + name: "underlying err has duplicate prefix", + err: ErrDatabaseOp{ + Op: "test", + Table: "testTable", + Err: fmt.Errorf("some wrapper around %w", &mysql.MySQLError{Number: 1062}), + }, + expectDuplicate: true, + }, + { + name: "underlying err doesn't have duplicate prefix", + err: ErrDatabaseOp{ + Op: "test", + Table: "testTable", + Err: fmt.Errorf("some wrapper around %w", &mysql.MySQLError{Number: 1234}), + }, + expectDuplicate: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test.AssertEquals(t, IsDuplicate(tc.err), tc.expectDuplicate) + }) + } +} + +func TestTableFromQuery(t *testing.T) { + // A sample of example queries logged by the SA during Boulder + // unit/integration tests. + testCases := []struct { + query string + expectedTable string + }{ + { + query: "SELECT id, jwk, jwk_sha256, contact, agreement, initialIP, createdAt, LockCol, status FROM registrations WHERE jwk_sha256 = ?", + expectedTable: "registrations", + }, + { + query: "\n\t\t\t\t\tSELECT orderID, registrationID\n\t\t\t\t\tFROM orderFqdnSets\n\t\t\t\t\tWHERE setHash = ?\n\t\t\t\t\tAND expires > ?\n\t\t\t\t\tORDER BY expires ASC\n\t\t\t\t\tLIMIT 1", + expectedTable: "orderFqdnSets", + }, + { + query: "SELECT id, identifierType, identifierValue, registrationID, status, expires, challenges, attempted, token, validationError, validationRecord FROM authz2 WHERE\n\t\t\tregistrationID = :regID AND\n\t\t\tstatus = :status AND\n\t\t\texpires > :validUntil AND\n\t\t\tidentifierType = :dnsType AND\n\t\t\tidentifierValue = :ident\n\t\t\tORDER BY expires ASC\n\t\t\tLIMIT 1 ", + expectedTable: "authz2", + }, + { + query: "insert into `registrations` (`id`,`jwk`,`jw k_sha256`,`contact`,`agreement`,`initialIp`,`createdAt`,`LockCol`,`status`) values (null,?,?,?,?,?,?,?,?);", + expectedTable: "`registrations`", + }, + { + query: "update `registrations` set `jwk`=?, `jwk_sh a256`=?, `contact`=?, `agreement`=?, `initialIp`=?, `createdAt`=?, `LockCol` =?, `status`=? where `id`=? and `LockCol`=?;", + expectedTable: "`registrations`", + }, + { + query: "SELECT COUNT(*) FROM registrations WHERE initialIP = ? AND ? < createdAt AND createdAt <= ?", + expectedTable: "registrations", + }, + { + query: "SELECT COUNT(*) FROM orders WHERE registrationID = ? AND created >= ? AND created < ?", + expectedTable: "orders", + }, + { + query: " SELECT id, identifierType, identifierValue, registrationID, status, expires, challenges, attempted, token, validationError, validationRecord FROM authz2 WHERE registrationID = ? AND status IN (?,?) AND expires > ? AND identifierType = ? AND identifierValue IN (?)", + expectedTable: "authz2", + }, + { + query: "insert into `authz2` (`id`,`identifierType`,`identifierValue`,`registrationID`,`status`,`expires`,`challenges`,`attempted`,`token`,`validationError`,`validationRecord`) values (null,?,?,?,?,?,?,?,?,?,?);", + expectedTable: "`authz2`", + }, + { + query: "insert into `orders` (`ID`,`RegistrationID`,`Expires`,`Created`,`Error`,`CertificateSerial`,`BeganProcessing`) values (null,?,?,?,?,?,?)", + expectedTable: "`orders`", + }, + { + query: "insert into `orderToAuthz2` (`OrderID`,`AuthzID`) values (?,?);", + expectedTable: "`orderToAuthz2`", + }, + { + query: "UPDATE authz2 SET status = :status, attempted = :attempted, validationRecord = :validationRecord, validationError = :validationError, expires = :expires WHERE id = :id AND status = :pending", + expectedTable: "authz2", + }, + { + query: "insert into `precertificates` (`ID`,`Serial`,`RegistrationID`,`DER`,`Issued`,`Expires`) values (null,?,?,?,?,?);", + expectedTable: "`precertificates`", + }, + { + query: "INSERT INTO certificateStatus (serial, status, ocspLastUpdated, revokedDate, revokedReason, lastExpirationNagSent, ocspResponse, notAfter, isExpired, issuerID) VALUES (?,?,?,?,?,?,?,?,?,?)", + expectedTable: "certificateStatus", + }, + { + query: "INSERT INTO issuedNames (reversedName, serial, notBefore, renewal) VALUES (?, ?, ?, ?);", + expectedTable: "issuedNames", + }, + { + query: "insert into `certificates` (`registrationID`,`serial`,`digest`,`der`,`issued`,`expires`) values (?,?,?,?,?,?);", + expectedTable: "`certificates`", + }, + { + query: "INSERT INTO certificatesPerName (eTLDPlusOne, time, count) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE count=count+1;", + expectedTable: "certificatesPerName", + }, + { + query: "insert into `fqdnSets` (`ID`,`SetHash`,`Serial`,`Issued`,`Expires`) values (null,?,?,?,?);", + expectedTable: "`fqdnSets`", + }, + { + query: "UPDATE orders SET certificateSerial = ? WHERE id = ? AND beganProcessing = true", + expectedTable: "orders", + }, + { + query: "DELETE FROM orderFqdnSets WHERE orderID = ?", + expectedTable: "orderFqdnSets", + }, + { + query: "insert into `serials` (`ID`,`Serial`,`RegistrationID`,`Created`,`Expires`) values (null,?,?,?,?);", + expectedTable: "`serials`", + }, + { + query: "UPDATE orders SET beganProcessing = ? WHERE id = ? AND beganProcessing = ?", + expectedTable: "orders", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("testCases.%d", i), func(t *testing.T) { + table := tableFromQuery(tc.query) + test.AssertEquals(t, table, tc.expectedTable) + }) + } +} + +func testDbMap(t *testing.T) *WrappedMap { + // NOTE(@cpu): We avoid using sa.NewDBMapFromConfig here because it would + // create a cyclic dependency. The `sa` package depends on `db` for + // `WithTransaction`. The `db` package can't depend on the `sa` for creating + // a DBMap. Since we only need a map for simple unit tests we can make our + // own dbMap by hand (how artisanal). + var config *mysql.Config + config, err := mysql.ParseDSN(vars.DBConnSA) + test.AssertNotError(t, err, "parsing DBConnSA DSN") + + dbConn, err := sql.Open("mysql", config.FormatDSN()) + test.AssertNotError(t, err, "opening DB connection") + + dialect := borp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"} + // NOTE(@cpu): We avoid giving a sa.BoulderTypeConverter to the DbMap field to + // avoid the cyclic dep. We don't need to convert any types in the db tests. + dbMap := &borp.DbMap{Db: dbConn, Dialect: dialect, TypeConverter: nil} + return &WrappedMap{dbMap: dbMap} +} + +func TestWrappedMap(t *testing.T) { + mustDbErr := func(err error) ErrDatabaseOp { + t.Helper() + var dbOpErr ErrDatabaseOp + test.AssertErrorWraps(t, err, &dbOpErr) + return dbOpErr + } + + ctx := context.Background() + + testWrapper := func(dbMap Executor) { + reg := &core.Registration{} + + // Test wrapped Get + _, err := dbMap.Get(ctx, reg) + test.AssertError(t, err, "expected err Getting Registration w/o type converter") + dbOpErr := mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "get") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Insert + err = dbMap.Insert(ctx, reg) + test.AssertError(t, err, "expected err Inserting Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "insert") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Update + _, err = dbMap.Update(ctx, reg) + test.AssertError(t, err, "expected err Updating Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "update") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Delete + _, err = dbMap.Delete(ctx, reg) + test.AssertError(t, err, "expected err Deleting Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "delete") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Select with a bogus query + _, err = dbMap.Select(ctx, reg, "blah") + test.AssertError(t, err, "expected err Selecting Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "select") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration (unknown table)") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Select with a valid query + _, err = dbMap.Select(ctx, reg, "SELECT id, contact FROM registrationzzz WHERE id > 1;") + test.AssertError(t, err, "expected err Selecting Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "select") + test.AssertEquals(t, dbOpErr.Table, "registrationzzz") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped SelectOne with a bogus query + err = dbMap.SelectOne(ctx, reg, "blah") + test.AssertError(t, err, "expected err SelectOne-ing Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "select one") + test.AssertEquals(t, dbOpErr.Table, "*core.Registration (unknown table)") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped SelectOne with a valid query + err = dbMap.SelectOne(ctx, reg, "SELECT contact FROM doesNotExist WHERE id=1;") + test.AssertError(t, err, "expected err SelectOne-ing Registration w/o type converter") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "select one") + test.AssertEquals(t, dbOpErr.Table, "doesNotExist") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + + // Test wrapped Exec + _, err = dbMap.ExecContext(ctx, "INSERT INTO whatever (id) VALUES (?) WHERE id = ?", 10) + test.AssertError(t, err, "expected err Exec-ing bad query") + dbOpErr = mustDbErr(err) + test.AssertEquals(t, dbOpErr.Op, "exec") + test.AssertEquals(t, dbOpErr.Table, "whatever") + test.AssertError(t, dbOpErr.Err, "expected non-nil underlying err") + } + + // Create a test wrapped map. It won't have a type converted registered. + dbMap := testDbMap(t) + + // A top level WrappedMap should operate as expected with respect to wrapping + // database errors. + testWrapper(dbMap) + + // Using Begin to start a transaction with the dbMap should return a + // transaction that continues to operate in the expected fashion. + tx, err := dbMap.BeginTx(ctx) + defer func() { _ = tx.Rollback() }() + test.AssertNotError(t, err, "unexpected error beginning transaction") + testWrapper(tx) +} diff --git a/third-party/github.com/letsencrypt/boulder/db/multi.go b/third-party/github.com/letsencrypt/boulder/db/multi.go new file mode 100644 index 000000000..bcb2fbe3f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/multi.go @@ -0,0 +1,139 @@ +package db + +import ( + "context" + "fmt" + "strings" +) + +// MultiInserter makes it easy to construct a +// `INSERT INTO table (...) VALUES ... RETURNING id;` +// query which inserts multiple rows into the same table. It can also execute +// the resulting query. +type MultiInserter struct { + // These are validated by the constructor as containing only characters + // that are allowed in an unquoted identifier. + // https://mariadb.com/kb/en/identifier-names/#unquoted + table string + fields []string + returningColumn string + + values [][]interface{} +} + +// NewMultiInserter creates a new MultiInserter, checking for reasonable table +// name and list of fields. returningColumn is the name of a column to be used +// in a `RETURNING xyz` clause at the end. If it is empty, no `RETURNING xyz` +// clause is used. If returningColumn is present, it must refer to a column +// that can be parsed into an int64. +// Safety: `table`, `fields`, and `returningColumn` must contain only strings +// that are known at compile time. They must not contain user-controlled +// strings. +func NewMultiInserter(table string, fields []string, returningColumn string) (*MultiInserter, error) { + if len(table) == 0 || len(fields) == 0 { + return nil, fmt.Errorf("empty table name or fields list") + } + + err := validMariaDBUnquotedIdentifier(table) + if err != nil { + return nil, err + } + for _, field := range fields { + err := validMariaDBUnquotedIdentifier(field) + if err != nil { + return nil, err + } + } + if returningColumn != "" { + err := validMariaDBUnquotedIdentifier(returningColumn) + if err != nil { + return nil, err + } + } + + return &MultiInserter{ + table: table, + fields: fields, + returningColumn: returningColumn, + values: make([][]interface{}, 0), + }, nil +} + +// Add registers another row to be included in the Insert query. +func (mi *MultiInserter) Add(row []interface{}) error { + if len(row) != len(mi.fields) { + return fmt.Errorf("field count mismatch, got %d, expected %d", len(row), len(mi.fields)) + } + mi.values = append(mi.values, row) + return nil +} + +// query returns the formatted query string, and the slice of arguments for +// for borp to use in place of the query's question marks. Currently only +// used by .Insert(), below. +func (mi *MultiInserter) query() (string, []interface{}) { + var questionsBuf strings.Builder + var queryArgs []interface{} + for _, row := range mi.values { + // Safety: We are interpolating a string that will be used in a SQL + // query, but we constructed that string in this function and know it + // consists only of question marks joined with commas. + fmt.Fprintf(&questionsBuf, "(%s),", QuestionMarks(len(mi.fields))) + queryArgs = append(queryArgs, row...) + } + + questions := strings.TrimRight(questionsBuf.String(), ",") + + // Safety: we are interpolating `mi.returningColumn` into an SQL query. We + // know it is a valid unquoted identifier in MariaDB because we verified + // that in the constructor. + returning := "" + if mi.returningColumn != "" { + returning = fmt.Sprintf(" RETURNING %s", mi.returningColumn) + } + // Safety: we are interpolating `mi.table` and `mi.fields` into an SQL + // query. We know they contain, respectively, a valid unquoted identifier + // and a slice of valid unquoted identifiers because we verified that in + // the constructor. We know the query overall has valid syntax because we + // generate it entirely within this function. + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s%s", mi.table, strings.Join(mi.fields, ","), questions, returning) + + return query, queryArgs +} + +// Insert inserts all the collected rows into the database represented by +// `queryer`. If a non-empty returningColumn was provided, then it returns +// the list of values from that column returned by the query. +func (mi *MultiInserter) Insert(ctx context.Context, queryer Queryer) ([]int64, error) { + query, queryArgs := mi.query() + rows, err := queryer.QueryContext(ctx, query, queryArgs...) + if err != nil { + return nil, err + } + + ids := make([]int64, 0, len(mi.values)) + if mi.returningColumn != "" { + for rows.Next() { + var id int64 + err = rows.Scan(&id) + if err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + } + + // Hack: sometimes in unittests we make a mock Queryer that returns a nil + // `*sql.Rows`. A nil `*sql.Rows` is not actually valid— calling `Close()` + // on it will panic— but here we choose to treat it like an empty list, + // and skip calling `Close()` to avoid the panic. + if rows != nil { + err = rows.Close() + if err != nil { + return nil, err + } + } + + return ids, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/db/multi_test.go b/third-party/github.com/letsencrypt/boulder/db/multi_test.go new file mode 100644 index 000000000..f972f4748 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/multi_test.go @@ -0,0 +1,81 @@ +package db + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestNewMulti(t *testing.T) { + _, err := NewMultiInserter("", []string{"colA"}, "") + test.AssertError(t, err, "Empty table name should fail") + + _, err = NewMultiInserter("myTable", nil, "") + test.AssertError(t, err, "Empty fields list should fail") + + mi, err := NewMultiInserter("myTable", []string{"colA"}, "") + test.AssertNotError(t, err, "Single-column construction should not fail") + test.AssertEquals(t, len(mi.fields), 1) + + mi, err = NewMultiInserter("myTable", []string{"colA", "colB", "colC"}, "") + test.AssertNotError(t, err, "Multi-column construction should not fail") + test.AssertEquals(t, len(mi.fields), 3) + + _, err = NewMultiInserter("", []string{"colA"}, "colB") + test.AssertError(t, err, "expected error for empty table name") + _, err = NewMultiInserter("foo\"bar", []string{"colA"}, "colB") + test.AssertError(t, err, "expected error for invalid table name") + + _, err = NewMultiInserter("myTable", []string{"colA", "foo\"bar"}, "colB") + test.AssertError(t, err, "expected error for invalid column name") + + _, err = NewMultiInserter("myTable", []string{"colA"}, "foo\"bar") + test.AssertError(t, err, "expected error for invalid returning column name") +} + +func TestMultiAdd(t *testing.T) { + mi, err := NewMultiInserter("table", []string{"a", "b", "c"}, "") + test.AssertNotError(t, err, "Failed to create test MultiInserter") + + err = mi.Add([]interface{}{}) + test.AssertError(t, err, "Adding empty row should fail") + + err = mi.Add([]interface{}{"foo"}) + test.AssertError(t, err, "Adding short row should fail") + + err = mi.Add([]interface{}{"foo", "bar", "baz", "bing", "boom"}) + test.AssertError(t, err, "Adding long row should fail") + + err = mi.Add([]interface{}{"one", "two", "three"}) + test.AssertNotError(t, err, "Adding correct-length row shouldn't fail") + test.AssertEquals(t, len(mi.values), 1) + + err = mi.Add([]interface{}{1, "two", map[string]int{"three": 3}}) + test.AssertNotError(t, err, "Adding heterogeneous row shouldn't fail") + test.AssertEquals(t, len(mi.values), 2) + // Note that .Add does *not* enforce that each row is of the same types. +} + +func TestMultiQuery(t *testing.T) { + mi, err := NewMultiInserter("table", []string{"a", "b", "c"}, "") + test.AssertNotError(t, err, "Failed to create test MultiInserter") + err = mi.Add([]interface{}{"one", "two", "three"}) + test.AssertNotError(t, err, "Failed to insert test row") + err = mi.Add([]interface{}{"egy", "kettö", "három"}) + test.AssertNotError(t, err, "Failed to insert test row") + + query, queryArgs := mi.query() + test.AssertEquals(t, query, "INSERT INTO table (a,b,c) VALUES (?,?,?),(?,?,?)") + test.AssertDeepEquals(t, queryArgs, []interface{}{"one", "two", "three", "egy", "kettö", "három"}) + + mi, err = NewMultiInserter("table", []string{"a", "b", "c"}, "id") + test.AssertNotError(t, err, "Failed to create test MultiInserter") + err = mi.Add([]interface{}{"one", "two", "three"}) + test.AssertNotError(t, err, "Failed to insert test row") + err = mi.Add([]interface{}{"egy", "kettö", "három"}) + test.AssertNotError(t, err, "Failed to insert test row") + + query, queryArgs = mi.query() + test.AssertEquals(t, query, "INSERT INTO table (a,b,c) VALUES (?,?,?),(?,?,?) RETURNING id") + test.AssertDeepEquals(t, queryArgs, []interface{}{"one", "two", "three", "egy", "kettö", "három"}) +} diff --git a/third-party/github.com/letsencrypt/boulder/db/qmarks.go b/third-party/github.com/letsencrypt/boulder/db/qmarks.go new file mode 100644 index 000000000..d69cc5220 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/qmarks.go @@ -0,0 +1,21 @@ +package db + +import "strings" + +// QuestionMarks returns a string consisting of N question marks, joined by +// commas. If n is <= 0, panics. +func QuestionMarks(n int) string { + if n <= 0 { + panic("db.QuestionMarks called with n <=0") + } + var qmarks strings.Builder + qmarks.Grow(2 * n) + for i := range n { + if i == 0 { + qmarks.WriteString("?") + } else { + qmarks.WriteString(",?") + } + } + return qmarks.String() +} diff --git a/third-party/github.com/letsencrypt/boulder/db/qmarks_test.go b/third-party/github.com/letsencrypt/boulder/db/qmarks_test.go new file mode 100644 index 000000000..f76ee4f4f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/qmarks_test.go @@ -0,0 +1,19 @@ +package db + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestQuestionMarks(t *testing.T) { + test.AssertEquals(t, QuestionMarks(1), "?") + test.AssertEquals(t, QuestionMarks(2), "?,?") + test.AssertEquals(t, QuestionMarks(3), "?,?,?") +} + +func TestQuestionMarksPanic(t *testing.T) { + defer func() { _ = recover() }() + QuestionMarks(0) + t.Errorf("calling QuestionMarks(0) did not panic as expected") +} diff --git a/third-party/github.com/letsencrypt/boulder/db/rollback.go b/third-party/github.com/letsencrypt/boulder/db/rollback.go new file mode 100644 index 000000000..296dae76e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/rollback.go @@ -0,0 +1,33 @@ +package db + +import ( + "fmt" +) + +// RollbackError is a combination of a database error and the error, if any, +// encountered while trying to rollback the transaction. +type RollbackError struct { + Err error + RollbackErr error +} + +// Error implements the error interface +func (re *RollbackError) Error() string { + if re.RollbackErr == nil { + return re.Err.Error() + } + return fmt.Sprintf("%s (also, while rolling back: %s)", re.Err, re.RollbackErr) +} + +// rollback rolls back the provided transaction. If the rollback fails for any +// reason a `RollbackError` error is returned wrapping the original error. If no +// rollback error occurs then the original error is returned. +func rollback(tx Transaction, err error) error { + if txErr := tx.Rollback(); txErr != nil { + return &RollbackError{ + Err: err, + RollbackErr: txErr, + } + } + return err +} diff --git a/third-party/github.com/letsencrypt/boulder/db/rollback_test.go b/third-party/github.com/letsencrypt/boulder/db/rollback_test.go new file mode 100644 index 000000000..99df5431c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/rollback_test.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "testing" + + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/test" +) + +func TestRollback(t *testing.T) { + ctx := context.Background() + dbMap := testDbMap(t) + + tx, _ := dbMap.BeginTx(ctx) + // Commit the transaction so that a subsequent rollback will always fail. + _ = tx.Commit() + + innerErr := berrors.NotFoundError("Gone, gone, gone") + result := rollback(tx, innerErr) + + // Since the tx.Rollback will fail we expect the result to be a wrapped error + test.AssertNotEquals(t, result, innerErr) + if rbErr, ok := result.(*RollbackError); ok { + test.AssertEquals(t, rbErr.Err, innerErr) + test.AssertNotNil(t, rbErr.RollbackErr, "RollbackErr was nil") + } else { + t.Fatalf("Result was not a RollbackError: %#v", result) + } + + // Create a new transaction and don't commit it this time. The rollback should + // succeed. + tx, _ = dbMap.BeginTx(ctx) + result = rollback(tx, innerErr) + + // We expect that the err is returned unwrapped. + test.AssertEquals(t, result, innerErr) +} diff --git a/third-party/github.com/letsencrypt/boulder/db/transaction.go b/third-party/github.com/letsencrypt/boulder/db/transaction.go new file mode 100644 index 000000000..f6020962f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/db/transaction.go @@ -0,0 +1,26 @@ +package db + +import "context" + +// txFunc represents a function that does work in the context of a transaction. +type txFunc func(tx Executor) (interface{}, error) + +// WithTransaction runs the given function in a transaction, rolling back if it +// returns an error and committing if not. The provided context is also attached +// to the transaction. WithTransaction also passes through a value returned by +// `f`, if there is no error. +func WithTransaction(ctx context.Context, dbMap DatabaseMap, f txFunc) (interface{}, error) { + tx, err := dbMap.BeginTx(ctx) + if err != nil { + return nil, err + } + result, err := f(tx) + if err != nil { + return nil, rollback(tx, err) + } + err = tx.Commit() + if err != nil { + return nil, err + } + return result, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/docker-compose.next.yml b/third-party/github.com/letsencrypt/boulder/docker-compose.next.yml new file mode 100644 index 000000000..b18fb5ee7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docker-compose.next.yml @@ -0,0 +1,7 @@ +services: + boulder: + environment: + FAKE_DNS: 10.77.77.77 + BOULDER_CONFIG_DIR: test/config-next + GOFLAGS: -mod=vendor + GOCACHE: /boulder/.gocache/go-build-next diff --git a/third-party/github.com/letsencrypt/boulder/docker-compose.yml b/third-party/github.com/letsencrypt/boulder/docker-compose.yml new file mode 100644 index 000000000..f25309579 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docker-compose.yml @@ -0,0 +1,209 @@ +services: + boulder: + # The `letsencrypt/boulder-tools:latest` tag is automatically built in local + # dev environments. In CI a specific BOULDER_TOOLS_TAG is passed, and it is + # pulled with `docker compose pull`. + image: &boulder_tools_image letsencrypt/boulder-tools:${BOULDER_TOOLS_TAG:-latest} + build: + context: test/boulder-tools/ + # Should match one of the GO_CI_VERSIONS in test/boulder-tools/tag_and_upload.sh. + args: + GO_VERSION: 1.22.2 + environment: + # To solve HTTP-01 and TLS-ALPN-01 challenges, change the IP in FAKE_DNS + # to the IP address where your ACME client's solver is listening. + # FAKE_DNS: 172.17.0.1 + FAKE_DNS: 10.77.77.77 + BOULDER_CONFIG_DIR: test/config + GOCACHE: /boulder/.gocache/go-build + GOFLAGS: -mod=vendor + volumes: + - .:/boulder:cached + - ./.gocache:/root/.cache/go-build:cached + - ./test/certs/.softhsm-tokens/:/var/lib/softhsm/tokens/:cached + networks: + bouldernet: + ipv4_address: 10.77.77.77 + integrationtestnet: + ipv4_address: 10.88.88.88 + redisnet: + ipv4_address: 10.33.33.33 + consulnet: + ipv4_address: 10.55.55.55 + # Use consul as a backup to Docker's embedded DNS server. If there's a name + # Docker's DNS server doesn't know about, it will forward the query to this + # IP (running consul). + # (https://docs.docker.com/config/containers/container-networking/#dns-services). + # This is used to look up service names via A records (like ra.service.consul) that + # are configured via the ServerAddress field of cmd.GRPCClientConfig. + # TODO: Remove this when ServerAddress is deprecated in favor of SRV records + # and DNSAuthority. + dns: 10.55.55.10 + extra_hosts: + # Allow the boulder container to be reached as "ca.example.org", so that + # we can put that name inside our integration test certs (e.g. as a crl + # url) and have it look like a publicly-accessible name. + - "ca.example.org:10.77.77.77" + ports: + - 4001:4001 # ACMEv2 + - 4002:4002 # OCSP + - 4003:4003 # OCSP + depends_on: + - bmysql + - bproxysql + - bredis_1 + - bredis_2 + - bredis_3 + - bredis_4 + - bconsul + - bjaeger + - bpkilint + entrypoint: test/entrypoint.sh + working_dir: &boulder_working_dir /boulder + + bsetup: + image: *boulder_tools_image + volumes: + - .:/boulder:cached + - ./.gocache:/root/.cache/go-build:cached + - ./test/certs/.softhsm-tokens/:/var/lib/softhsm/tokens/:cached + entrypoint: test/certs/generate.sh + working_dir: *boulder_working_dir + profiles: + # Adding a profile to this container means that it won't be started by a + # normal "docker compose up/run boulder", only when specifically invoked + # with a "docker compose up bsetup". + - setup + + bmysql: + image: mariadb:10.5 + networks: + bouldernet: + aliases: + - boulder-mysql + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + # Send slow queries to a table so we can check for them in the + # integration tests. For now we ignore queries not using indexes, + # because that seems to trigger based on the optimizer's choice to not + # use an index for certain queries, particularly when tables are still + # small. + command: mysqld --bind-address=0.0.0.0 --slow-query-log --log-output=TABLE --log-queries-not-using-indexes=ON + logging: + driver: none + bproxysql: + image: proxysql/proxysql:2.5.4 + # The --initial flag force resets the ProxySQL database on startup. By + # default, ProxySQL ignores new configuration if the database already + # exists. Without this flag, new configuration wouldn't be applied until you + # ran `docker compose down`. + entrypoint: proxysql -f --idle-threads -c /test/proxysql/proxysql.cnf --initial + volumes: + - ./test/:/test/:cached + depends_on: + - bmysql + networks: + bouldernet: + aliases: + - boulder-proxysql + + bredis_1: + image: redis:6.2.7 + volumes: + - ./test/:/test/:cached + command: redis-server /test/redis-ocsp.config + networks: + redisnet: + ipv4_address: 10.33.33.2 + + bredis_2: + image: redis:6.2.7 + volumes: + - ./test/:/test/:cached + command: redis-server /test/redis-ocsp.config + networks: + redisnet: + ipv4_address: 10.33.33.3 + + bredis_3: + image: redis:6.2.7 + volumes: + - ./test/:/test/:cached + command: redis-server /test/redis-ratelimits.config + networks: + redisnet: + ipv4_address: 10.33.33.4 + + bredis_4: + image: redis:6.2.7 + volumes: + - ./test/:/test/:cached + command: redis-server /test/redis-ratelimits.config + networks: + redisnet: + ipv4_address: 10.33.33.5 + + bconsul: + image: hashicorp/consul:1.15.4 + volumes: + - ./test/:/test/:cached + networks: + consulnet: + ipv4_address: 10.55.55.10 + bouldernet: + ipv4_address: 10.77.77.10 + command: "consul agent -dev -config-format=hcl -config-file=/test/consul/config.hcl" + + bjaeger: + image: jaegertracing/all-in-one:1.50 + networks: + bouldernet: + ipv4_address: 10.77.77.17 + + bpkilint: + image: ghcr.io/digicert/pkilint:v0.10.1 + networks: + bouldernet: + ipv4_address: 10.77.77.9 + command: "gunicorn -w 8 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:80 pkilint.rest:app" + +networks: + # This network is primarily used for boulder services. It is also used by + # challtestsrv, which is used in the integration tests. + bouldernet: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.77.77.0/24 + + # This network is used for two things in the integration tests: + # - challtestsrv binds to 10.88.88.88:443 for its tls-alpn-01 challenge + # responder, to avoid interfering with the HTTPS port used for testing + # HTTP->HTTPS redirects during http-01 challenges. Note: this could + # probably be updated in the future so that challtestsrv can handle + # both tls-alpn-01 and HTTPS on the same port. + # - test/v2_integration.py has some test cases that start their own HTTP + # server instead of relying on challtestsrv, because they want very + # specific behavior. For these cases, v2_integration.py creates a Python + # HTTP server and binds it to 10.88.88.88:80. + integrationtestnet: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.88.88.0/24 + + redisnet: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.33.33.0/24 + + consulnet: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.55.55.0/24 diff --git a/third-party/github.com/letsencrypt/boulder/docs/CODE_OF_CONDUCT.md b/third-party/github.com/letsencrypt/boulder/docs/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f5121d46f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +The code of conduct for everyone participating in this community in any capacity +is available for reference +[on the community forum](https://community.letsencrypt.org/guidelines). diff --git a/third-party/github.com/letsencrypt/boulder/docs/CONTRIBUTING.md b/third-party/github.com/letsencrypt/boulder/docs/CONTRIBUTING.md new file mode 100644 index 000000000..7e311ae9e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/CONTRIBUTING.md @@ -0,0 +1,423 @@ +Thanks for helping us build Boulder! This page contains requirements and +guidelines for Boulder contributions. + +# Patch Requirements + +* All new functionality and fixed bugs must be accompanied by tests. +* All patches must meet the deployability requirements listed below. +* We prefer pull requests from external forks be created with the ["Allow edits + from + maintainers"](https://github.com/blog/2247-improving-collaboration-with-forks) + checkbox selected. + +# Review Requirements + +* All pull requests must receive at least one approval by a [CODEOWNER](../CODEOWNERS) other than the author. This is enforced by GitHub itself. +* All pull requests should receive at least two approvals by [Trusted Contributors](https://github.com/letsencrypt/cp-cps/blob/main/CP-CPS.md#161-definitions). + This requirement may be waived when: + * the change only modifies documentation; + * the change only modifies tests; + * in exceptional circumstances, such as when no second reviewer is available at all. + + This requirement should not be waived when: + * the change is not written by a Trusted Contributor, to ensure that at least two TCs have eyes on it. +* New commits pushed to a branch invalidate previous reviews. In other words, a + reviewer must give positive reviews of a branch after its most recent pushed + commit. +* If a branch contains commits from multiple authors, it needs a reviewer who + is not an author of commits on that branch. +* Review changes to or addition of tests just as rigorously as you review code + changes. Consider: Do tests actually test what they mean to test? Is this the + best way to test the functionality in question? Do the tests cover all the + functionality in the patch, including error cases? +* Are there new RPCs or config fields? Make sure the patch meets the + Deployability rules below. + +# Patch Guidelines + +* Please include helpful comments. No need to gratuitously comment clear code, + but make sure it's clear why things are being done. Include information in + your pull request about what you're trying to accomplish with your patch. +* Avoid named return values. See + [#3017](https://github.com/letsencrypt/boulder/pull/3017) for an example of a + subtle problem they can cause. +* Do not include `XXX`s or naked `TODO`s. Use + the formats: + + ```go + // TODO(): Hoverboard + Time-machine unsupported until upstream patch. + // TODO(#): Pending hoverboard/time-machine interface. + // TODO(@githubusername): Enable hoverboard kickflips once interface is stable. + ``` + +# Squash merging + +Once a pull request is approved and the tests are passing, the author or any +other committer can merge it. We always use [squash +merges](https://github.com/blog/2141-squash-your-commits) via GitHub's web +interface. That means that during the course of your review you should +generally not squash or amend commits, or force push. Even if the changes in +each commit are small, keeping them separate makes it easier for us to review +incremental changes to a pull request. Rest assured that those tiny changes +will get squashed into a nice meaningful-size commit when we merge. + +If the CI tests are failing on your branch, you should look at the logs +to figure out why. Sometimes (though rarely) they fail spuriously, in which +case you can post a comment requesting that a project owner kick the build. + +# Error handling + +All errors must be addressed in some way: That may be simply by returning an +error up the stack, or by handling it in some intelligent way where it is +generated, or by explicitly ignoring it and assigning to `_`. We use the +`errcheck` tool in our integration tests to make sure all errors are +addressed. Note that ignoring errors, even in tests, should be rare, since +they may generate hard-to-debug problems. + +When handling errors, always do the operation which creates the error (usually +a function call) and the error checking on separate lines: +``` +err := someOperation(args) +if err != nil { + return nil, fmt.Errorf("some operation failed: %w", err) +} +``` +We avoid the `if err := someOperation(args); err != nil {...}` style as we find +it to be less readable and it can give rise to surprising scoping behavior. + +We define two special types of error. `BoulderError`, defined in +errors/errors.go, is used specifically when an typed error needs to be passed +across an RPC boundary. For instance, if the SA returns "not found", callers +need to be able to distinguish that from a network error. Not every error that +may pass across an RPC boundary needs to be a BoulderError, only those errors +that need to be handled by type elsewhere. Handling by type may be as simple as +turning a BoulderError into a specific type of ProblemDetail. + +The other special type of error is `ProblemDetails`. We try to treat these as a +presentation-layer detail, and use them only in parts of the system that are +responsible for rendering errors to end-users, i.e. WFE2. Note +one exception: The VA RPC layer defines its own `ProblemDetails` type, which is +returned to the RA and stored as part of a challenge (to eventually be rendered +to the user). + +Within WFE2, ProblemDetails are sent to the client by calling +`sendError()`, which also logs the error. For internal errors like timeout, +or any error type that we haven't specifically turned into a ProblemDetail, we +return a ServerInternal error. This avoids unnecessarily exposing internals. +It's possible to add additional errors to a logEvent using `.AddError()`, but +this should only be done when there is is internal-only information to log +that isn't redundant with the ProblemDetails sent to the user. Note that the +final argument to `sendError()`, `ierr`, will automatically get added to the +logEvent for ServerInternal errors, so when sending a ServerInternal error it's +not necessary to separately call `.AddError`. + +# Deployability + +We want to ensure that a new Boulder revision can be deployed to the +currently running Boulder production instance without requiring config +changes first. We also want to ensure that during a deploy, services can be +restarted in any order. That means two things: + +## Good zero values for config fields + +Any newly added config field must have a usable [zero +value](https://tour.golang.org/basics/12). That is to say, if a config field +is absent, Boulder shouldn't crash or misbehave. If that config file names a +file to be read, Boulder should be able to proceed without that file being +read. + +Note that there are some config fields that we want to be a hard requirement. +To handle such a field, first add it as optional, then file an issue to make +it required after the next deploy is complete. + +In general, we would like our deploy process to be: deploy new code + old +config; then immediately after deploy the same code + new config. This makes +deploys cheaper so we can do them more often, and allows us to more readily +separate deploy-triggered problems from config-triggered problems. + +## Flag-gating features + +When adding significant new features or replacing existing RPCs the +`boulder/features` package should be used to gate its usage. To add a flag, a +new `const FeatureFlag` should be added and its default value specified in +`features.features` in `features/features.go`. In order to test if the flag +is enabled elsewhere in the codebase you can use +`features.Enabled(features.ExampleFeatureName)` which returns a `bool` +indicating if the flag is enabled or not. + +Each service should include a `map[string]bool` named `Features` in its +configuration object at the top level and call `features.Set` with that map +immediately after parsing the configuration. For example to enable +`UseNewMetrics` and disable `AccountRevocation` you would add this object: + +```json +{ + ... + "features": { + "UseNewMetrics": true, + "AccountRevocation": false, + } +} +``` + +Avoid negative flag names such as `"DontCancelRequest": false` because such +names are difficult to reason about. + +Feature flags are meant to be used temporarily and should not be used for +permanent boolean configuration options. Once a feature has been enabled in +both staging and production the flag should be removed making the previously +gated functionality the default in future deployments. + +### Gating RPCs + +When you add a new RPC to a Boulder service (e.g. `SA.GetFoo()`), all +components that call that RPC should gate those calls using a feature flag. +Since the feature's zero value is false, a deploy with the existing config +will not call `SA.GetFoo()`. Then, once the deploy is complete and we know +that all SA instances support the `GetFoo()` RPC, we do a followup config +deploy that sets the default value to true, and finally remove the flag +entirely once we are confident the functionality it gates behaves correctly. + +### Gating migrations + +We use [database migrations](https://en.wikipedia.org/wiki/Schema_migration) +to modify the existing schema. These migrations will be run on live data +while Boulder is still running, so we need Boulder code at any given commit +to be capable of running without depending on any changes in schemas that +have not yet been applied. + +For instance, if we're adding a new column to an existing table, Boulder should +run correctly in three states: + +1. Migration not yet applied. +2. Migration applied, flag not yet flipped. +3. Migration applied, flag flipped. + +Specifically, that means that all of our `SELECT` statements should enumerate +columns to select, and not use `*`. Also, generally speaking, we will need a +separate model `struct` for serializing and deserializing data before and +after the migration. This is because the ORM package we use, +[`borp`](https://github.com/letsencrypt/borp), expects every field in a struct to +map to a column in the table. If we add a new field to a model struct and +Boulder attempts to write that struct to a table that doesn't yet have the +corresponding column (case 1), borp will fail with `Insert failed table posts +has no column named Foo`. There are examples of such models in sa/model.go, +along with code to turn a model into a `struct` used internally. + +An example of a flag-gated migration, adding a new `IsWizard` field to Person +controlled by a `AllowWizards` feature flag: + +```go +# features/features.go: + +const ( + unused FeatureFlag = iota // unused is used for testing + AllowWizards // Added! +) + +... + +var features = map[FeatureFlag]bool{ + unused: false, + AllowWizards: false, // Added! +} +``` + +```go +# sa/sa.go: + +struct Person { + HatSize int + IsWizard bool // Added! +} + +struct personModelv1 { + HatSize int +} + +// Added! +struct personModelv2 { + personModelv1 + IsWizard bool +} + +func (ssa *SQLStorageAuthority) GetPerson() (Person, error) { + if features.Enabled(features.AllowWizards) { // Added! + var model personModelv2 + ssa.dbMap.SelectOne(&model, "SELECT hatSize, isWizard FROM people") + return Person{ + HatSize: model.HatSize, + IsWizard: model.IsWizard, + } + } else { + var model personModelv1 + ssa.dbMap.SelectOne(&model, "SELECT hatSize FROM people") + return Person{ + HatSize: model.HatSize, + } + } +} + +func (ssa *SQLStorageAuthority) AddPerson(p Person) (error) { + if features.Enabled(features.AllowWizards) { // Added! + return ssa.dbMap.Insert(context.Background(), personModelv2{ + personModelv1: { + HatSize: p.HatSize, + }, + IsWizard: p.IsWizard, + }) + } else { + return ssa.dbMap.Insert(context.Background(), personModelv1{ + HatSize: p.HatSize, + // p.IsWizard ignored + }) + } +} +``` + +You will also need to update the `initTables` function from `sa/database.go` to +tell borp which table to use for your versioned model structs. Make sure to +consult the flag you defined so that only **one** of the table maps is added at +any given time, otherwise borp will error. Depending on your table you may also +need to add `SetKeys` and `SetVersionCol` entries for your versioned models. +Example: + +```go +func initTables(dbMap *borp.DbMap) { + // < unrelated lines snipped for brevity > + + if features.Enabled(features.AllowWizards) { + dbMap.AddTableWithName(personModelv2, "person") + } else { + dbMap.AddTableWithName(personModelv1, "person") + } +} +``` + +New migrations should be added at `./sa/db-next`: + +```shell +$ cd sa/db +$ sql-migrate new -env="boulder_sa_test" AddWizards +Created migration boulder_sa/20220906165519-AddWizards.sql +``` + +Finally, edit the resulting file +(`sa/db-next/boulder_sa/20220906165519-AddWizards.sql`) to define your migration: + +```mysql +-- +migrate Up +ALTER TABLE people ADD isWizard BOOLEAN SET DEFAULT false; + +-- +migrate Down +ALTER TABLE people DROP isWizard BOOLEAN SET DEFAULT false; +``` + +# Expressing "optional" Timestamps +Timestamps in protocol buffers must always be expressed as +[timestamppb.Timestamp](https://pkg.go.dev/google.golang.org/protobuf/types/known/timestamppb). +Timestamps must never contain their zero value, in the sense of +`timestamp.AsTime().IsZero()`. When a timestamp field is optional, absence must +be expressed through the absence of the field, rather than present with a zero +value. The `core.IsAnyNilOrZero` function can check these cases. + +Senders must check that timestamps are non-zero before sending them. Receivers +must check that timestamps are non-zero before accepting them. + +# Rounding time in DB + +All times that we write to the database are truncated to one second's worth of +precision. This reduces the size of indexes that include timestamps, and makes +querying them more efficient. The Storage Authority (SA) is responsible for this +truncation, and performs it for SELECT queries as well as INSERT and UPDATE. + +# Release Process + +The current Boulder release process is described in +[release.md](https://github.com/letsencrypt/boulder/docs/release.md). New +releases are tagged weekly, and artifacts are automatically produced for each +release by GitHub Actions. + +# Dependencies + +We use [go modules](https://github.com/golang/go/wiki/Modules) and vendor our +dependencies. As of Go 1.12, this may require setting the `GO111MODULE=on` and +`GOFLAGS=-mod=vendor` environment variables. Inside the Docker containers for +Boulder tests, these variables are set for you, but if you ever work outside +those containers you will want to set them yourself. + +To add a dependency, add the import statement to your .go file, then run +`go build` on it. This will automatically add the dependency to go.mod. Next, +run `go mod vendor && git add vendor/` to save a copy in the vendor folder. + +When vendorizing dependencies, it's important to make sure tests pass on the +version you are vendorizing. Currently we enforce this by requiring that pull +requests containing a dependency update to any version other than a tagged +release include a comment indicating that you ran the tests and that they +succeeded, preferably with the command line you run them with. Note that you +may have to get a separate checkout of the dependency (using `go get` outside +of the boulder repository) in order to run its tests, as some vendored +modules do not bring their tests with them. + +## Updating Dependencies + +To upgrade a dependency, [see the Go +docs](https://github.com/golang/go/wiki/Modules#how-to-upgrade-and-downgrade-dependencies). +Typically you want `go get ` rather than `go get -u +`, which can introduce a lot of unexpected updates. After running +`go get`, make sure to run `go mod vendor && git add vendor/` to update the +vendor directory. If you forget, CI tests will catch this. + +If you are updating a dependency to a version which is not a tagged release, +see the note above about how to run all of a dependency's tests and note that +you have done so in the PR. + +Note that updating dependencies can introduce new, transitive dependencies. In +general we try to keep our dependencies as narrow as possible in order to +minimize the number of people and organizations whose code we need to trust. +As a rule of thumb: If an update introduces new packages or modules that are +inside a repository where we already depend on other packages or modules, it's +not a big deal. If it introduces a new dependency in a different repository, +please try to figure out where that dependency came from and why (for instance: +"package X, which we depend on, started supporting XML config files, so now we +depend on an XML parser") and include that in the PR description. When there are +a large number of new dependencies introduced, and we don't need the +functionality they provide, we should consider asking the relevant upstream +repository for a refactoring to reduce the number of transitive dependencies. + +# Go Version + +The [Boulder development +environment](https://github.com/letsencrypt/boulder/blob/main/README.md#setting-up-boulder) +does not use the Go version installed on the host machine, and instead uses a +Go environment baked into a "boulder-tools" Docker image. We build a separate +boulder-tools container for each supported Go version. Please see [the +Boulder-tools +README](https://github.com/letsencrypt/boulder/blob/main/test/boulder-tools/README.md) +for more information on upgrading Go versions. + +# ACME Protocol Divergences + +While Boulder attempts to implement the ACME specification as strictly as +possible there are places at which we will diverge from the letter of the +specification for various reasons. We detail these divergences (for both the +V1 and V2 API) in the [ACME divergences +doc](https://github.com/letsencrypt/boulder/blob/main/docs/acme-divergences.md). + +# ACME Protocol Implementation Details + +The ACME specification allows developers to make certain decisions as to how +various elements in the RFC are implemented. Some of these fully conformant +decisions are listed in [ACME implementation details +doc](https://github.com/letsencrypt/boulder/blob/main/docs/acme-implementation_details.md). + +## Code of Conduct + +The code of conduct for everyone participating in this community in any capacity +is available for reference +[on the community forum](https://community.letsencrypt.org/guidelines). + +## Problems or questions? + +The best place to ask dev related questions is on the [Community +Forums](https://community.letsencrypt.org/). diff --git a/third-party/github.com/letsencrypt/boulder/docs/DESIGN.md b/third-party/github.com/letsencrypt/boulder/docs/DESIGN.md new file mode 100644 index 000000000..3fd6f8053 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/DESIGN.md @@ -0,0 +1,388 @@ +# Boulder flow diagrams + +Boulder is built out of multiple components that can be deployed in different +security contexts. + +In order for you to understand how Boulder works and ensure it's working correctly, +this document lays out how various operations flow through boulder. It is +expected you're already familiar with the [ACME +protocol](https://github.com/ietf-wg-acme/acme). We show a diagram of how calls +go between Boulder components, and provide notes on what each +component does to help the process along. Each step is in its own subsection +below, in roughly the order that they happen in certificate issuance for both +ACME v1 and ACME v2. + +A couple of notes: + +* For simplicity, we do not show interactions with the Storage Authority. + The SA simply acts as a common data store for the various components. It + is written to by the RA (registrations and authorizations) and the CA + (certificates), and read by WFEv2, RA, and CA. + +* The interactions shown in the diagrams are the calls that go between + components. These calls are done via [gRPC](https://grpc.io/). + +* In various places the Boulder implementation of ACME diverges from the current + RFC draft. These divergences are documented in [docs/acme-divergences.md](https://github.com/letsencrypt/boulder/blob/main/docs/acme-divergences.md). + +* The RFC draft leaves many decisions on it's implementation to the discretion + of server and client developers. The ACME RFC is also silent on some matters, + as the relevant implementation details would be influenced by other RFCs. + Several of these details and decisions particular to Boulder are documented in [docs/acme-implementation_details.md](https://github.com/letsencrypt/boulder/blob/main/docs/acme-implementation_details.md). + +* We focus on the primary ACME operations and do not include all possible + interactions (e.g. account key change, authorization deactivation) + +* We presently ignore the POST-as-GET construction introduced in + [draft-15](https://tools.ietf.org/html/draft-ietf-acme-acme-15) and show + unauthenticated GET requests for ACME v2 operations. + +## New Account/Registration + +ACME v2: + +``` +1: Client ---newAccount---> WFEv2 +2: WFEv2 ---NewRegistration--> RA +3: WFEv2 <-------return------- RA +4: Client <---------------- WFEv2 +``` + +Notes: + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Parse the registration/account object + * Filters illegal fields from the registration/account object + * We ignore the WFEv2 possibly returning early based on the OnlyReturnExisting + flag to simplify explanation. + +* 2-3: RA does the following: + * Verify that the registered account key is acceptable + * Create a new registration/account and add the client's information + * Store the registration/account (which gives it an ID) + * Return the registration/account as stored + +* 3-4: WFEv2 does the following: + * Return the registration/account, with a unique URL + + +## Updated Registration + +ACME v2: + +``` +1: Client ---acct--> WFEv2 +2: WFEv2 ---UpdateRegistration--> RA +3: WFEv2 <--------return--------- RA +4: Client <--------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Verify that the JWS key matches the registration for the URL + * WFEv2: Verify that the account agrees to the terms of service + * Parse the registration/account object + * Filter illegal fields from the registration/account object + +* 2-3: RA does the following: + * Merge the update into the existing registration/account + * Store the updated registration/account + * Return the updated registration/account + +* 3-4: WFEv2 does the following: + * Return the updated registration/account + +## New Authorization (ACME v1 Only) + +ACME v2: +We do not implement "pre-authorization" and the newAuthz endpoint for ACME v2. +Clients are expected to get authorizations by way of creating orders. + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Verify that the client has indicated agreement to terms + * Parse the initial authorization object + +* 2-3: RA does the following: + * Verify that the requested identifier is allowed by policy + * Verify that the CAA policy for for each DNS identifier allows issuance + * Create challenges as required by policy + * Construct URIs for the challenges + * Store the authorization + +* 3-4: WFEv2 does the following: + * Return the authorization, with a unique URL + +## New Order (ACME v2 Only) + +ACME v2: +``` +1: Client ---newOrder---> WFEv2 +2: WFEv2 -------NewOrder------> RA +3: WFEv2 <-------return-------- RA +4: Client <-------------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Parse the initial order object and identifiers + +* 2-3: RA does the following: + * Verify that the requested identifiers are allowed by policy + * Create authorizations and challenges as required by policy + * Construct URIs for the challenges and authorizations + * Store the authorizations and challenges + +* 3-4: WFEv2 does the following: + * Return the order object, containing authorizations and challenges, with + a unique URL + +## Challenge Response + +ACME v2: + +``` +1: Client ---chal--> WFEv2 +2: WFEv2 ---UpdateAuthorization--> RA +3: RA ---PerformValidation--> VA +4: Client <~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~> VA +5: RA <-------return--------- VA +6: WFEv2 <--------return---------- RA +7: Client <--------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Look up the referenced authorization object + * Look up the referenced challenge within the authorization object + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Verify that the JWS key corresponds to the authorization + +* 2-3: RA does the following: + * Store the updated authorization object + +* 3-4: VA does the following: + * Dispatch a goroutine to do validation + +* 4-5: RA does the following: + * Return the updated authorization object + +* 5-6: WFEv2 does the following: + * Return the updated authorization object + +* 6: VA does the following: + * Validate domain control according to the challenge responded to + * Notify the RA of the result + +* 6-7: RA does the following: + * Check that a sufficient set of challenges has been validated + * Mark the authorization as valid or invalid + * Store the updated authorization object + +* 6-7: WFEv2 does the following: + * Return the updated challenge object + +## Authorization Poll + +ACME v2: + +``` +1: Client ---authz--> WFEv2 +2: Client <---------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Look up the referenced authorization + * Verify that the request is a GET + * Return the authorization object + +## Order Poll (ACME v2 Only) + +ACME v1: +This version of the protocol does not use order objects. + +ACME v2: + +``` +1: Client ---order--> WFEv2 +2: Client <---------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Look up the referenced order + * Return the order object + +## New Certificate (ACME v1 Only) + +ACME v2: +This version of the protocol expects certificate issuance to occur only through +order finalization and does not offer the new-cert endpoint. + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Verify that the client has indicated agreement to terms + * Parse the certificate request object + +* 3-4: RA does the following: + * Verify the PKCS#10 CSR in the certificate request object + * Verify that the CSR has a non-zero number of domain names + * Verify that the public key in the CSR is different from the account key + * For each authorization referenced in the certificate request + * Retrieve the authorization from the database + * Verify that the authorization corresponds to the account key + * Verify that the authorization is valid + * Verify that the CAA policy for the identifier is still valid + * Verify that all domains in the CSR are covered by authorizations + * Compute the earliest expiration date among the authorizations + * Instruct the CA to issue a precertificate + +* 3-4: CA does the following: + * Verify that the public key in the CSR meets quality requirements + * RSA only for the moment + * Modulus >= 2048 bits and not divisible by small primes + * Exponent > 2^16 + * Remove any duplicate names in the CSR + * Verify that all names are allowed by policy (also checked at new-authz time) + * Verify that the issued cert will not be valid longer than the CA cert + * Verify that the issued cert will not be valid longer than the underlying authorizations + * Open a CA DB transaction and allocate a new serial number + * Sign a poisoned precertificate + +* 5-6: RA does the following: + * Collect the SCTs needed to satisfy the ctpolicy + * Instruct the CA to issue a final certificate with the SCTs + +* 5-6: CA does the following: + * Remove the precertificate poison and sign a final certificate with SCTs provided by the RA + * Create the first OCSP response for the final certificate + * Sign the final certificate and the first OCSP response + * Store the final certificate + * Commit the CA DB transaction if everything worked + * Return the final certificate serial number + +* 6-7: RA does the following: + * Log the success or failure of the request + * Return the certificate object + +* 7-8: WFEv2 does the following: + * Create a URL from the certificate's serial number + * Return the certificate with its URL + +## Order Finalization (ACME v2 Only) + +ACME v2: + +``` +1: Client ---order finalize--> WFEv2 +2: WFEv2 ----FinalizeOrder--> RA +3: RA ----------IssuePreCertificate---------> CA +4: RA <---------------return----------------- CA +5: RA ---IssueCertificateForPrecertificate--> CA +6: RA <---------------return----------------- CA +7: WFEv2 <----return--------- RA +8: Client <------------- WFEv2 +``` + +* 1-2: WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is by a registered key + * Verify the registered account owns the order being finalized + * Parse the certificate signing request (CSR) from the request + +* 2-4: RA does the following: + * Verify the PKCS#10 CSR in the certificate request object + * Verify that the CSR has a non-zero number of domain names + * Verify that the public key in the CSR is different from the account key + * Retrieve and verify the status and expiry of the order object + * For each identifier referenced in the order request + * Retrieve the authorization from the database + * Verify that the authorization corresponds to the account key + * Verify that the authorization is valid + * Verify that the CAA policy for the identifier is still valid + * Verify that all domains in the order are included in the CSR + * Instruct the CA to issue a precertificate + +* 3-4: CA does the following: + * Verify that the public key in the CSR meets quality requirements + * RSA only for the moment + * Modulus >= 2048 bits and not divisible by small primes + * Exponent > 2^16 + * Remove any duplicate names in the CSR + * Verify that all names are allowed by policy (also checked at new-authz time) + * Verify that the issued cert will not be valid longer than the CA cert + * Verify that the issued cert will not be valid longer than the underlying authorizations + * Open a CA DB transaction and allocate a new serial number + * Sign a poisoned precertificate + +* 5-6: RA does the following + * Collect the SCTs needed to satisfy the ctpolicy + * Instruct the CA to issue a final certificate with the SCTs + +* 5-6: CA does the following: + * Sign a final certificate with SCTs provided by the RA + * Create the first OCSP response for the final certificate + * Sign the final certificate and the first OCSP response + * Store the final certificate + * Commit the CA DB transaction if everything worked + * Return the final certificate serial number + +* 6-7: RA does the following: + * Log the success or failure of the request + * Updates the order to have status valid if the request succeeded + * Updates the order with the serial number of the certificate object + +* 7-8: WFEv2 does the following: + * Create a URL from the order's certificate's serial number + * Return the order with a certificate URL + +## Revoke Certificate + +ACME v2: + +``` +1: Client ---cert--> WFEv2 +2: WFEv2 ---RevokeCertByApplicant--> RA +3: WFEv2 <-----------return--------- RA +4: Client <--------- WFEv2 +``` +or +``` +1: Client ---cert--> WFEv2 +2: WFEv2 ------RevokeCertByKey-----> RA +3: WFEv2 <-----------return--------- RA +4: Client <--------- WFEv2 +``` + + +* 1-2:WFEv2 does the following: + * Verify that the request is a POST + * Verify the JWS signature on the POST body + * Verify that the JWS signature is either: + * The account key for the certificate, or + * The account key for an account with valid authorizations for all names in + the certificate, or + * The public key from the certificate + * Parse the certificate request object + +* 3-4: RA does the following: + * Mark the certificate as revoked. + * Log the success or failure of the revocation + +* Later, (not-pictured) the CA will: + * Sign an OCSP response indicating revoked status for this certificate + * Store the OCSP response in the database + +* 3-4: WFEv2 does the following: + * Return an indication of the success or failure of the revocation diff --git a/third-party/github.com/letsencrypt/boulder/docs/ISSUANCE-CYCLE.md b/third-party/github.com/letsencrypt/boulder/docs/ISSUANCE-CYCLE.md new file mode 100644 index 000000000..eb365c3e9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/ISSUANCE-CYCLE.md @@ -0,0 +1,51 @@ +# The Issuance Cycle + +What happens during an ACME finalize request? + +At a high level: + +1. Check that all authorizations are good. +2. Recheck CAA for hostnames that need it. +3. Allocate and store a serial number. +4. Select a certificate profile. +5. Generate and store linting certificate, set status to "wait" (precommit). +6. Sign, log (and don't store) precertificate, set status to "good". +7. Submit precertificate to CT. +8. Generate linting final certificate. Not logged or stored. +9. Sign, log, and store final certificate. + +Revocation can happen at any time after (5), whether or not step (6) was successful. We do things this way so that even in the event of a power failure or error storing data, we have a record of what we planned to sign (the tbsCertificate bytes of the linting certificate). + +Note that to avoid needing a migration, we chose to store the linting certificate from (5)in the "precertificates" table, which is now a bit of a misnomer. + +# OCSP Status state machine: + +wait -> good -> revoked + \ + -> revoked + +Serial numbers with a "wait" status recorded have not been submitted to CT, +because issuing the precertificate is a prerequisite to setting the status to +"good". And because they haven't been submitted to CT, they also haven't been +turned into a final certificate, nor have they been returned to a user. + +OCSP requests for serial numbers in "wait" status will return 500, but we expect +not to serve any 500s in practice because these serial numbers never wind up in +users' hands. Serial numbers in "wait" status are not added to CRLs. + +Note that "serial numbers never wind up in users' hands" does not relieve us of +any compliance duties. Our duties start from the moment of signing a +precertificate with trusted key material. + +Since serial numbers in "wait" status _may_ have had a precertificate signed, +We need the ability to set revocation status for them. For instance if the public key +we planned to sign for turns out to be weak or compromised, we would want to serve +a revoked status for that serial. However since they also _may not_ have had a +Precertificate signed, we also can't serve an OCSP "good" status. That's why we +serve 500. A 500 is appropriate because the only way a serial number can have "wait" +status for any significant amount of time is if there was an internal error of some +sort: an error during or before signing, or an error storing a record of the +signing success in the database. + +For clarity, "wait" is not an RFC 6960 status, but is an internal placeholder +value specific to Boulder. diff --git a/third-party/github.com/letsencrypt/boulder/docs/acme-divergences.md b/third-party/github.com/letsencrypt/boulder/docs/acme-divergences.md new file mode 100644 index 000000000..4a6e7a88b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/acme-divergences.md @@ -0,0 +1,40 @@ +# Boulder divergences from ACME + +While Boulder attempts to implement the ACME specification ([RFC 8555]) as strictly as possible there are places at which we will diverge from the letter of the specification for various reasons. This document describes the difference between [RFC 8555] and Boulder's implementation of ACME, informally called ACMEv2 and available at https://acme-v02.api.letsencrypt.org/directory. A listing of RFC conformant design decisions that may differ from other ACME servers is listed in [implementation_details](https://github.com/letsencrypt/boulder/blob/main/docs/acme-implementation_details.md). + +Presently, Boulder diverges from the [RFC 8555] ACME spec in the following ways: + +## [Section 6.3](https://tools.ietf.org/html/rfc8555#section-6.3) + +Boulder supports POST-as-GET but does not mandate it for requests +that simply fetch a resource (certificate, order, authorization, or challenge). + +## [Section 6.6](https://tools.ietf.org/html/rfc8555#section-6.6) + +For all rate-limits, Boulder includes a `Link` header to additional documentation on rate-limiting. Only rate-limits on `duplicate certificates` and `certificates per registered domain` are accompanied by a `Retry-After` header. + +## [Section 7.1.2](https://tools.ietf.org/html/rfc8555#section-7.1.2) + +Boulder does not supply the `orders` field on account objects. We intend to +support this non-essential feature in the future. Please follow Boulder Issue +[#3335](https://github.com/letsencrypt/boulder/issues/3335). + +## [Section 7.4](https://tools.ietf.org/html/rfc8555#section-7.4) + +Boulder does not accept the optional `notBefore` and `notAfter` fields of a +`newOrder` request paylod. + +## [Section 7.4.1](https://tools.ietf.org/html/rfc8555#section-7.4.1) + +Pre-authorization is an optional feature and we have no plans to implement it. +V2 clients should use order based issuance without pre-authorization. + +## [Section 7.4.2](https://tools.ietf.org/html/rfc8555#section-7.4.2) + +Boulder does not process `Accept` headers for `Content-Type` negotiation when retrieving certificates. + +## [Section 8.2](https://tools.ietf.org/html/rfc8555#section-8.2) + +Boulder does not implement the ability to retry challenges or the `Retry-After` header. + +[RFC 8555]: https://tools.ietf.org/html/rfc8555 diff --git a/third-party/github.com/letsencrypt/boulder/docs/acme-implementation_details.md b/third-party/github.com/letsencrypt/boulder/docs/acme-implementation_details.md new file mode 100644 index 000000000..99c9a9b00 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/acme-implementation_details.md @@ -0,0 +1,76 @@ +# Boulder implementation details + +The ACME specification ([RFC 8555]) clearly dictates what Clients and Servers +must do to properly implement the protocol. + +The specification is intentionally silent, or vague, on certain points to give +developers freedom in making certain decisions or to follow guidance from other +RFCs. Due to this, two ACME Servers might fully conform to the RFC but behave +slightly differently. ACME Clients should not "over-fit" on Boulder or the +Let's Encrypt production service, and aim to be compatible with a wide range of +ACME Servers, including the [Pebble](https://github.com/letsencrypt/pebble) +test server. + +The following items are a partial listing of RFC-conformant design decisions +Boulder and/or LetsEncrypt have made. This listing is not complete, and is +based on known details which have caused issues for developers in the past. This +listing may not reflect the current status of Boulder or the configuration of +LetsEncrypt's production instance and is provided only as a reference for client +developers. + +Please note: these design implementation decisions are fully conformant with the +RFC specification and are not +[divergences](https://github.com/letsencrypt/boulder/blob/main/docs/acme-divergences.md). + + +## Object Reuse + +The ACME specification does not prohibit certain objects to be re-used. + +### Authorization + +Boulder may recycle previously "valid" or "pending" `Authorizations` for a given +`Account` when creating a new `Order`. + +### Order + +Boulder may return a previously created `Order` when a given `Account` submits +a new `Order` that is identical to a previously submitted `Order` that is in +the "pending" or "ready" state. + +## Alternate Chains + +The production Boulder instance for LetsEncrypt in enabled with support for +Alternate chains. + + +## Certificate Request Domains + +The RFC states the following: + + The CSR MUST indicate the exact same + set of requested identifiers as the initial newOrder request. + Identifiers of type "dns" MUST appear either in the commonName + portion of the requested subject name or in an extensionRequest + attribute [RFC2985] requesting a subjectAltName extension, or both. + +Boulder requires all domains to be specified in the `subjectAltName` +extension, and will reject a CSR if a domain specified in the `commonName` is +not present in the `subjectAltName`. Additionally, usage of the `commonName` +was previously deprecated by the CA/B Forum and in earlier RFCs. + +For more information on this see [Pebble Issue #304](https://github.com/letsencrypt/pebble/issues/304) +and [Pebble Issue #233](https://github.com/letsencrypt/pebble/issues/233). + + +## RSA Key Size + +The ACME specification is silent as to minimum key size. +The [CA/Browser Forum](https://cabforum.org/) sets the key size requirements +which LetsEncrypt adheres to. + +Effective 2020-09-17, LetsEncrypt further requires all RSA keys for end-entity +(leaf) certificates have a modulus of length 2048, 3072, or 4096. Other CAs may +or may not have the same restricted set of supported RSA key sizes. +For more information +[read the Official Announcement](https://community.letsencrypt.org/t/issuing-for-common-rsa-key-sizes-only/133839). diff --git a/third-party/github.com/letsencrypt/boulder/docs/config-validation.md b/third-party/github.com/letsencrypt/boulder/docs/config-validation.md new file mode 100644 index 000000000..6f22e169e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/config-validation.md @@ -0,0 +1,183 @@ +# Configuration Validation + +We use a fork of https://github.com/go-playground/validator which can be found +at https://github.com/letsencrypt/validator. + +## Usage + +By default Boulder validates config files for all components with a registered +validator. Validating a config file for a given component is as simple as +running the component directly: + +```shell +$ ./bin/boulder-observer -config test/config-next/observer.yml +Error validating config file "test/config-next/observer.yml": Key: 'ObsConf.MonConfs[1].Kind' Error:Field validation for 'Kind' failed on the 'oneof' tag +``` + +or by running the `boulder` binary and passing the component name as a +subcommand: + +```shell +$ ./bin/boulder boulder-observer -config test/config-next/observer.yml +Error validating config file "test/config-next/observer.yml": Key: 'ObsConf.MonConfs[1].Kind' Error:Field validation for 'Kind' failed on the 'oneof' tag +``` + +## Struct Tag Tips + +You can find the full list of struct tags supported by the validator [here] +(https://pkg.go.dev/github.com/go-playground/validator/v10#section-documentation). +The following are some tips for struct tags that are commonly used in our +configuration files. + +### `required` + +The required tag means that the field is not allowed to take its zero value, or +equivalently, is not allowed to be omitted. Note that this does not validate +that slices or maps have contents, it simply guarantees that they are not nil. +For fields of those types, you should use min=1 or similar to ensure they are +not empty. + +There are also "conditional" required tags, such as `required_with`, +`required_with_all`, `required_without`, `required_without_all`, and +`required_unless`. These behave exactly like the basic required tag, but only if +their conditional (usually the presence or absence of one or more other named +fields) is met. + +### `omitempty` + +The omitempty tag allows a field to be empty, or equivalently, to take its zero +value. If the field is omitted, none of the other validation tags on the field +will be enforced. This can be useful for tags like validate="omitempty,url", for +a field which is optional, but must be a URL if it is present. + +The omitempty tag can be "overruled" by the various conditional required tags. +For example, a field with tag `validate="omitempty,url,required_with=Foo"` is +allowed to be empty when field Foo is not present, but if field Foo is present, +then this field must be present and must be a URL. + +### `-` + +Normally, config validation descends into all struct-type fields, recursively +validating their fields all the way down. Sometimes this can pose a problem, +when a nested struct declares one of its fields as required, but a parent struct +wants to treat the whole nested struct as optional. The "-" tag tells the +validation not to recurse, marking the tagged field as optional, and therefore +making all of its sub-fields optional as well. We use this tag for many config +duration and password file struct valued fields which are optional in some +configs but required in others. + +### `structonly` + +The structonly tag allows a struct valued field to be empty, or equivalently, to +take its zero value, if it's not "overruled" by various conditional tags. If the +field is omitted the recursive validation of the structs fields will be skipped. +This can be useful for tags like `validate:"required_without=Foo,structonly"` +for a struct valued field which is only required, and thus should only be +validated, if field `Foo` is not present. + +### `min=1`, `gte=1` + +These validate that the value of integer valued field is greater than zero and +that the length of the slice or map is greater than zero. + +For instance, the following would be valid config for a slice valued field +tagged with `required`. +```json +{ + "foo": [], +} +``` + +But, only the following would be valid config for a slice valued field tagged +with `min=1`. +```json +{ + "foo": ["bar"], +} +``` + +### `len` + +Same as `eq` (equal to) but can also be used to validate the length of the +strings. + +### `hostname_port` + +The +[docs](https://pkg.go.dev/github.com/go-playground/validator/v10#hdr-HostPort) +for this tag are scant with detail, but it validates that the value is a valid +RFC 1123 hostname and port. It is used to validate many of the +`ListenAddress` and `DebugAddr` fields of our components. + +#### Future Work + +This tag is compatible with IPv4 addresses, but not IPv6 addresses. We should +consider fixing this in our fork of the validator. + +### `dive` + +This tag is used to validate the values of a slice or map. For instance, the +following would be valid config for a slice valued field (`[]string`) tagged +with `min=1,dive,oneof=bar baz`. + +```json +{ + "foo": ["bar", "baz"], +} +``` + +Note that the `dive` tag introduces an order-dependence in writing tags: tags +that come before `dive` apply to the current field, while tags that come after +`dive` apply to the current field's child values. In the example above: `min=1` +applies to the length of the slice (`[]string`), while `oneof=bar baz` applies +to the value of each string in the slice. + +We can also use `dive` to validate the values of a map. For instance, the +following would be valid config for a map valued field (`map[string]string`) +tagged with `min=1,dive,oneof=one two`. + +```json +{ + "foo": { + "bar": "one", + "baz": "two" + }, +} +``` + +`dive` can also be invoked multiple times to validate the values of nested +slices or maps. For instance, the following would be valid config for a slice of +slice valued field (`[][]string`) tagged with `min=1,dive,min=2,dive,oneof=bar +baz`. + +```json +{ + "foo": [ + ["bar", "baz"], + ["baz", "bar"], + ], +} +``` + +- `min=1` will be applied to the outer slice (`[]`). +- `min=2` will be applied to inner slice (`[]string`). +- `oneof=bar baz` will be applied to each string in the inner slice. + +### `keys` and `endkeys` + +These tags are used to validate the keys of a map. For instance, the following +would be valid config for a map valued field (`map[string]string`) tagged with +`min=1,dive,keys,eq=1|eq=2,endkeys,required`. + +```json +{ + "foo": { + "1": "bar", + "2": "baz", + }, +} +``` + +- `min=1` will be applied to the map itself +- `eq=1|eq=2` will be applied to the map keys +- `required` will be applied to map values diff --git a/third-party/github.com/letsencrypt/boulder/docs/error-handling.md b/third-party/github.com/letsencrypt/boulder/docs/error-handling.md new file mode 100644 index 000000000..34ef01671 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/error-handling.md @@ -0,0 +1,11 @@ +# Error Handling Guidance + +Previously Boulder has used a mix of various error types to represent errors internally, mainly the `core.XXXError` types and `probs.ProblemDetails`, without any guidance on which should be used when or where. + +We have switched away from this to using a single unified internal error type, `boulder/errors.BoulderError` which should be used anywhere we need to pass errors between components and need to be able to indicate and test the type of the error that was passed. `probs.ProblemDetails` should only be used in the WFE when creating a problem document to pass directly back to the user client. + +A mapping exists in the WFE to map all of the available `boulder/errors.ErrorType`s to the relevant `probs.ProblemType`s. Internally errors should be wrapped when doing so provides some further context to the error that aides in debugging or will be passed back to the user client. An error may be unwrapped, or a simple stdlib `error` may be used, but doing so means the `probs.ProblemType` mapping will always be `probs.ServerInternalProblem` so should only be used for errors that do not need to be presented back to the user client. + +`boulder/errors.BoulderError`s have two components: an internal type, `boulder/errors.ErrorType`, and a detail string. The internal type should be used for a. allowing the receiver to determine what caused the error, e.g. by using `boulder/errors.NotFound` to indicate a DB operation couldn't find the requested resource, and b. allowing the WFE to convert the error to the relevant `probs.ProblemType` for display to the user. The detail string should provide a user readable explanation of the issue to be presented to the user; the only exception to this is when the internal type is `boulder/errors.InternalServer` in which case the detail of the error will be stripped by the WFE and the only message presented to the user will be provided by the caller in the WFE. + +Error type testing should be done with `boulder/errors.Is` instead of locally doing a type cast test. diff --git a/third-party/github.com/letsencrypt/boulder/docs/logging.md b/third-party/github.com/letsencrypt/boulder/docs/logging.md new file mode 100644 index 000000000..9fc6405d0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/logging.md @@ -0,0 +1,53 @@ +# Logging + +Boulder can log to stdout/stderr, syslog, or both. Boulder components +generally have a `syslog` portion of their JSON config that indicates the +maximum level of log that should be sent to a given destination. For instance, +in `test/config/wfe2.json`: + +``` + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 6 + }, +``` + +This indicates that logs of level 4 or below (error and warning) should be +emitted to stdout/stderr, and logs of level 6 or below (error, warning, notice, and +info) should be emitted to syslog, using the local Unix socket method. The +highest meaningful value is 7, which enables debug logging. + +The stdout/stderr logger uses ANSI escape codes to color warnings as yellow +and errors as red, if stdout is detected to be a terminal. + +The default value for these fields is 6 (INFO) for syslogLevel and 0 (no logs) +for stdoutLevel. To turn off syslog logging entirely, set syslogLevel to -1. + +In Boulder's development environment, we enable stdout logging because that +makes it easier to see what's going on quickly. In production, we disable stdout +logging because it would duplicate the syslog logging. We preferred the syslog +logging because it provides things like severity level in a consistent way with +other components. But we may move to stdout/stderr logging to make it easier to +containerize Boulder. + +Boulder has a number of adapters to take other packages' log APIs and send them +to syslog as expected. For instance, we provide a custom logger for mysql, grpc, +and prometheus that forwards to syslog. This is configured in StatsAndLogging in +cmd/shell.go. + +There are some cases where we output to stdout regardless of the JSON config +settings: + + - Panics are always emitted to stdout + - Packages that Boulder relies on may occasionally emit to stdout (though this + is generally not ideal and we try to get it changed). + +Typically these output lines will be collected by systemd and forwarded to +syslog. + +## Verification + +We attach a simple checksum to each log line. This is not a cryptographically +secure hash, but is intended to let us catch corruption in the log system. This +is a short chunk of base64 encoded data near the beginning of the log line. It +is consumed by cmd/log-validator. diff --git a/third-party/github.com/letsencrypt/boulder/docs/multi-va.md b/third-party/github.com/letsencrypt/boulder/docs/multi-va.md new file mode 100644 index 000000000..4c8df880d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/multi-va.md @@ -0,0 +1,52 @@ +# Multi-VA implementation + +Boulder supports a multi-perspective validation feature intended to increase +resilience against local network hijacks and BGP attacks. It is currently +[deployed in a production +capacity](https://letsencrypt.org/2020/02/19/multi-perspective-validation.html) +by Let's Encrypt. + +If you follow the [Development Instructions](https://github.com/letsencrypt/boulder#development) +to set up a Boulder environment in Docker and then change your `docker-compose.yml`'s +`BOULDER_CONFIG_DIR` to `test/config-next` instead of `test/config` you'll have +a Boulder environment configured with two primary VA instances (validation +requests are load balanced across the two) and two remote VA instances (each +primary VA will ask both remote VAs to perform matching validations for each +primary validation). Of course this is a development environment so both the +primary and remote VAs are all running on one host. + +The `boulder-va` service ([here](https://github.com/letsencrypt/boulder/tree/main/cmd/boulder-va) and `remoteva` service ([here](https://github.com/letsencrypt/boulder/tree/main/cmd/remoteva)) are distinct pieces of software that utilize the same package ([here](https://github.com/letsencrypt/boulder/tree/main/va)). +The boulder-ra uses [the same RPC interface](https://github.com/letsencrypt/boulder/blob/ea231adc36746cce97f860e818c2cdf92f060543/va/proto/va.proto#L8-L10) +to ask for a primary validation as the primary VA uses to ask a remote VA for a +confirmation validation. + +Primary VA instances contain a `"remoteVAs"` configuration element. If present +it specifies gRPC service addresses for `remoteva` instances to use as remote +VAs. There's also a handful of feature flags that control how the primary VAs +handle the remote VAs. + +In the development environment with `config-next` the two primary VAs are `va1.service.consul:9092` and +`va2.service.consul:9092` and use +[`test/config-next/va.json`](https://github.com/letsencrypt/boulder/blob/ea231adc36746cce97f860e818c2cdf92f060543/test/config-next/va.json) +as their configuration. This config file specifies two `"remoteVA"s`, +`rva1.service.consul:9097` and `va2.service.consul:9098` and enforces +[that a maximum of 1 of the 2 remote VAs disagree](https://github.com/letsencrypt/boulder/blob/ea231adc36746cce97f860e818c2cdf92f060543/test/config-next/va.json#L44) +with the primary VA for all validations. The remote VA instances use +[`test/config-next/remoteva-a.json`](https://github.com/letsencrypt/boulder/blob/5c27eadb1db0605f380e41c8bd444a7f4ffe3c08/test/config-next/remoteva-a.json) +and +[`test/config-next/remoteva-b.json`](https://github.com/letsencrypt/boulder/blob/5c27eadb1db0605f380e41c8bd444a7f4ffe3c08/test/config-next/remoteva-b.json) +as their config files. + +We require that almost all remote validation requests succeed; the exact number +is controlled by the VA's `maxRemoteFailures` config variable. If the number of +failing remote VAs exceeds that threshold, validation is terminated. If the +number of successful remote VAs is high enough that it would be impossible for +the outstanding remote VAs to exceed that threshold, validation immediately +succeeds. + +There are some integration tests that test this end to end. The most relevant is +probably +[`test_http_multiva_threshold_fail`](https://github.com/letsencrypt/boulder/blob/ea231adc36746cce97f860e818c2cdf92f060543/test/v2_integration.py#L876-L908). +It tests that a HTTP-01 challenge made to a webserver that only gives the +correct key authorization to the primary VA and not the remotes will fail the +multi-perspective validation. diff --git a/third-party/github.com/letsencrypt/boulder/docs/redis.md b/third-party/github.com/letsencrypt/boulder/docs/redis.md new file mode 100644 index 000000000..5ef6a5b93 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/redis.md @@ -0,0 +1,50 @@ +# Redis + +We use Redis for OCSP. The Boulder dev environment stands up a two nodes. We use +the Ring client in the github.com/redis/go-redis package to consistently hash +our reads and writes across these two nodes. + +## Debugging + +Our main tool for interacting with our OCSP storage in Redis is cmd/rocsp-tool. +However, sometimes if things aren't working right you might want to drop down a +level. + +The first tool you might turn to is `redis-cli`. You probably don't +have redis-cli on your host, so we'll run it in a Docker container. We +also need to pass some specific arguments for TLS and authentication. There's a +script that handles all that for you: `test/redis-cli.sh`. First, make sure your +redis is running: + +```shell +docker compose up boulder +``` + +Then, in a different window, run the following to connect to `bredis_1`: + +```shell +./test/redis-cli.sh -h 10.33.33.2 +``` + +Similarly, to connect to `bredis_2`: + +```shell +./test/redis-cli.sh -h 10.33.33.3 +``` + +You can pass any IP address for the -h (host) parameter. The full list of IP +addresses for Redis nodes is in `docker-compose.yml`. You can also pass other +redis-cli commandline parameters. They'll get passed through. + +You may want to go a level deeper and communicate with a Redis node using the +Redis protocol. Here's the command to do that (run from the Boulder root): + +```shell +openssl s_client -connect 10.33.33.2:4218 \ + -CAfile test/certs/ipki/minica.pem \ + -cert test/certs/ipki/localhost/cert.pem \ + -key test/certs/ipki/localhost/key.pem +``` + +Then, first thing when you connect, run `AUTH `. You can get a +list of usernames and passwords from test/redis.config. diff --git a/third-party/github.com/letsencrypt/boulder/docs/release.md b/third-party/github.com/letsencrypt/boulder/docs/release.md new file mode 100644 index 000000000..8afc30e36 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/docs/release.md @@ -0,0 +1,133 @@ +# Boulder Release Process + +A description and demonstration of the full process for tagging a normal weekly +release, a "clean" hotfix release, and a "dirty" hotfix release. + +Once a release is tagged, it will be generally deployed to +[staging](https://letsencrypt.org/docs/staging-environment/) and then to +[production](https://acme-v02.api.letsencrypt.org/) over the next few days. + +## Goals + +1. All development, including reverts and hotfixes needed to patch a broken + release, happens on the `main` branch of this repository. Code is never + deployed without being reviewed and merged here first, and code is never + landed on a release branch that isn't landed on `main` first. + +2. Doing a normal release requires approximately zero thought. It Just Works. + +3. Doing a hotfix release differs as little as possible from the normal release + process. + +## Release Schedule + +Boulder developers make a new release at the beginning of each week, typically +around 10am PST **Monday**. Operations deploys the new release to the [staging +environment](https://letsencrypt.org/docs/staging-environment/) on **Tuesday**, +typically by 2pm PST. If there have been no issues discovered with the release +from its time in staging, then on **Thursday** the operations team deploys the +release to the production environment. + +Holidays, unexpected bugs, and other resource constraints may affect the above +schedule and result in staging or production updates being skipped. It should be +considered a guideline for normal releases but not a strict contract. + +## Release Structure + +All releases are tagged with a tag of the form `release-YYYY-MM-DD[x]`, where +the `YYYY-MM-DD` is the date that the initial release is cut (usually the Monday +of the current week), and the `[x]` is an optional lowercase letter suffix +indicating that the release is an incremental hotfix release. For example, the +second hotfix release (i.e. third release overall) in the third week of January +2022 was +[`release-2022-01-18b`](https://github.com/letsencrypt/boulder/releases/tag/release-2022-01-18b). + +All release tags are signed with a key associated with a Boulder developer. Tag +signatures are automatically verified by GitHub using the public keys that +developer has uploaded, and are additionally checked before being built and +deployed to our staging and production environments. Note that, due to how Git +works, in order for a tag to be signed it must also have a message; we set the +tag message to just be a slightly more readable version of the tag name. + +## Making a Release + +### Prerequisites + +* You must have a GPG key with signing capability: + * [Checking for existing GPG keys](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/checking-for-existing-gpg-keys) + +* If you don't have a GPG key with signing capability, create one: + * [Generating a new local GPG key](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/generating-a-new-gpg-key) + * [Generating a new Yubikey GPG key](https://support.yubico.com/hc/en-us/articles/360013790259-Using-Your-YubiKey-with-OpenPGP) + +* The signing GPG key must be added to your GitHub account: + * [Adding a new GPG key to your GitHub + account](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/adding-a-new-gpg-key-to-your-github-account) + +* `git` *may* need to be configured to call the correct GPG binary: + * The default: `git config --global gpg.program gpg` is correct for most Linux platforms + * On macOS and some Linux platforms: `git config --global gpg.program gpg2` is correct + +* `git` must be configured to use the correct GPG key: + * [Telling Git about your GPG key](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/telling-git-about-your-signing-key) + +* Understand the [process for signing tags](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/signing-tags) + +### Regular Releases + +Simply create a signed tag whose name and message both include the date that the +release is being tagged (not the date that the release is expected to be +deployed): + +```sh +git tag -s -m "Boulder release $(date +%F)" -s "release-$(date +%F)" +git push origin "release-$(date +%F)" +``` + +### Clean Hotfix Releases + +If a hotfix release is necessary, and the desired hotfix commits are the **only** commits which have landed on `main` since the initial release was cut (i.e. there are not any commits on `main` which we want to exclude from the hotfix release), then the hotfix tag can be created much like a normal release tag. + +If it is still the same day as an already-tagged release, increment the letter suffix of the tag: + +```sh +git tag -s -m "Boulder hotfix release $(date +%F)a" -s "release-$(date +%F)a" +git push origin "release-$(date +%F)a" +``` + +If it is a new day, simply follow the regular release process above. + +### Dirty Hotfix Release + +If a hotfix release is necessary, but `main` already contains both commits that +we do and commits that we do not want to include in the hotfix release, then we +must go back and create a release branch for just the desired commits to be +cherry-picked to. Then, all subsequent hotfix releases will be tagged on this +branch. + +The commands below assume that it is still the same day as the original release +tag was created (hence the use of "`date +%F`"), but this may not always be the +case. The rule is that the date in the release branch name should be identical +to the date in the original release tag. Similarly, this may not be the first +hotfix release; the rule is that the letter suffix should increment (e.g. "b", +"c", etc.) for each hotfix release with the same date. + +```sh +git checkout -b "release-branch-$(date +%F)" "release-$(date +%F)" +git cherry-pick baddecaf +git tag -s -m "Boulder hotfix release $(date +%F)a" "release-$(date +%F)a" +git push origin "release-branch-$(date +%F)" "release-$(date +%F)a" +``` + +## Deploying Releases + +When doing a release, SRE's tooling will check that: + +1. GitHub shows that tests have passed for the commit at the planned release + tag. + +2. The planned release tag is an ancestor of the current `main` on GitHub, or + the planned release tag is equal to the head of a branch named + `release-branch-XXX`, and all commits between `main` and the head of that + branch are cherry-picks of commits which landed on `main` following the + normal review process. diff --git a/third-party/github.com/letsencrypt/boulder/errors/errors.go b/third-party/github.com/letsencrypt/boulder/errors/errors.go new file mode 100644 index 000000000..d7328b08d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/errors/errors.go @@ -0,0 +1,264 @@ +// Package errors provides internal-facing error types for use in Boulder. Many +// of these are transformed directly into Problem Details documents by the WFE. +// Some, like NotFound, may be handled internally. We avoid using Problem +// Details documents as part of our internal error system to avoid layering +// confusions. +// +// These errors are specifically for use in errors that cross RPC boundaries. +// An error type that does not need to be passed through an RPC can use a plain +// Go type locally. Our gRPC code is aware of these error types and will +// serialize and deserialize them automatically. +package errors + +import ( + "fmt" + "time" + + "github.com/letsencrypt/boulder/identifier" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ErrorType provides a coarse category for BoulderErrors. +// Objects of type ErrorType should never be directly returned by other +// functions; instead use the methods below to create an appropriate +// BoulderError wrapping one of these types. +type ErrorType int + +// These numeric constants are used when sending berrors through gRPC. +const ( + // InternalServer is deprecated. Instead, pass a plain Go error. That will get + // turned into a probs.InternalServerError by the WFE. + InternalServer ErrorType = iota + _ + Malformed + Unauthorized + NotFound + RateLimit + RejectedIdentifier + InvalidEmail + ConnectionFailure + _ // Reserved, previously WrongAuthorizationState + CAA + MissingSCTs + Duplicate + OrderNotReady + DNS + BadPublicKey + BadCSR + AlreadyRevoked + BadRevocationReason + UnsupportedContact + // The requesteed serial number does not exist in the `serials` table. + UnknownSerial + // The certificate being indicated for replacement already has a replacement + // order. + Conflict +) + +func (ErrorType) Error() string { + return "urn:ietf:params:acme:error" +} + +// BoulderError represents internal Boulder errors +type BoulderError struct { + Type ErrorType + Detail string + SubErrors []SubBoulderError + + // RetryAfter the duration a client should wait before retrying the request + // which resulted in this error. + RetryAfter time.Duration +} + +// SubBoulderError represents sub-errors specific to an identifier that are +// related to a top-level internal Boulder error. +type SubBoulderError struct { + *BoulderError + Identifier identifier.ACMEIdentifier +} + +func (be *BoulderError) Error() string { + return be.Detail +} + +func (be *BoulderError) Unwrap() error { + return be.Type +} + +// GRPCStatus implements the interface implicitly defined by gRPC's +// status.FromError, which uses this function to detect if the error produced +// by the gRPC server implementation code is a gRPC status.Status. Implementing +// this means that BoulderErrors serialized in gRPC response metadata can be +// accompanied by a gRPC status other than "UNKNOWN". +func (be *BoulderError) GRPCStatus() *status.Status { + var c codes.Code + switch be.Type { + case InternalServer: + c = codes.Internal + case Malformed: + c = codes.InvalidArgument + case Unauthorized: + c = codes.PermissionDenied + case NotFound: + c = codes.NotFound + case RateLimit: + c = codes.Unknown + case RejectedIdentifier: + c = codes.InvalidArgument + case InvalidEmail: + c = codes.InvalidArgument + case ConnectionFailure: + c = codes.Unavailable + case CAA: + c = codes.FailedPrecondition + case MissingSCTs: + c = codes.Internal + case Duplicate: + c = codes.AlreadyExists + case OrderNotReady: + c = codes.FailedPrecondition + case DNS: + c = codes.Unknown + case BadPublicKey: + c = codes.InvalidArgument + case BadCSR: + c = codes.InvalidArgument + case AlreadyRevoked: + c = codes.AlreadyExists + case BadRevocationReason: + c = codes.InvalidArgument + case UnsupportedContact: + c = codes.InvalidArgument + default: + c = codes.Unknown + } + return status.New(c, be.Error()) +} + +// WithSubErrors returns a new BoulderError instance created by adding the +// provided subErrs to the existing BoulderError. +func (be *BoulderError) WithSubErrors(subErrs []SubBoulderError) *BoulderError { + return &BoulderError{ + Type: be.Type, + Detail: be.Detail, + SubErrors: append(be.SubErrors, subErrs...), + RetryAfter: be.RetryAfter, + } +} + +// New is a convenience function for creating a new BoulderError +func New(errType ErrorType, msg string, args ...interface{}) error { + return &BoulderError{ + Type: errType, + Detail: fmt.Sprintf(msg, args...), + } +} + +func InternalServerError(msg string, args ...interface{}) error { + return New(InternalServer, msg, args...) +} + +func MalformedError(msg string, args ...interface{}) error { + return New(Malformed, msg, args...) +} + +func UnauthorizedError(msg string, args ...interface{}) error { + return New(Unauthorized, msg, args...) +} + +func NotFoundError(msg string, args ...interface{}) error { + return New(NotFound, msg, args...) +} + +func RateLimitError(retryAfter time.Duration, msg string, args ...interface{}) error { + return &BoulderError{ + Type: RateLimit, + Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/", args...), + RetryAfter: retryAfter, + } +} + +func DuplicateCertificateError(retryAfter time.Duration, msg string, args ...interface{}) error { + return &BoulderError{ + Type: RateLimit, + Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/duplicate-certificate-limit/", args...), + RetryAfter: retryAfter, + } +} + +func FailedValidationError(retryAfter time.Duration, msg string, args ...interface{}) error { + return &BoulderError{ + Type: RateLimit, + Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/failed-validation-limit/", args...), + RetryAfter: retryAfter, + } +} + +func RegistrationsPerIPError(retryAfter time.Duration, msg string, args ...interface{}) error { + return &BoulderError{ + Type: RateLimit, + Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/too-many-registrations-for-this-ip/", args...), + RetryAfter: retryAfter, + } +} + +func RejectedIdentifierError(msg string, args ...interface{}) error { + return New(RejectedIdentifier, msg, args...) +} + +func InvalidEmailError(msg string, args ...interface{}) error { + return New(InvalidEmail, msg, args...) +} + +func UnsupportedContactError(msg string, args ...interface{}) error { + return New(UnsupportedContact, msg, args...) +} + +func ConnectionFailureError(msg string, args ...interface{}) error { + return New(ConnectionFailure, msg, args...) +} + +func CAAError(msg string, args ...interface{}) error { + return New(CAA, msg, args...) +} + +func MissingSCTsError(msg string, args ...interface{}) error { + return New(MissingSCTs, msg, args...) +} + +func DuplicateError(msg string, args ...interface{}) error { + return New(Duplicate, msg, args...) +} + +func OrderNotReadyError(msg string, args ...interface{}) error { + return New(OrderNotReady, msg, args...) +} + +func DNSError(msg string, args ...interface{}) error { + return New(DNS, msg, args...) +} + +func BadPublicKeyError(msg string, args ...interface{}) error { + return New(BadPublicKey, msg, args...) +} + +func BadCSRError(msg string, args ...interface{}) error { + return New(BadCSR, msg, args...) +} + +func AlreadyRevokedError(msg string, args ...interface{}) error { + return New(AlreadyRevoked, msg, args...) +} + +func BadRevocationReasonError(reason int64) error { + return New(BadRevocationReason, "disallowed revocation reason: %d", reason) +} + +func UnknownSerialError() error { + return New(UnknownSerial, "unknown serial") +} + +func ConflictError(msg string, args ...interface{}) error { + return New(Conflict, msg, args...) +} diff --git a/third-party/github.com/letsencrypt/boulder/errors/errors_test.go b/third-party/github.com/letsencrypt/boulder/errors/errors_test.go new file mode 100644 index 000000000..675b23597 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/errors/errors_test.go @@ -0,0 +1,50 @@ +package errors + +import ( + "testing" + + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/test" +) + +// TestWithSubErrors tests that a boulder error can be created by adding +// suberrors to an existing top level boulder error +func TestWithSubErrors(t *testing.T) { + topErr := &BoulderError{ + Type: RateLimit, + Detail: "don't you think you have enough certificates already?", + } + + subErrs := []SubBoulderError{ + { + Identifier: identifier.DNSIdentifier("example.com"), + BoulderError: &BoulderError{ + Type: RateLimit, + Detail: "everyone uses this example domain", + }, + }, + { + Identifier: identifier.DNSIdentifier("what about example.com"), + BoulderError: &BoulderError{ + Type: RateLimit, + Detail: "try a real identifier value next time", + }, + }, + } + + outResult := topErr.WithSubErrors(subErrs) + // The outResult should be a new, distinct error + test.AssertNotEquals(t, topErr, outResult) + // The outResult error should have the correct sub errors + test.AssertDeepEquals(t, outResult.SubErrors, subErrs) + // Adding another suberr shouldn't squash the original sub errors + anotherSubErr := SubBoulderError{ + Identifier: identifier.DNSIdentifier("another ident"), + BoulderError: &BoulderError{ + Type: RateLimit, + Detail: "another rate limit err", + }, + } + outResult = outResult.WithSubErrors([]SubBoulderError{anotherSubErr}) + test.AssertDeepEquals(t, outResult.SubErrors, append(subErrs, anotherSubErr)) +} diff --git a/third-party/github.com/letsencrypt/boulder/features/features.go b/third-party/github.com/letsencrypt/boulder/features/features.go new file mode 100644 index 000000000..c3d6be771 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/features/features.go @@ -0,0 +1,130 @@ +// features provides the Config struct, which is used to define feature flags +// that can affect behavior across Boulder components. It also maintains a +// global singleton Config which can be referenced by arbitrary Boulder code +// without having to pass a collection of feature flags through the function +// call graph. +package features + +import ( + "sync" +) + +// Config contains one boolean field for every Boulder feature flag. It can be +// included directly in an executable's Config struct to have feature flags be +// automatically parsed by the json config loader; executables that do so must +// then call features.Set(parsedConfig) to load the parsed struct into this +// package's global Config. +type Config struct { + // Deprecated features. These features have no effect. Removing them from + // configuration is safe. + // + // Once all references to them have been removed from deployed configuration, + // they can be deleted from this struct, after which Boulder will fail to + // start if they are present in configuration. + CAAAfterValidation bool + AllowNoCommonName bool + SHA256SubjectKeyIdentifier bool + EnforceMultiVA bool + MultiVAFullResults bool + CertCheckerRequiresCorrespondence bool + + // ECDSAForAll enables all accounts, regardless of their presence in the CA's + // ecdsaAllowedAccounts config value, to get issuance from ECDSA issuers. + ECDSAForAll bool + + // ServeRenewalInfo exposes the renewalInfo endpoint in the directory and for + // GET requests. WARNING: This feature is a draft and highly unstable. + ServeRenewalInfo bool + + // ExpirationMailerUsesJoin enables using a JOIN query in expiration-mailer + // rather than a SELECT from certificateStatus followed by thousands of + // one-row SELECTs from certificates. + ExpirationMailerUsesJoin bool + + // CertCheckerChecksValidations enables an extra query for each certificate + // checked, to find the relevant authzs. Since this query might be + // expensive, we gate it behind a feature flag. + CertCheckerChecksValidations bool + + // CertCheckerRequiresValidations causes cert-checker to fail if the + // query enabled by CertCheckerChecksValidations didn't find corresponding + // authorizations. + CertCheckerRequiresValidations bool + + // AsyncFinalize enables the RA to return approximately immediately from + // requests to finalize orders. This allows us to take longer getting SCTs, + // issuing certs, and updating the database; it indirectly reduces the number + // of issuances that fail due to timeouts during storage. However, it also + // requires clients to properly implement polling the Order object to wait + // for the cert URL to appear. + AsyncFinalize bool + + // DOH enables DNS-over-HTTPS queries for validation + DOH bool + + // EnforceMultiCAA causes the VA to kick off remote CAA rechecks when true. + // When false, no remote CAA rechecks will be performed. The primary VA will + // make a valid/invalid decision with the results. The primary VA will + // return an early decision if MultiCAAFullResults is false. + EnforceMultiCAA bool + + // MultiCAAFullResults will cause the main VA to block and wait for all of + // the remote VA CAA recheck results instead of returning early if the + // number of failures is greater than the configured + // maxRemoteValidationFailures. Only used when EnforceMultiCAA is true. + MultiCAAFullResults bool + + // TrackReplacementCertificatesARI, when enabled, triggers the following + // behavior: + // - SA.NewOrderAndAuthzs: upon receiving a NewOrderRequest with a + // 'replacesSerial' value, will create a new entry in the 'replacement + // Orders' table. This will occur inside of the new order transaction. + // - SA.FinalizeOrder will update the 'replaced' column of any row with + // a 'orderID' matching the finalized order to true. This will occur + // inside of the finalize (order) transaction. + TrackReplacementCertificatesARI bool + + // MultipleCertificateProfiles, when enabled, triggers the following + // behavior: + // - SA.NewOrderAndAuthzs: upon receiving a NewOrderRequest with a + // `certificateProfileName` value, will add that value to the database's + // `orders.certificateProfileName` column. Values in this column are + // allowed to be empty. + MultipleCertificateProfiles bool +} + +var fMu = new(sync.RWMutex) +var global = Config{} + +// Set changes the global FeatureSet to match the input FeatureSet. This +// overrides any previous changes made to the global FeatureSet. +// +// When used in tests, the caller must defer features.Reset() to avoid leaving +// dirty global state. +func Set(fs Config) { + fMu.Lock() + defer fMu.Unlock() + // If the FeatureSet type ever changes, this must be updated to still copy + // the input argument, never hold a reference to it. + global = fs +} + +// Reset resets all features to their initial state (false). +func Reset() { + fMu.Lock() + defer fMu.Unlock() + global = Config{} +} + +// Get returns a copy of the current global FeatureSet, indicating which +// features are currently enabled (set to true). Expected caller behavior looks +// like: +// +// if features.Get().FeatureName { ... +func Get() Config { + fMu.RLock() + defer fMu.RUnlock() + // If the FeatureSet type ever changes, this must be updated to still return + // only a copy of the current state, never a reference directly to it. + return global +} diff --git a/third-party/github.com/letsencrypt/boulder/go.mod b/third-party/github.com/letsencrypt/boulder/go.mod new file mode 100644 index 000000000..5f668f3a2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/go.mod @@ -0,0 +1,99 @@ +module github.com/letsencrypt/boulder + +go 1.22.0 + +require ( + github.com/aws/aws-sdk-go-v2 v1.27.2 + github.com/aws/aws-sdk-go-v2/config v1.27.18 + github.com/aws/aws-sdk-go-v2/service/s3 v1.55.1 + github.com/aws/smithy-go v1.20.2 + github.com/eggsampler/acme/v3 v3.6.0 + github.com/go-jose/go-jose/v4 v4.0.1 + github.com/go-logr/stdr v1.2.2 + github.com/go-sql-driver/mysql v1.5.0 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da + github.com/google/certificate-transparency-go v1.1.6 + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 + github.com/jmhodges/clock v1.2.0 + github.com/letsencrypt/borp v0.0.0-20230707160741-6cc6ce580243 + github.com/letsencrypt/challtestsrv v1.2.1 + github.com/letsencrypt/pkcs11key/v4 v4.0.0 + github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158 + github.com/miekg/dns v1.1.58 + github.com/miekg/pkcs11 v1.1.1 + github.com/nxadm/tail v1.4.11 + github.com/prometheus/client_golang v1.15.1 + github.com/prometheus/client_model v0.4.0 + github.com/redis/go-redis/v9 v9.3.0 + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 + github.com/weppos/publicsuffix-go v0.30.3-0.20240510084413-5f1d03393b3d + github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c + github.com/zmap/zlint/v3 v3.6.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.52.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 + go.opentelemetry.io/otel v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 + go.opentelemetry.io/otel/sdk v1.27.0 + go.opentelemetry.io/otel/trace v1.27.0 + golang.org/x/crypto v0.23.0 + golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 + golang.org/x/net v0.25.0 + golang.org/x/sync v0.7.0 + golang.org/x/term v0.20.0 + golang.org/x/text v0.15.0 + google.golang.org/grpc v1.64.0 + google.golang.org/protobuf v1.34.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/poy/onpar v1.1.2 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/tools v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + k8s.io/klog/v2 v2.100.1 // indirect +) + +// Versions of go-sql-driver/mysql >1.5.0 introduce performance regressions for +// us, so we exclude them. + +// This version is required by parts of the honeycombio/beeline-go package +exclude github.com/go-sql-driver/mysql v1.6.0 + +// This version is required by borp +exclude github.com/go-sql-driver/mysql v1.7.1 diff --git a/third-party/github.com/letsencrypt/boulder/go.sum b/third-party/github.com/letsencrypt/boulder/go.sum new file mode 100644 index 000000000..8d476f8cb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/go.sum @@ -0,0 +1,436 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/a8m/expect v1.0.0/go.mod h1:4IwSCMumY49ScypDnjNbYEjgVeqy1/U2cEs3Lat96eA= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= +github.com/aws/aws-sdk-go-v2 v1.27.2/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= +github.com/aws/aws-sdk-go-v2/config v1.27.18 h1:wFvAnwOKKe7QAyIxziwSKjmer9JBMH1vzIL6W+fYuKk= +github.com/aws/aws-sdk-go-v2/config v1.27.18/go.mod h1:0xz6cgdX55+kmppvPm2IaKzIXOheGJhAufacPJaXZ7c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18 h1:D/ALDWqK4JdY3OFgA2thcPO1c9aYTT5STS/CvnkqY1c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18/go.mod h1:JuitCWq+F5QGUrmMPsk945rop6bB57jdscu+Glozdnc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 h1:dDgptDO9dxeFkXy+tEgVkzSClHZje/6JkPW5aZyEvrQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5/go.mod h1:gjvE2KBUgUQhcv89jqxrIxH9GaKs1JbZzWejj/DaHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 h1:cy8ahBJuhtM8GTTSyOkfy6WVPV1IE+SS5/wfXUYuulw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9/go.mod h1:CZBXGLaJnEZI6EVNcPd7a6B5IC5cA/GkRWtu9fp3S6Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 h1:A4SYk07ef04+vxZToz9LWvAXl9LW0NClpPpMsi31cz0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9/go.mod h1:5jJcHuwDagxN+ErjQ3PU3ocf6Ylc/p9x+BLO/+X4iXw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.9 h1:vHyZxoLVOgrI8GqX7OMHLXp4YYoxeEsrjweXKpye+ds= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.9/go.mod h1:z9VXZsWA2BvZNH1dT0ToUYwMu/CR9Skkj/TBX+mceZw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.11 h1:4vt9Sspk59EZyHCAEMaktHKiq0C09noRTQorXD/qV+s= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.11/go.mod h1:5jHR79Tv+Ccq6rwYh+W7Nptmw++WiFafMfR42XhwNl8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 h1:o4T+fKxA3gTMcluBNZZXE9DNaMkJuUL1O3mffCUjoJo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11/go.mod h1:84oZdJ+VjuJKs9v1UTC9NaodRZRseOXCTgku+vQJWR8= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.9 h1:TE2i0A9ErH1YfRSvXfCr2SQwfnqsoJT9nPQ9kj0lkxM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.9/go.mod h1:9TzXX3MehQNGPwCZ3ka4CpwQsoAMWSF48/b+De9rfVM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.55.1 h1:UAxBuh0/8sFJk1qOkvOKewP5sWeWaTPDknbQz0ZkDm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.55.1/go.mod h1:hWjsYGjVuqCgfoveVcVFPXIWgz0aByzwaxKlN1StKcM= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 h1:gEYM2GSpr4YNWc6hCd5nod4+d4kd9vWIAWrmGuLdlMw= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11/go.mod h1:gVvwPdPNYehHSP9Rs7q27U1EU+3Or2ZpXvzAYJNh63w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 h1:iXjh3uaH3vsVcnyZX7MqCoCfcyxIrVE9iOQruRaWPrQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5/go.mod h1:5ZXesEuy/QcO0WUnt+4sDkxhdXRHTu2yG0uCSH8B6os= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 h1:M/1u4HBpwLuMtjlxuI2y6HoVLzF5e2mfxHCg7ZVMYmk= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12/go.mod h1:kcfd+eTdEi/40FIbLq4Hif3XMXnl5b/+t/KTfLt9xIk= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/eggsampler/acme/v3 v3.6.0 h1:TbQYoWlpl62fTdJq5i2LHBDY6h3LDU3pPAdyoUSQMOc= +github.com/eggsampler/acme/v3 v3.6.0/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/certificate-transparency-go v1.1.6 h1:SW5K3sr7ptST/pIvNkSVWMiJqemRmkjJPPT0jzXdOOY= +github.com/google/certificate-transparency-go v1.1.6/go.mod h1:0OJjOsOk+wj6aYQgP7FU0ioQ0AJUmnWPFMqTjQeazPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/letsencrypt/borp v0.0.0-20230707160741-6cc6ce580243 h1:xS2U6PQYRURk61YN4Y5xvyLbQVyAP/8fpE6hJZdwEWs= +github.com/letsencrypt/borp v0.0.0-20230707160741-6cc6ce580243/go.mod h1:podMDq5wDu2ZO6JMKYQcjD3QdqOfNLWtP2RDSy8CHUU= +github.com/letsencrypt/challtestsrv v1.2.1 h1:Lzv4jM+wSgVMCeO5a/F/IzSanhClstFMnX6SfrAJXjI= +github.com/letsencrypt/challtestsrv v1.2.1/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc= +github.com/letsencrypt/pkcs11key/v4 v4.0.0 h1:qLc/OznH7xMr5ARJgkZCCWk+EomQkiNTOoOF5LAgagc= +github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag= +github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158 h1:HGFsIltYMUiB5eoFSowFzSoXkocM2k9ctmJ57QMGjys= +github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158/go.mod h1:ZFNBS3H6OEsprCRjscty6GCBe5ZiX44x6qY4s7+bDX0= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nelsam/hel/v2 v2.3.2/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v0.0.0-20200406201722-06f95a1c68e8/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= +github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= +github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.30.2-0.20230730094716-a20f9abcc222/go.mod h1:s41lQh6dIsDWIC1OWh7ChWJXLH0zkJ9KHZVqA7vHyuQ= +github.com/weppos/publicsuffix-go v0.30.3-0.20240510084413-5f1d03393b3d h1:q80YKUcDWRNvvQcziH63e3ammTWARwrhohBCunHaYAg= +github.com/weppos/publicsuffix-go v0.30.3-0.20240510084413-5f1d03393b3d/go.mod h1:vLdXKydr/OJssAXmjY0XBgLXUfivBMrNRIBljgtqCnw= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= +github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk= +github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c h1:U1b4THKcgOpJ+kILupuznNwPiURtwVW3e9alJvji9+s= +github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c/go.mod h1:GSDpFDD4TASObxvfZfvpZZ3OWHIUHMlhVWlkOe4ewVk= +github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= +github.com/zmap/zlint/v3 v3.6.0 h1:vTEaDRtYN0d/1Ax60T+ypvbLQUHwHxbvYRnUMVr35ug= +github.com/zmap/zlint/v3 v3.6.0/go.mod h1:NVgiIWssgzp0bNl8P4Gz94NHV2ep/4Jyj9V69uTmZyg= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.52.0 h1:vS1Ao/R55RNV4O7TA2Qopok8yN+X0LIP6RVWLFkprck= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.52.0/go.mod h1:BMsdeOxN04K0L5FNUBfjFdvwWGNe/rkmSwH4Aelu/X0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= +go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= +golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200313205530-4303120df7d8/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 h1:P8OJ/WCl/Xo4E4zoe4/bifHpSmmKwARqyqE4nW6J2GQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5/go.mod h1:RGnPtTG7r4i8sPlNyDeikXF99hMM+hN6QMm4ooG9g2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 h1:Q2RxlXqh1cgzzUgV261vBO2jI5R/3DD1J2pM0nI4NhU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/blocked.go b/third-party/github.com/letsencrypt/boulder/goodkey/blocked.go new file mode 100644 index 000000000..198c09db4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/blocked.go @@ -0,0 +1,95 @@ +package goodkey + +import ( + "crypto" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "os" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/strictyaml" +) + +// blockedKeys is a type for maintaining a map of SHA256 hashes +// of SubjectPublicKeyInfo's that should be considered blocked. +// blockedKeys are created by using loadBlockedKeysList. +type blockedKeys map[core.Sha256Digest]bool + +var ErrWrongDecodedSize = errors.New("not enough bytes decoded for sha256 hash") + +// blocked checks if the given public key is considered administratively +// blocked based on a SHA256 hash of the SubjectPublicKeyInfo. +// Important: blocked should not be called except on a blockedKeys instance +// returned from loadBlockedKeysList. +// function should not be used until after `loadBlockedKeysList` has returned. +func (b blockedKeys) blocked(key crypto.PublicKey) (bool, error) { + hash, err := core.KeyDigest(key) + if err != nil { + // the bool result should be ignored when err is != nil but to be on the + // paranoid side return true anyway so that a key we can't compute the + // digest for will always be blocked even if a caller foolishly discards the + // err result. + return true, err + } + return b[hash], nil +} + +// loadBlockedKeysList creates a blockedKeys object that can be used to check if +// a key is blocked. It creates a lookup map from a list of +// SHA256 hashes of SubjectPublicKeyInfo's in the input YAML file +// with the expected format: +// +// blocked: +// - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= +// +// - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= +// +// If no hashes are found in the input YAML an error is returned. +func loadBlockedKeysList(filename string) (*blockedKeys, error) { + yamlBytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var list struct { + BlockedHashes []string `yaml:"blocked"` + BlockedHashesHex []string `yaml:"blockedHashesHex"` + } + err = strictyaml.Unmarshal(yamlBytes, &list) + if err != nil { + return nil, err + } + + if len(list.BlockedHashes) == 0 && len(list.BlockedHashesHex) == 0 { + return nil, errors.New("no blocked hashes in YAML") + } + + blockedKeys := make(blockedKeys, len(list.BlockedHashes)+len(list.BlockedHashesHex)) + for _, b64Hash := range list.BlockedHashes { + decoded, err := base64.StdEncoding.DecodeString(b64Hash) + if err != nil { + return nil, err + } + if len(decoded) != sha256.Size { + return nil, ErrWrongDecodedSize + } + var sha256Digest core.Sha256Digest + copy(sha256Digest[:], decoded[0:sha256.Size]) + blockedKeys[sha256Digest] = true + } + for _, hexHash := range list.BlockedHashesHex { + decoded, err := hex.DecodeString(hexHash) + if err != nil { + return nil, err + } + if len(decoded) != sha256.Size { + return nil, ErrWrongDecodedSize + } + var sha256Digest core.Sha256Digest + copy(sha256Digest[:], decoded[0:sha256.Size]) + blockedKeys[sha256Digest] = true + } + return &blockedKeys, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/blocked_test.go b/third-party/github.com/letsencrypt/boulder/goodkey/blocked_test.go new file mode 100644 index 000000000..b3c2cdfce --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/blocked_test.go @@ -0,0 +1,100 @@ +package goodkey + +import ( + "context" + "crypto" + "os" + "testing" + + yaml "gopkg.in/yaml.v3" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/web" +) + +func TestBlockedKeys(t *testing.T) { + // Start with an empty list + var inList struct { + BlockedHashes []string `yaml:"blocked"` + BlockedHashesHex []string `yaml:"blockedHashesHex"` + } + + yamlList, err := yaml.Marshal(&inList) + test.AssertNotError(t, err, "error marshaling test blockedKeys list") + + yamlListFile, err := os.CreateTemp("", "test-blocked-keys-list.*.yaml") + test.AssertNotError(t, err, "error creating test blockedKeys yaml file") + defer os.Remove(yamlListFile.Name()) + + err = os.WriteFile(yamlListFile.Name(), yamlList, 0640) + test.AssertNotError(t, err, "error writing test blockedKeys yaml file") + + // Trying to load it should error + _, err = loadBlockedKeysList(yamlListFile.Name()) + test.AssertError(t, err, "expected error loading empty blockedKeys yaml file") + + // Load some test certs/keys - see ../test/block-a-key/test/README.txt + // for more information. + testCertA, err := core.LoadCert("../test/block-a-key/test/test.rsa.cert.pem") + test.AssertNotError(t, err, "error loading test.rsa.cert.pem") + testCertB, err := core.LoadCert("../test/block-a-key/test/test.ecdsa.cert.pem") + test.AssertNotError(t, err, "error loading test.ecdsa.cert.pem") + testJWKA, err := web.LoadJWK("../test/block-a-key/test/test.rsa.jwk.json") + test.AssertNotError(t, err, "error loading test.rsa.jwk.pem") + testJWKB, err := web.LoadJWK("../test/block-a-key/test/test.ecdsa.jwk.json") + test.AssertNotError(t, err, "error loading test.ecdsa.jwk.pem") + + // All of the above should be blocked + blockedKeys := []crypto.PublicKey{ + testCertA.PublicKey, + testCertB.PublicKey, + testJWKA.Key, + testJWKB.Key, + } + + // Now use a populated list - these values match the base64 digest of the + // public keys in the test certs/JWKs + inList.BlockedHashes = []string{ + "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + } + inList.BlockedHashesHex = []string{ + "41e6dcd55dd2917de2ce461118d262966f4172ebdfd28a31e14d919fe6f824e1", + } + + yamlList, err = yaml.Marshal(&inList) + test.AssertNotError(t, err, "error marshaling test blockedKeys list") + + yamlListFile, err = os.CreateTemp("", "test-blocked-keys-list.*.yaml") + test.AssertNotError(t, err, "error creating test blockedKeys yaml file") + defer os.Remove(yamlListFile.Name()) + + err = os.WriteFile(yamlListFile.Name(), yamlList, 0640) + test.AssertNotError(t, err, "error writing test blockedKeys yaml file") + + // Trying to load it should not error + outList, err := loadBlockedKeysList(yamlListFile.Name()) + test.AssertNotError(t, err, "unexpected error loading empty blockedKeys yaml file") + + // Create a test policy that doesn't reference the blocked list + testingPolicy := &KeyPolicy{allowedKeys: AllowedKeys{ + RSA2048: true, RSA3072: true, RSA4096: true, ECDSAP256: true, ECDSAP384: true, + }} + + // All of the test keys should not be considered blocked + for _, k := range blockedKeys { + err := testingPolicy.GoodKey(context.Background(), k) + test.AssertNotError(t, err, "test key was blocked by key policy without block list") + } + + // Now update the key policy with the blocked list + testingPolicy.blockedList = outList + + // Now all of the test keys should be considered blocked, and with the correct + // type of error. + for _, k := range blockedKeys { + err := testingPolicy.GoodKey(context.Background(), k) + test.AssertError(t, err, "test key was not blocked by key policy with block list") + test.AssertErrorIs(t, err, ErrBadKey) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/good_key.go b/third-party/github.com/letsencrypt/boulder/goodkey/good_key.go new file mode 100644 index 000000000..04a075d35 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/good_key.go @@ -0,0 +1,460 @@ +package goodkey + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "errors" + "fmt" + "math/big" + "sync" + + "github.com/letsencrypt/boulder/core" + + "github.com/titanous/rocacheck" +) + +// To generate, run: primes 2 752 | tr '\n' , +var smallPrimeInts = []int64{ + 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, + 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, + 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, + 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, + 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, + 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, + 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, + 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, + 499, 503, 509, 521, 523, 541, 547, 557, 563, 569, 571, + 577, 587, 593, 599, 601, 607, 613, 617, 619, 631, 641, + 643, 647, 653, 659, 661, 673, 677, 683, 691, 701, 709, + 719, 727, 733, 739, 743, 751, +} + +// singleton defines the object of a Singleton pattern +var ( + smallPrimesSingleton sync.Once + smallPrimesProduct *big.Int +) + +type Config struct { + // AllowedKeys enables or disables specific key algorithms and sizes. If + // nil, defaults to just those keys allowed by the Let's Encrypt CPS. + AllowedKeys *AllowedKeys + // WeakKeyFile is the path to a JSON file containing truncated modulus hashes + // of known weak RSA keys. If this config value is empty, then RSA modulus + // hash checking will be disabled. + WeakKeyFile string + // BlockedKeyFile is the path to a YAML file containing base64-encoded SHA256 + // hashes of PKIX Subject Public Keys that should be blocked. If this config + // value is empty, then blocked key checking will be disabled. + BlockedKeyFile string + // FermatRounds is an integer number of rounds of Fermat's factorization + // method that should be performed to attempt to detect keys whose modulus can + // be trivially factored because the two factors are very close to each other. + // If this config value is empty (0), no factorization will be attempted. + FermatRounds int +} + +// AllowedKeys is a map of six specific key algorithm and size combinations to +// booleans indicating whether keys of that type are considered good. +type AllowedKeys struct { + // Baseline Requirements, Section 6.1.5 requires key size >= 2048 and a multiple + // of 8 bits: https://github.com/cabforum/servercert/blob/main/docs/BR.md#615-key-sizes + // Baseline Requirements, Section 6.1.1.3 requires that we reject any keys which + // have a known method to easily compute their private key, such as Debian Weak + // Keys. Our enforcement mechanism relies on enumerating all Debian Weak Keys at + // common key sizes, so we restrict all issuance to those common key sizes. + RSA2048 bool + RSA3072 bool + RSA4096 bool + // Baseline Requirements, Section 6.1.5 requires that ECDSA keys be valid + // points on the NIST P-256, P-384, or P-521 elliptic curves. + ECDSAP256 bool + ECDSAP384 bool + ECDSAP521 bool +} + +// LetsEncryptCPS encodes the five key algorithms and sizes allowed by the Let's +// Encrypt CPS CV-SSL Subscriber Certificate Profile: RSA 2048, RSA 3076, RSA +// 4096, ECDSA 256 and ECDSA P384. +// https://github.com/letsencrypt/cp-cps/blob/main/CP-CPS.md#dv-ssl-subscriber-certificate +// If this is ever changed, the CP/CPS MUST be changed first. +func LetsEncryptCPS() AllowedKeys { + return AllowedKeys{ + RSA2048: true, + RSA3072: true, + RSA4096: true, + ECDSAP256: true, + ECDSAP384: true, + } +} + +// ErrBadKey represents an error with a key. It is distinct from the various +// ways in which an ACME request can have an erroneous key (BadPublicKeyError, +// BadCSRError) because this library is used to check both JWS signing keys and +// keys in CSRs. +var ErrBadKey = errors.New("") + +func badKey(msg string, args ...interface{}) error { + return fmt.Errorf("%w%s", ErrBadKey, fmt.Errorf(msg, args...)) +} + +// BlockedKeyCheckFunc is used to pass in the sa.BlockedKey functionality to KeyPolicy, +// rather than storing a full sa.SQLStorageAuthority. This allows external +// users who don’t want to import all of boulder/sa, and makes testing +// significantly simpler. +// On success, the function returns a boolean which is true if the key is blocked. +type BlockedKeyCheckFunc func(ctx context.Context, keyHash []byte) (bool, error) + +// KeyPolicy determines which types of key may be used with various boulder +// operations. +type KeyPolicy struct { + allowedKeys AllowedKeys + weakRSAList *WeakRSAKeys + blockedList *blockedKeys + fermatRounds int + blockedCheck BlockedKeyCheckFunc +} + +// NewPolicy returns a key policy based on the given configuration, with sane +// defaults. If the config's AllowedKeys is nil, the LetsEncryptCPS AllowedKeys +// is used. If the config's WeakKeyFile or BlockedKeyFile paths are empty, those +// checks are disabled. If the config's FermatRounds is 0, Fermat Factorization +// is disabled. +func NewPolicy(config *Config, bkc BlockedKeyCheckFunc) (KeyPolicy, error) { + if config == nil { + config = &Config{} + } + kp := KeyPolicy{ + blockedCheck: bkc, + } + if config.AllowedKeys == nil { + kp.allowedKeys = LetsEncryptCPS() + } else { + kp.allowedKeys = *config.AllowedKeys + } + if config.WeakKeyFile != "" { + keyList, err := LoadWeakRSASuffixes(config.WeakKeyFile) + if err != nil { + return KeyPolicy{}, err + } + kp.weakRSAList = keyList + } + if config.BlockedKeyFile != "" { + blocked, err := loadBlockedKeysList(config.BlockedKeyFile) + if err != nil { + return KeyPolicy{}, err + } + kp.blockedList = blocked + } + if config.FermatRounds < 0 { + return KeyPolicy{}, fmt.Errorf("Fermat factorization rounds cannot be negative: %d", config.FermatRounds) + } + kp.fermatRounds = config.FermatRounds + return kp, nil +} + +// GoodKey returns true if the key is acceptable for both TLS use and account +// key use (our requirements are the same for either one), according to basic +// strength and algorithm checking. GoodKey only supports pointers: *rsa.PublicKey +// and *ecdsa.PublicKey. It will reject non-pointer types. +// TODO: Support JSONWebKeys once go-jose migration is done. +func (policy *KeyPolicy) GoodKey(ctx context.Context, key crypto.PublicKey) error { + // Early rejection of unacceptable key types to guard subsequent checks. + switch t := key.(type) { + case *rsa.PublicKey, *ecdsa.PublicKey: + break + default: + return badKey("unsupported key type %T", t) + } + // If there is a blocked list configured then check if the public key is one + // that has been administratively blocked. + if policy.blockedList != nil { + if blocked, err := policy.blockedList.blocked(key); err != nil { + return fmt.Errorf("error checking blocklist for key: %v", key) + } else if blocked { + return badKey("public key is forbidden") + } + } + if policy.blockedCheck != nil { + digest, err := core.KeyDigest(key) + if err != nil { + return badKey("%w", err) + } + exists, err := policy.blockedCheck(ctx, digest[:]) + if err != nil { + return err + } else if exists { + return badKey("public key is forbidden") + } + } + switch t := key.(type) { + case *rsa.PublicKey: + return policy.goodKeyRSA(t) + case *ecdsa.PublicKey: + return policy.goodKeyECDSA(t) + default: + return badKey("unsupported key type %T", key) + } +} + +// GoodKeyECDSA determines if an ECDSA pubkey meets our requirements +func (policy *KeyPolicy) goodKeyECDSA(key *ecdsa.PublicKey) (err error) { + // Check the curve. + // + // The validity of the curve is an assumption for all following tests. + err = policy.goodCurve(key.Curve) + if err != nil { + return err + } + + // Key validation routine adapted from NIST SP800-56A § 5.6.2.3.2. + // + // + // Assuming a prime field since a) we are only allowing such curves and b) + // crypto/elliptic only supports prime curves. Where this assumption + // simplifies the code below, it is explicitly stated and explained. If ever + // adapting this code to support non-prime curves, refer to NIST SP800-56A § + // 5.6.2.3.2 and adapt this code appropriately. + params := key.Params() + + // SP800-56A § 5.6.2.3.2 Step 1. + // Partial check of the public key for an invalid range in the EC group: + // Verify that key is not the point at infinity O. + // This code assumes that the point at infinity is (0,0), which is the + // case for all supported curves. + if isPointAtInfinityNISTP(key.X, key.Y) { + return badKey("key x, y must not be the point at infinity") + } + + // SP800-56A § 5.6.2.3.2 Step 2. + // "Verify that x_Q and y_Q are integers in the interval [0,p-1] in the + // case that q is an odd prime p, or that x_Q and y_Q are bit strings + // of length m bits in the case that q = 2**m." + // + // Prove prime field: ASSUMED. + // Prove q != 2: ASSUMED. (Curve parameter. No supported curve has q == 2.) + // Prime field && q != 2 => q is an odd prime p + // Therefore "verify that x, y are in [0, p-1]" satisfies step 2. + // + // Therefore verify that both x and y of the public key point have the unique + // correct representation of an element in the underlying field by verifying + // that x and y are integers in [0, p-1]. + if key.X.Sign() < 0 || key.Y.Sign() < 0 { + return badKey("key x, y must not be negative") + } + + if key.X.Cmp(params.P) >= 0 || key.Y.Cmp(params.P) >= 0 { + return badKey("key x, y must not exceed P-1") + } + + // SP800-56A § 5.6.2.3.2 Step 3. + // "If q is an odd prime p, verify that (y_Q)**2 === (x_Q)***3 + a*x_Q + b (mod p). + // If q = 2**m, verify that (y_Q)**2 + (x_Q)*(y_Q) == (x_Q)**3 + a*(x_Q)*2 + b in + // the finite field of size 2**m. + // (Ensures that the public key is on the correct elliptic curve.)" + // + // q is an odd prime p: proven/assumed above. + // a = -3 for all supported curves. + // + // Therefore step 3 is satisfied simply by showing that + // y**2 === x**3 - 3*x + B (mod P). + // + // This proves that the public key is on the correct elliptic curve. + // But in practice, this test is provided by crypto/elliptic, so use that. + if !key.Curve.IsOnCurve(key.X, key.Y) { + return badKey("key point is not on the curve") + } + + // SP800-56A § 5.6.2.3.2 Step 4. + // "Verify that n*Q == Ø. + // (Ensures that the public key has the correct order. Along with check 1, + // ensures that the public key is in the correct range in the correct EC + // subgroup, that is, it is in the correct EC subgroup and is not the + // identity element.)" + // + // Ensure that public key has the correct order: + // verify that n*Q = Ø. + // + // n*Q = Ø iff n*Q is the point at infinity (see step 1). + ox, oy := key.Curve.ScalarMult(key.X, key.Y, params.N.Bytes()) + if !isPointAtInfinityNISTP(ox, oy) { + return badKey("public key does not have correct order") + } + + // End of SP800-56A § 5.6.2.3.2 Public Key Validation Routine. + // Key is valid. + return nil +} + +// Returns true iff the point (x,y) on NIST P-256, NIST P-384 or NIST P-521 is +// the point at infinity. These curves all have the same point at infinity +// (0,0). This function must ONLY be used on points on curves verified to have +// (0,0) as their point at infinity. +func isPointAtInfinityNISTP(x, y *big.Int) bool { + return x.Sign() == 0 && y.Sign() == 0 +} + +// GoodCurve determines if an elliptic curve meets our requirements. +func (policy *KeyPolicy) goodCurve(c elliptic.Curve) (err error) { + // Simply use a whitelist for now. + params := c.Params() + switch { + case policy.allowedKeys.ECDSAP256 && params == elliptic.P256().Params(): + return nil + case policy.allowedKeys.ECDSAP384 && params == elliptic.P384().Params(): + return nil + case policy.allowedKeys.ECDSAP521 && params == elliptic.P521().Params(): + return nil + default: + return badKey("ECDSA curve %v not allowed", params.Name) + } +} + +// GoodKeyRSA determines if a RSA pubkey meets our requirements +func (policy *KeyPolicy) goodKeyRSA(key *rsa.PublicKey) error { + modulus := key.N + + err := policy.goodRSABitLen(key) + if err != nil { + return err + } + + if policy.weakRSAList != nil && policy.weakRSAList.Known(key) { + return badKey("key is on a known weak RSA key list") + } + + // Rather than support arbitrary exponents, which significantly increases + // the size of the key space we allow, we restrict E to the defacto standard + // RSA exponent 65537. There is no specific standards document that specifies + // 65537 as the 'best' exponent, but ITU X.509 Annex C suggests there are + // notable merits for using it if using a fixed exponent. + // + // The CABF Baseline Requirements state: + // The CA SHALL confirm that the value of the public exponent is an + // odd number equal to 3 or more. Additionally, the public exponent + // SHOULD be in the range between 2^16 + 1 and 2^256-1. + // + // By only allowing one exponent, which fits these constraints, we satisfy + // these requirements. + if key.E != 65537 { + return badKey("key exponent must be 65537") + } + + // The modulus SHOULD also have the following characteristics: an odd + // number, not the power of a prime, and have no factors smaller than 752. + // TODO: We don't yet check for "power of a prime." + if checkSmallPrimes(modulus) { + return badKey("key divisible by small prime") + } + // Check for weak keys generated by Infineon hardware + // (see https://crocs.fi.muni.cz/public/papers/rsa_ccs17) + if rocacheck.IsWeak(key) { + return badKey("key generated by vulnerable Infineon-based hardware") + } + // Check if the key can be easily factored via Fermat's factorization method. + if policy.fermatRounds > 0 { + err := checkPrimeFactorsTooClose(modulus, policy.fermatRounds) + if err != nil { + return badKey("key generated with factors too close together: %w", err) + } + } + + return nil +} + +func (policy *KeyPolicy) goodRSABitLen(key *rsa.PublicKey) error { + // See comment on AllowedKeys above. + modulusBitLen := key.N.BitLen() + switch { + case modulusBitLen == 2048 && policy.allowedKeys.RSA2048: + return nil + case modulusBitLen == 3072 && policy.allowedKeys.RSA3072: + return nil + case modulusBitLen == 4096 && policy.allowedKeys.RSA4096: + return nil + default: + return badKey("key size not supported: %d", modulusBitLen) + } +} + +// Returns true iff integer i is divisible by any of the primes in smallPrimes. +// +// Short circuits; execution time is dependent on i. Do not use this on secret +// values. +// +// Rather than checking each prime individually (invoking Mod on each), +// multiply the primes together and let GCD do our work for us: if the +// GCD between and is not one, we know we have +// a bad key. This is substantially faster than checking each prime +// individually. +func checkSmallPrimes(i *big.Int) bool { + smallPrimesSingleton.Do(func() { + smallPrimesProduct = big.NewInt(1) + for _, prime := range smallPrimeInts { + smallPrimesProduct.Mul(smallPrimesProduct, big.NewInt(prime)) + } + }) + + // When the GCD is 1, i and smallPrimesProduct are coprime, meaning they + // share no common factors. When the GCD is not one, it is the product of + // all common factors, meaning we've identified at least one small prime + // which invalidates i as a valid key. + + var result big.Int + result.GCD(nil, nil, i, smallPrimesProduct) + return result.Cmp(big.NewInt(1)) != 0 +} + +// Returns an error if the modulus n is able to be factored into primes p and q +// via Fermat's factorization method. This method relies on the two primes being +// very close together, which means that they were almost certainly not picked +// independently from a uniform random distribution. Basically, if we can factor +// the key this easily, so can anyone else. +func checkPrimeFactorsTooClose(n *big.Int, rounds int) error { + // Pre-allocate some big numbers that we'll use a lot down below. + one := big.NewInt(1) + bb := new(big.Int) + + // Any odd integer is equal to a difference of squares of integers: + // n = a^2 - b^2 = (a + b)(a - b) + // Any RSA public key modulus is equal to a product of two primes: + // n = pq + // Here we try to find values for a and b, since doing so also gives us the + // prime factors p = (a + b) and q = (a - b). + + // We start with a close to the square root of the modulus n, to start with + // two candidate prime factors that are as close together as possible and + // work our way out from there. Specifically, we set a = ceil(sqrt(n)), the + // first integer greater than the square root of n. Unfortunately, big.Int's + // built-in square root function takes the floor, so we have to add one to get + // the ceil. + a := new(big.Int) + a.Sqrt(n).Add(a, one) + + // We calculate b2 to see if it is a perfect square (i.e. b^2), and therefore + // b is an integer. Specifically, b2 = a^2 - n. + b2 := new(big.Int) + b2.Mul(a, a).Sub(b2, n) + + for range rounds { + // To see if b2 is a perfect square, we take its square root, square that, + // and check to see if we got the same result back. + bb.Sqrt(b2).Mul(bb, bb) + if b2.Cmp(bb) == 0 { + // b2 is a perfect square, so we've found integer values of a and b, + // and can easily compute p and q as their sum and difference. + bb.Sqrt(bb) + p := new(big.Int).Add(a, bb) + q := new(big.Int).Sub(a, bb) + return fmt.Errorf("public modulus n = pq factored into p: %s; q: %s", p, q) + } + + // Set up the next iteration by incrementing a by one and recalculating b2. + a.Add(a, one) + b2.Mul(a, a).Sub(b2, n) + } + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/good_key_test.go b/third-party/github.com/letsencrypt/boulder/goodkey/good_key_test.go new file mode 100644 index 000000000..e12e73c7a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/good_key_test.go @@ -0,0 +1,374 @@ +package goodkey + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "fmt" + "math/big" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +// testingPolicy is a simple policy which allows all of the key types, so that +// the unit tests can exercise checks against all key types. +var testingPolicy = &KeyPolicy{allowedKeys: AllowedKeys{ + RSA2048: true, RSA3072: true, RSA4096: true, + ECDSAP256: true, ECDSAP384: true, ECDSAP521: true, +}} + +func TestUnknownKeyType(t *testing.T) { + notAKey := struct{}{} + err := testingPolicy.GoodKey(context.Background(), notAKey) + test.AssertError(t, err, "Should have rejected a key of unknown type") + test.AssertEquals(t, err.Error(), "unsupported key type struct {}") + + // Check for early rejection and that no error is seen from blockedKeys.blocked. + testingPolicyWithBlockedKeys := *testingPolicy + testingPolicyWithBlockedKeys.blockedList = &blockedKeys{} + err = testingPolicyWithBlockedKeys.GoodKey(context.Background(), notAKey) + test.AssertError(t, err, "Should have rejected a key of unknown type") + test.AssertEquals(t, err.Error(), "unsupported key type struct {}") +} + +func TestNilKey(t *testing.T) { + err := testingPolicy.GoodKey(context.Background(), nil) + test.AssertError(t, err, "Should have rejected a nil key") + test.AssertEquals(t, err.Error(), "unsupported key type ") +} + +func TestSmallModulus(t *testing.T) { + pubKey := rsa.PublicKey{ + N: big.NewInt(0), + E: 65537, + } + // 2040 bits + _, ok := pubKey.N.SetString("104192126510885102608953552259747211060428328569316484779167706297543848858189721071301121307701498317286069484848193969810800653457088975832436062805901725915630417996487259956349018066196416400386483594314258078114607080545265502078791826837453107382149801328758721235866366842649389274931060463277516954884108984101391466769505088222180613883737986792254164577832157921425082478871935498631777878563742033332460445633026471887331001305450139473524438241478798689974351175769895824322173301257621327448162705637127373457350813027123239805772024171112299987923305882261194120410409098448380641378552305583392176287", 10) + if !ok { + t.Errorf("error parsing pubkey modulus") + } + err := testingPolicy.GoodKey(context.Background(), &pubKey) + test.AssertError(t, err, "Should have rejected too-short key") + test.AssertEquals(t, err.Error(), "key size not supported: 2040") +} + +func TestLargeModulus(t *testing.T) { + pubKey := rsa.PublicKey{ + N: big.NewInt(0), + E: 65537, + } + // 4097 bits + _, ok := pubKey.N.SetString("1528586537844618544364689295678280797814937047039447018548513699782432768815684971832418418955305671838918285565080181315448131784543332408348488544125812746629522583979538961638790013578302979210481729874191053412386396889481430969071543569003141391030053024684850548909056275565684242965892176703473950844930842702506635531145654194239072799616096020023445127233557468234181352398708456163013484600764686209741158795461806441111028922165846800488957692595308009319392149669715238691709012014980470238746838534949750493558807218940354555205690667168930634644030378921382266510932028134500172599110460167962515262077587741235811653717121760943005253103187409557573174347385738572144714188928416780963680160418832333908040737262282830643745963536624555340279793555475547508851494656512855403492456740439533790565640263514349940712999516725281940465613417922773583725174223806589481568984323871222072582132221706797917380250216291620957692131931099423995355390698925093903005385497308399692769135287821632877871068909305276870015125960884987746154344006895331078411141197233179446805991116541744285238281451294472577537413640009811940462311100056023815261650331552185459228689469446389165886801876700815724561451940764544990177661873073", 10) + if !ok { + t.Errorf("error parsing pubkey modulus") + } + err := testingPolicy.GoodKey(context.Background(), &pubKey) + test.AssertError(t, err, "Should have rejected too-long key") + test.AssertEquals(t, err.Error(), "key size not supported: 4097") +} + +func TestModulusModulo8(t *testing.T) { + bigOne := big.NewInt(1) + key := rsa.PublicKey{ + N: bigOne.Lsh(bigOne, 2048), + E: 5, + } + err := testingPolicy.GoodKey(context.Background(), &key) + test.AssertError(t, err, "Should have rejected modulus with length not divisible by 8") + test.AssertEquals(t, err.Error(), "key size not supported: 2049") +} + +var mod2048 = big.NewInt(0).Sub(big.NewInt(0).Lsh(big.NewInt(1), 2048), big.NewInt(1)) + +func TestNonStandardExp(t *testing.T) { + evenMod := big.NewInt(0).Add(big.NewInt(1).Lsh(big.NewInt(1), 2047), big.NewInt(2)) + key := rsa.PublicKey{ + N: evenMod, + E: (1 << 16), + } + err := testingPolicy.GoodKey(context.Background(), &key) + test.AssertError(t, err, "Should have rejected non-standard exponent") + test.AssertEquals(t, err.Error(), "key exponent must be 65537") +} + +func TestEvenModulus(t *testing.T) { + evenMod := big.NewInt(0).Add(big.NewInt(1).Lsh(big.NewInt(1), 2047), big.NewInt(2)) + key := rsa.PublicKey{ + N: evenMod, + E: (1 << 16) + 1, + } + err := testingPolicy.GoodKey(context.Background(), &key) + test.AssertError(t, err, "Should have rejected even modulus") + test.AssertEquals(t, err.Error(), "key divisible by small prime") +} + +func TestModulusDivisibleBySmallPrime(t *testing.T) { + key := rsa.PublicKey{ + N: mod2048, + E: (1 << 16) + 1, + } + err := testingPolicy.GoodKey(context.Background(), &key) + test.AssertError(t, err, "Should have rejected modulus divisible by 3") + test.AssertEquals(t, err.Error(), "key divisible by small prime") +} + +func TestROCA(t *testing.T) { + n, ok := big.NewInt(1).SetString("19089470491547632015867380494603366846979936677899040455785311493700173635637619562546319438505971838982429681121352968394792665704951454132311441831732124044135181992768774222852895664400681270897445415599851900461316070972022018317962889565731866601557238345786316235456299813772607869009873279585912430769332375239444892105064608255089298943707214066350230292124208314161171265468111771687514518823144499250339825049199688099820304852696380797616737008621384107235756455735861506433065173933123259184114000282435500939123478591192413006994709825840573671701120771013072419520134975733578923370992644987545261926257", 10) + if !ok { + t.Fatal("failed to parse") + } + key := rsa.PublicKey{ + N: n, + E: 65537, + } + err := testingPolicy.GoodKey(context.Background(), &key) + test.AssertError(t, err, "Should have rejected ROCA-weak key") + test.AssertEquals(t, err.Error(), "key generated by vulnerable Infineon-based hardware") +} + +func TestGoodKey(t *testing.T) { + private, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Error generating key") + test.AssertNotError(t, testingPolicy.GoodKey(context.Background(), &private.PublicKey), "Should have accepted good key") +} + +func TestECDSABadCurve(t *testing.T) { + for _, curve := range invalidCurves { + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should have rejected key with unsupported curve") + test.AssertEquals(t, err.Error(), fmt.Sprintf("ECDSA curve %s not allowed", curve.Params().Name)) + } +} + +var invalidCurves = []elliptic.Curve{ + elliptic.P224(), +} + +var validCurves = []elliptic.Curve{ + elliptic.P256(), + elliptic.P384(), + elliptic.P521(), +} + +func TestECDSAGoodKey(t *testing.T) { + for _, curve := range validCurves { + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + test.AssertNotError(t, testingPolicy.GoodKey(context.Background(), &private.PublicKey), "Should have accepted good key") + } +} + +func TestECDSANotOnCurveX(t *testing.T) { + for _, curve := range validCurves { + // Change a public key so that it is no longer on the curve. + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + private.X.Add(private.X, big.NewInt(1)) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key not on the curve") + test.AssertEquals(t, err.Error(), "key point is not on the curve") + } +} + +func TestECDSANotOnCurveY(t *testing.T) { + for _, curve := range validCurves { + // Again with Y. + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + // Change the public key so that it is no longer on the curve. + private.Y.Add(private.Y, big.NewInt(1)) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key not on the curve") + test.AssertEquals(t, err.Error(), "key point is not on the curve") + } +} + +func TestECDSANegative(t *testing.T) { + for _, curve := range validCurves { + // Check that negative X is not accepted. + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + private.X.Neg(private.X) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key with negative X") + test.AssertEquals(t, err.Error(), "key x, y must not be negative") + + // Check that negative Y is not accepted. + private.X.Neg(private.X) + private.Y.Neg(private.Y) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key with negative Y") + test.AssertEquals(t, err.Error(), "key x, y must not be negative") + } +} + +func TestECDSAXOutsideField(t *testing.T) { + for _, curve := range validCurves { + // Check that X outside [0, p-1] is not accepted. + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + private.X.Mul(private.X, private.Curve.Params().P) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key with a X > p-1") + test.AssertEquals(t, err.Error(), "key x, y must not exceed P-1") + } +} + +func TestECDSAYOutsideField(t *testing.T) { + for _, curve := range validCurves { + // Check that Y outside [0, p-1] is not accepted. + private, err := ecdsa.GenerateKey(curve, rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + private.X.Mul(private.Y, private.Curve.Params().P) + err = testingPolicy.GoodKey(context.Background(), &private.PublicKey) + test.AssertError(t, err, "Should not have accepted key with a Y > p-1") + test.AssertEquals(t, err.Error(), "key x, y must not exceed P-1") + } +} + +func TestECDSAIdentity(t *testing.T) { + for _, curve := range validCurves { + // The point at infinity is 0,0, it should not be accepted. + public := ecdsa.PublicKey{ + Curve: curve, + X: big.NewInt(0), + Y: big.NewInt(0), + } + + err := testingPolicy.GoodKey(context.Background(), &public) + test.AssertError(t, err, "Should not have accepted key with point at infinity") + test.AssertEquals(t, err.Error(), "key x, y must not be the point at infinity") + } +} + +func TestNonRefKey(t *testing.T) { + private, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Error generating key") + test.AssertError(t, testingPolicy.GoodKey(context.Background(), private.PublicKey), "Accepted non-reference key") +} + +func TestDBBlocklistAccept(t *testing.T) { + for _, testCheck := range []BlockedKeyCheckFunc{ + nil, + func(context.Context, []byte) (bool, error) { + return false, nil + }, + } { + policy, err := NewPolicy(nil, testCheck) + test.AssertNotError(t, err, "NewKeyPolicy failed") + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey failed") + err = policy.GoodKey(context.Background(), k.Public()) + test.AssertNotError(t, err, "GoodKey failed with a non-blocked key") + } +} + +func TestDBBlocklistReject(t *testing.T) { + testCheck := func(context.Context, []byte) (bool, error) { + return true, nil + } + + policy, err := NewPolicy(nil, testCheck) + test.AssertNotError(t, err, "NewKeyPolicy failed") + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey failed") + err = policy.GoodKey(context.Background(), k.Public()) + test.AssertError(t, err, "GoodKey didn't fail with a blocked key") + test.AssertErrorIs(t, err, ErrBadKey) + test.AssertEquals(t, err.Error(), "public key is forbidden") +} + +func TestDefaultAllowedKeys(t *testing.T) { + policy, err := NewPolicy(nil, nil) + test.AssertNotError(t, err, "NewPolicy with nil config failed") + test.Assert(t, policy.allowedKeys.RSA2048, "RSA 2048 should be allowed") + test.Assert(t, policy.allowedKeys.RSA3072, "RSA 3072 should be allowed") + test.Assert(t, policy.allowedKeys.RSA4096, "RSA 4096 should be allowed") + test.Assert(t, policy.allowedKeys.ECDSAP256, "NIST P256 should be allowed") + test.Assert(t, policy.allowedKeys.ECDSAP384, "NIST P384 should be allowed") + test.Assert(t, !policy.allowedKeys.ECDSAP521, "NIST P521 should not be allowed") + + policy, err = NewPolicy(&Config{FermatRounds: 100}, nil) + test.AssertNotError(t, err, "NewPolicy with nil config.AllowedKeys failed") + test.Assert(t, policy.allowedKeys.RSA2048, "RSA 2048 should be allowed") + test.Assert(t, policy.allowedKeys.RSA3072, "RSA 3072 should be allowed") + test.Assert(t, policy.allowedKeys.RSA4096, "RSA 4096 should be allowed") + test.Assert(t, policy.allowedKeys.ECDSAP256, "NIST P256 should be allowed") + test.Assert(t, policy.allowedKeys.ECDSAP384, "NIST P384 should be allowed") + test.Assert(t, !policy.allowedKeys.ECDSAP521, "NIST P521 should not be allowed") +} + +func TestRSAStrangeSize(t *testing.T) { + k := &rsa.PublicKey{N: big.NewInt(10)} + err := testingPolicy.GoodKey(context.Background(), k) + test.AssertError(t, err, "expected GoodKey to fail") + test.AssertEquals(t, err.Error(), "key size not supported: 4") +} + +func TestCheckPrimeFactorsTooClose(t *testing.T) { + // The prime factors of 5959 are 59 and 101. The values a and b calculated + // by Fermat's method will be 80 and 21. The ceil of the square root of 5959 + // is 78. Therefore it takes 3 rounds of Fermat's method to find the factors. + n := big.NewInt(5959) + err := checkPrimeFactorsTooClose(n, 2) + test.AssertNotError(t, err, "factored n in too few iterations") + err = checkPrimeFactorsTooClose(n, 3) + test.AssertError(t, err, "failed to factor n") + test.AssertContains(t, err.Error(), "p: 101") + test.AssertContains(t, err.Error(), "q: 59") + + // These factors differ only in their second-to-last digit. They're so close + // that a single iteration of Fermat's method is sufficient to find them. + p, ok := new(big.Int).SetString("12451309173743450529024753538187635497858772172998414407116324997634262083672423797183640278969532658774374576700091736519352600717664126766443002156788367", 10) + test.Assert(t, ok, "failed to create large prime") + q, ok := new(big.Int).SetString("12451309173743450529024753538187635497858772172998414407116324997634262083672423797183640278969532658774374576700091736519352600717664126766443002156788337", 10) + test.Assert(t, ok, "failed to create large prime") + n = n.Mul(p, q) + err = checkPrimeFactorsTooClose(n, 0) + test.AssertNotError(t, err, "factored n in too few iterations") + err = checkPrimeFactorsTooClose(n, 1) + test.AssertError(t, err, "failed to factor n") + test.AssertContains(t, err.Error(), fmt.Sprintf("p: %s", p)) + test.AssertContains(t, err.Error(), fmt.Sprintf("q: %s", q)) + + // These factors differ by slightly more than 2^256. + p, ok = p.SetString("11779932606551869095289494662458707049283241949932278009554252037480401854504909149712949171865707598142483830639739537075502512627849249573564209082969463", 10) + test.Assert(t, ok, "failed to create large prime") + q, ok = q.SetString("11779932606551869095289494662458707049283241949932278009554252037480401854503793357623711855670284027157475142731886267090836872063809791989556295953329083", 10) + test.Assert(t, ok, "failed to create large prime") + n = n.Mul(p, q) + err = checkPrimeFactorsTooClose(n, 13) + test.AssertNotError(t, err, "factored n in too few iterations") + err = checkPrimeFactorsTooClose(n, 14) + test.AssertError(t, err, "failed to factor n") + test.AssertContains(t, err.Error(), fmt.Sprintf("p: %s", p)) + test.AssertContains(t, err.Error(), fmt.Sprintf("q: %s", q)) +} + +func benchFermat(rounds int, b *testing.B) { + n := big.NewInt(0) + n.SetString("801622717394169050106926578578301725055526605503706912100006286161529273473377413824975745384114446662904851914935980611269769546695796451504160869649117000521094368058953989236438103975426680952076533198797388295193391779933559668812684470909409457778161223896975426492372231040386646816154793996920467596916193680611886097694746368434138296683172992347929528214464827172059378866098534956467670429228681248968588692628197119606249988365750115578731538804653322115223303388019261933988266126675740797091559541980722545880793708750882230374320698192373040882555154628949384420712168289605526223733016176898368282023301917856921049583659644200174763940543991507836551835324807116188739389620816364505209568211448815747330488813651206715564392791134964121857454359816296832013457790067067190116393364546525054134704119475840526673114964766611499226043189928040037210929720682839683846078550615582181112536768195193557758454282232948765374797970874053642822355832904812487562117265271449547063765654262549173209805579494164339236981348054782533307762260970390747872669357067489756517340817289701322583209366268084923373164395703994945233187987667632964509271169622904359262117908604555420100186491963838567445541249128944592555657626247", 10) + for range b.N { + if checkPrimeFactorsTooClose(n, rounds) != nil { + b.Fatal("factored the unfactorable!") + } + } +} + +func BenchmarkFermat1(b *testing.B) { benchFermat(1, b) } +func BenchmarkFermat10(b *testing.B) { benchFermat(10, b) } +func BenchmarkFermat100(b *testing.B) { benchFermat(100, b) } +func BenchmarkFermat1000(b *testing.B) { benchFermat(1000, b) } +func BenchmarkFermat10000(b *testing.B) { benchFermat(10000, b) } diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key.go b/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key.go new file mode 100644 index 000000000..a339b65f7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key.go @@ -0,0 +1,32 @@ +package sagoodkey + +import ( + "context" + + "google.golang.org/grpc" + + "github.com/letsencrypt/boulder/goodkey" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// BlockedKeyCheckFunc is used to pass in the sa.BlockedKey method to KeyPolicy, +// rather than storing a full sa.SQLStorageAuthority. This makes testing +// significantly simpler. +type BlockedKeyCheckFunc func(context.Context, *sapb.SPKIHash, ...grpc.CallOption) (*sapb.Exists, error) + +// NewPolicy returns a KeyPolicy that uses a sa.BlockedKey method. +// See goodkey.NewPolicy for more details about the policy itself. +func NewPolicy(config *goodkey.Config, bkc BlockedKeyCheckFunc) (goodkey.KeyPolicy, error) { + var genericCheck goodkey.BlockedKeyCheckFunc + if bkc != nil { + genericCheck = func(ctx context.Context, keyHash []byte) (bool, error) { + exists, err := bkc(ctx, &sapb.SPKIHash{KeyHash: keyHash}) + if err != nil { + return false, err + } + return exists.Exists, nil + } + } + + return goodkey.NewPolicy(config, genericCheck) +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key_test.go b/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key_test.go new file mode 100644 index 000000000..814804d3d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/sagoodkey/good_key_test.go @@ -0,0 +1,48 @@ +package sagoodkey + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "google.golang.org/grpc" + + "github.com/letsencrypt/boulder/goodkey" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +func TestDBBlocklistAccept(t *testing.T) { + for _, testCheck := range []BlockedKeyCheckFunc{ + nil, + func(context.Context, *sapb.SPKIHash, ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: false}, nil + }, + } { + policy, err := NewPolicy(&goodkey.Config{}, testCheck) + test.AssertNotError(t, err, "NewKeyPolicy failed") + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey failed") + err = policy.GoodKey(context.Background(), k.Public()) + test.AssertNotError(t, err, "GoodKey failed with a non-blocked key") + } +} + +func TestDBBlocklistReject(t *testing.T) { + testCheck := func(context.Context, *sapb.SPKIHash, ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: true}, nil + } + + policy, err := NewPolicy(&goodkey.Config{}, testCheck) + test.AssertNotError(t, err, "NewKeyPolicy failed") + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey failed") + err = policy.GoodKey(context.Background(), k.Public()) + test.AssertError(t, err, "GoodKey didn't fail with a blocked key") + test.AssertErrorIs(t, err, goodkey.ErrBadKey) + test.AssertEquals(t, err.Error(), "public key is forbidden") +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/weak.go b/third-party/github.com/letsencrypt/boulder/goodkey/weak.go new file mode 100644 index 000000000..dd7afd5e4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/weak.go @@ -0,0 +1,66 @@ +package goodkey + +// This file defines a basic method for testing if a given RSA public key is on one of +// the Debian weak key lists and is therefore considered compromised. Instead of +// directly loading the hash suffixes from the individual lists we flatten them all +// into a single JSON list using cmd/weak-key-flatten for ease of use. + +import ( + "crypto/rsa" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "os" +) + +type truncatedHash [10]byte + +type WeakRSAKeys struct { + suffixes map[truncatedHash]struct{} +} + +func LoadWeakRSASuffixes(path string) (*WeakRSAKeys, error) { + f, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var suffixList []string + err = json.Unmarshal(f, &suffixList) + if err != nil { + return nil, err + } + + wk := &WeakRSAKeys{suffixes: make(map[truncatedHash]struct{})} + for _, suffix := range suffixList { + err := wk.addSuffix(suffix) + if err != nil { + return nil, err + } + } + return wk, nil +} + +func (wk *WeakRSAKeys) addSuffix(str string) error { + var suffix truncatedHash + decoded, err := hex.DecodeString(str) + if err != nil { + return err + } + if len(decoded) != 10 { + return fmt.Errorf("unexpected suffix length of %d", len(decoded)) + } + copy(suffix[:], decoded) + wk.suffixes[suffix] = struct{}{} + return nil +} + +func (wk *WeakRSAKeys) Known(key *rsa.PublicKey) bool { + // Hash input is in the format "Modulus={upper-case hex of modulus}\n" + hash := sha1.Sum([]byte(fmt.Sprintf("Modulus=%X\n", key.N.Bytes()))) + var suffix truncatedHash + copy(suffix[:], hash[10:]) + _, present := wk.suffixes[suffix] + return present +} diff --git a/third-party/github.com/letsencrypt/boulder/goodkey/weak_test.go b/third-party/github.com/letsencrypt/boulder/goodkey/weak_test.go new file mode 100644 index 000000000..1f1d1db51 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/goodkey/weak_test.go @@ -0,0 +1,44 @@ +package goodkey + +import ( + "crypto/rsa" + "encoding/hex" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestKnown(t *testing.T) { + modBytes, err := hex.DecodeString("D673252AF6723C3F72529403EAB7C30DEF3C52F97E799825F4A70191C616ADCF1ECE1113F1625971074C492C592025FDEADBDB146A081826BDF0D77C3C913DCF1B6F0B3B78F5108D2E493AD0EEE8CA5C021711ADC13D358E61133870FCD19C8E5C22403959782AA82E72AEE53A3D491E3912CE27B27E1A85EA69C19A527D28F7934C9823B7E56FDD657DAC83FDC65BB22A98D843DF73238919781B714C81A5E2AFEC71F5C54AA2A27C590AD94C03C1062D50EFCFFAC743E3C8A3AE056846A1D756EB862BF4224169D467C35215ADE0AFCC11E85FE629AFB802C4786FF2E9C929BCCF502B3D3B8876C6A11785CC398B389F1D86BDD9CB0BD4EC13956EC3FA270D") + test.AssertNotError(t, err, "Failed to decode modulus bytes") + mod := &big.Int{} + mod.SetBytes(modBytes) + testKey := rsa.PublicKey{N: mod} + otherKey := rsa.PublicKey{N: big.NewInt(2020)} + + wk := &WeakRSAKeys{suffixes: make(map[truncatedHash]struct{})} + err = wk.addSuffix("8df20e6961a16398b85a") + // a3853d0c563765e504c18df20e6961a16398b85a + test.AssertNotError(t, err, "WeakRSAKeys.addSuffix failed") + test.Assert(t, wk.Known(&testKey), "WeakRSAKeys.Known failed to find suffix that has been added") + test.Assert(t, !wk.Known(&otherKey), "WeakRSAKeys.Known found a suffix that has not been added") +} + +func TestLoadKeys(t *testing.T) { + modBytes, err := hex.DecodeString("D673252AF6723C3F72529403EAB7C30DEF3C52F97E799825F4A70191C616ADCF1ECE1113F1625971074C492C592025FDEADBDB146A081826BDF0D77C3C913DCF1B6F0B3B78F5108D2E493AD0EEE8CA5C021711ADC13D358E61133870FCD19C8E5C22403959782AA82E72AEE53A3D491E3912CE27B27E1A85EA69C19A527D28F7934C9823B7E56FDD657DAC83FDC65BB22A98D843DF73238919781B714C81A5E2AFEC71F5C54AA2A27C590AD94C03C1062D50EFCFFAC743E3C8A3AE056846A1D756EB862BF4224169D467C35215ADE0AFCC11E85FE629AFB802C4786FF2E9C929BCCF502B3D3B8876C6A11785CC398B389F1D86BDD9CB0BD4EC13956EC3FA270D") + test.AssertNotError(t, err, "Failed to decode modulus bytes") + mod := &big.Int{} + mod.SetBytes(modBytes) + testKey := rsa.PublicKey{N: mod} + tempDir := t.TempDir() + tempPath := filepath.Join(tempDir, "a.json") + err = os.WriteFile(tempPath, []byte("[\"8df20e6961a16398b85a\"]"), os.ModePerm) + test.AssertNotError(t, err, "Failed to create temporary file") + + wk, err := LoadWeakRSASuffixes(tempPath) + test.AssertNotError(t, err, "Failed to load suffixes from directory") + test.Assert(t, wk.Known(&testKey), "WeakRSAKeys.Known failed to find suffix that has been added") +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/client.go b/third-party/github.com/letsencrypt/boulder/grpc/client.go new file mode 100644 index 000000000..6234d5e16 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/client.go @@ -0,0 +1,116 @@ +package grpc + +import ( + "crypto/tls" + "errors" + "fmt" + + grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + bcreds "github.com/letsencrypt/boulder/grpc/creds" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + + // 'grpc/health' is imported for its init function, which causes clients to + // rely on the Health Service for load-balancing. + // 'grpc/internal/resolver/dns' is imported for its init function, which + // registers the SRV resolver. + _ "github.com/letsencrypt/boulder/grpc/internal/resolver/dns" + "google.golang.org/grpc/balancer/roundrobin" + _ "google.golang.org/grpc/health" +) + +// ClientSetup creates a gRPC TransportCredentials that presents +// a client certificate and validates the server certificate based +// on the provided *tls.Config. +// It dials the remote service and returns a grpc.ClientConn if successful. +func ClientSetup(c *cmd.GRPCClientConfig, tlsConfig *tls.Config, statsRegistry prometheus.Registerer, clk clock.Clock) (*grpc.ClientConn, error) { + if c == nil { + return nil, errors.New("nil gRPC client config provided: JSON config is probably missing a fooService section") + } + if tlsConfig == nil { + return nil, errNilTLS + } + + metrics, err := newClientMetrics(statsRegistry) + if err != nil { + return nil, err + } + + cmi := clientMetadataInterceptor{c.Timeout.Duration, metrics, clk, !c.NoWaitForReady} + + unaryInterceptors := []grpc.UnaryClientInterceptor{ + cmi.Unary, + cmi.metrics.grpcMetrics.UnaryClientInterceptor(), + otelgrpc.UnaryClientInterceptor(), + } + + streamInterceptors := []grpc.StreamClientInterceptor{ + cmi.Stream, + cmi.metrics.grpcMetrics.StreamClientInterceptor(), + otelgrpc.StreamClientInterceptor(), + } + + target, hostOverride, err := c.MakeTargetAndHostOverride() + if err != nil { + return nil, err + } + + creds := bcreds.NewClientCredentials(tlsConfig.RootCAs, tlsConfig.Certificates, hostOverride) + return grpc.Dial( + target, + grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]}`, roundrobin.Name)), + grpc.WithTransportCredentials(creds), + grpc.WithChainUnaryInterceptor(unaryInterceptors...), + grpc.WithChainStreamInterceptor(streamInterceptors...), + ) +} + +// clientMetrics is a struct type used to return registered metrics from +// `NewClientMetrics` +type clientMetrics struct { + grpcMetrics *grpc_prometheus.ClientMetrics + // inFlightRPCs is a labelled gauge that slices by service/method the number + // of outstanding/in-flight RPCs. + inFlightRPCs *prometheus.GaugeVec +} + +// newClientMetrics constructs a *grpc_prometheus.ClientMetrics, registered with +// the given registry, with timing histogram enabled. It must be called a +// maximum of once per registry, or there will be conflicting names. +func newClientMetrics(stats prometheus.Registerer) (clientMetrics, error) { + // Create the grpc prometheus client metrics instance and register it + grpcMetrics := grpc_prometheus.NewClientMetrics() + grpcMetrics.EnableClientHandlingTimeHistogram() + err := stats.Register(grpcMetrics) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + grpcMetrics = are.ExistingCollector.(*grpc_prometheus.ClientMetrics) + } else { + return clientMetrics{}, err + } + } + + // Create a gauge to track in-flight RPCs and register it. + inFlightGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "grpc_in_flight", + Help: "Number of in-flight (sent, not yet completed) RPCs", + }, []string{"method", "service"}) + err = stats.Register(inFlightGauge) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + inFlightGauge = are.ExistingCollector.(*prometheus.GaugeVec) + } else { + return clientMetrics{}, err + } + } + + return clientMetrics{ + grpcMetrics: grpcMetrics, + inFlightRPCs: inFlightGauge, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/client_test.go b/third-party/github.com/letsencrypt/boulder/grpc/client_test.go new file mode 100644 index 000000000..ee42aa30d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/client_test.go @@ -0,0 +1,43 @@ +package grpc + +import ( + "crypto/tls" + "testing" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" + _ "google.golang.org/grpc/health" +) + +func TestClientSetup(t *testing.T) { + tests := []struct { + name string + cfg *cmd.GRPCClientConfig + expectTarget string + wantErr bool + }{ + {"valid, address provided", &cmd.GRPCClientConfig{ServerAddress: "localhost:8080"}, "dns:///localhost:8080", false}, + {"valid, implicit localhost with port provided", &cmd.GRPCClientConfig{ServerAddress: ":8080"}, "dns:///:8080", false}, + {"valid, IPv6 address provided", &cmd.GRPCClientConfig{ServerAddress: "[::1]:8080"}, "dns:///[::1]:8080", false}, + {"valid, two addresses provided", &cmd.GRPCClientConfig{ServerIPAddresses: []string{"127.0.0.1:8080", "127.0.0.2:8080"}}, "static:///127.0.0.1:8080,127.0.0.2:8080", false}, + {"valid, two addresses provided, one has an implicit localhost, ", &cmd.GRPCClientConfig{ServerIPAddresses: []string{":8080", "127.0.0.2:8080"}}, "static:///:8080,127.0.0.2:8080", false}, + {"valid, two addresses provided, one is IPv6, ", &cmd.GRPCClientConfig{ServerIPAddresses: []string{"[::1]:8080", "127.0.0.2:8080"}}, "static:///[::1]:8080,127.0.0.2:8080", false}, + {"invalid, both address and addresses provided", &cmd.GRPCClientConfig{ServerAddress: "localhost:8080", ServerIPAddresses: []string{"127.0.0.1:8080"}}, "", true}, + {"invalid, no address or addresses provided", &cmd.GRPCClientConfig{}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := ClientSetup(tt.cfg, &tls.Config{}, metrics.NoopRegisterer, clock.NewFake()) + if tt.wantErr { + test.AssertError(t, err, "expected error, got nil") + } else { + test.AssertNotError(t, err, "unexpected error") + } + if tt.expectTarget != "" { + test.AssertEquals(t, client.Target(), tt.expectTarget) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/creds/creds.go b/third-party/github.com/letsencrypt/boulder/grpc/creds/creds.go new file mode 100644 index 000000000..31da6e234 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/creds/creds.go @@ -0,0 +1,239 @@ +package creds + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + + "google.golang.org/grpc/credentials" +) + +var ( + ErrClientHandshakeNop = errors.New( + "boulder/grpc/creds: Client-side handshakes are not implemented with " + + "serverTransportCredentials") + ErrServerHandshakeNop = errors.New( + "boulder/grpc/creds: Server-side handshakes are not implemented with " + + "clientTransportCredentials") + ErrOverrideServerNameNop = errors.New( + "boulder/grpc/creds: OverrideServerName() is not implemented") + ErrNilServerConfig = errors.New( + "boulder/grpc/creds: `serverConfig` must not be nil") + ErrEmptyPeerCerts = errors.New( + "boulder/grpc/creds: validateClient given state with empty PeerCertificates") +) + +type ErrSANNotAccepted struct { + got, expected []string +} + +func (e ErrSANNotAccepted) Error() string { + return fmt.Sprintf("boulder/grpc/creds: client certificate SAN was invalid. "+ + "Got %q, expected one of %q.", e.got, e.expected) +} + +// clientTransportCredentials is a grpc/credentials.TransportCredentials which supports +// connecting to, and verifying multiple DNS names +type clientTransportCredentials struct { + roots *x509.CertPool + clients []tls.Certificate + // If set, this is used as the hostname to validate on certificates, instead + // of the value passed to ClientHandshake by grpc. + hostOverride string +} + +// NewClientCredentials returns a new initialized grpc/credentials.TransportCredentials for client usage +func NewClientCredentials(rootCAs *x509.CertPool, clientCerts []tls.Certificate, hostOverride string) credentials.TransportCredentials { + return &clientTransportCredentials{rootCAs, clientCerts, hostOverride} +} + +// ClientHandshake does the authentication handshake specified by the corresponding +// authentication protocol on rawConn for clients. It returns the authenticated +// connection and the corresponding auth information about the connection. +// Implementations must use the provided context to implement timely cancellation. +func (tc *clientTransportCredentials) ClientHandshake(ctx context.Context, addr string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + var err error + host := tc.hostOverride + if host == "" { + // IMPORTANT: Don't wrap the errors returned from this method. gRPC expects to be + // able to check err.Temporary to spot temporary errors and reconnect when they happen. + host, _, err = net.SplitHostPort(addr) + if err != nil { + return nil, nil, err + } + } + conn := tls.Client(rawConn, &tls.Config{ + ServerName: host, + RootCAs: tc.roots, + Certificates: tc.clients, + }) + err = conn.HandshakeContext(ctx) + if err != nil { + _ = rawConn.Close() + return nil, nil, err + } + return conn, nil, nil +} + +// ServerHandshake is not implemented for a `clientTransportCredentials`, use +// a `serverTransportCredentials` if you require `ServerHandshake`. +func (tc *clientTransportCredentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, ErrServerHandshakeNop +} + +// Info returns information about the transport protocol used +func (tc *clientTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: "tls"} +} + +// GetRequestMetadata returns nil, nil since TLS credentials do not have metadata. +func (tc *clientTransportCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + return nil, nil +} + +// RequireTransportSecurity always returns true because TLS is transport security +func (tc *clientTransportCredentials) RequireTransportSecurity() bool { + return true +} + +// Clone returns a copy of the clientTransportCredentials +func (tc *clientTransportCredentials) Clone() credentials.TransportCredentials { + return NewClientCredentials(tc.roots, tc.clients, tc.hostOverride) +} + +// OverrideServerName is not implemented and here only to satisfy the interface +func (tc *clientTransportCredentials) OverrideServerName(serverNameOverride string) error { + return ErrOverrideServerNameNop +} + +// serverTransportCredentials is a grpc/credentials.TransportCredentials which supports +// filtering acceptable peer connections by a list of accepted client certificate SANs +type serverTransportCredentials struct { + serverConfig *tls.Config + acceptedSANs map[string]struct{} +} + +// NewServerCredentials returns a new initialized grpc/credentials.TransportCredentials for server usage +func NewServerCredentials(serverConfig *tls.Config, acceptedSANs map[string]struct{}) (credentials.TransportCredentials, error) { + if serverConfig == nil { + return nil, ErrNilServerConfig + } + + return &serverTransportCredentials{serverConfig, acceptedSANs}, nil +} + +// validateClient checks a peer's client certificate's SAN entries against +// a list of accepted SANs. If the client certificate does not have a SAN on the +// list it is rejected. +// +// Note 1: This function *only* verifies the SAN entries! Callers are expected to +// have provided the `tls.ConnectionState` returned from a validate (e.g. +// non-error producing) `conn.Handshake()`. +// +// Note 2: We do *not* consider the client certificate subject common name. The +// CN field is deprecated and should be present as a DNS SAN! +func (tc *serverTransportCredentials) validateClient(peerState tls.ConnectionState) error { + /* + * If there's no list of accepted SANs, all clients are OK + * + * TODO(@cpu): This should be converted to a hard error at initialization time + * once we have deployed & updated all gRPC configurations to have an accepted + * SAN list configured + */ + if len(tc.acceptedSANs) == 0 { + return nil + } + + // If `conn.Handshake()` is called before `validateClient` this should not + // occur. We return an error in this event primarily for unit tests that may + // call `validateClient` with manufactured & artificial connection states. + if len(peerState.PeerCertificates) < 1 { + return ErrEmptyPeerCerts + } + + // Since we call `conn.Handshake()` before `validateClient` and ensure + // a non-error response we don't need to validate anything except the presence + // of an acceptable SAN in the leaf entry of `PeerCertificates`. The tls + // package's `serverHandshake` and in particular, `processCertsFromClient` + // will address everything else as an error returned from `Handshake()`. + leaf := peerState.PeerCertificates[0] + + // Combine both the DNS and IP address subjectAlternativeNames into a single + // list for checking. + var receivedSANs []string + receivedSANs = append(receivedSANs, leaf.DNSNames...) + for _, ip := range leaf.IPAddresses { + receivedSANs = append(receivedSANs, ip.String()) + } + + for _, name := range receivedSANs { + if _, ok := tc.acceptedSANs[name]; ok { + return nil + } + } + + // If none of the DNS or IP SANs on the leaf certificate matched the + // acceptable list, the client isn't valid and we error + var acceptableSANs []string + for k := range tc.acceptedSANs { + acceptableSANs = append(acceptableSANs, k) + } + return ErrSANNotAccepted{receivedSANs, acceptableSANs} +} + +// ServerHandshake does the authentication handshake for servers. It returns +// the authenticated connection and the corresponding auth information about +// the connection. +func (tc *serverTransportCredentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + // Perform the server <- client TLS handshake. This will validate the peer's + // client certificate. + conn := tls.Server(rawConn, tc.serverConfig) + err := conn.Handshake() + if err != nil { + return nil, nil, err + } + + // In addition to the validation from `conn.Handshake()` we apply further + // constraints on what constitutes a valid peer + err = tc.validateClient(conn.ConnectionState()) + if err != nil { + return nil, nil, err + } + + return conn, credentials.TLSInfo{State: conn.ConnectionState()}, nil +} + +// ClientHandshake is not implemented for a `serverTransportCredentials`, use +// a `clientTransportCredentials` if you require `ClientHandshake`. +func (tc *serverTransportCredentials) ClientHandshake(ctx context.Context, addr string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, ErrClientHandshakeNop +} + +// Info provides the ProtocolInfo of this TransportCredentials. +func (tc *serverTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: "tls"} +} + +// GetRequestMetadata returns nil, nil since TLS credentials do not have metadata. +func (tc *serverTransportCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + return nil, nil +} + +// RequireTransportSecurity always returns true because TLS is transport security +func (tc *serverTransportCredentials) RequireTransportSecurity() bool { + return true +} + +// Clone returns a copy of the serverTransportCredentials +func (tc *serverTransportCredentials) Clone() credentials.TransportCredentials { + clone, _ := NewServerCredentials(tc.serverConfig, tc.acceptedSANs) + return clone +} + +// OverrideServerName is not implemented and here only to satisfy the interface +func (tc *serverTransportCredentials) OverrideServerName(serverNameOverride string) error { + return ErrOverrideServerNameNop +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/creds/creds_test.go b/third-party/github.com/letsencrypt/boulder/grpc/creds/creds_test.go new file mode 100644 index 000000000..e252f004f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/creds/creds_test.go @@ -0,0 +1,199 @@ +package creds + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "net/http/httptest" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/test" +) + +func TestServerTransportCredentials(t *testing.T) { + _, badCert := test.ThrowAwayCert(t, clock.New()) + goodCert := &x509.Certificate{ + DNSNames: []string{"creds-test"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + acceptedSANs := map[string]struct{}{ + "creds-test": {}, + } + servTLSConfig := &tls.Config{} + + // NewServerCredentials with a nil serverTLSConfig should return an error + _, err := NewServerCredentials(nil, acceptedSANs) + test.AssertEquals(t, err, ErrNilServerConfig) + + // A creds with a nil acceptedSANs list should consider any peer valid + wrappedCreds, err := NewServerCredentials(servTLSConfig, nil) + test.AssertNotError(t, err, "NewServerCredentials failed with nil acceptedSANs") + bcreds := wrappedCreds.(*serverTransportCredentials) + err = bcreds.validateClient(tls.ConnectionState{}) + test.AssertNotError(t, err, "validateClient() errored for emptyState") + + // A creds with a empty acceptedSANs list should consider any peer valid + wrappedCreds, err = NewServerCredentials(servTLSConfig, map[string]struct{}{}) + test.AssertNotError(t, err, "NewServerCredentials failed with empty acceptedSANs") + bcreds = wrappedCreds.(*serverTransportCredentials) + err = bcreds.validateClient(tls.ConnectionState{}) + test.AssertNotError(t, err, "validateClient() errored for emptyState") + + // A properly-initialized creds should fail to verify an empty ConnectionState + bcreds = &serverTransportCredentials{servTLSConfig, acceptedSANs} + err = bcreds.validateClient(tls.ConnectionState{}) + test.AssertEquals(t, err, ErrEmptyPeerCerts) + + // A creds should reject peers that don't have a leaf certificate with + // a SAN on the accepted list. + err = bcreds.validateClient(tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{badCert}, + }) + var errSANNotAccepted ErrSANNotAccepted + test.AssertErrorWraps(t, err, &errSANNotAccepted) + + // A creds should accept peers that have a leaf certificate with a SAN + // that is on the accepted list + err = bcreds.validateClient(tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{goodCert}, + }) + test.AssertNotError(t, err, "validateClient(rightState) failed") + + // A creds configured with an IP SAN in the accepted list should accept a peer + // that has a leaf certificate containing an IP address SAN present in the + // accepted list. + acceptedIPSans := map[string]struct{}{ + "127.0.0.1": {}, + } + bcreds = &serverTransportCredentials{servTLSConfig, acceptedIPSans} + err = bcreds.validateClient(tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{goodCert}, + }) + test.AssertNotError(t, err, "validateClient(rightState) failed with an IP accepted SAN list") +} + +func TestClientTransportCredentials(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 1024) + test.AssertNotError(t, err, "rsa.GenerateKey failed") + + temp := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"A"}, + NotBefore: time.Unix(1000, 0), + NotAfter: time.Now().AddDate(1, 0, 0), + BasicConstraintsValid: true, + IsCA: true, + } + derA, err := x509.CreateCertificate(rand.Reader, temp, temp, priv.Public(), priv) + test.AssertNotError(t, err, "x509.CreateCertificate failed") + certA, err := x509.ParseCertificate(derA) + test.AssertNotError(t, err, "x509.ParserCertificate failed") + temp.DNSNames[0] = "B" + derB, err := x509.CreateCertificate(rand.Reader, temp, temp, priv.Public(), priv) + test.AssertNotError(t, err, "x509.CreateCertificate failed") + certB, err := x509.ParseCertificate(derB) + test.AssertNotError(t, err, "x509.ParserCertificate failed") + roots := x509.NewCertPool() + roots.AddCert(certA) + roots.AddCert(certB) + + serverA := httptest.NewUnstartedServer(nil) + serverA.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{derA}, PrivateKey: priv}}} + serverB := httptest.NewUnstartedServer(nil) + serverB.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{derB}, PrivateKey: priv}}} + + tc := NewClientCredentials(roots, []tls.Certificate{}, "") + + serverA.StartTLS() + defer serverA.Close() + addrA := serverA.Listener.Addr().String() + rawConnA, err := net.Dial("tcp", addrA) + test.AssertNotError(t, err, "net.Dial failed") + defer func() { + _ = rawConnA.Close() + }() + + conn, _, err := tc.ClientHandshake(context.Background(), "A:2020", rawConnA) + test.AssertNotError(t, err, "tc.ClientHandshake failed") + test.Assert(t, conn != nil, "tc.ClientHandshake returned a nil net.Conn") + + serverB.StartTLS() + defer serverB.Close() + addrB := serverB.Listener.Addr().String() + rawConnB, err := net.Dial("tcp", addrB) + test.AssertNotError(t, err, "net.Dial failed") + defer func() { + _ = rawConnB.Close() + }() + + conn, _, err = tc.ClientHandshake(context.Background(), "B:3030", rawConnB) + test.AssertNotError(t, err, "tc.ClientHandshake failed") + test.Assert(t, conn != nil, "tc.ClientHandshake returned a nil net.Conn") + + // Test timeout + ln, err := net.Listen("tcp", "127.0.0.1:0") + test.AssertNotError(t, err, "net.Listen failed") + defer func() { + _ = ln.Close() + }() + addrC := ln.Addr().String() + stop := make(chan struct{}, 1) + go func() { + for { + select { + case <-stop: + return + default: + _, _ = ln.Accept() + time.Sleep(2 * time.Millisecond) + } + } + }() + + rawConnC, err := net.Dial("tcp", addrC) + test.AssertNotError(t, err, "net.Dial failed") + defer func() { + _ = rawConnB.Close() + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + conn, _, err = tc.ClientHandshake(ctx, "A:2020", rawConnC) + test.AssertError(t, err, "tc.ClientHandshake didn't timeout") + test.AssertEquals(t, err.Error(), "context deadline exceeded") + test.Assert(t, conn == nil, "tc.ClientHandshake returned a non-nil net.Conn on failure") + + stop <- struct{}{} +} + +type brokenConn struct{} + +func (bc *brokenConn) Read([]byte) (int, error) { + return 0, &net.OpError{} +} + +func (bc *brokenConn) Write([]byte) (int, error) { + return 0, &net.OpError{} +} + +func (bc *brokenConn) LocalAddr() net.Addr { return nil } +func (bc *brokenConn) RemoteAddr() net.Addr { return nil } +func (bc *brokenConn) Close() error { return nil } +func (bc *brokenConn) SetDeadline(time.Time) error { return nil } +func (bc *brokenConn) SetReadDeadline(time.Time) error { return nil } +func (bc *brokenConn) SetWriteDeadline(time.Time) error { return nil } + +func TestClientReset(t *testing.T) { + tc := NewClientCredentials(nil, []tls.Certificate{}, "") + _, _, err := tc.ClientHandshake(context.Background(), "T:1010", &brokenConn{}) + test.AssertError(t, err, "ClientHandshake succeeded with brokenConn") + var netErr net.Error + test.AssertErrorWraps(t, err, &netErr) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/errors.go b/third-party/github.com/letsencrypt/boulder/grpc/errors.go new file mode 100644 index 000000000..7f9aabbb6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/errors.go @@ -0,0 +1,154 @@ +package grpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + berrors "github.com/letsencrypt/boulder/errors" +) + +// wrapError wraps the internal error types we use for transport across the gRPC +// layer and appends an appropriate errortype to the gRPC trailer via the provided +// context. errors.BoulderError error types are encoded using the grpc/metadata +// in the context.Context for the RPC which is considered to be the 'proper' +// method of encoding custom error types (grpc/grpc#4543 and grpc/grpc-go#478) +func wrapError(ctx context.Context, appErr error) error { + if appErr == nil { + return nil + } + + var berr *berrors.BoulderError + if errors.As(appErr, &berr) { + pairs := []string{ + "errortype", strconv.Itoa(int(berr.Type)), + } + + // If there are suberrors then extend the metadata pairs to include the JSON + // marshaling of the suberrors. Errors in marshaling are not ignored and + // instead result in a return of an explicit InternalServerError and not + // a wrapped error missing suberrors. + if len(berr.SubErrors) > 0 { + jsonSubErrs, err := json.Marshal(berr.SubErrors) + if err != nil { + return berrors.InternalServerError( + "error marshaling json SubErrors, orig error %q", err) + } + headerSafeSubErrs := strconv.QuoteToASCII(string(jsonSubErrs)) + pairs = append(pairs, "suberrors", headerSafeSubErrs) + } + + // If there is a RetryAfter value then extend the metadata pairs to + // include the value. + if berr.RetryAfter != 0 { + pairs = append(pairs, "retryafter", berr.RetryAfter.String()) + } + + err := grpc.SetTrailer(ctx, metadata.Pairs(pairs...)) + if err != nil { + return berrors.InternalServerError( + "error setting gRPC error metadata, orig error %q", appErr) + } + } + + return appErr +} + +// unwrapError unwraps errors returned from gRPC client calls which were wrapped +// with wrapError to their proper internal error type. If the provided metadata +// object has an "errortype" field, that will be used to set the type of the +// error. +func unwrapError(err error, md metadata.MD) error { + if err == nil { + return nil + } + + errTypeStrs, ok := md["errortype"] + if !ok { + return err + } + + inErrMsg := status.Convert(err).Message() + if len(errTypeStrs) != 1 { + return berrors.InternalServerError( + "multiple 'errortype' metadata, wrapped error %q", + inErrMsg, + ) + } + + inErrType, decErr := strconv.Atoi(errTypeStrs[0]) + if decErr != nil { + return berrors.InternalServerError( + "failed to decode error type, decoding error %q, wrapped error %q", + decErr, + inErrMsg, + ) + } + inErr := berrors.New(berrors.ErrorType(inErrType), inErrMsg) + var outErr *berrors.BoulderError + if !errors.As(inErr, &outErr) { + return fmt.Errorf( + "expected type of inErr to be %T got %T: %q", + outErr, + inErr, + inErr.Error(), + ) + } + + subErrorsVal, ok := md["suberrors"] + if ok { + if len(subErrorsVal) != 1 { + return berrors.InternalServerError( + "multiple 'suberrors' in metadata, wrapped error %q", + inErrMsg, + ) + } + + unquotedSubErrors, unquoteErr := strconv.Unquote(subErrorsVal[0]) + if unquoteErr != nil { + return fmt.Errorf( + "unquoting 'suberrors' %q, wrapped error %q: %w", + subErrorsVal[0], + inErrMsg, + unquoteErr, + ) + } + + unmarshalErr := json.Unmarshal([]byte(unquotedSubErrors), &outErr.SubErrors) + if unmarshalErr != nil { + return berrors.InternalServerError( + "JSON unmarshaling 'suberrors' %q, wrapped error %q: %s", + subErrorsVal[0], + inErrMsg, + unmarshalErr, + ) + } + } + + retryAfterVal, ok := md["retryafter"] + if ok { + if len(retryAfterVal) != 1 { + return berrors.InternalServerError( + "multiple 'retryafter' in metadata, wrapped error %q", + inErrMsg, + ) + } + var parseErr error + outErr.RetryAfter, parseErr = time.ParseDuration(retryAfterVal[0]) + if parseErr != nil { + return berrors.InternalServerError( + "parsing 'retryafter' as int64, wrapped error %q, parsing error: %s", + inErrMsg, + parseErr, + ) + } + } + return outErr +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/errors_test.go b/third-party/github.com/letsencrypt/boulder/grpc/errors_test.go new file mode 100644 index 000000000..02b4953fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/errors_test.go @@ -0,0 +1,115 @@ +package grpc + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/jmhodges/clock" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/grpc/test_proto" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +type errorServer struct { + test_proto.UnimplementedChillerServer + err error +} + +func (s *errorServer) Chill(_ context.Context, _ *test_proto.Time) (*test_proto.Time, error) { + return nil, s.err +} + +func TestErrorWrapping(t *testing.T) { + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + smi := newServerMetadataInterceptor(serverMetrics, clock.NewFake()) + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + cmi := clientMetadataInterceptor{time.Second, clientMetrics, clock.NewFake(), true} + srv := grpc.NewServer(grpc.UnaryInterceptor(smi.Unary)) + es := &errorServer{} + test_proto.RegisterChillerServer(srv, es) + lis, err := net.Listen("tcp", "127.0.0.1:") + test.AssertNotError(t, err, "Failed to create listener") + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := grpc.Dial( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(cmi.Unary), + ) + test.AssertNotError(t, err, "Failed to dial grpc test server") + client := test_proto.NewChillerClient(conn) + + // RateLimitError with a RetryAfter of 500ms. + expectRetryAfter := time.Millisecond * 500 + es.err = berrors.RateLimitError(expectRetryAfter, "yup") + _, err = client.Chill(context.Background(), &test_proto.Time{}) + test.Assert(t, err != nil, fmt.Sprintf("nil error returned, expected: %s", err)) + test.AssertDeepEquals(t, err, es.err) + var bErr *berrors.BoulderError + ok := errors.As(err, &bErr) + test.Assert(t, ok, "asserting error as boulder error") + // Ensure we got a RateLimitError + test.AssertErrorIs(t, bErr, berrors.RateLimit) + // Ensure our RetryAfter is still 500ms. + test.AssertEquals(t, bErr.RetryAfter, expectRetryAfter) + + test.AssertNil(t, wrapError(context.Background(), nil), "Wrapping nil should still be nil") + test.AssertNil(t, unwrapError(nil, nil), "Unwrapping nil should still be nil") +} + +// TestSubErrorWrapping tests that a boulder error with suberrors can be +// correctly wrapped and unwrapped across the RPC layer. +func TestSubErrorWrapping(t *testing.T) { + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + smi := newServerMetadataInterceptor(serverMetrics, clock.NewFake()) + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + cmi := clientMetadataInterceptor{time.Second, clientMetrics, clock.NewFake(), true} + srv := grpc.NewServer(grpc.UnaryInterceptor(smi.Unary)) + es := &errorServer{} + test_proto.RegisterChillerServer(srv, es) + lis, err := net.Listen("tcp", "127.0.0.1:") + test.AssertNotError(t, err, "Failed to create listener") + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := grpc.Dial( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(cmi.Unary), + ) + test.AssertNotError(t, err, "Failed to dial grpc test server") + client := test_proto.NewChillerClient(conn) + + subErrors := []berrors.SubBoulderError{ + { + Identifier: identifier.DNSIdentifier("chillserver.com"), + BoulderError: &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "2 ill 2 chill", + }, + }, + } + + es.err = (&berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "malformed chill req", + }).WithSubErrors(subErrors) + + _, err = client.Chill(context.Background(), &test_proto.Time{}) + test.Assert(t, err != nil, fmt.Sprintf("nil error returned, expected: %s", err)) + test.AssertDeepEquals(t, err, es.err) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/generate.go b/third-party/github.com/letsencrypt/boulder/grpc/generate.go new file mode 100644 index 000000000..48d4ff644 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/generate.go @@ -0,0 +1,3 @@ +package grpc + +//go:generate ./protogen.sh diff --git a/third-party/github.com/letsencrypt/boulder/grpc/interceptors.go b/third-party/github.com/letsencrypt/boulder/grpc/interceptors.go new file mode 100644 index 000000000..1d87a6dcf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/interceptors.go @@ -0,0 +1,518 @@ +package grpc + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + "github.com/letsencrypt/boulder/cmd" + berrors "github.com/letsencrypt/boulder/errors" +) + +const ( + returnOverhead = 20 * time.Millisecond + meaningfulWorkOverhead = 100 * time.Millisecond + clientRequestTimeKey = "client-request-time" +) + +type serverInterceptor interface { + Unary(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) + Stream(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error +} + +// noopServerInterceptor provides no-op interceptors. It can be substituted for +// an interceptor that has been disabled. +type noopServerInterceptor struct{} + +// Unary is a gRPC unary interceptor. +func (n *noopServerInterceptor) Unary(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + return handler(ctx, req) +} + +// Stream is a gRPC stream interceptor. +func (n *noopServerInterceptor) Stream(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return handler(srv, ss) +} + +// Ensure noopServerInterceptor matches the serverInterceptor interface. +var _ serverInterceptor = &noopServerInterceptor{} + +type clientInterceptor interface { + Unary(ctx context.Context, method string, req interface{}, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error + Stream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) +} + +// serverMetadataInterceptor is a gRPC interceptor that adds Prometheus +// metrics to requests handled by a gRPC server, and wraps Boulder-specific +// errors for transmission in a grpc/metadata trailer (see bcodes.go). +type serverMetadataInterceptor struct { + metrics serverMetrics + clk clock.Clock +} + +func newServerMetadataInterceptor(metrics serverMetrics, clk clock.Clock) serverMetadataInterceptor { + return serverMetadataInterceptor{ + metrics: metrics, + clk: clk, + } +} + +// Unary implements the grpc.UnaryServerInterceptor interface. +func (smi *serverMetadataInterceptor) Unary( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler) (interface{}, error) { + if info == nil { + return nil, berrors.InternalServerError("passed nil *grpc.UnaryServerInfo") + } + + // Extract the grpc metadata from the context. If the context has + // a `clientRequestTimeKey` field, and it has a value, then observe the RPC + // latency with Prometheus. + if md, ok := metadata.FromIncomingContext(ctx); ok && len(md[clientRequestTimeKey]) > 0 { + err := smi.observeLatency(md[clientRequestTimeKey][0]) + if err != nil { + return nil, err + } + } + + // Shave 20 milliseconds off the deadline to ensure that if the RPC server times + // out any sub-calls it makes (like DNS lookups, or onwards RPCs), it has a + // chance to report that timeout to the client. This allows for more specific + // errors, e.g "the VA timed out looking up CAA for example.com" (when called + // from RA.NewCertificate, which was called from WFE.NewCertificate), as + // opposed to "RA.NewCertificate timed out" (causing a 500). + // Once we've shaved the deadline, we ensure we have we have at least another + // 100ms left to do work; otherwise we abort early. + deadline, ok := ctx.Deadline() + // Should never happen: there was no deadline. + if !ok { + deadline = time.Now().Add(100 * time.Second) + } + deadline = deadline.Add(-returnOverhead) + remaining := time.Until(deadline) + if remaining < meaningfulWorkOverhead { + return nil, status.Errorf(codes.DeadlineExceeded, "not enough time left on clock: %s", remaining) + } + + localCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + + resp, err := handler(localCtx, req) + if err != nil { + err = wrapError(localCtx, err) + } + return resp, err +} + +// interceptedServerStream wraps an existing server stream, but replaces its +// context with its own. +type interceptedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +// Context implements part of the grpc.ServerStream interface. +func (iss interceptedServerStream) Context() context.Context { + return iss.ctx +} + +// Stream implements the grpc.StreamServerInterceptor interface. +func (smi *serverMetadataInterceptor) Stream( + srv interface{}, + ss grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler) error { + ctx := ss.Context() + + // Extract the grpc metadata from the context. If the context has + // a `clientRequestTimeKey` field, and it has a value, then observe the RPC + // latency with Prometheus. + if md, ok := metadata.FromIncomingContext(ctx); ok && len(md[clientRequestTimeKey]) > 0 { + err := smi.observeLatency(md[clientRequestTimeKey][0]) + if err != nil { + return err + } + } + + // Shave 20 milliseconds off the deadline to ensure that if the RPC server times + // out any sub-calls it makes (like DNS lookups, or onwards RPCs), it has a + // chance to report that timeout to the client. This allows for more specific + // errors, e.g "the VA timed out looking up CAA for example.com" (when called + // from RA.NewCertificate, which was called from WFE.NewCertificate), as + // opposed to "RA.NewCertificate timed out" (causing a 500). + // Once we've shaved the deadline, we ensure we have we have at least another + // 100ms left to do work; otherwise we abort early. + deadline, ok := ctx.Deadline() + // Should never happen: there was no deadline. + if !ok { + deadline = time.Now().Add(100 * time.Second) + } + deadline = deadline.Add(-returnOverhead) + remaining := time.Until(deadline) + if remaining < meaningfulWorkOverhead { + return status.Errorf(codes.DeadlineExceeded, "not enough time left on clock: %s", remaining) + } + + // Server stream interceptors are synchronous (they return their error, if + // any, when the stream is done) so defer cancel() is safe here. + localCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + + err := handler(srv, interceptedServerStream{ss, localCtx}) + if err != nil { + err = wrapError(localCtx, err) + } + return err +} + +// splitMethodName is borrowed directly from +// `grpc-ecosystem/go-grpc-prometheus/util.go` and is used to extract the +// service and method name from the `method` argument to +// a `UnaryClientInterceptor`. +func splitMethodName(fullMethodName string) (string, string) { + fullMethodName = strings.TrimPrefix(fullMethodName, "/") // remove leading slash + if i := strings.Index(fullMethodName, "/"); i >= 0 { + return fullMethodName[:i], fullMethodName[i+1:] + } + return "unknown", "unknown" +} + +// observeLatency is called with the `clientRequestTimeKey` value from +// a request's gRPC metadata. This string value is converted to a timestamp and +// used to calculate the latency between send and receive time. The latency is +// published to the server interceptor's rpcLag prometheus histogram. An error +// is returned if the `clientReqTime` string is not a valid timestamp. +func (smi *serverMetadataInterceptor) observeLatency(clientReqTime string) error { + // Convert the metadata request time into an int64 + reqTimeUnixNanos, err := strconv.ParseInt(clientReqTime, 10, 64) + if err != nil { + return berrors.InternalServerError("grpc metadata had illegal %s value: %q - %s", + clientRequestTimeKey, clientReqTime, err) + } + // Calculate the elapsed time since the client sent the RPC + reqTime := time.Unix(0, reqTimeUnixNanos) + elapsed := smi.clk.Since(reqTime) + // Publish an RPC latency observation to the histogram + smi.metrics.rpcLag.Observe(elapsed.Seconds()) + return nil +} + +// Ensure serverMetadataInterceptor matches the serverInterceptor interface. +var _ serverInterceptor = (*serverMetadataInterceptor)(nil) + +// clientMetadataInterceptor is a gRPC interceptor that adds Prometheus +// metrics to sent requests, and disables FailFast. We disable FailFast because +// non-FailFast mode is most similar to the old AMQP RPC layer: If a client +// makes a request while all backends are briefly down (e.g. for a restart), the +// request doesn't necessarily fail. A backend can service the request if it +// comes back up within the timeout. Under gRPC the same effect is achieved by +// retries up to the Context deadline. +type clientMetadataInterceptor struct { + timeout time.Duration + metrics clientMetrics + clk clock.Clock + + waitForReady bool +} + +// Unary implements the grpc.UnaryClientInterceptor interface. +func (cmi *clientMetadataInterceptor) Unary( + ctx context.Context, + fullMethod string, + req, + reply interface{}, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption) error { + // This should not occur but fail fast with a clear error if it does (e.g. + // because of buggy unit test code) instead of a generic nil panic later! + if cmi.metrics.inFlightRPCs == nil { + return berrors.InternalServerError("clientInterceptor has nil inFlightRPCs gauge") + } + + // Ensure that the context has a deadline set. + localCtx, cancel := context.WithTimeout(ctx, cmi.timeout) + defer cancel() + + // Convert the current unix nano timestamp to a string for embedding in the grpc metadata + nowTS := strconv.FormatInt(cmi.clk.Now().UnixNano(), 10) + // Create a grpc/metadata.Metadata instance for the request metadata. + // Initialize it with the request time. + reqMD := metadata.New(map[string]string{clientRequestTimeKey: nowTS}) + // Configure the localCtx with the metadata so it gets sent along in the request + localCtx = metadata.NewOutgoingContext(localCtx, reqMD) + + // Disable fail-fast so RPCs will retry until deadline, even if all backends + // are down. + opts = append(opts, grpc.WaitForReady(cmi.waitForReady)) + + // Create a grpc/metadata.Metadata instance for a grpc.Trailer. + respMD := metadata.New(nil) + // Configure a grpc Trailer with respMD. This allows us to wrap error + // types in the server interceptor later on. + opts = append(opts, grpc.Trailer(&respMD)) + + // Split the method and service name from the fullMethod. + // UnaryClientInterceptor's receive a `method` arg of the form + // "/ServiceName/MethodName" + service, method := splitMethodName(fullMethod) + // Slice the inFlightRPC inc/dec calls by method and service + labels := prometheus.Labels{ + "method": method, + "service": service, + } + // Increment the inFlightRPCs gauge for this method/service + cmi.metrics.inFlightRPCs.With(labels).Inc() + // And defer decrementing it when we're done + defer cmi.metrics.inFlightRPCs.With(labels).Dec() + + // Handle the RPC + begin := cmi.clk.Now() + err := invoker(localCtx, fullMethod, req, reply, cc, opts...) + if err != nil { + err = unwrapError(err, respMD) + if status.Code(err) == codes.DeadlineExceeded { + return deadlineDetails{ + service: service, + method: method, + latency: cmi.clk.Since(begin), + } + } + } + return err +} + +// interceptedClientStream wraps an existing client stream, and calls finish +// when the stream ends or any operation on it fails. +type interceptedClientStream struct { + grpc.ClientStream + finish func(error) error +} + +// Header implements part of the grpc.ClientStream interface. +func (ics interceptedClientStream) Header() (metadata.MD, error) { + md, err := ics.ClientStream.Header() + if err != nil { + err = ics.finish(err) + } + return md, err +} + +// SendMsg implements part of the grpc.ClientStream interface. +func (ics interceptedClientStream) SendMsg(m interface{}) error { + err := ics.ClientStream.SendMsg(m) + if err != nil { + err = ics.finish(err) + } + return err +} + +// RecvMsg implements part of the grpc.ClientStream interface. +func (ics interceptedClientStream) RecvMsg(m interface{}) error { + err := ics.ClientStream.RecvMsg(m) + if err != nil { + err = ics.finish(err) + } + return err +} + +// CloseSend implements part of the grpc.ClientStream interface. +func (ics interceptedClientStream) CloseSend() error { + err := ics.ClientStream.CloseSend() + if err != nil { + err = ics.finish(err) + } + return err +} + +// Stream implements the grpc.StreamClientInterceptor interface. +func (cmi *clientMetadataInterceptor) Stream( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + fullMethod string, + streamer grpc.Streamer, + opts ...grpc.CallOption) (grpc.ClientStream, error) { + // This should not occur but fail fast with a clear error if it does (e.g. + // because of buggy unit test code) instead of a generic nil panic later! + if cmi.metrics.inFlightRPCs == nil { + return nil, berrors.InternalServerError("clientInterceptor has nil inFlightRPCs gauge") + } + + // We don't defer cancel() here, because this function is going to return + // immediately. Instead we store it in the interceptedClientStream. + localCtx, cancel := context.WithTimeout(ctx, cmi.timeout) + + // Convert the current unix nano timestamp to a string for embedding in the grpc metadata + nowTS := strconv.FormatInt(cmi.clk.Now().UnixNano(), 10) + // Create a grpc/metadata.Metadata instance for the request metadata. + // Initialize it with the request time. + reqMD := metadata.New(map[string]string{clientRequestTimeKey: nowTS}) + // Configure the localCtx with the metadata so it gets sent along in the request + localCtx = metadata.NewOutgoingContext(localCtx, reqMD) + + // Disable fail-fast so RPCs will retry until deadline, even if all backends + // are down. + opts = append(opts, grpc.WaitForReady(cmi.waitForReady)) + + // Create a grpc/metadata.Metadata instance for a grpc.Trailer. + respMD := metadata.New(nil) + // Configure a grpc Trailer with respMD. This allows us to wrap error + // types in the server interceptor later on. + opts = append(opts, grpc.Trailer(&respMD)) + + // Split the method and service name from the fullMethod. + // UnaryClientInterceptor's receive a `method` arg of the form + // "/ServiceName/MethodName" + service, method := splitMethodName(fullMethod) + // Slice the inFlightRPC inc/dec calls by method and service + labels := prometheus.Labels{ + "method": method, + "service": service, + } + // Increment the inFlightRPCs gauge for this method/service + cmi.metrics.inFlightRPCs.With(labels).Inc() + begin := cmi.clk.Now() + + // Cancel the local context and decrement the metric when we're done. Also + // transform the error into a more usable form, if necessary. + finish := func(err error) error { + cancel() + cmi.metrics.inFlightRPCs.With(labels).Dec() + if err != nil { + err = unwrapError(err, respMD) + if status.Code(err) == codes.DeadlineExceeded { + return deadlineDetails{ + service: service, + method: method, + latency: cmi.clk.Since(begin), + } + } + } + return err + } + + // Handle the RPC + cs, err := streamer(localCtx, desc, cc, fullMethod, opts...) + ics := interceptedClientStream{cs, finish} + return ics, err +} + +var _ clientInterceptor = (*clientMetadataInterceptor)(nil) + +// deadlineDetails is an error type that we use in place of gRPC's +// DeadlineExceeded errors in order to add more detail for debugging. +type deadlineDetails struct { + service string + method string + latency time.Duration +} + +func (dd deadlineDetails) Error() string { + return fmt.Sprintf("%s.%s timed out after %d ms", + dd.service, dd.method, int64(dd.latency/time.Millisecond)) +} + +// authInterceptor provides two server interceptors (Unary and Stream) which can +// check that every request for a given gRPC service is being made over an mTLS +// connection from a client which is allow-listed for that particular service. +type authInterceptor struct { + // serviceClientNames is a map of gRPC service names (e.g. "ca.CertificateAuthority") + // to allowed client certificate SANs (e.g. "ra.boulder") which are allowed to + // make RPCs to that service. The set of client names is implemented as a map + // of names to empty structs for easy lookup. + serviceClientNames map[string]map[string]struct{} +} + +// newServiceAuthChecker takes a GRPCServerConfig and uses its Service stanzas +// to construct a serviceAuthChecker which enforces the service/client mappings +// contained in the config. +func newServiceAuthChecker(c *cmd.GRPCServerConfig) *authInterceptor { + names := make(map[string]map[string]struct{}) + for serviceName, service := range c.Services { + names[serviceName] = make(map[string]struct{}) + for _, clientName := range service.ClientNames { + names[serviceName][clientName] = struct{}{} + } + } + return &authInterceptor{names} +} + +// Unary is a gRPC unary interceptor. +func (ac *authInterceptor) Unary(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + err := ac.checkContextAuth(ctx, info.FullMethod) + if err != nil { + return nil, err + } + return handler(ctx, req) +} + +// Stream is a gRPC stream interceptor. +func (ac *authInterceptor) Stream(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + err := ac.checkContextAuth(ss.Context(), info.FullMethod) + if err != nil { + return err + } + return handler(srv, ss) +} + +// checkContextAuth does most of the heavy lifting. It extracts TLS information +// from the incoming context, gets the set of DNS names contained in the client +// mTLS cert, and returns nil if at least one of those names appears in the set +// of allowed client names for given service (or if the set of allowed client +// names is empty). +func (ac *authInterceptor) checkContextAuth(ctx context.Context, fullMethod string) error { + serviceName, _ := splitMethodName(fullMethod) + + allowedClientNames, ok := ac.serviceClientNames[serviceName] + if !ok || len(allowedClientNames) == 0 { + return fmt.Errorf("service %q has no allowed client names", serviceName) + } + + p, ok := peer.FromContext(ctx) + if !ok { + return fmt.Errorf("unable to fetch peer info from grpc context") + } + + if p.AuthInfo == nil { + return fmt.Errorf("grpc connection appears to be plaintext") + } + + tlsAuth, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + return fmt.Errorf("connection is not TLS authed") + } + + if len(tlsAuth.State.VerifiedChains) == 0 || len(tlsAuth.State.VerifiedChains[0]) == 0 { + return fmt.Errorf("connection auth not verified") + } + + cert := tlsAuth.State.VerifiedChains[0][0] + + for _, clientName := range cert.DNSNames { + _, ok := allowedClientNames[clientName] + if ok { + return nil + } + } + + return fmt.Errorf( + "client names %v are not authorized for service %q (%v)", + cert.DNSNames, serviceName, allowedClientNames) +} + +// Ensure authInterceptor matches the serverInterceptor interface. +var _ serverInterceptor = (*authInterceptor)(nil) diff --git a/third-party/github.com/letsencrypt/boulder/grpc/interceptors_test.go b/third-party/github.com/letsencrypt/boulder/grpc/interceptors_test.go new file mode 100644 index 000000000..5e543d497 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/interceptors_test.go @@ -0,0 +1,470 @@ +package grpc + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "log" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/balancer/roundrobin" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/letsencrypt/boulder/grpc/test_proto" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +var fc = clock.NewFake() + +func testHandler(_ context.Context, i interface{}) (interface{}, error) { + if i != nil { + return nil, errors.New("") + } + fc.Sleep(time.Second) + return nil, nil +} + +func testInvoker(_ context.Context, method string, _, _ interface{}, _ *grpc.ClientConn, opts ...grpc.CallOption) error { + switch method { + case "-service-brokeTest": + return errors.New("") + case "-service-requesterCanceledTest": + return status.Error(1, context.Canceled.Error()) + } + fc.Sleep(time.Second) + return nil +} + +func TestServerInterceptor(t *testing.T) { + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + si := newServerMetadataInterceptor(serverMetrics, clock.NewFake()) + + md := metadata.New(map[string]string{clientRequestTimeKey: "0"}) + ctxWithMetadata := metadata.NewIncomingContext(context.Background(), md) + + _, err = si.Unary(context.Background(), nil, nil, testHandler) + test.AssertError(t, err, "si.intercept didn't fail with a context missing metadata") + + _, err = si.Unary(ctxWithMetadata, nil, nil, testHandler) + test.AssertError(t, err, "si.intercept didn't fail with a nil grpc.UnaryServerInfo") + + _, err = si.Unary(ctxWithMetadata, nil, &grpc.UnaryServerInfo{FullMethod: "-service-test"}, testHandler) + test.AssertNotError(t, err, "si.intercept failed with a non-nil grpc.UnaryServerInfo") + + _, err = si.Unary(ctxWithMetadata, 0, &grpc.UnaryServerInfo{FullMethod: "brokeTest"}, testHandler) + test.AssertError(t, err, "si.intercept didn't fail when handler returned a error") +} + +func TestClientInterceptor(t *testing.T) { + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := clientMetadataInterceptor{ + timeout: time.Second, + metrics: clientMetrics, + clk: clock.NewFake(), + } + + err = ci.Unary(context.Background(), "-service-test", nil, nil, nil, testInvoker) + test.AssertNotError(t, err, "ci.intercept failed with a non-nil grpc.UnaryServerInfo") + + err = ci.Unary(context.Background(), "-service-brokeTest", nil, nil, nil, testInvoker) + test.AssertError(t, err, "ci.intercept didn't fail when handler returned a error") +} + +// TestWaitForReadyTrue configures a gRPC client with waitForReady: true and +// sends a request to a backend that is unavailable. It ensures that the +// request doesn't error out until the timeout is reached, i.e. that +// FailFast is set to false. +// https://github.com/grpc/grpc/blob/main/doc/wait-for-ready.md +func TestWaitForReadyTrue(t *testing.T) { + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := &clientMetadataInterceptor{ + timeout: 100 * time.Millisecond, + metrics: clientMetrics, + clk: clock.NewFake(), + waitForReady: true, + } + conn, err := grpc.Dial("localhost:19876", // random, probably unused port + grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]}`, roundrobin.Name)), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(ci.Unary)) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + defer conn.Close() + c := test_proto.NewChillerClient(conn) + + start := time.Now() + _, err = c.Chill(context.Background(), &test_proto.Time{Duration: durationpb.New(time.Second)}) + if err == nil { + t.Errorf("Successful Chill when we expected failure.") + } + if time.Since(start) < 90*time.Millisecond { + t.Errorf("Chill failed fast, when WaitForReady should be enabled.") + } +} + +// TestWaitForReadyFalse configures a gRPC client with waitForReady: false and +// sends a request to a backend that is unavailable, and ensures that the request +// errors out promptly. +func TestWaitForReadyFalse(t *testing.T) { + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := &clientMetadataInterceptor{ + timeout: time.Second, + metrics: clientMetrics, + clk: clock.NewFake(), + waitForReady: false, + } + conn, err := grpc.Dial("localhost:19876", // random, probably unused port + grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]}`, roundrobin.Name)), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(ci.Unary)) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + defer conn.Close() + c := test_proto.NewChillerClient(conn) + + start := time.Now() + _, err = c.Chill(context.Background(), &test_proto.Time{Duration: durationpb.New(time.Second)}) + if err == nil { + t.Errorf("Successful Chill when we expected failure.") + } + if time.Since(start) > 200*time.Millisecond { + t.Errorf("Chill failed slow, when WaitForReady should be disabled.") + } +} + +// testServer is used to implement TestTimeouts, and will attempt to sleep for +// the given amount of time (unless it hits a timeout or cancel). +type testServer struct { + test_proto.UnimplementedChillerServer +} + +// Chill implements ChillerServer.Chill +func (s *testServer) Chill(ctx context.Context, in *test_proto.Time) (*test_proto.Time, error) { + start := time.Now() + // Sleep for either the requested amount of time, or the context times out or + // is canceled. + select { + case <-time.After(in.Duration.AsDuration() * time.Nanosecond): + spent := time.Since(start) / time.Nanosecond + return &test_proto.Time{Duration: durationpb.New(spent)}, nil + case <-ctx.Done(): + return nil, errors.New("unique error indicating that the server's shortened context timed itself out") + } +} + +func TestTimeouts(t *testing.T) { + // start server + lis, err := net.Listen("tcp", ":0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + port := lis.Addr().(*net.TCPAddr).Port + + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + si := newServerMetadataInterceptor(serverMetrics, clock.NewFake()) + s := grpc.NewServer(grpc.UnaryInterceptor(si.Unary)) + test_proto.RegisterChillerServer(s, &testServer{}) + go func() { + start := time.Now() + err := s.Serve(lis) + if err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") { + t.Logf("s.Serve: %v after %s", err, time.Since(start)) + } + }() + defer s.Stop() + + // make client + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := &clientMetadataInterceptor{ + timeout: 30 * time.Second, + metrics: clientMetrics, + clk: clock.NewFake(), + } + conn, err := grpc.Dial(net.JoinHostPort("localhost", strconv.Itoa(port)), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(ci.Unary)) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + c := test_proto.NewChillerClient(conn) + + testCases := []struct { + timeout time.Duration + expectedErrorPrefix string + }{ + {250 * time.Millisecond, "rpc error: code = Unknown desc = unique error indicating that the server's shortened context timed itself out"}, + {100 * time.Millisecond, "Chiller.Chill timed out after 0 ms"}, + {10 * time.Millisecond, "Chiller.Chill timed out after 0 ms"}, + } + for _, tc := range testCases { + t.Run(tc.timeout.String(), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + _, err := c.Chill(ctx, &test_proto.Time{Duration: durationpb.New(time.Second)}) + if err == nil { + t.Fatal("Got no error, expected a timeout") + } + if !strings.HasPrefix(err.Error(), tc.expectedErrorPrefix) { + t.Errorf("Wrong error. Got %s, expected %s", err.Error(), tc.expectedErrorPrefix) + } + }) + } +} + +func TestRequestTimeTagging(t *testing.T) { + clk := clock.NewFake() + // Listen for TCP requests on a random system assigned port number + lis, err := net.Listen("tcp", ":0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + // Retrieve the concrete port numberthe system assigned our listener + port := lis.Addr().(*net.TCPAddr).Port + + // Create a new ChillerServer + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + si := newServerMetadataInterceptor(serverMetrics, clk) + s := grpc.NewServer(grpc.UnaryInterceptor(si.Unary)) + test_proto.RegisterChillerServer(s, &testServer{}) + // Chill until ill + go func() { + start := time.Now() + err := s.Serve(lis) + if err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") { + t.Logf("s.Serve: %v after %s", err, time.Since(start)) + } + }() + defer s.Stop() + + // Dial the ChillerServer + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := &clientMetadataInterceptor{ + timeout: 30 * time.Second, + metrics: clientMetrics, + clk: clk, + } + conn, err := grpc.Dial(net.JoinHostPort("localhost", strconv.Itoa(port)), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(ci.Unary)) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + // Create a ChillerClient with the connection to the ChillerServer + c := test_proto.NewChillerClient(conn) + + // Make an RPC request with the ChillerClient with a timeout higher than the + // requested ChillerServer delay so that the RPC completes normally + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := c.Chill(ctx, &test_proto.Time{Duration: durationpb.New(time.Second * 5)}); err != nil { + t.Fatalf("Unexpected error calling Chill RPC: %s", err) + } + + // There should be one histogram sample in the serverInterceptor rpcLag stat + test.AssertMetricWithLabelsEquals(t, si.metrics.rpcLag, prometheus.Labels{}, 1) +} + +// blockedServer implements a ChillerServer with a Chill method that: +// 1. Calls Done() on the received waitgroup when receiving an RPC +// 2. Blocks the RPC on the roadblock waitgroup +// +// This is used by TestInFlightRPCStat to test that the gauge for in-flight RPCs +// is incremented and decremented as expected. +type blockedServer struct { + test_proto.UnimplementedChillerServer + roadblock, received sync.WaitGroup +} + +// Chill implements ChillerServer.Chill +func (s *blockedServer) Chill(_ context.Context, _ *test_proto.Time) (*test_proto.Time, error) { + // Note that a client RPC arrived + s.received.Done() + // Wait for the roadblock to be cleared + s.roadblock.Wait() + // Return a dummy spent value to adhere to the chiller protocol + return &test_proto.Time{Duration: durationpb.New(time.Millisecond)}, nil +} + +func TestInFlightRPCStat(t *testing.T) { + clk := clock.NewFake() + // Listen for TCP requests on a random system assigned port number + lis, err := net.Listen("tcp", ":0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + // Retrieve the concrete port numberthe system assigned our listener + port := lis.Addr().(*net.TCPAddr).Port + + // Create a new blockedServer to act as a ChillerServer + server := &blockedServer{} + + // Increment the roadblock waitgroup - this will cause all chill RPCs to + // the server to block until we call Done()! + server.roadblock.Add(1) + + // Increment the sentRPCs waitgroup - we use this to find out when all the + // RPCs we want to send have been received and we can count the in-flight + // gauge + numRPCs := 5 + server.received.Add(numRPCs) + + serverMetrics, err := newServerMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating server metrics") + si := newServerMetadataInterceptor(serverMetrics, clk) + s := grpc.NewServer(grpc.UnaryInterceptor(si.Unary)) + test_proto.RegisterChillerServer(s, server) + // Chill until ill + go func() { + start := time.Now() + err := s.Serve(lis) + if err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") { + t.Logf("s.Serve: %v after %s", err, time.Since(start)) + } + }() + defer s.Stop() + + // Dial the ChillerServer + clientMetrics, err := newClientMetrics(metrics.NoopRegisterer) + test.AssertNotError(t, err, "creating client metrics") + ci := &clientMetadataInterceptor{ + timeout: 30 * time.Second, + metrics: clientMetrics, + clk: clk, + } + conn, err := grpc.Dial(net.JoinHostPort("localhost", strconv.Itoa(port)), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(ci.Unary)) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + // Create a ChillerClient with the connection to the ChillerServer + c := test_proto.NewChillerClient(conn) + + // Fire off a few RPCs. They will block on the blockedServer's roadblock wg + for range numRPCs { + go func() { + // Ignore errors, just chilllll. + _, _ = c.Chill(context.Background(), &test_proto.Time{}) + }() + } + + // wait until all of the client RPCs have been sent and are blocking. We can + // now check the gauge. + server.received.Wait() + + // Specify the labels for the RPCs we're interested in + labels := prometheus.Labels{ + "service": "Chiller", + "method": "Chill", + } + + // We expect the inFlightRPCs gauge for the Chiller.Chill RPCs to be equal to numRPCs. + test.AssertMetricWithLabelsEquals(t, ci.metrics.inFlightRPCs, labels, float64(numRPCs)) + + // Unblock the blockedServer to let all of the Chiller.Chill RPCs complete + server.roadblock.Done() + // Sleep for a little bit to let all the RPCs complete + time.Sleep(1 * time.Second) + + // Check the gauge value again + test.AssertMetricWithLabelsEquals(t, ci.metrics.inFlightRPCs, labels, 0) +} + +func TestServiceAuthChecker(t *testing.T) { + ac := authInterceptor{ + map[string]map[string]struct{}{ + "package.ServiceName": { + "allowed.client": {}, + "also.allowed": {}, + }, + }, + } + + // No allowlist is a bad configuration. + ctx := context.Background() + err := ac.checkContextAuth(ctx, "/package.OtherService/Method/") + test.AssertError(t, err, "checking empty allowlist") + + // Context with no peering information is disallowed. + err = ac.checkContextAuth(ctx, "/package.ServiceName/Method/") + test.AssertError(t, err, "checking un-peered context") + + // Context with no auth info is disallowed. + ctx = peer.NewContext(ctx, &peer.Peer{}) + err = ac.checkContextAuth(ctx, "/package.ServiceName/Method/") + test.AssertError(t, err, "checking peer with no auth") + + // Context with no verified chains is disallowed. + ctx = peer.NewContext(ctx, &peer.Peer{ + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{}, + }, + }) + err = ac.checkContextAuth(ctx, "/package.ServiceName/Method/") + test.AssertError(t, err, "checking TLS with no valid chains") + + // Context with cert with wrong name is disallowed. + ctx = peer.NewContext(ctx, &peer.Peer{ + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{ + { + &x509.Certificate{ + DNSNames: []string{ + "disallowed.client", + }, + }, + }, + }, + }, + }, + }) + err = ac.checkContextAuth(ctx, "/package.ServiceName/Method/") + test.AssertError(t, err, "checking disallowed cert") + + // Context with cert with good name is allowed. + ctx = peer.NewContext(ctx, &peer.Peer{ + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{ + { + &x509.Certificate{ + DNSNames: []string{ + "disallowed.client", + "also.allowed", + }, + }, + }, + }, + }, + }, + }) + err = ac.checkContextAuth(ctx, "/package.ServiceName/Method/") + test.AssertNotError(t, err, "checking allowed cert") +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/backoff/backoff.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/backoff/backoff.go new file mode 100644 index 000000000..e8baaf4d7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/backoff/backoff.go @@ -0,0 +1,73 @@ +/* + * + * Copyright 2017 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package backoff implement the backoff strategy for gRPC. +// +// This is kept in internal until the gRPC project decides whether or not to +// allow alternative backoff strategies. +package backoff + +import ( + "time" + + "github.com/letsencrypt/boulder/grpc/internal/grpcrand" + grpcbackoff "google.golang.org/grpc/backoff" +) + +// Strategy defines the methodology for backing off after a grpc connection +// failure. +type Strategy interface { + // Backoff returns the amount of time to wait before the next retry given + // the number of consecutive failures. + Backoff(retries int) time.Duration +} + +// DefaultExponential is an exponential backoff implementation using the +// default values for all the configurable knobs defined in +// https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md. +var DefaultExponential = Exponential{Config: grpcbackoff.DefaultConfig} + +// Exponential implements exponential backoff algorithm as defined in +// https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md. +type Exponential struct { + // Config contains all options to configure the backoff algorithm. + Config grpcbackoff.Config +} + +// Backoff returns the amount of time to wait before the next retry given the +// number of retries. +func (bc Exponential) Backoff(retries int) time.Duration { + if retries == 0 { + return bc.Config.BaseDelay + } + backoff, max := float64(bc.Config.BaseDelay), float64(bc.Config.MaxDelay) + for backoff < max && retries > 0 { + backoff *= bc.Config.Multiplier + retries-- + } + if backoff > max { + backoff = max + } + // Randomize backoff delays so that if a cluster of requests start at + // the same time, they won't operate in lockstep. + backoff *= 1 + bc.Config.Jitter*(grpcrand.Float64()*2-1) + if backoff < 0 { + return 0 + } + return time.Duration(backoff) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/grpcrand/grpcrand.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/grpcrand/grpcrand.go new file mode 100644 index 000000000..740f83c2b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/grpcrand/grpcrand.go @@ -0,0 +1,67 @@ +/* + * + * Copyright 2018 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package grpcrand implements math/rand functions in a concurrent-safe way +// with a global random source, independent of math/rand's global source. +package grpcrand + +import ( + "math/rand" + "sync" + "time" +) + +var ( + r = rand.New(rand.NewSource(time.Now().UnixNano())) + mu sync.Mutex +) + +// Int implements rand.Int on the grpcrand global source. +func Int() int { + mu.Lock() + defer mu.Unlock() + return r.Int() +} + +// Int63n implements rand.Int63n on the grpcrand global source. +func Int63n(n int64) int64 { + mu.Lock() + defer mu.Unlock() + return r.Int63n(n) +} + +// Intn implements rand.Intn on the grpcrand global source. +func Intn(n int) int { + mu.Lock() + defer mu.Unlock() + return r.Intn(n) +} + +// Float64 implements rand.Float64 on the grpcrand global source. +func Float64() float64 { + mu.Lock() + defer mu.Unlock() + return r.Float64() +} + +// Uint64 implements rand.Uint64 on the grpcrand global source. +func Uint64() uint64 { + mu.Lock() + defer mu.Unlock() + return r.Uint64() +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck.go new file mode 100644 index 000000000..80e43beb6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck.go @@ -0,0 +1,124 @@ +/* + * + * Copyright 2017 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Package leakcheck contains functions to check leaked goroutines. +// +// Call "defer leakcheck.Check(t)" at the beginning of tests. +package leakcheck + +import ( + "runtime" + "sort" + "strings" + "time" +) + +var goroutinesToIgnore = []string{ + "testing.Main(", + "testing.tRunner(", + "testing.(*M).", + "runtime.goexit", + "created by runtime.gc", + "created by runtime/trace.Start", + "interestingGoroutines", + "runtime.MHeap_Scavenger", + "signal.signal_recv", + "sigterm.handler", + "runtime_mcall", + "(*loggingT).flushDaemon", + "goroutine in C code", + // Ignore the http read/write goroutines. gce metadata.OnGCE() was leaking + // these, root cause unknown. + // + // https://github.com/grpc/grpc-go/issues/5171 + // https://github.com/grpc/grpc-go/issues/5173 + "created by net/http.(*Transport).dialConn", +} + +// RegisterIgnoreGoroutine appends s into the ignore goroutine list. The +// goroutines whose stack trace contains s will not be identified as leaked +// goroutines. Not thread-safe, only call this function in init(). +func RegisterIgnoreGoroutine(s string) { + goroutinesToIgnore = append(goroutinesToIgnore, s) +} + +func ignore(g string) bool { + sl := strings.SplitN(g, "\n", 2) + if len(sl) != 2 { + return true + } + stack := strings.TrimSpace(sl[1]) + if strings.HasPrefix(stack, "testing.RunTests") { + return true + } + + if stack == "" { + return true + } + + for _, s := range goroutinesToIgnore { + if strings.Contains(stack, s) { + return true + } + } + + return false +} + +// interestingGoroutines returns all goroutines we care about for the purpose of +// leak checking. It excludes testing or runtime ones. +func interestingGoroutines() (gs []string) { + buf := make([]byte, 2<<20) + buf = buf[:runtime.Stack(buf, true)] + for _, g := range strings.Split(string(buf), "\n\n") { + if !ignore(g) { + gs = append(gs, g) + } + } + sort.Strings(gs) + return +} + +// Errorfer is the interface that wraps the Errorf method. It's a subset of +// testing.TB to make it easy to use Check. +type Errorfer interface { + Errorf(format string, args ...interface{}) +} + +func check(efer Errorfer, timeout time.Duration) { + // Loop, waiting for goroutines to shut down. + // Wait up to timeout, but finish as quickly as possible. + deadline := time.Now().Add(timeout) + var leaked []string + for time.Now().Before(deadline) { + if leaked = interestingGoroutines(); len(leaked) == 0 { + return + } + time.Sleep(50 * time.Millisecond) + } + for _, g := range leaked { + efer.Errorf("Leaked goroutine: %v", g) + } +} + +// Check looks at the currently-running goroutines and checks if there are any +// interesting (created by gRPC) goroutines leaked. It waits up to 10 seconds +// in the error cases. +func Check(efer Errorfer) { + check(efer, 10*time.Second) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck_test.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck_test.go new file mode 100644 index 000000000..58dfc12a1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/leakcheck/leakcheck_test.go @@ -0,0 +1,76 @@ +/* + * + * Copyright 2017 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package leakcheck + +import ( + "fmt" + "strings" + "testing" + "time" +) + +type testErrorfer struct { + errorCount int + errors []string +} + +func (e *testErrorfer) Errorf(format string, args ...interface{}) { + e.errors = append(e.errors, fmt.Sprintf(format, args...)) + e.errorCount++ +} + +func TestCheck(t *testing.T) { + const leakCount = 3 + for range leakCount { + go func() { time.Sleep(2 * time.Second) }() + } + if ig := interestingGoroutines(); len(ig) == 0 { + t.Error("blah") + } + e := &testErrorfer{} + check(e, time.Second) + if e.errorCount != leakCount { + t.Errorf("check found %v leaks, want %v leaks", e.errorCount, leakCount) + t.Logf("leaked goroutines:\n%v", strings.Join(e.errors, "\n")) + } + check(t, 3*time.Second) +} + +func ignoredTestingLeak(d time.Duration) { + time.Sleep(d) +} + +func TestCheckRegisterIgnore(t *testing.T) { + RegisterIgnoreGoroutine("ignoredTestingLeak") + const leakCount = 3 + for range leakCount { + go func() { time.Sleep(2 * time.Second) }() + } + go func() { ignoredTestingLeak(3 * time.Second) }() + if ig := interestingGoroutines(); len(ig) == 0 { + t.Error("blah") + } + e := &testErrorfer{} + check(e, time.Second) + if e.errorCount != leakCount { + t.Errorf("check found %v leaks, want %v leaks", e.errorCount, leakCount) + t.Logf("leaked goroutines:\n%v", strings.Join(e.errors, "\n")) + } + check(t, 3*time.Second) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver.go new file mode 100644 index 000000000..1f6460eff --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver.go @@ -0,0 +1,316 @@ +/* + * + * Copyright 2018 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// Forked from the default internal DNS resolver in the grpc-go package. The +// original source can be found at: +// https://github.com/grpc/grpc-go/blob/v1.49.0/internal/resolver/dns/dns_resolver.go + +package dns + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/grpc/internal/backoff" + "github.com/letsencrypt/boulder/grpc/noncebalancer" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/resolver" + "google.golang.org/grpc/serviceconfig" +) + +var logger = grpclog.Component("srv") + +// Globals to stub out in tests. TODO: Perhaps these two can be combined into a +// single variable for testing the resolver? +var ( + newTimer = time.NewTimer + newTimerDNSResRate = time.NewTimer +) + +func init() { + resolver.Register(NewDefaultSRVBuilder()) + resolver.Register(NewNonceSRVBuilder()) +} + +const defaultDNSSvrPort = "53" + +var defaultResolver netResolver = net.DefaultResolver + +var ( + // To prevent excessive re-resolution, we enforce a rate limit on DNS + // resolution requests. + minDNSResRate = 30 * time.Second +) + +var customAuthorityDialer = func(authority string) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, authority) + } +} + +var customAuthorityResolver = func(authority string) (*net.Resolver, error) { + host, port, err := bdns.ParseTarget(authority, defaultDNSSvrPort) + if err != nil { + return nil, err + } + return &net.Resolver{ + PreferGo: true, + Dial: customAuthorityDialer(net.JoinHostPort(host, port)), + }, nil +} + +// NewDefaultSRVBuilder creates a srvBuilder which is used to factory SRV DNS +// resolvers. +func NewDefaultSRVBuilder() resolver.Builder { + return &srvBuilder{scheme: "srv"} +} + +// NewNonceSRVBuilder creates a srvBuilder which is used to factory SRV DNS +// resolvers with a custom grpc.Balancer used by nonce-service clients. +func NewNonceSRVBuilder() resolver.Builder { + return &srvBuilder{scheme: noncebalancer.SRVResolverScheme, balancer: noncebalancer.Name} +} + +type srvBuilder struct { + scheme string + balancer string +} + +// Build creates and starts a DNS resolver that watches the name resolution of the target. +func (b *srvBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) { + var names []name + for _, i := range strings.Split(target.Endpoint(), ",") { + service, domain, err := parseServiceDomain(i) + if err != nil { + return nil, err + } + names = append(names, name{service: service, domain: domain}) + } + + ctx, cancel := context.WithCancel(context.Background()) + d := &dnsResolver{ + names: names, + ctx: ctx, + cancel: cancel, + cc: cc, + rn: make(chan struct{}, 1), + } + + if target.URL.Host == "" { + d.resolver = defaultResolver + } else { + var err error + d.resolver, err = customAuthorityResolver(target.URL.Host) + if err != nil { + return nil, err + } + } + + if b.balancer != "" { + d.serviceConfig = cc.ParseServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]}`, b.balancer)) + } + + d.wg.Add(1) + go d.watcher() + return d, nil +} + +// Scheme returns the naming scheme of this resolver builder. +func (b *srvBuilder) Scheme() string { + return b.scheme +} + +type netResolver interface { + LookupHost(ctx context.Context, host string) (addrs []string, err error) + LookupSRV(ctx context.Context, service, proto, name string) (cname string, addrs []*net.SRV, err error) +} + +type name struct { + service string + domain string +} + +// dnsResolver watches for the name resolution update for a non-IP target. +type dnsResolver struct { + names []name + resolver netResolver + ctx context.Context + cancel context.CancelFunc + cc resolver.ClientConn + // rn channel is used by ResolveNow() to force an immediate resolution of the target. + rn chan struct{} + // wg is used to enforce Close() to return after the watcher() goroutine has finished. + // Otherwise, data race will be possible. [Race Example] in dns_resolver_test we + // replace the real lookup functions with mocked ones to facilitate testing. + // If Close() doesn't wait for watcher() goroutine finishes, race detector sometimes + // will warns lookup (READ the lookup function pointers) inside watcher() goroutine + // has data race with replaceNetFunc (WRITE the lookup function pointers). + wg sync.WaitGroup + serviceConfig *serviceconfig.ParseResult +} + +// ResolveNow invoke an immediate resolution of the target that this dnsResolver watches. +func (d *dnsResolver) ResolveNow(resolver.ResolveNowOptions) { + select { + case d.rn <- struct{}{}: + default: + } +} + +// Close closes the dnsResolver. +func (d *dnsResolver) Close() { + d.cancel() + d.wg.Wait() +} + +func (d *dnsResolver) watcher() { + defer d.wg.Done() + backoffIndex := 1 + for { + state, err := d.lookup() + if err != nil { + // Report error to the underlying grpc.ClientConn. + d.cc.ReportError(err) + } else { + if d.serviceConfig != nil { + state.ServiceConfig = d.serviceConfig + } + err = d.cc.UpdateState(*state) + } + + var timer *time.Timer + if err == nil { + // Success resolving, wait for the next ResolveNow. However, also wait 30 seconds at the very least + // to prevent constantly re-resolving. + backoffIndex = 1 + timer = newTimerDNSResRate(minDNSResRate) + select { + case <-d.ctx.Done(): + timer.Stop() + return + case <-d.rn: + } + } else { + // Poll on an error found in DNS Resolver or an error received from ClientConn. + timer = newTimer(backoff.DefaultExponential.Backoff(backoffIndex)) + backoffIndex++ + } + select { + case <-d.ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +func (d *dnsResolver) lookupSRV() ([]resolver.Address, error) { + var newAddrs []resolver.Address + var errs []error + for _, n := range d.names { + _, srvs, err := d.resolver.LookupSRV(d.ctx, n.service, "tcp", n.domain) + if err != nil { + err = handleDNSError(err, "SRV") // may become nil + if err != nil { + errs = append(errs, err) + continue + } + } + for _, s := range srvs { + backendAddrs, err := d.resolver.LookupHost(d.ctx, s.Target) + if err != nil { + err = handleDNSError(err, "A") // may become nil + if err != nil { + errs = append(errs, err) + continue + } + } + for _, a := range backendAddrs { + ip, ok := formatIP(a) + if !ok { + errs = append(errs, fmt.Errorf("srv: error parsing A record IP address %v", a)) + continue + } + addr := ip + ":" + strconv.Itoa(int(s.Port)) + newAddrs = append(newAddrs, resolver.Address{Addr: addr, ServerName: s.Target}) + } + } + } + // Only return an error if all lookups failed. + if len(errs) > 0 && len(newAddrs) == 0 { + return nil, errors.Join(errs...) + } + return newAddrs, nil +} + +func handleDNSError(err error, lookupType string) error { + if dnsErr, ok := err.(*net.DNSError); ok && !dnsErr.IsTimeout && !dnsErr.IsTemporary { + // Timeouts and temporary errors should be communicated to gRPC to + // attempt another DNS query (with backoff). Other errors should be + // suppressed (they may represent the absence of a TXT record). + return nil + } + if err != nil { + err = fmt.Errorf("srv: %v record lookup error: %v", lookupType, err) + logger.Info(err) + } + return err +} + +func (d *dnsResolver) lookup() (*resolver.State, error) { + addrs, err := d.lookupSRV() + if err != nil { + return nil, err + } + return &resolver.State{Addresses: addrs}, nil +} + +// formatIP returns ok = false if addr is not a valid textual representation of an IP address. +// If addr is an IPv4 address, return the addr and ok = true. +// If addr is an IPv6 address, return the addr enclosed in square brackets and ok = true. +func formatIP(addr string) (addrIP string, ok bool) { + ip := net.ParseIP(addr) + if ip == nil { + return "", false + } + if ip.To4() != nil { + return addr, true + } + return "[" + addr + "]", true +} + +// parseServiceDomain takes the user input target string and parses the service domain +// names for SRV lookup. Input is expected to be a hostname containing at least +// two labels (e.g. "foo.bar", "foo.bar.baz"). The first label is the service +// name and the rest is the domain name. If the target is not in the expected +// format, an error is returned. +func parseServiceDomain(target string) (string, string, error) { + sd := strings.SplitN(target, ".", 2) + if len(sd) < 2 || sd[0] == "" || sd[1] == "" { + return "", "", fmt.Errorf("srv: hostname %q contains < 2 labels", target) + } + return sd[0], sd[1], nil +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver_test.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver_test.go new file mode 100644 index 000000000..891fb970e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/resolver/dns/dns_resolver_test.go @@ -0,0 +1,840 @@ +/* + * + * Copyright 2018 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dns + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "slices" + "strings" + "sync" + "testing" + "time" + + "google.golang.org/grpc/balancer" + "google.golang.org/grpc/resolver" + + "github.com/letsencrypt/boulder/grpc/internal/leakcheck" + "github.com/letsencrypt/boulder/grpc/internal/testutils" + "github.com/letsencrypt/boulder/test" +) + +func TestMain(m *testing.M) { + // Set a non-zero duration only for tests which are actually testing that + // feature. + replaceDNSResRate(time.Duration(0)) // No need to clean up since we os.Exit + overrideDefaultResolver(false) // No need to clean up since we os.Exit + code := m.Run() + os.Exit(code) +} + +const ( + txtBytesLimit = 255 + defaultTestTimeout = 10 * time.Second + defaultTestShortTimeout = 10 * time.Millisecond +) + +type testClientConn struct { + resolver.ClientConn // For unimplemented functions + target string + m1 sync.Mutex + state resolver.State + updateStateCalls int + errChan chan error + updateStateErr error +} + +func (t *testClientConn) UpdateState(s resolver.State) error { + t.m1.Lock() + defer t.m1.Unlock() + t.state = s + t.updateStateCalls++ + // This error determines whether DNS Resolver actually decides to exponentially backoff or not. + // This can be any error. + return t.updateStateErr +} + +func (t *testClientConn) getState() (resolver.State, int) { + t.m1.Lock() + defer t.m1.Unlock() + return t.state, t.updateStateCalls +} + +func (t *testClientConn) ReportError(err error) { + t.errChan <- err +} + +type testResolver struct { + // A write to this channel is made when this resolver receives a resolution + // request. Tests can rely on reading from this channel to be notified about + // resolution requests instead of sleeping for a predefined period of time. + lookupHostCh *testutils.Channel +} + +func (tr *testResolver) LookupHost(ctx context.Context, host string) ([]string, error) { + if tr.lookupHostCh != nil { + tr.lookupHostCh.Send(nil) + } + return hostLookup(host) +} + +func (*testResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + return srvLookup(service, proto, name) +} + +// overrideDefaultResolver overrides the defaultResolver used by the code with +// an instance of the testResolver. pushOnLookup controls whether the +// testResolver created here pushes lookupHost events on its channel. +func overrideDefaultResolver(pushOnLookup bool) func() { + oldResolver := defaultResolver + + var lookupHostCh *testutils.Channel + if pushOnLookup { + lookupHostCh = testutils.NewChannel() + } + defaultResolver = &testResolver{lookupHostCh: lookupHostCh} + + return func() { + defaultResolver = oldResolver + } +} + +func replaceDNSResRate(d time.Duration) func() { + oldMinDNSResRate := minDNSResRate + minDNSResRate = d + + return func() { + minDNSResRate = oldMinDNSResRate + } +} + +var hostLookupTbl = struct { + sync.Mutex + tbl map[string][]string +}{ + tbl: map[string][]string{ + "ipv4.single.fake": {"2.4.6.8"}, + "ipv4.multi.fake": {"1.2.3.4", "5.6.7.8", "9.10.11.12"}, + "ipv6.single.fake": {"2607:f8b0:400a:801::1001"}, + "ipv6.multi.fake": {"2607:f8b0:400a:801::1001", "2607:f8b0:400a:801::1002", "2607:f8b0:400a:801::1003"}, + }, +} + +func hostLookup(host string) ([]string, error) { + hostLookupTbl.Lock() + defer hostLookupTbl.Unlock() + if addrs, ok := hostLookupTbl.tbl[host]; ok { + return addrs, nil + } + return nil, &net.DNSError{ + Err: "hostLookup error", + Name: host, + Server: "fake", + IsTemporary: true, + } +} + +var srvLookupTbl = struct { + sync.Mutex + tbl map[string][]*net.SRV +}{ + tbl: map[string][]*net.SRV{ + "_foo._tcp.ipv4.single.fake": {&net.SRV{Target: "ipv4.single.fake", Port: 1234}}, + "_foo._tcp.ipv4.multi.fake": {&net.SRV{Target: "ipv4.multi.fake", Port: 1234}}, + "_foo._tcp.ipv6.single.fake": {&net.SRV{Target: "ipv6.single.fake", Port: 1234}}, + "_foo._tcp.ipv6.multi.fake": {&net.SRV{Target: "ipv6.multi.fake", Port: 1234}}, + }, +} + +func srvLookup(service, proto, name string) (string, []*net.SRV, error) { + cname := "_" + service + "._" + proto + "." + name + srvLookupTbl.Lock() + defer srvLookupTbl.Unlock() + if srvs, cnt := srvLookupTbl.tbl[cname]; cnt { + return cname, srvs, nil + } + return "", nil, &net.DNSError{ + Err: "srvLookup error", + Name: cname, + Server: "fake", + IsTemporary: true, + } +} + +func TestResolve(t *testing.T) { + testDNSResolver(t) + testDNSResolveNow(t) +} + +func testDNSResolver(t *testing.T) { + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + newTimer = func(_ time.Duration) *time.Timer { + // Will never fire on its own, will protect from triggering exponential backoff. + return time.NewTimer(time.Hour) + } + tests := []struct { + target string + addrWant []resolver.Address + }{ + { + "foo.ipv4.single.fake", + []resolver.Address{{Addr: "2.4.6.8:1234", ServerName: "ipv4.single.fake"}}, + }, + { + "foo.ipv4.multi.fake", + []resolver.Address{ + {Addr: "1.2.3.4:1234", ServerName: "ipv4.multi.fake"}, + {Addr: "5.6.7.8:1234", ServerName: "ipv4.multi.fake"}, + {Addr: "9.10.11.12:1234", ServerName: "ipv4.multi.fake"}, + }, + }, + { + "foo.ipv6.single.fake", + []resolver.Address{{Addr: "[2607:f8b0:400a:801::1001]:1234", ServerName: "ipv6.single.fake"}}, + }, + { + "foo.ipv6.multi.fake", + []resolver.Address{ + {Addr: "[2607:f8b0:400a:801::1001]:1234", ServerName: "ipv6.multi.fake"}, + {Addr: "[2607:f8b0:400a:801::1002]:1234", ServerName: "ipv6.multi.fake"}, + {Addr: "[2607:f8b0:400a:801::1003]:1234", ServerName: "ipv6.multi.fake"}, + }, + }, + } + + for _, a := range tests { + b := NewDefaultSRVBuilder() + cc := &testClientConn{target: a.target} + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", a.target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("%v\n", err) + } + var state resolver.State + var cnt int + for range 2000 { + state, cnt = cc.getState() + if cnt > 0 { + break + } + time.Sleep(time.Millisecond) + } + if cnt == 0 { + t.Fatalf("UpdateState not called after 2s; aborting") + } + + if !slices.Equal(a.addrWant, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", a.target, state.Addresses, a.addrWant) + } + r.Close() + } +} + +// DNS Resolver immediately starts polling on an error from grpc. This should continue until the ClientConn doesn't +// send back an error from updating the DNS Resolver's state. +func TestDNSResolverExponentialBackoff(t *testing.T) { + defer leakcheck.Check(t) + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + timerChan := testutils.NewChannel() + newTimer = func(d time.Duration) *time.Timer { + // Will never fire on its own, allows this test to call timer immediately. + t := time.NewTimer(time.Hour) + timerChan.Send(t) + return t + } + target := "foo.ipv4.single.fake" + wantAddr := []resolver.Address{{Addr: "2.4.6.8:1234", ServerName: "ipv4.single.fake"}} + + b := NewDefaultSRVBuilder() + cc := &testClientConn{target: target} + // Cause ClientConn to return an error. + cc.updateStateErr = balancer.ErrBadResolverState + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("Error building resolver for target %v: %v", target, err) + } + defer r.Close() + var state resolver.State + var cnt int + for range 2000 { + state, cnt = cc.getState() + if cnt > 0 { + break + } + time.Sleep(time.Millisecond) + } + if cnt == 0 { + t.Fatalf("UpdateState not called after 2s; aborting") + } + if !slices.Equal(wantAddr, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", target, state.Addresses, target) + } + ctx, ctxCancel := context.WithTimeout(context.Background(), defaultTestTimeout) + defer ctxCancel() + // Cause timer to go off 10 times, and see if it calls updateState() correctly. + for range 10 { + timer, err := timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer := timer.(*time.Timer) + timerPointer.Reset(0) + } + // Poll to see if DNS Resolver updated state the correct number of times, which allows time for the DNS Resolver to call + // ClientConn update state. + deadline := time.Now().Add(defaultTestTimeout) + for { + cc.m1.Lock() + got := cc.updateStateCalls + cc.m1.Unlock() + if got == 11 { + break + } + + if time.Now().After(deadline) { + t.Fatalf("Exponential backoff is not working as expected - should update state 11 times instead of %d", got) + } + + time.Sleep(time.Millisecond) + } + + // Update resolver.ClientConn to not return an error anymore - this should stop it from backing off. + cc.updateStateErr = nil + timer, err := timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer := timer.(*time.Timer) + timerPointer.Reset(0) + // Poll to see if DNS Resolver updated state the correct number of times, which allows time for the DNS Resolver to call + // ClientConn update state the final time. The DNS Resolver should then stop polling. + deadline = time.Now().Add(defaultTestTimeout) + for { + cc.m1.Lock() + got := cc.updateStateCalls + cc.m1.Unlock() + if got == 12 { + break + } + + if time.Now().After(deadline) { + t.Fatalf("Exponential backoff is not working as expected - should stop backing off at 12 total UpdateState calls instead of %d", got) + } + + _, err := timerChan.ReceiveOrFail() + if err { + t.Fatalf("Should not poll again after Client Conn stops returning error.") + } + + time.Sleep(time.Millisecond) + } +} + +func mutateTbl(target string) func() { + hostLookupTbl.Lock() + oldHostTblEntry := hostLookupTbl.tbl[target] + + // Remove the last address from the target's entry. + hostLookupTbl.tbl[target] = hostLookupTbl.tbl[target][:len(oldHostTblEntry)-1] + hostLookupTbl.Unlock() + + return func() { + hostLookupTbl.Lock() + hostLookupTbl.tbl[target] = oldHostTblEntry + hostLookupTbl.Unlock() + } +} + +func testDNSResolveNow(t *testing.T) { + defer leakcheck.Check(t) + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + newTimer = func(_ time.Duration) *time.Timer { + // Will never fire on its own, will protect from triggering exponential backoff. + return time.NewTimer(time.Hour) + } + tests := []struct { + target string + addrWant []resolver.Address + addrNext []resolver.Address + }{ + { + "foo.ipv4.multi.fake", + []resolver.Address{ + {Addr: "1.2.3.4:1234", ServerName: "ipv4.multi.fake"}, + {Addr: "5.6.7.8:1234", ServerName: "ipv4.multi.fake"}, + {Addr: "9.10.11.12:1234", ServerName: "ipv4.multi.fake"}, + }, + []resolver.Address{ + {Addr: "1.2.3.4:1234", ServerName: "ipv4.multi.fake"}, + {Addr: "5.6.7.8:1234", ServerName: "ipv4.multi.fake"}, + }, + }, + } + + for _, a := range tests { + b := NewDefaultSRVBuilder() + cc := &testClientConn{target: a.target} + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", a.target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("%v\n", err) + } + defer r.Close() + var state resolver.State + var cnt int + for range 2000 { + state, cnt = cc.getState() + if cnt > 0 { + break + } + time.Sleep(time.Millisecond) + } + if cnt == 0 { + t.Fatalf("UpdateState not called after 2s; aborting. state=%v", state) + } + if !slices.Equal(a.addrWant, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", a.target, state.Addresses, a.addrWant) + } + + revertTbl := mutateTbl(strings.TrimPrefix(a.target, "foo.")) + r.ResolveNow(resolver.ResolveNowOptions{}) + for range 2000 { + state, cnt = cc.getState() + if cnt == 2 { + break + } + time.Sleep(time.Millisecond) + } + if cnt != 2 { + t.Fatalf("UpdateState not called after 2s; aborting. state=%v", state) + } + if !slices.Equal(a.addrNext, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", a.target, state.Addresses, a.addrNext) + } + revertTbl() + } +} + +func TestDNSResolverRetry(t *testing.T) { + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + newTimer = func(d time.Duration) *time.Timer { + // Will never fire on its own, will protect from triggering exponential backoff. + return time.NewTimer(time.Hour) + } + b := NewDefaultSRVBuilder() + target := "foo.ipv4.single.fake" + cc := &testClientConn{target: target} + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("%v\n", err) + } + defer r.Close() + var state resolver.State + for range 2000 { + state, _ = cc.getState() + if len(state.Addresses) == 1 { + break + } + time.Sleep(time.Millisecond) + } + if len(state.Addresses) != 1 { + t.Fatalf("UpdateState not called with 1 address after 2s; aborting. state=%v", state) + } + want := []resolver.Address{{Addr: "2.4.6.8:1234", ServerName: "ipv4.single.fake"}} + if !slices.Equal(want, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", target, state.Addresses, want) + } + // mutate the host lookup table so the target has 0 address returned. + revertTbl := mutateTbl(strings.TrimPrefix(target, "foo.")) + // trigger a resolve that will get empty address list + r.ResolveNow(resolver.ResolveNowOptions{}) + for range 2000 { + state, _ = cc.getState() + if len(state.Addresses) == 0 { + break + } + time.Sleep(time.Millisecond) + } + if len(state.Addresses) != 0 { + t.Fatalf("UpdateState not called with 0 address after 2s; aborting. state=%v", state) + } + revertTbl() + // wait for the retry to happen in two seconds. + r.ResolveNow(resolver.ResolveNowOptions{}) + for range 2000 { + state, _ = cc.getState() + if len(state.Addresses) == 1 { + break + } + time.Sleep(time.Millisecond) + } + if !slices.Equal(want, state.Addresses) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", target, state.Addresses, want) + } +} + +func TestCustomAuthority(t *testing.T) { + defer leakcheck.Check(t) + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + newTimer = func(d time.Duration) *time.Timer { + // Will never fire on its own, will protect from triggering exponential backoff. + return time.NewTimer(time.Hour) + } + + tests := []struct { + authority string + authorityWant string + expectError bool + }{ + { + "4.3.2.1:" + defaultDNSSvrPort, + "4.3.2.1:" + defaultDNSSvrPort, + false, + }, + { + "4.3.2.1:123", + "4.3.2.1:123", + false, + }, + { + "4.3.2.1", + "4.3.2.1:" + defaultDNSSvrPort, + false, + }, + { + "::1", + "[::1]:" + defaultDNSSvrPort, + false, + }, + { + "[::1]", + "[::1]:" + defaultDNSSvrPort, + false, + }, + { + "[::1]:123", + "[::1]:123", + false, + }, + { + "dnsserver.com", + "dnsserver.com:" + defaultDNSSvrPort, + false, + }, + { + ":123", + "localhost:123", + false, + }, + { + ":", + "", + true, + }, + { + "[::1]:", + "", + true, + }, + { + "dnsserver.com:", + "", + true, + }, + } + oldcustomAuthorityDialer := customAuthorityDialer + defer func() { + customAuthorityDialer = oldcustomAuthorityDialer + }() + + for _, a := range tests { + errChan := make(chan error, 1) + customAuthorityDialer = func(authority string) func(ctx context.Context, network, address string) (net.Conn, error) { + if authority != a.authorityWant { + errChan <- fmt.Errorf("wrong custom authority passed to resolver. input: %s expected: %s actual: %s", a.authority, a.authorityWant, authority) + } else { + errChan <- nil + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + return nil, errors.New("no need to dial") + } + } + + mockEndpointTarget := "foo.bar.com" + b := NewDefaultSRVBuilder() + cc := &testClientConn{target: mockEndpointTarget, errChan: make(chan error, 1)} + target := resolver.Target{ + URL: *testutils.MustParseURL(fmt.Sprintf("scheme://%s/%s", a.authority, mockEndpointTarget)), + } + r, err := b.Build(target, cc, resolver.BuildOptions{}) + + if err == nil { + r.Close() + + err = <-errChan + if err != nil { + t.Errorf(err.Error()) + } + + if a.expectError { + t.Errorf("custom authority should have caused an error: %s", a.authority) + } + } else if !a.expectError { + t.Errorf("unexpected error using custom authority %s: %s", a.authority, err) + } + } +} + +// TestRateLimitedResolve exercises the rate limit enforced on re-resolution +// requests. It sets the re-resolution rate to a small value and repeatedly +// calls ResolveNow() and ensures only the expected number of resolution +// requests are made. +func TestRateLimitedResolve(t *testing.T) { + defer leakcheck.Check(t) + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + newTimer = func(d time.Duration) *time.Timer { + // Will never fire on its own, will protect from triggering exponential + // backoff. + return time.NewTimer(time.Hour) + } + defer func(nt func(d time.Duration) *time.Timer) { + newTimerDNSResRate = nt + }(newTimerDNSResRate) + + timerChan := testutils.NewChannel() + newTimerDNSResRate = func(d time.Duration) *time.Timer { + // Will never fire on its own, allows this test to call timer + // immediately. + t := time.NewTimer(time.Hour) + timerChan.Send(t) + return t + } + + // Create a new testResolver{} for this test because we want the exact count + // of the number of times the resolver was invoked. + nc := overrideDefaultResolver(true) + defer nc() + + target := "foo.ipv4.single.fake" + b := NewDefaultSRVBuilder() + cc := &testClientConn{target: target} + + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("resolver.Build() returned error: %v\n", err) + } + defer r.Close() + + dnsR, ok := r.(*dnsResolver) + if !ok { + t.Fatalf("resolver.Build() returned unexpected type: %T\n", dnsR) + } + + tr, ok := dnsR.resolver.(*testResolver) + if !ok { + t.Fatalf("delegate resolver returned unexpected type: %T\n", tr) + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) + defer cancel() + + // Wait for the first resolution request to be done. This happens as part + // of the first iteration of the for loop in watcher(). + if _, err := tr.lookupHostCh.Receive(ctx); err != nil { + t.Fatalf("Timed out waiting for lookup() call.") + } + + // Call Resolve Now 100 times, shouldn't continue onto next iteration of + // watcher, thus shouldn't lookup again. + for range 100 { + r.ResolveNow(resolver.ResolveNowOptions{}) + } + + continueCtx, continueCancel := context.WithTimeout(context.Background(), defaultTestShortTimeout) + defer continueCancel() + + if _, err := tr.lookupHostCh.Receive(continueCtx); err == nil { + t.Fatalf("Should not have looked up again as DNS Min Res Rate timer has not gone off.") + } + + // Make the DNSMinResRate timer fire immediately (by receiving it, then + // resetting to 0), this will unblock the resolver which is currently + // blocked on the DNS Min Res Rate timer going off, which will allow it to + // continue to the next iteration of the watcher loop. + timer, err := timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer := timer.(*time.Timer) + timerPointer.Reset(0) + + // Now that DNS Min Res Rate timer has gone off, it should lookup again. + if _, err := tr.lookupHostCh.Receive(ctx); err != nil { + t.Fatalf("Timed out waiting for lookup() call.") + } + + // Resolve Now 1000 more times, shouldn't lookup again as DNS Min Res Rate + // timer has not gone off. + for range 1000 { + r.ResolveNow(resolver.ResolveNowOptions{}) + } + + if _, err = tr.lookupHostCh.Receive(continueCtx); err == nil { + t.Fatalf("Should not have looked up again as DNS Min Res Rate timer has not gone off.") + } + + // Make the DNSMinResRate timer fire immediately again. + timer, err = timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer = timer.(*time.Timer) + timerPointer.Reset(0) + + // Now that DNS Min Res Rate timer has gone off, it should lookup again. + if _, err = tr.lookupHostCh.Receive(ctx); err != nil { + t.Fatalf("Timed out waiting for lookup() call.") + } + + wantAddrs := []resolver.Address{{Addr: "2.4.6.8:1234", ServerName: "ipv4.single.fake"}} + var state resolver.State + for { + var cnt int + state, cnt = cc.getState() + if cnt > 0 { + break + } + time.Sleep(time.Millisecond) + } + if !slices.Equal(state.Addresses, wantAddrs) { + t.Errorf("Resolved addresses of target: %q = %+v, want %+v", target, state.Addresses, wantAddrs) + } +} + +// DNS Resolver immediately starts polling on an error. This will cause the re-resolution to return another error. +// Thus, test that it constantly sends errors to the grpc.ClientConn. +func TestReportError(t *testing.T) { + const target = "not.found" + defer func(nt func(d time.Duration) *time.Timer) { + newTimer = nt + }(newTimer) + timerChan := testutils.NewChannel() + newTimer = func(d time.Duration) *time.Timer { + // Will never fire on its own, allows this test to call timer immediately. + t := time.NewTimer(time.Hour) + timerChan.Send(t) + return t + } + cc := &testClientConn{target: target, errChan: make(chan error)} + totalTimesCalledError := 0 + b := NewDefaultSRVBuilder() + r, err := b.Build(resolver.Target{URL: *testutils.MustParseURL(fmt.Sprintf("scheme:///%s", target))}, cc, resolver.BuildOptions{}) + if err != nil { + t.Fatalf("Error building resolver for target %v: %v", target, err) + } + // Should receive first error. + err = <-cc.errChan + if !strings.Contains(err.Error(), "srvLookup error") { + t.Fatalf(`ReportError(err=%v) called; want err contains "srvLookupError"`, err) + } + totalTimesCalledError++ + ctx, ctxCancel := context.WithTimeout(context.Background(), defaultTestTimeout) + defer ctxCancel() + timer, err := timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer := timer.(*time.Timer) + timerPointer.Reset(0) + defer r.Close() + + // Cause timer to go off 10 times, and see if it matches DNS Resolver updating Error. + for range 10 { + // Should call ReportError(). + err = <-cc.errChan + if !strings.Contains(err.Error(), "srvLookup error") { + t.Fatalf(`ReportError(err=%v) called; want err contains "srvLookupError"`, err) + } + totalTimesCalledError++ + timer, err := timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } + timerPointer := timer.(*time.Timer) + timerPointer.Reset(0) + } + + if totalTimesCalledError != 11 { + t.Errorf("ReportError() not called 11 times, instead called %d times.", totalTimesCalledError) + } + // Clean up final watcher iteration. + <-cc.errChan + _, err = timerChan.Receive(ctx) + if err != nil { + t.Fatalf("Error receiving timer from mock NewTimer call: %v", err) + } +} + +func Test_parseServiceDomain(t *testing.T) { + tests := []struct { + target string + expectService string + expectDomain string + wantErr bool + }{ + // valid + {"foo.bar", "foo", "bar", false}, + {"foo.bar.baz", "foo", "bar.baz", false}, + {"foo.bar.baz.", "foo", "bar.baz.", false}, + + // invalid + {"", "", "", true}, + {".", "", "", true}, + {"foo", "", "", true}, + {".foo", "", "", true}, + {"foo.", "", "", true}, + {".foo.bar.baz", "", "", true}, + {".foo.bar.baz.", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + gotService, gotDomain, err := parseServiceDomain(tt.target) + if tt.wantErr { + test.AssertError(t, err, "expect err got nil") + } else { + test.AssertNotError(t, err, "expect nil err") + test.AssertEquals(t, gotService, tt.expectService) + test.AssertEquals(t, gotDomain, tt.expectDomain) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/channel.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/channel.go new file mode 100644 index 000000000..6a08a94a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/channel.go @@ -0,0 +1,104 @@ +/* + * + * Copyright 2020 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package testutils + +import ( + "context" +) + +// DefaultChanBufferSize is the default buffer size of the underlying channel. +const DefaultChanBufferSize = 1 + +// Channel wraps a generic channel and provides a timed receive operation. +type Channel struct { + ch chan interface{} +} + +// Send sends value on the underlying channel. +func (c *Channel) Send(value interface{}) { + c.ch <- value +} + +// SendContext sends value on the underlying channel, or returns an error if +// the context expires. +func (c *Channel) SendContext(ctx context.Context, value interface{}) error { + select { + case c.ch <- value: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// SendOrFail attempts to send value on the underlying channel. Returns true +// if successful or false if the channel was full. +func (c *Channel) SendOrFail(value interface{}) bool { + select { + case c.ch <- value: + return true + default: + return false + } +} + +// ReceiveOrFail returns the value on the underlying channel and true, or nil +// and false if the channel was empty. +func (c *Channel) ReceiveOrFail() (interface{}, bool) { + select { + case got := <-c.ch: + return got, true + default: + return nil, false + } +} + +// Receive returns the value received on the underlying channel, or the error +// returned by ctx if it is closed or cancelled. +func (c *Channel) Receive(ctx context.Context) (interface{}, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case got := <-c.ch: + return got, nil + } +} + +// Replace clears the value on the underlying channel, and sends the new value. +// +// It's expected to be used with a size-1 channel, to only keep the most +// up-to-date item. This method is inherently racy when invoked concurrently +// from multiple goroutines. +func (c *Channel) Replace(value interface{}) { + for { + select { + case c.ch <- value: + return + case <-c.ch: + } + } +} + +// NewChannel returns a new Channel. +func NewChannel() *Channel { + return NewChannelWithSize(DefaultChanBufferSize) +} + +// NewChannelWithSize returns a new Channel with a buffer of bufSize. +func NewChannelWithSize(bufSize int) *Channel { + return &Channel{ch: make(chan interface{}, bufSize)} +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/parse_url.go b/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/parse_url.go new file mode 100644 index 000000000..ff276e4d0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/internal/testutils/parse_url.go @@ -0,0 +1,34 @@ +/* + * + * Copyright 2023 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package testutils + +import ( + "fmt" + "net/url" +) + +// MustParseURL attempts to parse the provided target using url.Parse() +// and panics if parsing fails. +func MustParseURL(target string) *url.URL { + u, err := url.Parse(target) + if err != nil { + panic(fmt.Sprintf("Error parsing target(%s): %v", target, err)) + } + return u +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer.go b/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer.go new file mode 100644 index 000000000..cf4e56671 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer.go @@ -0,0 +1,127 @@ +package noncebalancer + +import ( + "errors" + + "github.com/letsencrypt/boulder/nonce" + + "google.golang.org/grpc/balancer" + "google.golang.org/grpc/balancer/base" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // Name is the name used to register the nonce balancer with the gRPC + // runtime. + Name = "nonce" + + // SRVResolverScheme is the scheme used to invoke an instance of the SRV + // resolver which will use the noncebalancer to pick backends. It would be + // ideal to export this from the SRV resolver package but that package is + // internal. + SRVResolverScheme = "nonce-srv" +) + +// ErrNoBackendsMatchPrefix indicates that no backends were found which match +// the nonce prefix provided in the RPC context. This can happen when the +// provided nonce is stale, valid but the backend has since been removed from +// the balancer, or valid but the backend has not yet been added to the +// balancer. +// +// In any case, when the WFE receives this error it will return a badNonce error +// to the ACME client. +var ErrNoBackendsMatchPrefix = status.New(codes.Unavailable, "no backends match the nonce prefix") +var errMissingPrefixCtxKey = errors.New("nonce.PrefixCtxKey value required in RPC context") +var errMissingHMACKeyCtxKey = errors.New("nonce.HMACKeyCtxKey value required in RPC context") +var errInvalidPrefixCtxKeyType = errors.New("nonce.PrefixCtxKey value in RPC context must be a string") +var errInvalidHMACKeyCtxKeyType = errors.New("nonce.HMACKeyCtxKey value in RPC context must be a string") + +// Balancer implements the base.PickerBuilder interface. It's used to create new +// balancer.Picker instances. It should only be used by nonce-service clients. +type Balancer struct{} + +// Compile-time assertion that *Balancer implements the base.PickerBuilder +// interface. +var _ base.PickerBuilder = (*Balancer)(nil) + +// Build implements the base.PickerBuilder interface. It is called by the gRPC +// runtime when the balancer is first initialized and when the set of backend +// (SubConn) addresses changes. +func (b *Balancer) Build(buildInfo base.PickerBuildInfo) balancer.Picker { + if len(buildInfo.ReadySCs) == 0 { + // The Picker must be rebuilt if there are no backends available. + return base.NewErrPicker(balancer.ErrNoSubConnAvailable) + } + return &Picker{ + backends: buildInfo.ReadySCs, + } +} + +// Picker implements the balancer.Picker interface. It picks a backend (SubConn) +// based on the nonce prefix contained in each request's Context. +type Picker struct { + backends map[balancer.SubConn]base.SubConnInfo + prefixToBackend map[string]balancer.SubConn +} + +// Compile-time assertion that *Picker implements the balancer.Picker interface. +var _ balancer.Picker = (*Picker)(nil) + +// Pick implements the balancer.Picker interface. It is called by the gRPC +// runtime for each RPC message. It is responsible for picking a backend +// (SubConn) based on the context of each RPC message. +func (p *Picker) Pick(info balancer.PickInfo) (balancer.PickResult, error) { + if len(p.backends) == 0 { + // This should never happen, the Picker should only be built when there + // are backends available. + return balancer.PickResult{}, balancer.ErrNoSubConnAvailable + } + + // Get the HMAC key from the RPC context. + hmacKeyVal := info.Ctx.Value(nonce.HMACKeyCtxKey{}) + if hmacKeyVal == nil { + // This should never happen. + return balancer.PickResult{}, errMissingHMACKeyCtxKey + } + hmacKey, ok := hmacKeyVal.(string) + if !ok { + // This should never happen. + return balancer.PickResult{}, errInvalidHMACKeyCtxKeyType + } + + if p.prefixToBackend == nil { + // First call to Pick with a new Picker. + prefixToBackend := make(map[string]balancer.SubConn) + for sc, scInfo := range p.backends { + scPrefix := nonce.DerivePrefix(scInfo.Address.Addr, hmacKey) + prefixToBackend[scPrefix] = sc + } + p.prefixToBackend = prefixToBackend + } + + // Get the destination prefix from the RPC context. + destPrefixVal := info.Ctx.Value(nonce.PrefixCtxKey{}) + if destPrefixVal == nil { + // This should never happen. + return balancer.PickResult{}, errMissingPrefixCtxKey + } + destPrefix, ok := destPrefixVal.(string) + if !ok { + // This should never happen. + return balancer.PickResult{}, errInvalidPrefixCtxKeyType + } + + sc, ok := p.prefixToBackend[destPrefix] + if !ok { + // No backend SubConn was found for the destination prefix. + return balancer.PickResult{}, ErrNoBackendsMatchPrefix.Err() + } + return balancer.PickResult{SubConn: sc}, nil +} + +func init() { + balancer.Register( + base.NewBalancerBuilder(Name, &Balancer{}, base.Config{}), + ) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer_test.go b/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer_test.go new file mode 100644 index 000000000..ce7a05649 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/noncebalancer/noncebalancer_test.go @@ -0,0 +1,132 @@ +package noncebalancer + +import ( + "context" + "testing" + + "github.com/letsencrypt/boulder/nonce" + "github.com/letsencrypt/boulder/test" + "google.golang.org/grpc/balancer" + "google.golang.org/grpc/balancer/base" + "google.golang.org/grpc/resolver" +) + +func TestPickerPicksCorrectBackend(t *testing.T) { + _, p, subConns := setupTest(false) + prefix := nonce.DerivePrefix(subConns[0].addrs[0].Addr, "Kala namak") + + testCtx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, "HNmOnt8w") + testCtx = context.WithValue(testCtx, nonce.HMACKeyCtxKey{}, prefix) + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertNotError(t, err, "Pick failed") + test.AssertDeepEquals(t, subConns[0], gotPick.SubConn) +} + +func TestPickerMissingPrefixInCtx(t *testing.T) { + _, p, subConns := setupTest(false) + prefix := nonce.DerivePrefix(subConns[0].addrs[0].Addr, "Kala namak") + + testCtx := context.WithValue(context.Background(), nonce.HMACKeyCtxKey{}, prefix) + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, errMissingPrefixCtxKey) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func TestPickerInvalidPrefixInCtx(t *testing.T) { + _, p, _ := setupTest(false) + + testCtx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, 9) + testCtx = context.WithValue(testCtx, nonce.HMACKeyCtxKey{}, "foobar") + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, errInvalidPrefixCtxKeyType) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func TestPickerMissingHMACKeyInCtx(t *testing.T) { + _, p, _ := setupTest(false) + + testCtx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, "HNmOnt8w") + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, errMissingHMACKeyCtxKey) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func TestPickerInvalidHMACKeyInCtx(t *testing.T) { + _, p, _ := setupTest(false) + + testCtx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, "HNmOnt8w") + testCtx = context.WithValue(testCtx, nonce.HMACKeyCtxKey{}, 9) + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, errInvalidHMACKeyCtxKeyType) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func TestPickerNoMatchingSubConnAvailable(t *testing.T) { + _, p, subConns := setupTest(false) + prefix := nonce.DerivePrefix(subConns[0].addrs[0].Addr, "Kala namak") + + testCtx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, "rUsTrUin") + testCtx = context.WithValue(testCtx, nonce.HMACKeyCtxKey{}, prefix) + info := balancer.PickInfo{Ctx: testCtx} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, ErrNoBackendsMatchPrefix.Err()) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func TestPickerNoSubConnsAvailable(t *testing.T) { + b, p, _ := setupTest(true) + b.Build(base.PickerBuildInfo{}) + info := balancer.PickInfo{Ctx: context.Background()} + + gotPick, err := p.Pick(info) + test.AssertErrorIs(t, err, balancer.ErrNoSubConnAvailable) + test.AssertNil(t, gotPick.SubConn, "subConn should be nil") +} + +func setupTest(noSubConns bool) (*Balancer, balancer.Picker, []*subConn) { + var subConns []*subConn + bi := base.PickerBuildInfo{ + ReadySCs: make(map[balancer.SubConn]base.SubConnInfo), + } + + sc := &subConn{} + addr := resolver.Address{Addr: "10.77.77.77:8080"} + sc.UpdateAddresses([]resolver.Address{addr}) + + if !noSubConns { + bi.ReadySCs[sc] = base.SubConnInfo{Address: addr} + subConns = append(subConns, sc) + } + + b := &Balancer{} + p := b.Build(bi) + return b, p, subConns +} + +// subConn implements the balancer.SubConn interface. +type subConn struct { + addrs []resolver.Address +} + +func (s *subConn) UpdateAddresses(addrs []resolver.Address) { + s.addrs = addrs +} + +func (s *subConn) Connect() {} + +func (s *subConn) GetOrBuildProducer(balancer.ProducerBuilder) (p balancer.Producer, close func()) { + panic("unimplemented") +} + +func (s *subConn) Shutdown() {} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling.go b/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling.go new file mode 100644 index 000000000..90de4a9eb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling.go @@ -0,0 +1,434 @@ +// Copyright 2016 ISRG. All rights reserved +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grpc + +import ( + "fmt" + "net" + "time" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +var ErrMissingParameters = CodedError(codes.FailedPrecondition, "required RPC parameter was missing") + +// This file defines functions to translate between the protobuf types and the +// code types. + +func ProblemDetailsToPB(prob *probs.ProblemDetails) (*corepb.ProblemDetails, error) { + if prob == nil { + // nil problemDetails is valid + return nil, nil + } + return &corepb.ProblemDetails{ + ProblemType: string(prob.Type), + Detail: prob.Detail, + HttpStatus: int32(prob.HTTPStatus), + }, nil +} + +func PBToProblemDetails(in *corepb.ProblemDetails) (*probs.ProblemDetails, error) { + if in == nil { + // nil problemDetails is valid + return nil, nil + } + if in.ProblemType == "" || in.Detail == "" { + return nil, ErrMissingParameters + } + prob := &probs.ProblemDetails{ + Type: probs.ProblemType(in.ProblemType), + Detail: in.Detail, + } + if in.HttpStatus != 0 { + prob.HTTPStatus = int(in.HttpStatus) + } + return prob, nil +} + +func ChallengeToPB(challenge core.Challenge) (*corepb.Challenge, error) { + prob, err := ProblemDetailsToPB(challenge.Error) + if err != nil { + return nil, err + } + recordAry := make([]*corepb.ValidationRecord, len(challenge.ValidationRecord)) + for i, v := range challenge.ValidationRecord { + recordAry[i], err = ValidationRecordToPB(v) + if err != nil { + return nil, err + } + } + + var validated *timestamppb.Timestamp + if challenge.Validated != nil { + validated = timestamppb.New(challenge.Validated.UTC()) + if !validated.IsValid() { + return nil, fmt.Errorf("error creating *timestamppb.Timestamp for *corepb.Challenge object") + } + } + + return &corepb.Challenge{ + Type: string(challenge.Type), + Status: string(challenge.Status), + Token: challenge.Token, + KeyAuthorization: challenge.ProvidedKeyAuthorization, + Error: prob, + Validationrecords: recordAry, + Validated: validated, + }, nil +} + +func PBToChallenge(in *corepb.Challenge) (challenge core.Challenge, err error) { + if in == nil { + return core.Challenge{}, ErrMissingParameters + } + if in.Type == "" || in.Status == "" || in.Token == "" { + return core.Challenge{}, ErrMissingParameters + } + var recordAry []core.ValidationRecord + if len(in.Validationrecords) > 0 { + recordAry = make([]core.ValidationRecord, len(in.Validationrecords)) + for i, v := range in.Validationrecords { + recordAry[i], err = PBToValidationRecord(v) + if err != nil { + return core.Challenge{}, err + } + } + } + prob, err := PBToProblemDetails(in.Error) + if err != nil { + return core.Challenge{}, err + } + var validated *time.Time + if !core.IsAnyNilOrZero(in.Validated) { + val := in.Validated.AsTime() + validated = &val + } + ch := core.Challenge{ + Type: core.AcmeChallenge(in.Type), + Status: core.AcmeStatus(in.Status), + Token: in.Token, + Error: prob, + ValidationRecord: recordAry, + Validated: validated, + } + if in.KeyAuthorization != "" { + ch.ProvidedKeyAuthorization = in.KeyAuthorization + } + return ch, nil +} + +func ValidationRecordToPB(record core.ValidationRecord) (*corepb.ValidationRecord, error) { + addrs := make([][]byte, len(record.AddressesResolved)) + addrsTried := make([][]byte, len(record.AddressesTried)) + var err error + for i, v := range record.AddressesResolved { + addrs[i] = []byte(v) + } + for i, v := range record.AddressesTried { + addrsTried[i] = []byte(v) + } + addrUsed, err := record.AddressUsed.MarshalText() + if err != nil { + return nil, err + } + return &corepb.ValidationRecord{ + Hostname: record.Hostname, + Port: record.Port, + AddressesResolved: addrs, + AddressUsed: addrUsed, + Url: record.URL, + AddressesTried: addrsTried, + ResolverAddrs: record.ResolverAddrs, + }, nil +} + +func PBToValidationRecord(in *corepb.ValidationRecord) (record core.ValidationRecord, err error) { + if in == nil { + return core.ValidationRecord{}, ErrMissingParameters + } + addrs := make([]net.IP, len(in.AddressesResolved)) + for i, v := range in.AddressesResolved { + addrs[i] = net.IP(v) + } + addrsTried := make([]net.IP, len(in.AddressesTried)) + for i, v := range in.AddressesTried { + addrsTried[i] = net.IP(v) + } + var addrUsed net.IP + err = addrUsed.UnmarshalText(in.AddressUsed) + if err != nil { + return + } + return core.ValidationRecord{ + Hostname: in.Hostname, + Port: in.Port, + AddressesResolved: addrs, + AddressUsed: addrUsed, + URL: in.Url, + AddressesTried: addrsTried, + ResolverAddrs: in.ResolverAddrs, + }, nil +} + +func ValidationResultToPB(records []core.ValidationRecord, prob *probs.ProblemDetails) (*vapb.ValidationResult, error) { + recordAry := make([]*corepb.ValidationRecord, len(records)) + var err error + for i, v := range records { + recordAry[i], err = ValidationRecordToPB(v) + if err != nil { + return nil, err + } + } + marshalledProbs, err := ProblemDetailsToPB(prob) + if err != nil { + return nil, err + } + return &vapb.ValidationResult{ + Records: recordAry, + Problems: marshalledProbs, + }, nil +} + +func pbToValidationResult(in *vapb.ValidationResult) ([]core.ValidationRecord, *probs.ProblemDetails, error) { + if in == nil { + return nil, nil, ErrMissingParameters + } + recordAry := make([]core.ValidationRecord, len(in.Records)) + var err error + for i, v := range in.Records { + recordAry[i], err = PBToValidationRecord(v) + if err != nil { + return nil, nil, err + } + } + prob, err := PBToProblemDetails(in.Problems) + if err != nil { + return nil, nil, err + } + return recordAry, prob, nil +} + +func RegistrationToPB(reg core.Registration) (*corepb.Registration, error) { + keyBytes, err := reg.Key.MarshalJSON() + if err != nil { + return nil, err + } + ipBytes, err := reg.InitialIP.MarshalText() + if err != nil { + return nil, err + } + var contacts []string + // Since the default value of corepb.Registration.Contact is a slice + // we need a indicator as to if the value is actually important on + // the other side (pb -> reg). + contactsPresent := reg.Contact != nil + if reg.Contact != nil { + contacts = *reg.Contact + } + var createdAt *timestamppb.Timestamp + if reg.CreatedAt != nil { + createdAt = timestamppb.New(reg.CreatedAt.UTC()) + if !createdAt.IsValid() { + return nil, fmt.Errorf("error creating *timestamppb.Timestamp for *corepb.Authorization object") + } + } + + return &corepb.Registration{ + Id: reg.ID, + Key: keyBytes, + Contact: contacts, + ContactsPresent: contactsPresent, + Agreement: reg.Agreement, + InitialIP: ipBytes, + CreatedAt: createdAt, + Status: string(reg.Status), + }, nil +} + +func PbToRegistration(pb *corepb.Registration) (core.Registration, error) { + var key jose.JSONWebKey + err := key.UnmarshalJSON(pb.Key) + if err != nil { + return core.Registration{}, err + } + var initialIP net.IP + err = initialIP.UnmarshalText(pb.InitialIP) + if err != nil { + return core.Registration{}, err + } + var createdAt *time.Time + if !core.IsAnyNilOrZero(pb.CreatedAt) { + c := pb.CreatedAt.AsTime() + createdAt = &c + } + var contacts *[]string + if pb.ContactsPresent { + if len(pb.Contact) != 0 { + contacts = &pb.Contact + } else { + // When gRPC creates an empty slice it is actually a nil slice. Since + // certain things boulder uses, like encoding/json, differentiate between + // these we need to de-nil these slices. Without this we are unable to + // properly do registration updates as contacts would always be removed + // as we use the difference between a nil and empty slice in ra.mergeUpdate. + empty := []string{} + contacts = &empty + } + } + return core.Registration{ + ID: pb.Id, + Key: &key, + Contact: contacts, + Agreement: pb.Agreement, + InitialIP: initialIP, + CreatedAt: createdAt, + Status: core.AcmeStatus(pb.Status), + }, nil +} + +func AuthzToPB(authz core.Authorization) (*corepb.Authorization, error) { + challs := make([]*corepb.Challenge, len(authz.Challenges)) + for i, c := range authz.Challenges { + pbChall, err := ChallengeToPB(c) + if err != nil { + return nil, err + } + challs[i] = pbChall + } + var expires *timestamppb.Timestamp + if authz.Expires != nil { + expires = timestamppb.New(authz.Expires.UTC()) + if !expires.IsValid() { + return nil, fmt.Errorf("error creating *timestamppb.Timestamp for *corepb.Authorization object") + } + } + + return &corepb.Authorization{ + Id: authz.ID, + Identifier: authz.Identifier.Value, + RegistrationID: authz.RegistrationID, + Status: string(authz.Status), + Expires: expires, + Challenges: challs, + }, nil +} + +func PBToAuthz(pb *corepb.Authorization) (core.Authorization, error) { + challs := make([]core.Challenge, len(pb.Challenges)) + for i, c := range pb.Challenges { + chall, err := PBToChallenge(c) + if err != nil { + return core.Authorization{}, err + } + challs[i] = chall + } + var expires *time.Time + if !core.IsAnyNilOrZero(pb.Expires) { + c := pb.Expires.AsTime() + expires = &c + } + authz := core.Authorization{ + ID: pb.Id, + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: pb.Identifier}, + RegistrationID: pb.RegistrationID, + Status: core.AcmeStatus(pb.Status), + Expires: expires, + Challenges: challs, + } + return authz, nil +} + +// orderValid checks that a corepb.Order is valid. In addition to the checks +// from `newOrderValid` it ensures the order ID and the Created fields are not +// the zero value. +func orderValid(order *corepb.Order) bool { + return order.Id != 0 && order.Created != nil && newOrderValid(order) +} + +// newOrderValid checks that a corepb.Order is valid. It allows for a nil +// `order.Id` because the order has not been assigned an ID yet when it is being +// created initially. It allows `order.BeganProcessing` to be nil because +// `sa.NewOrder` explicitly sets it to the default value. It allows +// `order.Created` to be nil because the SA populates this. It also allows +// `order.CertificateSerial` to be nil such that it can be used in places where +// the order has not been finalized yet. +func newOrderValid(order *corepb.Order) bool { + return !(order.RegistrationID == 0 || order.Expires == nil || len(order.Names) == 0) +} + +func CertToPB(cert core.Certificate) *corepb.Certificate { + return &corepb.Certificate{ + RegistrationID: cert.RegistrationID, + Serial: cert.Serial, + Digest: cert.Digest, + Der: cert.DER, + Issued: timestamppb.New(cert.Issued), + Expires: timestamppb.New(cert.Expires), + } +} + +func PBToCert(pb *corepb.Certificate) core.Certificate { + return core.Certificate{ + RegistrationID: pb.RegistrationID, + Serial: pb.Serial, + Digest: pb.Digest, + DER: pb.Der, + Issued: pb.Issued.AsTime(), + Expires: pb.Expires.AsTime(), + } +} + +func CertStatusToPB(certStatus core.CertificateStatus) *corepb.CertificateStatus { + return &corepb.CertificateStatus{ + Serial: certStatus.Serial, + Status: string(certStatus.Status), + OcspLastUpdated: timestamppb.New(certStatus.OCSPLastUpdated), + RevokedDate: timestamppb.New(certStatus.RevokedDate), + RevokedReason: int64(certStatus.RevokedReason), + LastExpirationNagSent: timestamppb.New(certStatus.LastExpirationNagSent), + NotAfter: timestamppb.New(certStatus.NotAfter), + IsExpired: certStatus.IsExpired, + IssuerID: certStatus.IssuerNameID, + } +} + +func PBToCertStatus(pb *corepb.CertificateStatus) core.CertificateStatus { + return core.CertificateStatus{ + Serial: pb.Serial, + Status: core.OCSPStatus(pb.Status), + OCSPLastUpdated: pb.OcspLastUpdated.AsTime(), + RevokedDate: pb.RevokedDate.AsTime(), + RevokedReason: revocation.Reason(pb.RevokedReason), + LastExpirationNagSent: pb.LastExpirationNagSent.AsTime(), + NotAfter: pb.NotAfter.AsTime(), + IsExpired: pb.IsExpired, + IssuerNameID: pb.IssuerID, + } +} + +// PBToAuthzMap converts a protobuf map of domains mapped to protobuf authorizations to a +// golang map[string]*core.Authorization. +func PBToAuthzMap(pb *sapb.Authorizations) (map[string]*core.Authorization, error) { + m := make(map[string]*core.Authorization, len(pb.Authz)) + for _, v := range pb.Authz { + authz, err := PBToAuthz(v.Authz) + if err != nil { + return nil, err + } + m[v.Domain] = &authz + } + return m, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling_test.go b/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling_test.go new file mode 100644 index 000000000..2973703bf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/pb-marshalling_test.go @@ -0,0 +1,384 @@ +package grpc + +import ( + "encoding/json" + "net" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +const JWK1JSON = `{"kty":"RSA","n":"vuc785P8lBj3fUxyZchF_uZw6WtbxcorqgTyq-qapF5lrO1U82Tp93rpXlmctj6fyFHBVVB5aXnUHJ7LZeVPod7Wnfl8p5OyhlHQHC8BnzdzCqCMKmWZNX5DtETDId0qzU7dPzh0LP0idt5buU7L9QNaabChw3nnaL47iu_1Di5Wp264p2TwACeedv2hfRDjDlJmaQXuS8Rtv9GnRWyC9JBu7XmGvGDziumnJH7Hyzh3VNu-kSPQD3vuAFgMZS6uUzOztCkT0fpOalZI6hqxtWLvXUMj-crXrn-Maavz8qRhpAyp5kcYk3jiHGgQIi7QSK2JIdRJ8APyX9HlmTN5AQ","e":"AQAB"}` + +func TestProblemDetails(t *testing.T) { + pb, err := ProblemDetailsToPB(nil) + test.AssertNotEquals(t, err, "problemDetailToPB failed") + test.Assert(t, pb == nil, "Returned corepb.ProblemDetails is not nil") + + prob := &probs.ProblemDetails{Type: probs.TLSProblem, Detail: "asd", HTTPStatus: 200} + pb, err = ProblemDetailsToPB(prob) + test.AssertNotError(t, err, "problemDetailToPB failed") + test.Assert(t, pb != nil, "return corepb.ProblemDetails is nill") + test.AssertDeepEquals(t, pb.ProblemType, string(prob.Type)) + test.AssertEquals(t, pb.Detail, prob.Detail) + test.AssertEquals(t, int(pb.HttpStatus), prob.HTTPStatus) + + recon, err := PBToProblemDetails(pb) + test.AssertNotError(t, err, "PBToProblemDetails failed") + test.AssertDeepEquals(t, recon, prob) + + recon, err = PBToProblemDetails(nil) + test.AssertNotError(t, err, "PBToProblemDetails failed") + test.Assert(t, recon == nil, "Returned core.PRoblemDetails is not nil") + _, err = PBToProblemDetails(&corepb.ProblemDetails{}) + test.AssertError(t, err, "PBToProblemDetails did not fail") + test.AssertEquals(t, err, ErrMissingParameters) + _, err = PBToProblemDetails(&corepb.ProblemDetails{ProblemType: ""}) + test.AssertError(t, err, "PBToProblemDetails did not fail") + test.AssertEquals(t, err, ErrMissingParameters) + _, err = PBToProblemDetails(&corepb.ProblemDetails{Detail: ""}) + test.AssertError(t, err, "PBToProblemDetails did not fail") + test.AssertEquals(t, err, ErrMissingParameters) +} + +func TestChallenge(t *testing.T) { + var jwk jose.JSONWebKey + err := json.Unmarshal([]byte(JWK1JSON), &jwk) + test.AssertNotError(t, err, "Failed to unmarshal test key") + validated := time.Now().Round(0).UTC() + chall := core.Challenge{ + Type: core.ChallengeTypeDNS01, + Status: core.StatusValid, + Token: "asd", + ProvidedKeyAuthorization: "keyauth", + Validated: &validated, + } + + pb, err := ChallengeToPB(chall) + test.AssertNotError(t, err, "ChallengeToPB failed") + test.Assert(t, pb != nil, "Returned corepb.Challenge is nil") + + recon, err := PBToChallenge(pb) + test.AssertNotError(t, err, "PBToChallenge failed") + test.AssertDeepEquals(t, recon, chall) + + ip := net.ParseIP("1.1.1.1") + chall.ValidationRecord = []core.ValidationRecord{ + { + Hostname: "example.com", + Port: "2020", + AddressesResolved: []net.IP{ip}, + AddressUsed: ip, + URL: "https://example.com:2020", + AddressesTried: []net.IP{ip}, + }, + } + chall.Error = &probs.ProblemDetails{Type: probs.TLSProblem, Detail: "asd", HTTPStatus: 200} + pb, err = ChallengeToPB(chall) + test.AssertNotError(t, err, "ChallengeToPB failed") + test.Assert(t, pb != nil, "Returned corepb.Challenge is nil") + + recon, err = PBToChallenge(pb) + test.AssertNotError(t, err, "PBToChallenge failed") + test.AssertDeepEquals(t, recon, chall) + + _, err = PBToChallenge(nil) + test.AssertError(t, err, "PBToChallenge did not fail") + test.AssertEquals(t, err, ErrMissingParameters) + _, err = PBToChallenge(&corepb.Challenge{}) + test.AssertError(t, err, "PBToChallenge did not fail") + test.AssertEquals(t, err, ErrMissingParameters) + + challNilValidation := core.Challenge{ + Type: core.ChallengeTypeDNS01, + Status: core.StatusValid, + Token: "asd", + ProvidedKeyAuthorization: "keyauth", + Validated: nil, + } + pb, err = ChallengeToPB(challNilValidation) + test.AssertNotError(t, err, "ChallengeToPB failed") + test.Assert(t, pb != nil, "Returned corepb.Challenge is nil") + recon, err = PBToChallenge(pb) + test.AssertNotError(t, err, "PBToChallenge failed") + test.AssertDeepEquals(t, recon, challNilValidation) +} + +func TestValidationRecord(t *testing.T) { + ip := net.ParseIP("1.1.1.1") + vr := core.ValidationRecord{ + Hostname: "exampleA.com", + Port: "80", + AddressesResolved: []net.IP{ip}, + AddressUsed: ip, + URL: "http://exampleA.com", + AddressesTried: []net.IP{ip}, + ResolverAddrs: []string{"resolver:5353"}, + } + + pb, err := ValidationRecordToPB(vr) + test.AssertNotError(t, err, "ValidationRecordToPB failed") + test.Assert(t, pb != nil, "Return core.ValidationRecord is nil") + + recon, err := PBToValidationRecord(pb) + test.AssertNotError(t, err, "PBToValidationRecord failed") + test.AssertDeepEquals(t, recon, vr) +} + +func TestValidationResult(t *testing.T) { + ip := net.ParseIP("1.1.1.1") + vrA := core.ValidationRecord{ + Hostname: "exampleA.com", + Port: "443", + AddressesResolved: []net.IP{ip}, + AddressUsed: ip, + URL: "https://exampleA.com", + AddressesTried: []net.IP{ip}, + ResolverAddrs: []string{"resolver:5353"}, + } + vrB := core.ValidationRecord{ + Hostname: "exampleB.com", + Port: "443", + AddressesResolved: []net.IP{ip}, + AddressUsed: ip, + URL: "https://exampleB.com", + AddressesTried: []net.IP{ip}, + ResolverAddrs: []string{"resolver:5353"}, + } + result := []core.ValidationRecord{vrA, vrB} + prob := &probs.ProblemDetails{Type: probs.TLSProblem, Detail: "asd", HTTPStatus: 200} + + pb, err := ValidationResultToPB(result, prob) + test.AssertNotError(t, err, "ValidationResultToPB failed") + test.Assert(t, pb != nil, "Returned vapb.ValidationResult is nil") + + reconResult, reconProb, err := pbToValidationResult(pb) + test.AssertNotError(t, err, "pbToValidationResult failed") + test.AssertDeepEquals(t, reconResult, result) + test.AssertDeepEquals(t, reconProb, prob) +} + +func TestRegistration(t *testing.T) { + contacts := []string{"email"} + var key jose.JSONWebKey + err := json.Unmarshal([]byte(` + { + "e": "AQAB", + "kty": "RSA", + "n": "tSwgy3ORGvc7YJI9B2qqkelZRUC6F1S5NwXFvM4w5-M0TsxbFsH5UH6adigV0jzsDJ5imAechcSoOhAh9POceCbPN1sTNwLpNbOLiQQ7RD5mY_pSUHWXNmS9R4NZ3t2fQAzPeW7jOfF0LKuJRGkekx6tXP1uSnNibgpJULNc4208dgBaCHo3mvaE2HV2GmVl1yxwWX5QZZkGQGjNDZYnjFfa2DKVvFs0QbAk21ROm594kAxlRlMMrvqlf24Eq4ERO0ptzpZgm_3j_e4hGRD39gJS7kAzK-j2cacFQ5Qi2Y6wZI2p-FCq_wiYsfEAIkATPBiLKl_6d_Jfcvs_impcXQ" + } + `), &key) + test.AssertNotError(t, err, "Could not unmarshal testing key") + createdAt := time.Now().Round(0).UTC() + inReg := core.Registration{ + ID: 1, + Key: &key, + Contact: &contacts, + Agreement: "yup", + InitialIP: net.ParseIP("1.1.1.1"), + CreatedAt: &createdAt, + Status: core.StatusValid, + } + pbReg, err := RegistrationToPB(inReg) + test.AssertNotError(t, err, "registrationToPB failed") + outReg, err := PbToRegistration(pbReg) + test.AssertNotError(t, err, "PbToRegistration failed") + test.AssertDeepEquals(t, inReg, outReg) + + inReg.Contact = nil + pbReg, err = RegistrationToPB(inReg) + test.AssertNotError(t, err, "registrationToPB failed") + pbReg.Contact = []string{} + outReg, err = PbToRegistration(pbReg) + test.AssertNotError(t, err, "PbToRegistration failed") + test.AssertDeepEquals(t, inReg, outReg) + + var empty []string + inReg.Contact = &empty + pbReg, err = RegistrationToPB(inReg) + test.AssertNotError(t, err, "registrationToPB failed") + outReg, err = PbToRegistration(pbReg) + test.AssertNotError(t, err, "PbToRegistration failed") + test.Assert(t, *outReg.Contact != nil, "Empty slice was converted to a nil slice") + + inRegNilCreatedAt := core.Registration{ + ID: 1, + Key: &key, + Contact: &contacts, + Agreement: "yup", + InitialIP: net.ParseIP("1.1.1.1"), + CreatedAt: nil, + Status: core.StatusValid, + } + pbReg, err = RegistrationToPB(inRegNilCreatedAt) + test.AssertNotError(t, err, "registrationToPB failed") + outReg, err = PbToRegistration(pbReg) + test.AssertNotError(t, err, "PbToRegistration failed") + test.AssertDeepEquals(t, inRegNilCreatedAt, outReg) +} + +func TestAuthz(t *testing.T) { + exp := time.Now().AddDate(0, 0, 1).UTC() + identifier := identifier.ACMEIdentifier{Type: identifier.DNS, Value: "example.com"} + challA := core.Challenge{ + Type: core.ChallengeTypeDNS01, + Status: core.StatusPending, + Token: "asd", + ProvidedKeyAuthorization: "keyauth", + } + challB := core.Challenge{ + Type: core.ChallengeTypeDNS01, + Status: core.StatusPending, + Token: "asd2", + ProvidedKeyAuthorization: "keyauth4", + } + inAuthz := core.Authorization{ + ID: "1", + Identifier: identifier, + RegistrationID: 5, + Status: core.StatusPending, + Expires: &exp, + Challenges: []core.Challenge{challA, challB}, + } + pbAuthz, err := AuthzToPB(inAuthz) + test.AssertNotError(t, err, "AuthzToPB failed") + outAuthz, err := PBToAuthz(pbAuthz) + test.AssertNotError(t, err, "PBToAuthz failed") + test.AssertDeepEquals(t, inAuthz, outAuthz) + + inAuthzNilExpires := core.Authorization{ + ID: "1", + Identifier: identifier, + RegistrationID: 5, + Status: core.StatusPending, + Expires: nil, + Challenges: []core.Challenge{challA, challB}, + } + pbAuthz2, err := AuthzToPB(inAuthzNilExpires) + test.AssertNotError(t, err, "AuthzToPB failed") + outAuthz2, err := PBToAuthz(pbAuthz2) + test.AssertNotError(t, err, "PBToAuthz failed") + test.AssertDeepEquals(t, inAuthzNilExpires, outAuthz2) +} + +func TestCert(t *testing.T) { + now := time.Now().Round(0).UTC() + cert := core.Certificate{ + RegistrationID: 1, + Serial: "serial", + Digest: "digest", + DER: []byte{255}, + Issued: now, + Expires: now.Add(time.Hour), + } + + certPB := CertToPB(cert) + outCert := PBToCert(certPB) + + test.AssertDeepEquals(t, cert, outCert) +} + +func TestOrderValid(t *testing.T) { + created := time.Now() + expires := created.Add(1 * time.Hour) + testCases := []struct { + Name string + Order *corepb.Order + ExpectedValid bool + }{ + { + Name: "All valid", + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Expires: timestamppb.New(expires), + CertificateSerial: "", + V2Authorizations: []int64{}, + Names: []string{"example.com"}, + BeganProcessing: false, + Created: timestamppb.New(created), + }, + ExpectedValid: true, + }, + { + Name: "Serial empty", + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Expires: timestamppb.New(expires), + V2Authorizations: []int64{}, + Names: []string{"example.com"}, + BeganProcessing: false, + Created: timestamppb.New(created), + }, + ExpectedValid: true, + }, + { + Name: "All zero", + Order: &corepb.Order{}, + }, + { + Name: "ID 0", + Order: &corepb.Order{ + Id: 0, + RegistrationID: 1, + Expires: timestamppb.New(expires), + CertificateSerial: "", + V2Authorizations: []int64{}, + Names: []string{"example.com"}, + BeganProcessing: false, + }, + }, + { + Name: "Reg ID zero", + Order: &corepb.Order{ + Id: 1, + RegistrationID: 0, + Expires: timestamppb.New(expires), + CertificateSerial: "", + V2Authorizations: []int64{}, + Names: []string{"example.com"}, + BeganProcessing: false, + }, + }, + { + Name: "Expires 0", + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Expires: nil, + CertificateSerial: "", + V2Authorizations: []int64{}, + Names: []string{"example.com"}, + BeganProcessing: false, + }, + }, + { + Name: "Names empty", + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Expires: timestamppb.New(expires), + CertificateSerial: "", + V2Authorizations: []int64{}, + Names: []string{}, + BeganProcessing: false, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := orderValid(tc.Order) + test.AssertEquals(t, result, tc.ExpectedValid) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/protogen.sh b/third-party/github.com/letsencrypt/boulder/grpc/protogen.sh new file mode 100644 index 000000000..8e5701d00 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/protogen.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Should point to /path/to/boulder, given that this script +# lives in the //grpc subdirectory of the boulder repo. +root_dir=$(dirname $(dirname $(readlink -f "$0"))) + +# Find each file below root_dir whose name matches *.proto and whose +# path does not include the "vendor" directory. Emit them null-delimited +# (to allow for spaces and newlines in filenames), and assign each to the +# local variable `file`. +find "${root_dir}" -name "*.proto" -not -path "*/vendor/*" -print0 | while read -d $'\0' file +do + # Have to use absolute paths to make protoc happy. + proto_file=$(realpath "${file}") + proto_dir=$(dirname "${proto_file}") + # -I "${proto_dir}" makes imports search the current directory first + # -I "${root_dir}" ensures that our proto files can import each other + # --go_out="${proto_dir}" writes the .pb.go file adjacent to the proto file + # --go-grpc_out="${proto_dir}" does the same for _grpc.pb.go + # --go_opt=paths=source_relative derives output filenames from input filenames + # --go-grpc_opt=paths=source_relative does the same for _grpc.pb.go + # --go-grpc_opt=use_generic_streams=true causes protoc-gen-go-grpc to use generics for its stream objects, rather than generating a new impl for each one + protoc -I "${proto_dir}" -I "${root_dir}" --go_out="${proto_dir}" --go-grpc_out="${proto_dir}" --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative,use_generic_streams_experimental=true "${proto_file}" +done diff --git a/third-party/github.com/letsencrypt/boulder/grpc/resolver.go b/third-party/github.com/letsencrypt/boulder/grpc/resolver.go new file mode 100644 index 000000000..ea26baefe --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/resolver.go @@ -0,0 +1,108 @@ +package grpc + +import ( + "fmt" + "net" + "strings" + + "google.golang.org/grpc/resolver" +) + +// staticBuilder implements the `resolver.Builder` interface. +type staticBuilder struct{} + +// newStaticBuilder creates a `staticBuilder` used to construct static DNS +// resolvers. +func newStaticBuilder() resolver.Builder { + return &staticBuilder{} +} + +// Build implements the `resolver.Builder` interface and is usually called by +// the gRPC dialer. It takes a target containing a comma separated list of +// IPv4/6 addresses and a `resolver.ClientConn` and returns a `staticResolver` +// which implements the `resolver.Resolver` interface. +func (sb *staticBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) (resolver.Resolver, error) { + var resolverAddrs []resolver.Address + for _, address := range strings.Split(target.Endpoint(), ",") { + parsedAddress, err := parseResolverIPAddress(address) + if err != nil { + return nil, err + } + resolverAddrs = append(resolverAddrs, *parsedAddress) + } + r, err := newStaticResolver(cc, resolverAddrs) + if err != nil { + return nil, err + } + return r, nil +} + +// Scheme returns the scheme that `staticBuilder` will be registered for, for +// example: `static:///`. +func (sb *staticBuilder) Scheme() string { + return "static" +} + +// staticResolver is used to wrap an inner `resolver.ClientConn` and implements +// the `resolver.Resolver` interface. +type staticResolver struct { + cc resolver.ClientConn +} + +// newStaticResolver takes a `resolver.ClientConn` and a list of +// `resolver.Addresses`. It updates the state of the `resolver.ClientConn` with +// the provided addresses and returns a `staticResolver` which wraps the +// `resolver.ClientConn` and implements the `resolver.Resolver` interface. +func newStaticResolver(cc resolver.ClientConn, resolverAddrs []resolver.Address) (resolver.Resolver, error) { + err := cc.UpdateState(resolver.State{Addresses: resolverAddrs}) + if err != nil { + return nil, err + } + return &staticResolver{cc: cc}, nil +} + +// ResolveNow is a no-op necessary for `staticResolver` to implement the +// `resolver.Resolver` interface. This resolver is constructed once by +// staticBuilder.Build and the state of the inner `resolver.ClientConn` is never +// updated. +func (sr *staticResolver) ResolveNow(_ resolver.ResolveNowOptions) {} + +// Close is a no-op necessary for `staticResolver` to implement the +// `resolver.Resolver` interface. +func (sr *staticResolver) Close() {} + +// parseResolverIPAddress takes an IPv4/6 address (ip:port, [ip]:port, or :port) +// and returns a properly formatted `resolver.Address` object. The `Addr` and +// `ServerName` fields of the returned `resolver.Address` will both be set to +// host:port or [host]:port if the host is an IPv6 address. +func parseResolverIPAddress(addr string) (*resolver.Address, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("splitting host and port for address %q: %w", addr, err) + } + if port == "" { + // If the port field is empty the address ends with colon (e.g. + // "[::1]:"). + return nil, fmt.Errorf("address %q missing port after port-separator colon", addr) + } + if host == "" { + // Address only has a port (i.e ipv4-host:port, [ipv6-host]:port, + // host-name:port). Keep consistent with net.Dial(); if the host is + // empty (e.g. :80), the local system is assumed. + host = "127.0.0.1" + } + if net.ParseIP(host) == nil { + // Host is a DNS name or an IPv6 address without brackets. + return nil, fmt.Errorf("address %q is not an IP address", addr) + } + parsedAddr := net.JoinHostPort(host, port) + return &resolver.Address{ + Addr: parsedAddr, + ServerName: parsedAddr, + }, nil +} + +// init registers the `staticBuilder` with the gRPC resolver registry. +func init() { + resolver.Register(newStaticBuilder()) +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/resolver_test.go b/third-party/github.com/letsencrypt/boulder/grpc/resolver_test.go new file mode 100644 index 000000000..32eca5dd9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/resolver_test.go @@ -0,0 +1,34 @@ +package grpc + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" + "google.golang.org/grpc/resolver" +) + +func Test_parseResolverIPAddress(t *testing.T) { + tests := []struct { + name string + addr string + expectTarget *resolver.Address + wantErr bool + }{ + {"valid, IPv4 address", "127.0.0.1:1337", &resolver.Address{Addr: "127.0.0.1:1337", ServerName: "127.0.0.1:1337"}, false}, + {"valid, IPv6 address", "[::1]:1337", &resolver.Address{Addr: "[::1]:1337", ServerName: "[::1]:1337"}, false}, + {"valid, port only", ":1337", &resolver.Address{Addr: "127.0.0.1:1337", ServerName: "127.0.0.1:1337"}, false}, + {"invalid, hostname address", "localhost:1337", nil, true}, + {"invalid, IPv6 address, no brackets", "::1:1337", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseResolverIPAddress(tt.addr) + if tt.wantErr { + test.AssertError(t, err, "expected error, got nil") + } else { + test.AssertNotError(t, err, "unexpected error") + } + test.AssertDeepEquals(t, got, tt.expectTarget) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/server.go b/third-party/github.com/letsencrypt/boulder/grpc/server.go new file mode 100644 index 000000000..b3313d46b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/server.go @@ -0,0 +1,328 @@ +package grpc + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strings" + "time" + + grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/filters" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/keepalive" + "google.golang.org/grpc/status" + + "github.com/letsencrypt/boulder/cmd" + bcreds "github.com/letsencrypt/boulder/grpc/creds" + blog "github.com/letsencrypt/boulder/log" +) + +// CodedError is a alias required to appease go vet +var CodedError = status.Errorf + +var errNilTLS = errors.New("boulder/grpc: received nil tls.Config") + +// checker is an interface for checking the health of a grpc service +// implementation. +type checker interface { + // Health returns nil if the service is healthy, or an error if it is not. + // If the passed context is canceled, it should return immediately with an + // error. + Health(context.Context) error +} + +// service represents a single gRPC service that can be registered with a gRPC +// server. +type service struct { + desc *grpc.ServiceDesc + impl any +} + +// serverBuilder implements a builder pattern for constructing new gRPC servers +// and registering gRPC services on those servers. +type serverBuilder struct { + cfg *cmd.GRPCServerConfig + services map[string]service + healthSrv *health.Server + checkInterval time.Duration + logger blog.Logger + err error +} + +// NewServer returns an object which can be used to build gRPC servers. It takes +// the server's configuration to perform initialization and a logger for deep +// health checks. +func NewServer(c *cmd.GRPCServerConfig, logger blog.Logger) *serverBuilder { + return &serverBuilder{cfg: c, services: make(map[string]service), logger: logger} +} + +// WithCheckInterval sets the interval at which the server will check the health +// of its registered services. If this is not called, a default interval of 5 +// seconds will be used. +func (sb *serverBuilder) WithCheckInterval(i time.Duration) *serverBuilder { + sb.checkInterval = i + return sb +} + +// Add registers a new service (consisting of its description and its +// implementation) to the set of services which will be exposed by this server. +// It returns the modified-in-place serverBuilder so that calls can be chained. +// If there is an error adding this service, it will be exposed when .Build() is +// called. +func (sb *serverBuilder) Add(desc *grpc.ServiceDesc, impl any) *serverBuilder { + if _, found := sb.services[desc.ServiceName]; found { + // We've already registered a service with this same name, error out. + sb.err = fmt.Errorf("attempted double-registration of gRPC service %q", desc.ServiceName) + return sb + } + sb.services[desc.ServiceName] = service{desc: desc, impl: impl} + return sb +} + +// Build creates a gRPC server that uses the provided *tls.Config and exposes +// all of the services added to the builder. It also exposes a health check +// service. It returns one functions, start(), which should be used to start +// the server. It spawns a goroutine which will listen for OS signals and +// gracefully stop the server if one is caught, causing the start() function to +// exit. +func (sb *serverBuilder) Build(tlsConfig *tls.Config, statsRegistry prometheus.Registerer, clk clock.Clock) (func() error, error) { + // Register the health service with the server. + sb.healthSrv = health.NewServer() + sb.Add(&healthpb.Health_ServiceDesc, sb.healthSrv) + + // Check to see if any of the calls to .Add() resulted in an error. + if sb.err != nil { + return nil, sb.err + } + + // Ensure that every configured service also got added. + var registeredServices []string + for r := range sb.services { + registeredServices = append(registeredServices, r) + } + for serviceName := range sb.cfg.Services { + _, ok := sb.services[serviceName] + if !ok { + return nil, fmt.Errorf("gRPC service %q in config does not match any service: %s", serviceName, strings.Join(registeredServices, ", ")) + } + } + + if tlsConfig == nil { + return nil, errNilTLS + } + + // Collect all names which should be allowed to connect to the server at all. + // This is the names which are allowlisted at the server level, plus the union + // of all names which are allowlisted for any individual service. + acceptedSANs := make(map[string]struct{}) + for _, service := range sb.cfg.Services { + for _, name := range service.ClientNames { + acceptedSANs[name] = struct{}{} + } + } + + creds, err := bcreds.NewServerCredentials(tlsConfig, acceptedSANs) + if err != nil { + return nil, err + } + + // Set up all of our interceptors which handle metrics, traces, error + // propagation, and more. + metrics, err := newServerMetrics(statsRegistry) + if err != nil { + return nil, err + } + + var ai serverInterceptor + if len(sb.cfg.Services) > 0 { + ai = newServiceAuthChecker(sb.cfg) + } else { + ai = &noopServerInterceptor{} + } + + mi := newServerMetadataInterceptor(metrics, clk) + + unaryInterceptors := []grpc.UnaryServerInterceptor{ + mi.metrics.grpcMetrics.UnaryServerInterceptor(), + ai.Unary, + mi.Unary, + } + + streamInterceptors := []grpc.StreamServerInterceptor{ + mi.metrics.grpcMetrics.StreamServerInterceptor(), + ai.Stream, + mi.Stream, + } + + options := []grpc.ServerOption{ + grpc.Creds(creds), + grpc.ChainUnaryInterceptor(unaryInterceptors...), + grpc.ChainStreamInterceptor(streamInterceptors...), + grpc.StatsHandler(otelgrpc.NewServerHandler(otelgrpc.WithFilter(filters.Not(filters.HealthCheck())))), + } + if sb.cfg.MaxConnectionAge.Duration > 0 { + options = append(options, + grpc.KeepaliveParams(keepalive.ServerParameters{ + MaxConnectionAge: sb.cfg.MaxConnectionAge.Duration, + })) + } + + // Create the server itself and register all of our services on it. + server := grpc.NewServer(options...) + for _, service := range sb.services { + server.RegisterService(service.desc, service.impl) + } + + if sb.cfg.Address == "" { + return nil, errors.New("GRPC listen address not configured") + } + sb.logger.Infof("grpc listening on %s", sb.cfg.Address) + + // Finally return the functions which will start and stop the server. + listener, err := net.Listen("tcp", sb.cfg.Address) + if err != nil { + return nil, err + } + + start := func() error { + return server.Serve(listener) + } + + // Initialize long-running health checks of all services which implement the + // checker interface. + if sb.checkInterval <= 0 { + sb.checkInterval = 5 * time.Second + } + healthCtx, stopHealthChecks := context.WithCancel(context.Background()) + for _, s := range sb.services { + check, ok := s.impl.(checker) + if !ok { + continue + } + sb.initLongRunningCheck(healthCtx, s.desc.ServiceName, check.Health) + } + + // Start a goroutine which listens for a termination signal, and then + // gracefully stops the gRPC server. This in turn causes the start() function + // to exit, allowing its caller (generally a main() function) to exit. + go cmd.CatchSignals(func() { + stopHealthChecks() + sb.healthSrv.Shutdown() + server.GracefulStop() + }) + + return start, nil +} + +// initLongRunningCheck initializes a goroutine which will periodically check +// the health of the provided service and update the health server accordingly. +func (sb *serverBuilder) initLongRunningCheck(shutdownCtx context.Context, service string, checkImpl func(context.Context) error) { + // Set the initial health status for the service. + sb.healthSrv.SetServingStatus(service, healthpb.HealthCheckResponse_NOT_SERVING) + + // check is a helper function that checks the health of the service and, if + // necessary, updates its status in the health server. + checkAndMaybeUpdate := func(checkCtx context.Context, last healthpb.HealthCheckResponse_ServingStatus) healthpb.HealthCheckResponse_ServingStatus { + // Make a context with a timeout at 90% of the interval. + checkImplCtx, cancel := context.WithTimeout(checkCtx, sb.checkInterval*9/10) + defer cancel() + + var next healthpb.HealthCheckResponse_ServingStatus + err := checkImpl(checkImplCtx) + if err != nil { + next = healthpb.HealthCheckResponse_NOT_SERVING + } else { + next = healthpb.HealthCheckResponse_SERVING + } + + if last == next { + // No change in health status. + return next + } + + if next != healthpb.HealthCheckResponse_SERVING { + sb.logger.Errf("transitioning health of %q from %q to %q, due to: %s", service, last, next, err) + } else { + sb.logger.Infof("transitioning health of %q from %q to %q", service, last, next) + } + sb.healthSrv.SetServingStatus(service, next) + return next + } + + go func() { + ticker := time.NewTicker(sb.checkInterval) + defer ticker.Stop() + + // Assume the service is not healthy to start. + last := healthpb.HealthCheckResponse_NOT_SERVING + + // Check immediately, and then at the specified interval. + last = checkAndMaybeUpdate(shutdownCtx, last) + for { + select { + case <-shutdownCtx.Done(): + // The server is shutting down. + return + case <-ticker.C: + last = checkAndMaybeUpdate(shutdownCtx, last) + } + } + }() +} + +// serverMetrics is a struct type used to return a few registered metrics from +// `newServerMetrics` +type serverMetrics struct { + grpcMetrics *grpc_prometheus.ServerMetrics + rpcLag prometheus.Histogram +} + +// newServerMetrics registers metrics with a registry. It constructs and +// registers a *grpc_prometheus.ServerMetrics with timing histogram enabled as +// well as a prometheus Histogram for RPC latency. If called more than once on a +// single registry, it will gracefully avoid registering duplicate metrics. +func newServerMetrics(stats prometheus.Registerer) (serverMetrics, error) { + // Create the grpc prometheus server metrics instance and register it + grpcMetrics := grpc_prometheus.NewServerMetrics() + grpcMetrics.EnableHandlingTimeHistogram() + err := stats.Register(grpcMetrics) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + grpcMetrics = are.ExistingCollector.(*grpc_prometheus.ServerMetrics) + } else { + return serverMetrics{}, err + } + } + + // rpcLag is a prometheus histogram tracking the difference between the time + // the client sent an RPC and the time the server received it. Create and + // register it. + rpcLag := prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "grpc_lag", + Help: "Delta between client RPC send time and server RPC receipt time", + }) + err = stats.Register(rpcLag) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + rpcLag = are.ExistingCollector.(prometheus.Histogram) + } else { + return serverMetrics{}, err + } + } + + return serverMetrics{ + grpcMetrics: grpcMetrics, + rpcLag: rpcLag, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/server_test.go b/third-party/github.com/letsencrypt/boulder/grpc/server_test.go new file mode 100644 index 000000000..7553e24c7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/server_test.go @@ -0,0 +1,72 @@ +package grpc + +import ( + "context" + "errors" + "testing" + "time" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" + "google.golang.org/grpc/health" +) + +func Test_serverBuilder_initLongRunningCheck(t *testing.T) { + t.Parallel() + hs := health.NewServer() + mockLogger := blog.NewMock() + sb := &serverBuilder{ + healthSrv: hs, + logger: mockLogger, + checkInterval: time.Millisecond * 50, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + count := 0 + failEveryThirdCheck := func(context.Context) error { + count++ + if count%3 == 0 { + return errors.New("oops") + } + return nil + } + sb.initLongRunningCheck(ctx, "test", failEveryThirdCheck) + time.Sleep(time.Millisecond * 110) + cancel() + + // We expect the following transition timeline: + // - ~0ms 1st check passed, NOT_SERVING to SERVING + // - ~50ms 2nd check passed, [no transition] + // - ~100ms 3rd check failed, SERVING to NOT_SERVING + serving := mockLogger.GetAllMatching(".*\"NOT_SERVING\" to \"SERVING\"") + notServing := mockLogger.GetAllMatching((".*\"SERVING\" to \"NOT_SERVING\"")) + test.Assert(t, len(serving) == 1, "expected one serving log line") + test.Assert(t, len(notServing) == 1, "expected one not serving log line") + + mockLogger.Clear() + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + + count = 0 + failEveryOtherCheck := func(context.Context) error { + count++ + if count%2 == 0 { + return errors.New("oops") + } + return nil + } + sb.initLongRunningCheck(ctx, "test", failEveryOtherCheck) + time.Sleep(time.Millisecond * 110) + cancel() + + // We expect the following transition timeline: + // - ~0ms 1st check passed, NOT_SERVING to SERVING + // - ~50ms 2nd check failed, SERVING to NOT_SERVING + // - ~100ms 3rd check passed, NOT_SERVING to SERVING + serving = mockLogger.GetAllMatching(".*\"NOT_SERVING\" to \"SERVING\"") + notServing = mockLogger.GetAllMatching((".*\"SERVING\" to \"NOT_SERVING\"")) + test.Assert(t, len(serving) == 2, "expected two serving log lines") + test.Assert(t, len(notServing) == 1, "expected one not serving log line") +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/test_proto/generate.go b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/generate.go new file mode 100644 index 000000000..87d86be8a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/generate.go @@ -0,0 +1,3 @@ +package test_proto + +//go:generate sh -c "cd ../.. && protoc -I grpc/test_proto/ -I . --go_out=grpc/test_proto --go-grpc_out=grpc/test_proto --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative grpc/test_proto/interceptors_test.proto" diff --git a/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.pb.go b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.pb.go new file mode 100644 index 000000000..09ffb40ad --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.pb.go @@ -0,0 +1,155 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: interceptors_test.proto + +package test_proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Time struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Duration *durationpb.Duration `protobuf:"bytes,2,opt,name=duration,proto3" json:"duration,omitempty"` +} + +func (x *Time) Reset() { + *x = Time{} + if protoimpl.UnsafeEnabled { + mi := &file_interceptors_test_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Time) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Time) ProtoMessage() {} + +func (x *Time) ProtoReflect() protoreflect.Message { + mi := &file_interceptors_test_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Time.ProtoReflect.Descriptor instead. +func (*Time) Descriptor() ([]byte, []int) { + return file_interceptors_test_proto_rawDescGZIP(), []int{0} +} + +func (x *Time) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +var File_interceptors_test_proto protoreflect.FileDescriptor + +var file_interceptors_test_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x6f, 0x72, 0x73, 0x5f, 0x74, + 0x65, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x69, 0x6d, + 0x65, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x32, 0x22, + 0x0a, 0x07, 0x43, 0x68, 0x69, 0x6c, 0x6c, 0x65, 0x72, 0x12, 0x17, 0x0a, 0x05, 0x43, 0x68, 0x69, + 0x6c, 0x6c, 0x12, 0x05, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x1a, 0x05, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x22, 0x00, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, + 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_interceptors_test_proto_rawDescOnce sync.Once + file_interceptors_test_proto_rawDescData = file_interceptors_test_proto_rawDesc +) + +func file_interceptors_test_proto_rawDescGZIP() []byte { + file_interceptors_test_proto_rawDescOnce.Do(func() { + file_interceptors_test_proto_rawDescData = protoimpl.X.CompressGZIP(file_interceptors_test_proto_rawDescData) + }) + return file_interceptors_test_proto_rawDescData +} + +var file_interceptors_test_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_interceptors_test_proto_goTypes = []interface{}{ + (*Time)(nil), // 0: Time + (*durationpb.Duration)(nil), // 1: google.protobuf.Duration +} +var file_interceptors_test_proto_depIdxs = []int32{ + 1, // 0: Time.duration:type_name -> google.protobuf.Duration + 0, // 1: Chiller.Chill:input_type -> Time + 0, // 2: Chiller.Chill:output_type -> Time + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_interceptors_test_proto_init() } +func file_interceptors_test_proto_init() { + if File_interceptors_test_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_interceptors_test_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Time); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_interceptors_test_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_interceptors_test_proto_goTypes, + DependencyIndexes: file_interceptors_test_proto_depIdxs, + MessageInfos: file_interceptors_test_proto_msgTypes, + }.Build() + File_interceptors_test_proto = out.File + file_interceptors_test_proto_rawDesc = nil + file_interceptors_test_proto_goTypes = nil + file_interceptors_test_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.proto b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.proto new file mode 100644 index 000000000..f53468fd9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +option go_package = "github.com/letsencrypt/boulder/grpc/test_proto"; + +import "google/protobuf/duration.proto"; + +service Chiller { + // Sleep for the given amount of time, and return the amount of time slept. + rpc Chill(Time) returns (Time) {} +} + +message Time { + // Next unused field number: 3 + reserved 1; // previously timeNS + google.protobuf.Duration duration = 2; + } diff --git a/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test_grpc.pb.go new file mode 100644 index 000000000..01d660b64 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/grpc/test_proto/interceptors_test_grpc.pb.go @@ -0,0 +1,112 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: interceptors_test.proto + +package test_proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.62.0 or later. +const _ = grpc.SupportPackageIsVersion8 + +const ( + Chiller_Chill_FullMethodName = "/Chiller/Chill" +) + +// ChillerClient is the client API for Chiller service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChillerClient interface { + // Sleep for the given amount of time, and return the amount of time slept. + Chill(ctx context.Context, in *Time, opts ...grpc.CallOption) (*Time, error) +} + +type chillerClient struct { + cc grpc.ClientConnInterface +} + +func NewChillerClient(cc grpc.ClientConnInterface) ChillerClient { + return &chillerClient{cc} +} + +func (c *chillerClient) Chill(ctx context.Context, in *Time, opts ...grpc.CallOption) (*Time, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Time) + err := c.cc.Invoke(ctx, Chiller_Chill_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChillerServer is the server API for Chiller service. +// All implementations must embed UnimplementedChillerServer +// for forward compatibility +type ChillerServer interface { + // Sleep for the given amount of time, and return the amount of time slept. + Chill(context.Context, *Time) (*Time, error) + mustEmbedUnimplementedChillerServer() +} + +// UnimplementedChillerServer must be embedded to have forward compatible implementations. +type UnimplementedChillerServer struct { +} + +func (UnimplementedChillerServer) Chill(context.Context, *Time) (*Time, error) { + return nil, status.Errorf(codes.Unimplemented, "method Chill not implemented") +} +func (UnimplementedChillerServer) mustEmbedUnimplementedChillerServer() {} + +// UnsafeChillerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChillerServer will +// result in compilation errors. +type UnsafeChillerServer interface { + mustEmbedUnimplementedChillerServer() +} + +func RegisterChillerServer(s grpc.ServiceRegistrar, srv ChillerServer) { + s.RegisterService(&Chiller_ServiceDesc, srv) +} + +func _Chiller_Chill_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Time) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChillerServer).Chill(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Chiller_Chill_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChillerServer).Chill(ctx, req.(*Time)) + } + return interceptor(ctx, in, info, handler) +} + +// Chiller_ServiceDesc is the grpc.ServiceDesc for Chiller service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Chiller_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "Chiller", + HandlerType: (*ChillerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Chill", + Handler: _Chiller_Chill_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "interceptors_test.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/iana/iana.go b/third-party/github.com/letsencrypt/boulder/iana/iana.go new file mode 100644 index 000000000..8e138e1db --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/iana/iana.go @@ -0,0 +1,32 @@ +package iana + +import ( + "fmt" + + "github.com/weppos/publicsuffix-go/publicsuffix" +) + +// ExtractSuffix returns the public suffix of the domain using only the "ICANN" +// section of the Public Suffix List database. +// If the domain does not end in a suffix that belongs to an IANA-assigned +// domain, ExtractSuffix returns an error. +func ExtractSuffix(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("Blank name argument passed to ExtractSuffix") + } + + rule := publicsuffix.DefaultList.Find(name, &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if rule == nil { + return "", fmt.Errorf("Domain %s has no IANA TLD", name) + } + + suffix := rule.Decompose(name)[1] + + // If the TLD is empty, it means name is actually a suffix. + // In fact, decompose returns an array of empty strings in this case. + if suffix == "" { + suffix = name + } + + return suffix, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/iana/iana_test.go b/third-party/github.com/letsencrypt/boulder/iana/iana_test.go new file mode 100644 index 000000000..214952abc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/iana/iana_test.go @@ -0,0 +1,65 @@ +package iana + +import "testing" + +func TestExtractSuffix_Valid(t *testing.T) { + testCases := []struct { + domain, want string + }{ + // TLD with only 1 rule. + {"biz", "biz"}, + {"domain.biz", "biz"}, + {"b.domain.biz", "biz"}, + + // The relevant {kobe,kyoto}.jp rules are: + // jp + // *.kobe.jp + // !city.kobe.jp + // kyoto.jp + // ide.kyoto.jp + {"jp", "jp"}, + {"kobe.jp", "jp"}, + {"c.kobe.jp", "c.kobe.jp"}, + {"b.c.kobe.jp", "c.kobe.jp"}, + {"a.b.c.kobe.jp", "c.kobe.jp"}, + {"city.kobe.jp", "kobe.jp"}, + {"www.city.kobe.jp", "kobe.jp"}, + {"kyoto.jp", "kyoto.jp"}, + {"test.kyoto.jp", "kyoto.jp"}, + {"ide.kyoto.jp", "ide.kyoto.jp"}, + {"b.ide.kyoto.jp", "ide.kyoto.jp"}, + {"a.b.ide.kyoto.jp", "ide.kyoto.jp"}, + + // Domain with a private public suffix should return the ICANN public suffix. + {"foo.compute-1.amazonaws.com", "com"}, + // Domain equal to a private public suffix should return the ICANN public + // suffix. + {"cloudapp.net", "net"}, + } + + for _, tc := range testCases { + got, err := ExtractSuffix(tc.domain) + if err != nil { + t.Errorf("%q: returned error", tc.domain) + continue + } + if got != tc.want { + t.Errorf("%q: got %q, want %q", tc.domain, got, tc.want) + } + } +} + +func TestExtractSuffix_Invalid(t *testing.T) { + testCases := []string{ + "", + "example", + "example.example", + } + + for _, tc := range testCases { + _, err := ExtractSuffix(tc) + if err == nil { + t.Errorf("%q: expected err, got none", tc) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/identifier/identifier.go b/third-party/github.com/letsencrypt/boulder/identifier/identifier.go new file mode 100644 index 000000000..cbf228f86 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/identifier/identifier.go @@ -0,0 +1,32 @@ +// The identifier package defines types for RFC 8555 ACME identifiers. +package identifier + +// IdentifierType is a named string type for registered ACME identifier types. +// See https://tools.ietf.org/html/rfc8555#section-9.7.7 +type IdentifierType string + +const ( + // DNS is specified in RFC 8555 for DNS type identifiers. + DNS = IdentifierType("dns") +) + +// ACMEIdentifier is a struct encoding an identifier that can be validated. The +// protocol allows for different types of identifier to be supported (DNS +// names, IP addresses, etc.), but currently we only support RFC 8555 DNS type +// identifiers for domain names. +type ACMEIdentifier struct { + // Type is the registered IdentifierType of the identifier. + Type IdentifierType `json:"type"` + // Value is the value of the identifier. For a DNS type identifier it is + // a domain name. + Value string `json:"value"` +} + +// DNSIdentifier is a convenience function for creating an ACMEIdentifier with +// Type DNS for a given domain name. +func DNSIdentifier(domain string) ACMEIdentifier { + return ACMEIdentifier{ + Type: DNS, + Value: domain, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/cert.go b/third-party/github.com/letsencrypt/boulder/issuance/cert.go new file mode 100644 index 000000000..6b8734b7c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/cert.go @@ -0,0 +1,376 @@ +package issuance + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "sync" + "time" + + ct "github.com/google/certificate-transparency-go" + cttls "github.com/google/certificate-transparency-go/tls" + ctx509 "github.com/google/certificate-transparency-go/x509" + "github.com/jmhodges/clock" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/precert" +) + +// ProfileConfig describes the certificate issuance constraints for all issuers. +type ProfileConfig struct { + AllowMustStaple bool + AllowCTPoison bool + AllowSCTList bool + AllowCommonName bool + + MaxValidityPeriod config.Duration + MaxValidityBackdate config.Duration + + // Deprecated: we do not respect this field. + Policies []PolicyConfig `validate:"-"` +} + +// PolicyConfig describes a policy +type PolicyConfig struct { + OID string `validate:"required"` +} + +// Profile is the validated structure created by reading in ProfileConfigs and IssuerConfigs +type Profile struct { + allowMustStaple bool + allowCTPoison bool + allowSCTList bool + allowCommonName bool + + maxBackdate time.Duration + maxValidity time.Duration + + lints lint.Registry +} + +// NewProfile converts the profile config and lint registry into a usable profile. +func NewProfile(profileConfig ProfileConfig, lints lint.Registry) (*Profile, error) { + sp := &Profile{ + allowMustStaple: profileConfig.AllowMustStaple, + allowCTPoison: profileConfig.AllowCTPoison, + allowSCTList: profileConfig.AllowSCTList, + allowCommonName: profileConfig.AllowCommonName, + maxBackdate: profileConfig.MaxValidityBackdate.Duration, + maxValidity: profileConfig.MaxValidityPeriod.Duration, + lints: lints, + } + + return sp, nil +} + +// requestValid verifies the passed IssuanceRequest against the profile. If the +// request doesn't match the signing profile an error is returned. +func (i *Issuer) requestValid(clk clock.Clock, prof *Profile, req *IssuanceRequest) error { + switch req.PublicKey.(type) { + case *rsa.PublicKey, *ecdsa.PublicKey: + default: + return errors.New("unsupported public key type") + } + + if len(req.precertDER) == 0 && !i.active { + return errors.New("inactive issuer cannot issue precert") + } + + if len(req.SubjectKeyId) != 20 { + return errors.New("unexpected subject key ID length") + } + + if !prof.allowMustStaple && req.IncludeMustStaple { + return errors.New("must-staple extension cannot be included") + } + + if !prof.allowCTPoison && req.IncludeCTPoison { + return errors.New("ct poison extension cannot be included") + } + + if !prof.allowSCTList && req.sctList != nil { + return errors.New("sct list extension cannot be included") + } + + if req.IncludeCTPoison && req.sctList != nil { + return errors.New("cannot include both ct poison and sct list extensions") + } + + if !prof.allowCommonName && req.CommonName != "" { + return errors.New("common name cannot be included") + } + + // The validity period is calculated inclusive of the whole second represented + // by the notAfter timestamp. + validity := req.NotAfter.Add(time.Second).Sub(req.NotBefore) + if validity <= 0 { + return errors.New("NotAfter must be after NotBefore") + } + if validity > prof.maxValidity { + return fmt.Errorf("validity period is more than the maximum allowed period (%s>%s)", validity, prof.maxValidity) + } + backdatedBy := clk.Now().Sub(req.NotBefore) + if backdatedBy > prof.maxBackdate { + return fmt.Errorf("NotBefore is backdated more than the maximum allowed period (%s>%s)", backdatedBy, prof.maxBackdate) + } + if backdatedBy < 0 { + return errors.New("NotBefore is in the future") + } + + // We use 19 here because a 20-byte serial could produce >20 octets when + // encoded in ASN.1. That happens when the first byte is >0x80. See + // https://letsencrypt.org/docs/a-warm-welcome-to-asn1-and-der/#integer-encoding + if len(req.Serial) > 19 || len(req.Serial) < 9 { + return errors.New("serial must be between 9 and 19 bytes") + } + + return nil +} + +func (i *Issuer) generateTemplate() *x509.Certificate { + template := &x509.Certificate{ + SignatureAlgorithm: i.sigAlg, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + OCSPServer: []string{i.ocspURL}, + IssuingCertificateURL: []string{i.issuerURL}, + BasicConstraintsValid: true, + // Baseline Requirements, Section 7.1.6.1: domain-validated + PolicyIdentifiers: []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}}, + } + + // TODO(#7294): Use i.crlURLBase and a shard calculation to create a + // crlDistributionPoint. + + return template +} + +var ctPoisonExt = pkix.Extension{ + // OID for CT poison, RFC 6962 (was never assigned a proper id-pe- name) + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3}, + Value: asn1.NullBytes, + Critical: true, +} + +// OID for SCT list, RFC 6962 (was never assigned a proper id-pe- name) +var sctListOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + +func generateSCTListExt(scts []ct.SignedCertificateTimestamp) (pkix.Extension, error) { + list := ctx509.SignedCertificateTimestampList{} + for _, sct := range scts { + sctBytes, err := cttls.Marshal(sct) + if err != nil { + return pkix.Extension{}, err + } + list.SCTList = append(list.SCTList, ctx509.SerializedSCT{Val: sctBytes}) + } + listBytes, err := cttls.Marshal(list) + if err != nil { + return pkix.Extension{}, err + } + extBytes, err := asn1.Marshal(listBytes) + if err != nil { + return pkix.Extension{}, err + } + return pkix.Extension{ + Id: sctListOID, + Value: extBytes, + }, nil +} + +var mustStapleExt = pkix.Extension{ + // RFC 7633: id-pe-tlsfeature OBJECT IDENTIFIER ::= { id-pe 24 } + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}, + // ASN.1 encoding of: + // SEQUENCE + // INTEGER 5 + // where "5" is the status_request feature (RFC 6066) + Value: []byte{0x30, 0x03, 0x02, 0x01, 0x05}, +} + +// IssuanceRequest describes a certificate issuance request +type IssuanceRequest struct { + PublicKey crypto.PublicKey + SubjectKeyId []byte + + Serial []byte + + NotBefore time.Time + NotAfter time.Time + + CommonName string + DNSNames []string + + IncludeMustStaple bool + IncludeCTPoison bool + + // sctList is a list of SCTs to include in a final certificate. + // If it is non-empty, PrecertDER must also be non-empty. + sctList []ct.SignedCertificateTimestamp + // precertDER is the encoded bytes of the precertificate that a + // final certificate is expected to correspond to. If it is non-empty, + // SCTList must also be non-empty. + precertDER []byte +} + +// An issuanceToken represents an assertion that Issuer.Lint has generated +// a linting certificate for a given input and run the linter over it with no +// errors. The token may be redeemed (at most once) to sign a certificate or +// precertificate with the same Issuer's private key, containing the same +// contents that were linted. +type issuanceToken struct { + mu sync.Mutex + template *x509.Certificate + pubKey any + // A pointer to the issuer that created this token. This token may only + // be redeemed by the same issuer. + issuer *Issuer +} + +// Prepare combines the given profile and request with the Issuer's information +// to create a template certificate. It then generates a linting certificate +// from that template and runs the linter over it. If successful, returns both +// the linting certificate (which can be stored) and an issuanceToken. The +// issuanceToken can be used to sign a matching certificate with this Issuer's +// private key. +func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuanceToken, error) { + // check request is valid according to the issuance profile + err := i.requestValid(i.clk, prof, req) + if err != nil { + return nil, nil, err + } + + // generate template from the issuer's data + template := i.generateTemplate() + + // populate template from the issuance request + template.NotBefore, template.NotAfter = req.NotBefore, req.NotAfter + template.SerialNumber = big.NewInt(0).SetBytes(req.Serial) + if req.CommonName != "" { + template.Subject.CommonName = req.CommonName + } + template.DNSNames = req.DNSNames + + switch req.PublicKey.(type) { + case *rsa.PublicKey: + template.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + case *ecdsa.PublicKey: + template.KeyUsage = x509.KeyUsageDigitalSignature + } + + template.SubjectKeyId = req.SubjectKeyId + + if req.IncludeCTPoison { + template.ExtraExtensions = append(template.ExtraExtensions, ctPoisonExt) + } else if len(req.sctList) > 0 { + if len(req.precertDER) == 0 { + return nil, nil, errors.New("inconsistent request contains sctList but no precertDER") + } + sctListExt, err := generateSCTListExt(req.sctList) + if err != nil { + return nil, nil, err + } + template.ExtraExtensions = append(template.ExtraExtensions, sctListExt) + } else { + return nil, nil, errors.New("invalid request contains neither sctList nor precertDER") + } + + if req.IncludeMustStaple { + template.ExtraExtensions = append(template.ExtraExtensions, mustStapleExt) + } + + // check that the tbsCertificate is properly formed by signing it + // with a throwaway key and then linting it using zlint + lintCertBytes, err := i.Linter.Check(template, req.PublicKey, prof.lints) + if err != nil { + return nil, nil, fmt.Errorf("tbsCertificate linting failed: %w", err) + } + + if len(req.precertDER) > 0 { + err = precert.Correspond(req.precertDER, lintCertBytes) + if err != nil { + return nil, nil, fmt.Errorf("precert does not correspond to linted final cert: %w", err) + } + } + + token := &issuanceToken{sync.Mutex{}, template, req.PublicKey, i} + return lintCertBytes, token, nil +} + +// Issue performs a real issuance using an issuanceToken resulting from a +// previous call to Prepare(). Call this at most once per token. Calls after +// the first will receive an error. +func (i *Issuer) Issue(token *issuanceToken) ([]byte, error) { + if token == nil { + return nil, errors.New("nil issuanceToken") + } + token.mu.Lock() + defer token.mu.Unlock() + if token.template == nil { + return nil, errors.New("issuance token already redeemed") + } + template := token.template + token.template = nil + + if token.issuer != i { + return nil, errors.New("tried to redeem issuance token with the wrong issuer") + } + + return x509.CreateCertificate(rand.Reader, template, i.Cert.Certificate, token.pubKey, i.Signer) +} + +// ContainsMustStaple returns true if the provided set of extensions includes +// an entry whose OID and value both match the expected values for the OCSP +// Must-Staple (a.k.a. id-pe-tlsFeature) extension. +func ContainsMustStaple(extensions []pkix.Extension) bool { + for _, ext := range extensions { + if ext.Id.Equal(mustStapleExt.Id) && bytes.Equal(ext.Value, mustStapleExt.Value) { + return true + } + } + return false +} + +// containsCTPoison returns true if the provided set of extensions includes +// an entry whose OID and value both match the expected values for the CT +// Poison extension. +func containsCTPoison(extensions []pkix.Extension) bool { + for _, ext := range extensions { + if ext.Id.Equal(ctPoisonExt.Id) && bytes.Equal(ext.Value, asn1.NullBytes) { + return true + } + } + return false +} + +// RequestFromPrecert constructs a final certificate IssuanceRequest matching +// the provided precertificate. It returns an error if the precertificate doesn't +// contain the CT poison extension. +func RequestFromPrecert(precert *x509.Certificate, scts []ct.SignedCertificateTimestamp) (*IssuanceRequest, error) { + if !containsCTPoison(precert.Extensions) { + return nil, errors.New("provided certificate doesn't contain the CT poison extension") + } + return &IssuanceRequest{ + PublicKey: precert.PublicKey, + SubjectKeyId: precert.SubjectKeyId, + Serial: precert.SerialNumber.Bytes(), + NotBefore: precert.NotBefore, + NotAfter: precert.NotAfter, + CommonName: precert.Subject.CommonName, + DNSNames: precert.DNSNames, + IncludeMustStaple: ContainsMustStaple(precert.Extensions), + sctList: scts, + precertDER: precert.Raw, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/cert_test.go b/third-party/github.com/letsencrypt/boulder/issuance/cert_test.go new file mode 100644 index 000000000..87704745d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/cert_test.go @@ -0,0 +1,761 @@ +package issuance + +import ( + "crypto" + "crypto/dsa" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "testing" + "time" + + ct "github.com/google/certificate-transparency-go" + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/linter" + "github.com/letsencrypt/boulder/test" +) + +var ( + goodSKID = []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9} +) + +func defaultProfile() *Profile { + lints, _ := linter.NewRegistry([]string{ + "w_ct_sct_policy_count_unsatisfied", + "e_scts_from_same_operator", + }) + p, _ := NewProfile(defaultProfileConfig(), lints) + return p +} + +func TestRequestValid(t *testing.T) { + fc := clock.NewFake() + fc.Add(time.Hour * 24) + + tests := []struct { + name string + issuer *Issuer + profile *Profile + request *IssuanceRequest + expectedError string + }{ + { + name: "unsupported key type", + issuer: &Issuer{}, + profile: &Profile{}, + request: &IssuanceRequest{PublicKey: &dsa.PublicKey{}}, + expectedError: "unsupported public key type", + }, + { + name: "inactive (rsa)", + issuer: &Issuer{}, + profile: &Profile{}, + request: &IssuanceRequest{PublicKey: &rsa.PublicKey{}}, + expectedError: "inactive issuer cannot issue precert", + }, + { + name: "inactive (ecdsa)", + issuer: &Issuer{}, + profile: &Profile{}, + request: &IssuanceRequest{PublicKey: &ecdsa.PublicKey{}}, + expectedError: "inactive issuer cannot issue precert", + }, + { + name: "skid too short", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: []byte{0, 1, 2, 3, 4}, + }, + expectedError: "unexpected subject key ID length", + }, + { + name: "must staple not allowed", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + IncludeMustStaple: true, + }, + expectedError: "must-staple extension cannot be included", + }, + { + name: "ct poison not allowed", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + IncludeCTPoison: true, + }, + expectedError: "ct poison extension cannot be included", + }, + { + name: "sct list not allowed", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + sctList: []ct.SignedCertificateTimestamp{}, + }, + expectedError: "sct list extension cannot be included", + }, + { + name: "sct list and ct poison not allowed", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + allowCTPoison: true, + allowSCTList: true, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + IncludeCTPoison: true, + sctList: []ct.SignedCertificateTimestamp{}, + }, + expectedError: "cannot include both ct poison and sct list extensions", + }, + { + name: "common name not allowed", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + CommonName: "cn", + }, + expectedError: "common name cannot be included", + }, + { + name: "negative validity", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{}, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now().Add(time.Hour), + NotAfter: fc.Now(), + }, + expectedError: "NotAfter must be after NotBefore", + }, + { + name: "validity larger than max", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Minute, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + }, + expectedError: "validity period is more than the maximum allowed period (1h0m0s>1m0s)", + }, + { + name: "validity larger than max due to inclusivity", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour), + }, + expectedError: "validity period is more than the maximum allowed period (1h0m1s>1h0m0s)", + }, + { + name: "validity backdated more than max", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour * 2, + maxBackdate: time.Hour, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now().Add(-time.Hour * 2), + NotAfter: fc.Now().Add(-time.Hour), + }, + expectedError: "NotBefore is backdated more than the maximum allowed period (2h0m0s>1h0m0s)", + }, + { + name: "validity is forward dated", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour * 2, + maxBackdate: time.Hour, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now().Add(time.Hour), + NotAfter: fc.Now().Add(time.Hour * 2), + }, + expectedError: "NotBefore is in the future", + }, + { + name: "serial too short", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour * 2, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour), + Serial: []byte{0, 1, 2, 3, 4, 5, 6, 7}, + }, + expectedError: "serial must be between 9 and 19 bytes", + }, + { + name: "serial too long", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour * 2, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour), + Serial: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + }, + expectedError: "serial must be between 9 and 19 bytes", + }, + { + name: "good", + issuer: &Issuer{ + active: true, + }, + profile: &Profile{ + maxValidity: time.Hour * 2, + }, + request: &IssuanceRequest{ + PublicKey: &ecdsa.PublicKey{}, + SubjectKeyId: goodSKID, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour), + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.issuer.requestValid(fc, tc.profile, tc.request) + if err != nil { + if tc.expectedError == "" { + t.Errorf("failed with unexpected error: %s", err) + } else if tc.expectedError != err.Error() { + t.Errorf("failed with unexpected error, wanted: %q, got: %q", tc.expectedError, err.Error()) + } + return + } else if tc.expectedError != "" { + t.Errorf("didn't fail, expected %q", tc.expectedError) + } + }) + } +} + +func TestGenerateTemplate(t *testing.T) { + issuer := &Issuer{ + ocspURL: "http://ocsp", + issuerURL: "http://issuer", + crlURLBase: "http://crl/", + sigAlg: x509.SHA256WithRSA, + } + + actual := issuer.generateTemplate() + + expected := &x509.Certificate{ + BasicConstraintsValid: true, + SignatureAlgorithm: x509.SHA256WithRSA, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + IssuingCertificateURL: []string{"http://issuer"}, + OCSPServer: []string{"http://ocsp"}, + CRLDistributionPoints: nil, + PolicyIdentifiers: []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}}, + } + + test.AssertDeepEquals(t, actual, expected) +} + +func TestIssue(t *testing.T) { + for _, tc := range []struct { + name string + generateFunc func() (crypto.Signer, error) + ku x509.KeyUsage + }{ + { + name: "RSA", + generateFunc: func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + }, + ku: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + }, + { + name: "ECDSA", + generateFunc: func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + }, + ku: x509.KeyUsageDigitalSignature, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := tc.generateFunc() + test.AssertNotError(t, err, "failed to generate test key") + lintCertBytes, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "Prepare failed") + _, err = x509.ParseCertificate(lintCertBytes) + test.AssertNotError(t, err, "failed to parse certificate") + certBytes, err := signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse certificate") + err = cert.CheckSignatureFrom(issuerCert.Certificate) + test.AssertNotError(t, err, "signature validation failed") + test.AssertDeepEquals(t, cert.DNSNames, []string{"example.com"}) + test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertDeepEquals(t, cert.PublicKey, pk.Public()) + test.AssertEquals(t, len(cert.Extensions), 9) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, Poison + test.AssertEquals(t, cert.KeyUsage, tc.ku) + }) + } +} + +func TestIssueCommonName(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + lints, err := linter.NewRegistry([]string{ + "w_subject_common_name_included", + "w_ct_sct_policy_count_unsatisfied", + "e_scts_from_same_operator", + }) + test.AssertNotError(t, err, "building test lint registry") + cnProfile, err := NewProfile(defaultProfileConfig(), lints) + test.AssertNotError(t, err, "NewProfile failed") + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + ir := &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + CommonName: "example.com", + DNSNames: []string{"example.com", "www.example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + } + + _, issuanceToken, err := signer.Prepare(cnProfile, ir) + test.AssertNotError(t, err, "Prepare failed") + certBytes, err := signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse certificate") + test.AssertEquals(t, cert.Subject.CommonName, "example.com") + + cnProfile.allowCommonName = false + _, _, err = signer.Prepare(cnProfile, ir) + test.AssertError(t, err, "Prepare should have failed") + + ir.CommonName = "" + _, issuanceToken, err = signer.Prepare(cnProfile, ir) + test.AssertNotError(t, err, "Prepare failed") + certBytes, err = signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + cert, err = x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse certificate") + test.AssertEquals(t, cert.Subject.CommonName, "") + test.AssertDeepEquals(t, cert.DNSNames, []string{"example.com", "www.example.com"}) +} + +func TestIssueCTPoison(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + IncludeCTPoison: true, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + }) + test.AssertNotError(t, err, "Prepare failed") + certBytes, err := signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse certificate") + err = cert.CheckSignatureFrom(issuerCert.Certificate) + test.AssertNotError(t, err, "signature validation failed") + test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertDeepEquals(t, cert.PublicKey, pk.Public()) + test.AssertEquals(t, len(cert.Extensions), 9) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, CT Poison + test.AssertDeepEquals(t, cert.Extensions[8], ctPoisonExt) +} + +func mustDecodeB64(b string) []byte { + out, err := base64.StdEncoding.DecodeString(b) + if err != nil { + panic(err) + } + return out +} + +func TestIssueSCTList(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + err := loglist.InitLintList("../test/ct-test-srv/log_list.json") + test.AssertNotError(t, err, "failed to load log list") + + lints, err := linter.NewRegistry([]string{}) + test.AssertNotError(t, err, "building test lint registry") + enforceSCTsProfile, err := NewProfile(defaultProfileConfig(), lints) + test.AssertNotError(t, err, "NewProfile failed") + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, issuanceToken, err := signer.Prepare(enforceSCTsProfile, &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "Prepare failed") + precertBytes, err := signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + precert, err := x509.ParseCertificate(precertBytes) + test.AssertNotError(t, err, "failed to parse certificate") + + sctList := []ct.SignedCertificateTimestamp{ + { + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: *(*[32]byte)(mustDecodeB64("OJiMlNA1mMOTLd/pI7q68npCDrlsQeFaqAwasPwEvQM="))}, + }, + { + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: *(*[32]byte)(mustDecodeB64("UtToynGEyMkkXDMQei8Ll54oMwWHI0IieDEKs12/Td4="))}, + }, + } + + request2, err := RequestFromPrecert(precert, sctList) + test.AssertNotError(t, err, "generating request from precert") + + _, issuanceToken2, err := signer.Prepare(enforceSCTsProfile, request2) + test.AssertNotError(t, err, "preparing final cert issuance") + + finalCertBytes, err := signer.Issue(issuanceToken2) + test.AssertNotError(t, err, "Issue failed") + + finalCert, err := x509.ParseCertificate(finalCertBytes) + test.AssertNotError(t, err, "failed to parse certificate") + + err = finalCert.CheckSignatureFrom(issuerCert.Certificate) + test.AssertNotError(t, err, "signature validation failed") + test.AssertByteEquals(t, finalCert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertDeepEquals(t, finalCert.PublicKey, pk.Public()) + test.AssertEquals(t, len(finalCert.Extensions), 9) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, SCT list + test.AssertDeepEquals(t, finalCert.Extensions[8], pkix.Extension{ + Id: sctListOID, + Value: []byte{ + 4, 100, 0, 98, 0, 47, 0, 56, 152, 140, 148, 208, 53, 152, 195, 147, 45, + 223, 233, 35, 186, 186, 242, 122, 66, 14, 185, 108, 65, 225, 90, 168, 12, + 26, 176, 252, 4, 189, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, + 0, 82, 212, 232, 202, 113, 132, 200, 201, 36, 92, 51, 16, 122, 47, 11, + 151, 158, 40, 51, 5, 135, 35, 66, 34, 120, 49, 10, 179, 93, 191, 77, 222, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }, + }) +} + +func TestIssueMustStaple(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + IncludeMustStaple: true, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "Prepare failed") + certBytes, err := signer.Issue(issuanceToken) + test.AssertNotError(t, err, "Issue failed") + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "failed to parse certificate") + err = cert.CheckSignatureFrom(issuerCert.Certificate) + test.AssertNotError(t, err, "signature validation failed") + test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertDeepEquals(t, cert.PublicKey, pk.Public()) + test.AssertEquals(t, len(cert.Extensions), 10) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, Must-Staple, Poison + test.AssertDeepEquals(t, cert.Extensions[9], mustStapleExt) +} + +func TestIssueBadLint(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + lints, err := linter.NewRegistry([]string{}) + test.AssertNotError(t, err, "building test lint registry") + noSkipLintsProfile, err := NewProfile(defaultProfileConfig(), lints) + test.AssertNotError(t, err, "NewProfile failed") + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, _, err = signer.Prepare(noSkipLintsProfile, &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example-com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertError(t, err, "Prepare didn't fail") + test.AssertErrorIs(t, err, linter.ErrLinting) + test.AssertContains(t, err.Error(), "tbsCertificate linting failed: failed lint(s)") +} + +func TestIssuanceToken(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + + _, err = signer.Issue(&issuanceToken{}) + test.AssertError(t, err, "expected issuance with a zero token to fail") + + _, err = signer.Issue(nil) + test.AssertError(t, err, "expected issuance with a nil token to fail") + + pk, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "failed to generate test key") + _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "expected Prepare to succeed") + _, err = signer.Issue(issuanceToken) + test.AssertNotError(t, err, "expected first issuance to succeed") + + _, err = signer.Issue(issuanceToken) + test.AssertError(t, err, "expected second issuance with the same issuance token to fail") + test.AssertContains(t, err.Error(), "issuance token already redeemed") + + _, issuanceToken, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "expected Prepare to succeed") + + signer2, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + + _, err = signer2.Issue(issuanceToken) + test.AssertError(t, err, "expected redeeming an issuance token with the wrong issuer to fail") + test.AssertContains(t, err.Error(), "wrong issuer") +} + +func TestInvalidProfile(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + + err := loglist.InitLintList("../test/ct-test-srv/log_list.json") + test.AssertNotError(t, err, "failed to load log list") + + signer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + precertDER: []byte{6, 6, 6}, + }) + test.AssertError(t, err, "Invalid IssuanceRequest") + + _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + sctList: []ct.SignedCertificateTimestamp{ + { + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: *(*[32]byte)(mustDecodeB64("OJiMlNA1mMOTLd/pI7q68npCDrlsQeFaqAwasPwEvQM="))}, + }, + }, + precertDER: []byte{}, + }) + test.AssertError(t, err, "Invalid IssuanceRequest") +} + +// Generate a precert from one profile and a final cert from another, and verify +// that the final cert errors out when linted because the lint cert doesn't +// corresponding with the precert. +func TestMismatchedProfiles(t *testing.T) { + fc := clock.NewFake() + fc.Set(time.Now()) + err := loglist.InitLintList("../test/ct-test-srv/log_list.json") + test.AssertNotError(t, err, "failed to load log list") + + issuer1, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + + lints, err := linter.NewRegistry([]string{ + "w_subject_common_name_included", + "w_ct_sct_policy_count_unsatisfied", + "e_scts_from_same_operator", + }) + test.AssertNotError(t, err, "building test lint registry") + cnProfile, err := NewProfile(defaultProfileConfig(), lints) + test.AssertNotError(t, err, "NewProfile failed") + + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate test key") + _, issuanceToken, err := issuer1.Prepare(cnProfile, &IssuanceRequest{ + PublicKey: pk.Public(), + SubjectKeyId: goodSKID, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + CommonName: "example.com", + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(time.Hour - time.Second), + IncludeCTPoison: true, + }) + test.AssertNotError(t, err, "making IssuanceRequest") + + precertDER, err := issuer1.Issue(issuanceToken) + test.AssertNotError(t, err, "signing precert") + + // Create a new profile that differs slightly (no common name) + profileConfig := defaultProfileConfig() + profileConfig.AllowCommonName = false + lints, err = linter.NewRegistry([]string{ + "w_ct_sct_policy_count_unsatisfied", + "e_scts_from_same_operator", + }) + test.AssertNotError(t, err, "building test lint registry") + noCNProfile, err := NewProfile(profileConfig, lints) + test.AssertNotError(t, err, "NewProfile failed") + + issuer2, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, fc) + test.AssertNotError(t, err, "NewIssuer failed") + + sctList := []ct.SignedCertificateTimestamp{ + { + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: *(*[32]byte)(mustDecodeB64("OJiMlNA1mMOTLd/pI7q68npCDrlsQeFaqAwasPwEvQM="))}, + }, + { + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: *(*[32]byte)(mustDecodeB64("UtToynGEyMkkXDMQei8Ll54oMwWHI0IieDEKs12/Td4="))}, + }, + } + + precert, err := x509.ParseCertificate(precertDER) + test.AssertNotError(t, err, "parsing precert") + + request2, err := RequestFromPrecert(precert, sctList) + test.AssertNotError(t, err, "RequestFromPrecert") + request2.CommonName = "" + + _, _, err = issuer2.Prepare(noCNProfile, request2) + test.AssertError(t, err, "preparing final cert issuance") + test.AssertContains(t, err.Error(), "precert does not correspond to linted final cert") +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/crl.go b/third-party/github.com/letsencrypt/boulder/issuance/crl.go new file mode 100644 index 000000000..48fc54e3f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/crl.go @@ -0,0 +1,108 @@ +package issuance + +import ( + "crypto/rand" + "crypto/x509" + "fmt" + "math/big" + "time" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/crl/idp" + "github.com/letsencrypt/boulder/linter" +) + +type CRLProfileConfig struct { + ValidityInterval config.Duration + MaxBackdate config.Duration +} + +type CRLProfile struct { + validityInterval time.Duration + maxBackdate time.Duration + + lints lint.Registry +} + +func NewCRLProfile(config CRLProfileConfig) (*CRLProfile, error) { + lifetime := config.ValidityInterval.Duration + if lifetime >= 10*24*time.Hour { + return nil, fmt.Errorf("crl lifetime cannot be more than 10 days, got %q", lifetime) + } else if lifetime <= 0*time.Hour { + return nil, fmt.Errorf("crl lifetime must be positive, got %q", lifetime) + } + + if config.MaxBackdate.Duration < 0 { + return nil, fmt.Errorf("crl max backdate must be non-negative, got %q", config.MaxBackdate) + } + + reg, err := linter.NewRegistry(nil) + if err != nil { + return nil, fmt.Errorf("creating lint registry: %w", err) + } + + return &CRLProfile{ + validityInterval: config.ValidityInterval.Duration, + maxBackdate: config.MaxBackdate.Duration, + lints: reg, + }, nil +} + +type CRLRequest struct { + Number *big.Int + Shard int64 + + ThisUpdate time.Time + + Entries []x509.RevocationListEntry +} + +func (i *Issuer) IssueCRL(prof *CRLProfile, req *CRLRequest) ([]byte, error) { + backdatedBy := i.clk.Now().Sub(req.ThisUpdate) + if backdatedBy > prof.maxBackdate { + return nil, fmt.Errorf("ThisUpdate is too far in the past (%s>%s)", backdatedBy, prof.maxBackdate) + } + if backdatedBy < 0 { + return nil, fmt.Errorf("ThisUpdate is in the future (%s>%s)", req.ThisUpdate, i.clk.Now()) + } + + template := &x509.RevocationList{ + RevokedCertificateEntries: req.Entries, + Number: req.Number, + ThisUpdate: req.ThisUpdate, + NextUpdate: req.ThisUpdate.Add(-time.Second).Add(prof.validityInterval), + } + + if i.crlURLBase == "" { + return nil, fmt.Errorf("CRL must contain an issuingDistributionPoint") + } + + // Concat the base with the shard directly, since we require that the base + // end with a single trailing slash. + idp, err := idp.MakeUserCertsExt([]string{ + fmt.Sprintf("%s%d.crl", i.crlURLBase, req.Shard), + }) + if err != nil { + return nil, fmt.Errorf("creating IDP extension: %w", err) + } + template.ExtraExtensions = append(template.ExtraExtensions, idp) + + err = i.Linter.CheckCRL(template, prof.lints) + if err != nil { + return nil, err + } + + crlBytes, err := x509.CreateRevocationList( + rand.Reader, + template, + i.Cert.Certificate, + i.Signer, + ) + if err != nil { + return nil, err + } + + return crlBytes, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/crl_test.go b/third-party/github.com/letsencrypt/boulder/issuance/crl_test.go new file mode 100644 index 000000000..38b822c3f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/crl_test.go @@ -0,0 +1,250 @@ +package issuance + +import ( + "crypto/x509" + "errors" + "math/big" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/zmap/zlint/v3/lint" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/crl/idp" + "github.com/letsencrypt/boulder/test" +) + +func TestNewCRLProfile(t *testing.T) { + t.Parallel() + tests := []struct { + name string + config CRLProfileConfig + expected *CRLProfile + expectedErr string + }{ + { + name: "validity too long", + config: CRLProfileConfig{ValidityInterval: config.Duration{Duration: 30 * 24 * time.Hour}}, + expected: nil, + expectedErr: "lifetime cannot be more than 10 days", + }, + { + name: "validity too short", + config: CRLProfileConfig{ValidityInterval: config.Duration{Duration: 0}}, + expected: nil, + expectedErr: "lifetime must be positive", + }, + { + name: "negative backdate", + config: CRLProfileConfig{ + ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour}, + MaxBackdate: config.Duration{Duration: -time.Hour}, + }, + expected: nil, + expectedErr: "backdate must be non-negative", + }, + { + name: "happy path", + config: CRLProfileConfig{ + ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour}, + MaxBackdate: config.Duration{Duration: time.Hour}, + }, + expected: &CRLProfile{ + validityInterval: 7 * 24 * time.Hour, + maxBackdate: time.Hour, + }, + expectedErr: "", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + actual, err := NewCRLProfile(tc.config) + if err != nil { + if tc.expectedErr == "" { + t.Errorf("NewCRLProfile expected success but got %q", err) + return + } + test.AssertContains(t, err.Error(), tc.expectedErr) + } else { + if tc.expectedErr != "" { + t.Errorf("NewCRLProfile succeeded but expected error %q", tc.expectedErr) + return + } + test.AssertEquals(t, actual.validityInterval, tc.expected.validityInterval) + test.AssertEquals(t, actual.maxBackdate, tc.expected.maxBackdate) + test.AssertNotNil(t, actual.lints, "lint registry should be populated") + } + }) + } +} + +func TestIssueCRL(t *testing.T) { + clk := clock.NewFake() + clk.Set(time.Now()) + + issuer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, clk) + test.AssertNotError(t, err, "creating test issuer") + + defaultProfile := CRLProfile{ + validityInterval: 7 * 24 * time.Hour, + maxBackdate: 1 * time.Hour, + lints: lint.GlobalRegistry(), + } + + defaultRequest := CRLRequest{ + Number: big.NewInt(123), + Shard: 100, + ThisUpdate: clk.Now().Add(-time.Second), + Entries: []x509.RevocationListEntry{ + { + SerialNumber: big.NewInt(987), + RevocationTime: clk.Now().Add(-24 * time.Hour), + ReasonCode: 1, + }, + }, + } + + req := defaultRequest + req.ThisUpdate = clk.Now().Add(-24 * time.Hour) + _, err = issuer.IssueCRL(&defaultProfile, &req) + test.AssertError(t, err, "too old crl issuance should fail") + test.AssertContains(t, err.Error(), "ThisUpdate is too far in the past") + + req = defaultRequest + req.ThisUpdate = clk.Now().Add(time.Second) + _, err = issuer.IssueCRL(&defaultProfile, &req) + test.AssertError(t, err, "future crl issuance should fail") + test.AssertContains(t, err.Error(), "ThisUpdate is in the future") + + req = defaultRequest + req.Entries = append(req.Entries, x509.RevocationListEntry{ + SerialNumber: big.NewInt(876), + RevocationTime: clk.Now().Add(-24 * time.Hour), + ReasonCode: 6, + }) + _, err = issuer.IssueCRL(&defaultProfile, &req) + test.AssertError(t, err, "invalid reason code should result in lint failure") + test.AssertContains(t, err.Error(), "Reason code not included in BR") + + req = defaultRequest + res, err := issuer.IssueCRL(&defaultProfile, &req) + test.AssertNotError(t, err, "crl issuance should have succeeded") + parsedRes, err := x509.ParseRevocationList(res) + test.AssertNotError(t, err, "parsing test crl") + test.AssertEquals(t, parsedRes.Issuer.CommonName, issuer.Cert.Subject.CommonName) + test.AssertDeepEquals(t, parsedRes.Number, big.NewInt(123)) + expectUpdate := req.ThisUpdate.Add(-time.Second).Add(defaultProfile.validityInterval).Truncate(time.Second).UTC() + test.AssertEquals(t, parsedRes.NextUpdate, expectUpdate) + test.AssertEquals(t, len(parsedRes.Extensions), 3) + found, err := revokedCertificatesFieldExists(res) + test.AssertNotError(t, err, "Should have been able to parse CRL") + test.Assert(t, found, "Expected the revokedCertificates field to exist") + + idps, err := idp.GetIDPURIs(parsedRes.Extensions) + test.AssertNotError(t, err, "getting IDP URIs from test CRL") + test.AssertEquals(t, len(idps), 1) + test.AssertEquals(t, idps[0], "http://crl-url.example.org/100.crl") + + req = defaultRequest + crlURLBase := issuer.crlURLBase + issuer.crlURLBase = "" + _, err = issuer.IssueCRL(&defaultProfile, &req) + test.AssertError(t, err, "crl issuance with no IDP should fail") + test.AssertContains(t, err.Error(), "must contain an issuingDistributionPoint") + issuer.crlURLBase = crlURLBase + + // A CRL with no entries must not have the revokedCertificates field + req = defaultRequest + req.Entries = []x509.RevocationListEntry{} + res, err = issuer.IssueCRL(&defaultProfile, &req) + test.AssertNotError(t, err, "issuing crl with no entries") + parsedRes, err = x509.ParseRevocationList(res) + test.AssertNotError(t, err, "parsing test crl") + test.AssertEquals(t, parsedRes.Issuer.CommonName, issuer.Cert.Subject.CommonName) + test.AssertDeepEquals(t, parsedRes.Number, big.NewInt(123)) + test.AssertEquals(t, len(parsedRes.RevokedCertificateEntries), 0) + found, err = revokedCertificatesFieldExists(res) + test.AssertNotError(t, err, "Should have been able to parse CRL") + test.Assert(t, !found, "Violation of RFC 5280 Section 5.1.2.6") +} + +// revokedCertificatesFieldExists is a modified version of +// x509.ParseRevocationList that takes a given sequence of bytes representing a +// CRL and parses away layers until the optional `revokedCertificates` field of +// a TBSCertList is found. It returns a boolean indicating whether the field was +// found or an error if there was an issue processing a CRL. +// +// https://datatracker.ietf.org/doc/html/rfc5280#section-5.1.2.6 +// +// When there are no revoked certificates, the revoked certificates list +// MUST be absent. +// +// https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.1 page 118 +// +// CertificateList ::= SEQUENCE { +// tbsCertList TBSCertList +// .. +// } +// +// TBSCertList ::= SEQUENCE { +// .. +// revokedCertificates SEQUENCE OF SEQUENCE { +// .. +// } OPTIONAL, +// } +func revokedCertificatesFieldExists(der []byte) (bool, error) { + input := cryptobyte.String(der) + + // Extract the CertificateList + if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) { + return false, errors.New("malformed crl") + } + + var tbs cryptobyte.String + // Extract the TBSCertList from the CertificateList + if !input.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return false, errors.New("malformed tbs crl") + } + + // Skip optional version + tbs.SkipOptionalASN1(cryptobyte_asn1.INTEGER) + + // Skip the signature + tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) + + // Skip the issuer + tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) + + // SkipOptionalASN1 is identical to SkipASN1 except that it also does a + // peek. We'll handle the non-optional thisUpdate with these double peeks + // because there's no harm doing so. + skipTime := func(s *cryptobyte.String) { + switch { + case s.PeekASN1Tag(cryptobyte_asn1.UTCTime): + s.SkipOptionalASN1(cryptobyte_asn1.UTCTime) + case s.PeekASN1Tag(cryptobyte_asn1.GeneralizedTime): + s.SkipOptionalASN1(cryptobyte_asn1.GeneralizedTime) + } + } + + // Skip thisUpdate + skipTime(&tbs) + + // Skip optional nextUpdate + skipTime(&tbs) + + // Finally, the field which we care about: revokedCertificates. This will + // not trigger on the next field `crlExtensions` because that has + // context-specific tag [0] and EXPLICIT encoding, not `SEQUENCE` and is + // therefore a safe place to end this venture. + if tbs.PeekASN1Tag(cryptobyte_asn1.SEQUENCE) { + return true, nil + } + + return false, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/issuer.go b/third-party/github.com/letsencrypt/boulder/issuance/issuer.go new file mode 100644 index 000000000..4206b65c6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/issuer.go @@ -0,0 +1,370 @@ +package issuance + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "strings" + + "github.com/jmhodges/clock" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/linter" + "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/pkcs11key/v4" +) + +// ----- Name ID ----- + +// NameID is a statistically-unique small ID which can be computed from +// both CA and end-entity certs to link them together into a validation chain. +// It is computed as a truncated hash over the issuer Subject Name bytes, or +// over the end-entity's Issuer Name bytes, which are required to be equal. +type NameID int64 + +// SubjectNameID returns the NameID (a truncated hash over the raw bytes of a +// Distinguished Name) of this issuer certificate's Subject. Useful for storing +// as a lookup key in contexts that don't expect hash collisions. +func SubjectNameID(ic *Certificate) NameID { + return truncatedHash(ic.RawSubject) +} + +// IssuerNameID returns the IssuerNameID (a truncated hash over the raw bytes +// of the Issuer Distinguished Name) of the given end-entity certificate. +// Useful for performing lookups in contexts that don't expect hash collisions. +func IssuerNameID(ee *x509.Certificate) NameID { + return truncatedHash(ee.RawIssuer) +} + +// ResponderNameID returns the NameID (a truncated hash over the raw +// bytes of the Responder Distinguished Name) of the given OCSP Response. +// As per the OCSP spec, it is technically possible for this field to not be +// populated: the OCSP Response can instead contain a SHA-1 hash of the Issuer +// Public Key as the Responder ID. However, all OCSP responses that we produce +// contain it, because the Go stdlib always includes it. +func ResponderNameID(resp *ocsp.Response) NameID { + return truncatedHash(resp.RawResponderName) +} + +// truncatedHash computes a truncated SHA1 hash across arbitrary bytes. Uses +// SHA1 because that is the algorithm most commonly used in OCSP requests. +// PURPOSEFULLY NOT EXPORTED. Exists only to ensure that the implementations of +// SubjectNameID(), IssuerNameID(), and ResponderNameID never diverge. Use those +// instead. +func truncatedHash(name []byte) NameID { + h := crypto.SHA1.New() + h.Write(name) + s := h.Sum(nil) + return NameID(big.NewInt(0).SetBytes(s[:7]).Int64()) +} + +// ----- Issuer Certificates ----- + +// Certificate embeds an *x509.Certificate and represents the added semantics +// that this certificate is a CA certificate. +type Certificate struct { + *x509.Certificate + // nameID is stored here simply for the sake of precomputation. + nameID NameID +} + +// NameID is equivalent to SubjectNameID(ic), but faster because it is +// precomputed. +func (ic *Certificate) NameID() NameID { + return ic.nameID +} + +// NewCertificate wraps an in-memory cert in an issuance.Certificate, marking it +// as an issuer cert. It may fail if the certificate does not contain the +// attributes expected of an issuer certificate. +func NewCertificate(ic *x509.Certificate) (*Certificate, error) { + if !ic.IsCA { + return nil, errors.New("certificate is not a CA certificate") + } + + res := Certificate{ic, 0} + res.nameID = SubjectNameID(&res) + return &res, nil +} + +func LoadCertificate(path string) (*Certificate, error) { + cert, err := core.LoadCert(path) + if err != nil { + return nil, fmt.Errorf("loading issuer certificate: %w", err) + } + return NewCertificate(cert) +} + +// LoadChain takes a list of filenames containing pem-formatted certificates, +// and returns a chain representing all of those certificates in order. It +// ensures that the resulting chain is valid. The final file is expected to be +// a root certificate, which the chain will be verified against, but which will +// not be included in the resulting chain. +func LoadChain(certFiles []string) ([]*Certificate, error) { + if len(certFiles) < 2 { + return nil, errors.New( + "each chain must have at least two certificates: an intermediate and a root") + } + + // Pre-load all the certificates to make validation easier. + certs := make([]*Certificate, len(certFiles)) + var err error + for i := range len(certFiles) { + certs[i], err = LoadCertificate(certFiles[i]) + if err != nil { + return nil, fmt.Errorf("failed to load certificate %q: %w", certFiles[i], err) + } + } + + // Iterate over all certs except for the last, checking that their signature + // comes from the next cert in the list. + chain := make([]*Certificate, len(certFiles)-1) + for i := range len(certs) - 1 { + err = certs[i].CheckSignatureFrom(certs[i+1].Certificate) + if err != nil { + return nil, fmt.Errorf("failed to verify signature from %q to %q (%q to %q): %w", + certs[i+1].Subject, certs[i].Subject, certFiles[i+1], certFiles[i], err) + } + chain[i] = certs[i] + } + + // Verify that the last cert is self-signed. + lastCert := certs[len(certs)-1] + err = lastCert.CheckSignatureFrom(lastCert.Certificate) + if err != nil { + return nil, fmt.Errorf( + "final cert in chain (%q; %q) must be self-signed (used only for validation): %w", + lastCert.Subject, certFiles[len(certFiles)-1], err) + } + + return chain, nil +} + +// ----- Issuers with Signers ----- + +// IssuerConfig describes the constraints on and URLs used by a single issuer. +type IssuerConfig struct { + // Active determines if the issuer can be used to sign precertificates. All + // issuers, regardless of this field, can be used to sign final certificates + // (for which an issuance token is presented), OCSP responses, and CRLs. + // All Active issuers of a given key type (RSA or ECDSA) are part of a pool + // and each precertificate will be issued randomly from a selected pool. + // The selection of which pool depends on the precertificate's key algorithm, + // the ECDSAForAll feature flag, and the ECDSAAllowListFilename config field. + Active bool + + IssuerURL string `validate:"required,url"` + OCSPURL string `validate:"required,url"` + CRLURLBase string `validate:"omitempty,url,startswith=http://,endswith=/"` + + Location IssuerLoc +} + +// IssuerLoc describes the on-disk location and parameters that an issuer +// should use to retrieve its certificate and private key. +// Only one of File, ConfigFile, or PKCS11 should be set. +type IssuerLoc struct { + // A file from which a private key will be read and parsed. + File string `validate:"required_without_all=ConfigFile PKCS11"` + // A file from which a pkcs11key.Config will be read and parsed, if File is not set. + ConfigFile string `validate:"required_without_all=PKCS11 File"` + // An in-memory pkcs11key.Config, which will be used if ConfigFile is not set. + PKCS11 *pkcs11key.Config `validate:"required_without_all=ConfigFile File"` + // A file from which a certificate will be read and parsed. + CertFile string `validate:"required"` + // Number of sessions to open with the HSM. For maximum performance, + // this should be equal to the number of cores in the HSM. Defaults to 1. + NumSessions int +} + +// Issuer is capable of issuing new certificates. +type Issuer struct { + // TODO(#7159): make Cert, Signer, and Linter private when all signing ops + // are handled through this package (e.g. the CA doesn't need direct access + // while signing CRLs anymore). + Cert *Certificate + Signer crypto.Signer + Linter *linter.Linter + + keyAlg x509.PublicKeyAlgorithm + sigAlg x509.SignatureAlgorithm + active bool + + // Used to set the Authority Information Access caIssuers URL in issued + // certificates. + issuerURL string + // Used to set the Authority Information Access ocsp URL in issued + // certificates. + ocspURL string + // Used to set the Issuing Distribution Point extension in issued CRLs + // *and* (eventually) the CRL Distribution Point extension in issued certs. + crlURLBase string + + clk clock.Clock +} + +// newIssuer constructs a new Issuer from the in-memory certificate and signer. +// It exists as a helper for LoadIssuer to make testing simpler. +func newIssuer(config IssuerConfig, cert *Certificate, signer crypto.Signer, clk clock.Clock) (*Issuer, error) { + var keyAlg x509.PublicKeyAlgorithm + var sigAlg x509.SignatureAlgorithm + switch k := cert.PublicKey.(type) { + case *rsa.PublicKey: + keyAlg = x509.RSA + sigAlg = x509.SHA256WithRSA + case *ecdsa.PublicKey: + keyAlg = x509.ECDSA + switch k.Curve { + case elliptic.P256(): + sigAlg = x509.ECDSAWithSHA256 + case elliptic.P384(): + sigAlg = x509.ECDSAWithSHA384 + default: + return nil, fmt.Errorf("unsupported ECDSA curve: %q", k.Curve.Params().Name) + } + default: + return nil, errors.New("unsupported issuer key type") + } + + if config.IssuerURL == "" { + return nil, errors.New("Issuer URL is required") + } + if config.OCSPURL == "" { + return nil, errors.New("OCSP URL is required") + } + if config.CRLURLBase == "" { + return nil, errors.New("CRL URL base is required") + } + if !strings.HasPrefix(config.CRLURLBase, "http://") { + return nil, fmt.Errorf("crlURLBase must use HTTP scheme, got %q", config.CRLURLBase) + } + if !strings.HasSuffix(config.CRLURLBase, "/") { + return nil, fmt.Errorf("crlURLBase must end with exactly one forward slash, got %q", config.CRLURLBase) + } + + // We require that all of our issuers be capable of both issuing certs and + // providing revocation information. + if cert.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, errors.New("end-entity signing cert does not have keyUsage certSign") + } + if cert.KeyUsage&x509.KeyUsageCRLSign == 0 { + return nil, errors.New("end-entity signing cert does not have keyUsage crlSign") + } + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return nil, errors.New("end-entity signing cert does not have keyUsage digitalSignature") + } + + lintSigner, err := linter.New(cert.Certificate, signer) + if err != nil { + return nil, fmt.Errorf("creating fake lint signer: %w", err) + } + + i := &Issuer{ + Cert: cert, + Signer: signer, + Linter: lintSigner, + keyAlg: keyAlg, + sigAlg: sigAlg, + active: config.Active, + issuerURL: config.IssuerURL, + ocspURL: config.OCSPURL, + crlURLBase: config.CRLURLBase, + clk: clk, + } + return i, nil +} + +// KeyType returns either x509.RSA or x509.ECDSA, depending on whether the +// issuer has an RSA or ECDSA keypair. This is useful for determining which +// issuance requests should be routed to this issuer. +func (i *Issuer) KeyType() x509.PublicKeyAlgorithm { + return i.keyAlg +} + +// IsActive is true if the issuer is willing to issue precertificates, and false +// if the issuer is only willing to issue final certificates, OCSP, and CRLs. +func (i *Issuer) IsActive() bool { + return i.active +} + +// Name provides the Common Name specified in the issuer's certificate. +func (i *Issuer) Name() string { + return i.Cert.Subject.CommonName +} + +// NameID provides the NameID of the issuer's certificate. +func (i *Issuer) NameID() NameID { + return i.Cert.NameID() +} + +// LoadIssuer constructs a new Issuer, loading its certificate from disk and its +// private key material from the indicated location. It also verifies that the +// issuer metadata (such as AIA URLs) is well-formed. +func LoadIssuer(config IssuerConfig, clk clock.Clock) (*Issuer, error) { + issuerCert, err := LoadCertificate(config.Location.CertFile) + if err != nil { + return nil, err + } + + signer, err := loadSigner(config.Location, issuerCert.PublicKey) + if err != nil { + return nil, err + } + + if !core.KeyDigestEquals(signer.Public(), issuerCert.PublicKey) { + return nil, fmt.Errorf("issuer key did not match issuer cert %q", config.Location.CertFile) + } + + return newIssuer(config, issuerCert, signer, clk) +} + +func loadSigner(location IssuerLoc, pubkey crypto.PublicKey) (crypto.Signer, error) { + if location.File == "" && location.ConfigFile == "" && location.PKCS11 == nil { + return nil, errors.New("must supply File, ConfigFile, or PKCS11") + } + + if location.File != "" { + signer, _, err := privatekey.Load(location.File) + if err != nil { + return nil, err + } + return signer, nil + } + + var pkcs11Config *pkcs11key.Config + if location.ConfigFile != "" { + contents, err := os.ReadFile(location.ConfigFile) + if err != nil { + return nil, err + } + pkcs11Config = new(pkcs11key.Config) + err = json.Unmarshal(contents, pkcs11Config) + if err != nil { + return nil, err + } + } else { + pkcs11Config = location.PKCS11 + } + + if pkcs11Config.Module == "" || + pkcs11Config.TokenLabel == "" || + pkcs11Config.PIN == "" { + return nil, fmt.Errorf("missing a field in pkcs11Config %#v", pkcs11Config) + } + + numSessions := location.NumSessions + if numSessions <= 0 { + numSessions = 1 + } + + return pkcs11key.NewPool(numSessions, pkcs11Config.Module, + pkcs11Config.TokenLabel, pkcs11Config.PIN, pubkey) +} diff --git a/third-party/github.com/letsencrypt/boulder/issuance/issuer_test.go b/third-party/github.com/letsencrypt/boulder/issuance/issuer_test.go new file mode 100644 index 000000000..4e96145a1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/issuance/issuer_test.go @@ -0,0 +1,269 @@ +package issuance + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "os" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" +) + +func defaultProfileConfig() ProfileConfig { + return ProfileConfig{ + AllowCommonName: true, + AllowCTPoison: true, + AllowSCTList: true, + AllowMustStaple: true, + MaxValidityPeriod: config.Duration{Duration: time.Hour}, + MaxValidityBackdate: config.Duration{Duration: time.Hour}, + } +} + +func defaultIssuerConfig() IssuerConfig { + return IssuerConfig{ + Active: true, + IssuerURL: "http://issuer-url.example.org", + OCSPURL: "http://ocsp-url.example.org", + CRLURLBase: "http://crl-url.example.org/", + } +} + +var issuerCert *Certificate +var issuerSigner *ecdsa.PrivateKey + +func TestMain(m *testing.M) { + tk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + cmd.FailOnError(err, "failed to generate test key") + issuerSigner = tk + template := &x509.Certificate{ + SerialNumber: big.NewInt(123), + BasicConstraintsValid: true, + IsCA: true, + Subject: pkix.Name{ + CommonName: "big ca", + }, + KeyUsage: x509.KeyUsageCRLSign | x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + issuer, err := x509.CreateCertificate(rand.Reader, template, template, tk.Public(), tk) + cmd.FailOnError(err, "failed to generate test issuer") + cert, err := x509.ParseCertificate(issuer) + cmd.FailOnError(err, "failed to parse test issuer") + issuerCert = &Certificate{Certificate: cert} + os.Exit(m.Run()) +} + +func TestLoadCertificate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + path string + wantErr string + }{ + {"invalid cert file", "../test/hierarchy/int-e1.crl.pem", "loading issuer certificate"}, + {"non-CA cert file", "../test/hierarchy/ee-e1.cert.pem", "not a CA certificate"}, + {"happy path", "../test/hierarchy/int-e1.cert.pem", ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := LoadCertificate(tc.path) + if err != nil { + if tc.wantErr != "" { + test.AssertContains(t, err.Error(), tc.wantErr) + } else { + t.Errorf("expected no error but got %v", err) + } + } else { + if tc.wantErr != "" { + t.Errorf("expected error %q but got none", tc.wantErr) + } + } + }) + } +} + +func TestLoadSigner(t *testing.T) { + t.Parallel() + + // We're using this for its pubkey. This definitely doesn't match the private + // key loaded in any of the tests below, but that's okay because it still gets + // us through all the logic in loadSigner. + fakeKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + test.AssertNotError(t, err, "generating test key") + + tests := []struct { + name string + loc IssuerLoc + wantErr string + }{ + {"empty IssuerLoc", IssuerLoc{}, "must supply"}, + {"invalid key file", IssuerLoc{File: "../test/hierarchy/int-e1.crl.pem"}, "unable to parse"}, + {"ECDSA key file", IssuerLoc{File: "../test/hierarchy/int-e1.key.pem"}, ""}, + {"RSA key file", IssuerLoc{File: "../test/hierarchy/int-r3.key.pem"}, ""}, + {"invalid config file", IssuerLoc{ConfigFile: "../test/example-weak-keys.json"}, "json: cannot unmarshal"}, + // Note that we don't have a test for "valid config file" because it would + // always fail -- in CI, the softhsm hasn't been initialized, so there's no + // key to look up; locally even if the softhsm has been initialized, the + // keys in it don't match the fakeKey we generated above. + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := loadSigner(tc.loc, fakeKey.Public()) + if err != nil { + if tc.wantErr != "" { + test.AssertContains(t, err.Error(), tc.wantErr) + } else { + t.Errorf("expected no error but got %v", err) + } + } else { + if tc.wantErr != "" { + t.Errorf("expected error %q but got none", tc.wantErr) + } + } + }) + } +} + +func TestLoadIssuer(t *testing.T) { + _, err := newIssuer( + defaultIssuerConfig(), + issuerCert, + issuerSigner, + clock.NewFake(), + ) + test.AssertNotError(t, err, "newIssuer failed") +} + +func TestNewIssuerUnsupportedKeyType(t *testing.T) { + _, err := newIssuer( + defaultIssuerConfig(), + &Certificate{ + Certificate: &x509.Certificate{ + PublicKey: &ed25519.PublicKey{}, + }, + }, + &ed25519.PrivateKey{}, + clock.NewFake(), + ) + test.AssertError(t, err, "newIssuer didn't fail") + test.AssertEquals(t, err.Error(), "unsupported issuer key type") +} + +func TestNewIssuerKeyUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ku x509.KeyUsage + wantErr string + }{ + {"missing certSign", x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, "does not have keyUsage certSign"}, + {"missing crlSign", x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, "does not have keyUsage crlSign"}, + {"missing digitalSignature", x509.KeyUsageCertSign | x509.KeyUsageCRLSign, "does not have keyUsage digitalSignature"}, + {"all three", x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := newIssuer( + defaultIssuerConfig(), + &Certificate{ + Certificate: &x509.Certificate{ + SerialNumber: big.NewInt(123), + PublicKey: &ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + KeyUsage: tc.ku, + }, + }, + issuerSigner, + clock.NewFake(), + ) + if err != nil { + if tc.wantErr != "" { + test.AssertContains(t, err.Error(), tc.wantErr) + } else { + t.Errorf("expected no error but got %v", err) + } + } else { + if tc.wantErr != "" { + t.Errorf("expected error %q but got none", tc.wantErr) + } + } + }) + } +} + +func TestLoadChain_Valid(t *testing.T) { + chain, err := LoadChain([]string{ + "../test/hierarchy/int-e1.cert.pem", + "../test/hierarchy/root-x2.cert.pem", + }) + test.AssertNotError(t, err, "Should load valid chain") + + expectedIssuer, err := core.LoadCert("../test/hierarchy/int-e1.cert.pem") + test.AssertNotError(t, err, "Failed to load test issuer") + + chainIssuer := chain[0] + test.AssertNotNil(t, chainIssuer, "Failed to decode chain PEM") + + test.AssertByteEquals(t, chainIssuer.Raw, expectedIssuer.Raw) +} + +func TestLoadChain_TooShort(t *testing.T) { + _, err := LoadChain([]string{"/path/to/one/cert.pem"}) + test.AssertError(t, err, "Should reject too-short chain") +} + +func TestLoadChain_Unloadable(t *testing.T) { + _, err := LoadChain([]string{ + "does-not-exist.pem", + "../test/hierarchy/root-x2.cert.pem", + }) + test.AssertError(t, err, "Should reject unloadable chain") + + _, err = LoadChain([]string{ + "../test/hierarchy/int-e1.cert.pem", + "does-not-exist.pem", + }) + test.AssertError(t, err, "Should reject unloadable chain") + + invalidPEMFile, _ := os.CreateTemp("", "invalid.pem") + err = os.WriteFile(invalidPEMFile.Name(), []byte(""), 0640) + test.AssertNotError(t, err, "Error writing invalid PEM tmp file") + _, err = LoadChain([]string{ + invalidPEMFile.Name(), + "../test/hierarchy/root-x2.cert.pem", + }) + test.AssertError(t, err, "Should reject unloadable chain") +} + +func TestLoadChain_InvalidSig(t *testing.T) { + _, err := LoadChain([]string{ + "../test/hierarchy/int-e1.cert.pem", + "../test/hierarchy/root-x1.cert.pem", + }) + test.AssertError(t, err, "Should reject invalid signature") + test.Assert(t, strings.Contains(err.Error(), "root-x1.cert.pem"), + fmt.Sprintf("Expected error to mention filename, got: %s", err)) + test.Assert(t, strings.Contains(err.Error(), "signature from \"CN=(TEST) Ineffable Ice X1"), + fmt.Sprintf("Expected error to mention subject, got: %s", err)) +} diff --git a/third-party/github.com/letsencrypt/boulder/link.sh b/third-party/github.com/letsencrypt/boulder/link.sh new file mode 100644 index 000000000..77344d224 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/link.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# +# Symlink the various boulder subcommands into place. +# +BINDIR="$PWD/bin" +for n in `"${BINDIR}/boulder" --list` ; do + ln -sf boulder "${BINDIR}/$n" +done diff --git a/third-party/github.com/letsencrypt/boulder/linter/linter.go b/third-party/github.com/letsencrypt/boulder/linter/linter.go new file mode 100644 index 000000000..e9bf33b85 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/linter.go @@ -0,0 +1,279 @@ +package linter + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "fmt" + "strings" + + zlintx509 "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/core" + + _ "github.com/letsencrypt/boulder/linter/lints/cabf_br" + _ "github.com/letsencrypt/boulder/linter/lints/chrome" + _ "github.com/letsencrypt/boulder/linter/lints/cpcps" + _ "github.com/letsencrypt/boulder/linter/lints/rfc" +) + +var ErrLinting = fmt.Errorf("failed lint(s)") + +// Check accomplishes the entire process of linting: it generates a throwaway +// signing key, uses that to create a linting cert, and runs a default set of +// lints (everything except for the ETSI and EV lints) against it. If the +// subjectPubKey and realSigner indicate that this is a self-signed cert, the +// cert will have its pubkey replaced to also be self-signed. This is the +// primary public interface of this package, but it can be inefficient; creating +// a new signer and a new lint registry are expensive operations which +// performance-sensitive clients may want to cache via linter.New(). +func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x509.Certificate, realSigner crypto.Signer, skipLints []string) ([]byte, error) { + linter, err := New(realIssuer, realSigner) + if err != nil { + return nil, err + } + + reg, err := NewRegistry(skipLints) + if err != nil { + return nil, err + } + + lintCertBytes, err := linter.Check(tbs, subjectPubKey, reg) + if err != nil { + return nil, err + } + + return lintCertBytes, nil +} + +// CheckCRL is like Check, but for CRLs. +func CheckCRL(tbs *x509.RevocationList, realIssuer *x509.Certificate, realSigner crypto.Signer, skipLints []string) error { + linter, err := New(realIssuer, realSigner) + if err != nil { + return err + } + + reg, err := NewRegistry(skipLints) + if err != nil { + return err + } + + return linter.CheckCRL(tbs, reg) +} + +// Linter is capable of linting a to-be-signed (TBS) certificate. It does so by +// signing that certificate with a throwaway private key and a fake issuer whose +// public key matches the throwaway private key, and then running the resulting +// certificate through a registry of zlint lints. +type Linter struct { + issuer *x509.Certificate + signer crypto.Signer + realPubKey crypto.PublicKey +} + +// New constructs a Linter. It uses the provided real certificate and signer +// (private key) to generate a matching fake keypair and issuer cert that will +// be used to sign the lint certificate. It uses the provided list of lint names +// to skip to filter the zlint global registry to only those lints which should +// be run. +func New(realIssuer *x509.Certificate, realSigner crypto.Signer) (*Linter, error) { + lintSigner, err := makeSigner(realSigner) + if err != nil { + return nil, err + } + lintIssuer, err := makeIssuer(realIssuer, lintSigner) + if err != nil { + return nil, err + } + return &Linter{lintIssuer, lintSigner, realSigner.Public()}, nil +} + +// Check signs the given TBS certificate using the Linter's fake issuer cert and +// private key, then runs the resulting certificate through all lints in reg. +// If the subjectPubKey is identical to the public key of the real signer +// used to create this linter, then the throwaway cert will have its pubkey +// replaced with the linter's pubkey so that it appears self-signed. It returns +// an error if any lint fails. On success it also returns the DER bytes of the +// linting certificate. +func (l Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, reg lint.Registry) ([]byte, error) { + lintPubKey := subjectPubKey + selfSigned, err := core.PublicKeysEqual(subjectPubKey, l.realPubKey) + if err != nil { + return nil, err + } + if selfSigned { + lintPubKey = l.signer.Public() + } + + lintCertBytes, cert, err := makeLintCert(tbs, lintPubKey, l.issuer, l.signer) + if err != nil { + return nil, err + } + + lintRes := zlint.LintCertificateEx(cert, reg) + err = ProcessResultSet(lintRes) + if err != nil { + return nil, err + } + + return lintCertBytes, nil +} + +// CheckCRL signs the given RevocationList template using the Linter's fake +// issuer cert and private key, then runs the resulting CRL through all CRL +// lints in the registry. It returns an error if any check fails. +func (l Linter) CheckCRL(tbs *x509.RevocationList, reg lint.Registry) error { + crl, err := makeLintCRL(tbs, l.issuer, l.signer) + if err != nil { + return err + } + lintRes := zlint.LintRevocationListEx(crl, reg) + return ProcessResultSet(lintRes) +} + +func makeSigner(realSigner crypto.Signer) (crypto.Signer, error) { + var lintSigner crypto.Signer + var err error + switch k := realSigner.Public().(type) { + case *rsa.PublicKey: + lintSigner, err = rsa.GenerateKey(rand.Reader, k.Size()*8) + if err != nil { + return nil, fmt.Errorf("failed to create RSA lint signer: %w", err) + } + case *ecdsa.PublicKey: + lintSigner, err = ecdsa.GenerateKey(k.Curve, rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to create ECDSA lint signer: %w", err) + } + default: + return nil, fmt.Errorf("unsupported lint signer type: %T", k) + } + return lintSigner, nil +} + +func makeIssuer(realIssuer *x509.Certificate, lintSigner crypto.Signer) (*x509.Certificate, error) { + lintIssuerTBS := &x509.Certificate{ + // This is nearly the full list of attributes that + // x509.CreateCertificate() says it carries over from the template. + // Constructing this TBS certificate in this way ensures that the + // resulting lint issuer is as identical to the real issuer as we can + // get, without sharing a public key. + // + // We do not copy the SignatureAlgorithm field while constructing the + // lintIssuer because the lintIssuer is self-signed. Depending on the + // realIssuer, which could be either an intermediate or cross-signed + // intermediate, the SignatureAlgorithm of that certificate may differ + // from the root certificate that had signed it. + AuthorityKeyId: realIssuer.AuthorityKeyId, + BasicConstraintsValid: realIssuer.BasicConstraintsValid, + CRLDistributionPoints: realIssuer.CRLDistributionPoints, + DNSNames: realIssuer.DNSNames, + EmailAddresses: realIssuer.EmailAddresses, + ExcludedDNSDomains: realIssuer.ExcludedDNSDomains, + ExcludedEmailAddresses: realIssuer.ExcludedEmailAddresses, + ExcludedIPRanges: realIssuer.ExcludedIPRanges, + ExcludedURIDomains: realIssuer.ExcludedURIDomains, + ExtKeyUsage: realIssuer.ExtKeyUsage, + ExtraExtensions: realIssuer.ExtraExtensions, + IPAddresses: realIssuer.IPAddresses, + IsCA: realIssuer.IsCA, + IssuingCertificateURL: realIssuer.IssuingCertificateURL, + KeyUsage: realIssuer.KeyUsage, + MaxPathLen: realIssuer.MaxPathLen, + MaxPathLenZero: realIssuer.MaxPathLenZero, + NotAfter: realIssuer.NotAfter, + NotBefore: realIssuer.NotBefore, + OCSPServer: realIssuer.OCSPServer, + PermittedDNSDomains: realIssuer.PermittedDNSDomains, + PermittedDNSDomainsCritical: realIssuer.PermittedDNSDomainsCritical, + PermittedEmailAddresses: realIssuer.PermittedEmailAddresses, + PermittedIPRanges: realIssuer.PermittedIPRanges, + PermittedURIDomains: realIssuer.PermittedURIDomains, + PolicyIdentifiers: realIssuer.PolicyIdentifiers, + SerialNumber: realIssuer.SerialNumber, + Subject: realIssuer.Subject, + SubjectKeyId: realIssuer.SubjectKeyId, + URIs: realIssuer.URIs, + UnknownExtKeyUsage: realIssuer.UnknownExtKeyUsage, + } + lintIssuerBytes, err := x509.CreateCertificate(rand.Reader, lintIssuerTBS, lintIssuerTBS, lintSigner.Public(), lintSigner) + if err != nil { + return nil, fmt.Errorf("failed to create lint issuer: %w", err) + } + lintIssuer, err := x509.ParseCertificate(lintIssuerBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse lint issuer: %w", err) + } + return lintIssuer, nil +} + +// NewRegistry returns a zlint Registry with irrelevant (ETSI, EV) lints +// excluded. This registry also includes all custom lints defined in Boulder. +func NewRegistry(skipLints []string) (lint.Registry, error) { + reg, err := lint.GlobalRegistry().Filter(lint.FilterOptions{ + ExcludeNames: skipLints, + ExcludeSources: []lint.LintSource{ + // Excluded because Boulder does not issue EV certs. + lint.CABFEVGuidelines, + // Excluded because Boulder does not use the + // ETSI EN 319 412-5 qcStatements extension. + lint.EtsiEsi, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create lint registry: %w", err) + } + return reg, nil +} + +func makeLintCert(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, issuer *x509.Certificate, signer crypto.Signer) ([]byte, *zlintx509.Certificate, error) { + lintCertBytes, err := x509.CreateCertificate(rand.Reader, tbs, issuer, subjectPubKey, signer) + if err != nil { + return nil, nil, fmt.Errorf("failed to create lint certificate: %w", err) + } + lintCert, err := zlintx509.ParseCertificate(lintCertBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse lint certificate: %w", err) + } + // RFC 5280, Sections 4.1.2.6 and 8 + // + // When the subject of the certificate is a CA, the subject + // field MUST be encoded in the same way as it is encoded in the + // issuer field (Section 4.1.2.4) in all certificates issued by + // the subject CA. + if !bytes.Equal(issuer.RawSubject, lintCert.RawIssuer) { + return nil, nil, fmt.Errorf("mismatch between lint issuer RawSubject and lintCert.RawIssuer DER bytes: \"%x\" != \"%x\"", issuer.RawSubject, lintCert.RawIssuer) + } + + return lintCertBytes, lintCert, nil +} + +func ProcessResultSet(lintRes *zlint.ResultSet) error { + if lintRes.NoticesPresent || lintRes.WarningsPresent || lintRes.ErrorsPresent || lintRes.FatalsPresent { + var failedLints []string + for lintName, result := range lintRes.Results { + if result.Status > lint.Pass { + failedLints = append(failedLints, fmt.Sprintf("%s (%s)", lintName, result.Details)) + } + } + return fmt.Errorf("%w: %s", ErrLinting, strings.Join(failedLints, ", ")) + } + return nil +} + +func makeLintCRL(tbs *x509.RevocationList, issuer *x509.Certificate, signer crypto.Signer) (*zlintx509.RevocationList, error) { + lintCRLBytes, err := x509.CreateRevocationList(rand.Reader, tbs, issuer, signer) + if err != nil { + return nil, err + } + lintCRL, err := zlintx509.ParseRevocationList(lintCRLBytes) + if err != nil { + return nil, err + } + return lintCRL, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/linter_test.go b/third-party/github.com/letsencrypt/boulder/linter/linter_test.go new file mode 100644 index 000000000..5b2c06eb9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/linter_test.go @@ -0,0 +1,48 @@ +package linter + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "math/big" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestMakeSigner_RSA(t *testing.T) { + rsaMod, ok := big.NewInt(0).SetString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 16) + test.Assert(t, ok, "failed to set RSA mod") + realSigner := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: rsaMod, + }, + } + lintSigner, err := makeSigner(realSigner) + test.AssertNotError(t, err, "makeSigner failed") + _, ok = lintSigner.(*rsa.PrivateKey) + test.Assert(t, ok, "lint signer is not RSA") +} + +func TestMakeSigner_ECDSA(t *testing.T) { + realSigner := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + } + lintSigner, err := makeSigner(realSigner) + test.AssertNotError(t, err, "makeSigner failed") + _, ok := lintSigner.(*ecdsa.PrivateKey) + test.Assert(t, ok, "lint signer is not ECDSA") +} + +func TestMakeSigner_Unsupported(t *testing.T) { + realSigner := ed25519.NewKeyFromSeed([]byte("0123456789abcdef0123456789abcdef")) + _, err := makeSigner(realSigner) + test.AssertError(t, err, "makeSigner shouldn't have succeeded") +} + +func TestMakeIssuer(t *testing.T) { + +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes.go new file mode 100644 index 000000000..13b63d2b4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes.go @@ -0,0 +1,69 @@ +package cabfbr + +import ( + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlAcceptableReasonCodes struct{} + +/************************************************ +Baseline Requirements: 7.2.2.1: +The CRLReason indicated MUST NOT be unspecified (0). +The CRLReason MUST NOT be certificateHold (6). + +When the CRLReason code is not one of the following, then the reasonCode extension MUST NOT be provided: +- keyCompromise (RFC 5280 CRLReason #1); +- privilegeWithdrawn (RFC 5280 CRLReason #9); +- cessationOfOperation (RFC 5280 CRLReason #5); +- affiliationChanged (RFC 5280 CRLReason #3); or +- superseded (RFC 5280 CRLReason #4). +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_acceptable_reason_codes", + Description: "CRL entry Reason Codes must be 1, 3, 4, 5, or 9", + Citation: "BRs: 7.2.2.1", + Source: lint.CABFBaselineRequirements, + // We use the Mozilla Root Store Policy v2.8.1 effective date here + // because, although this lint enforces requirements from the BRs, those + // same requirements were in the MRSP first. + EffectiveDate: lints.MozillaPolicy281Date, + }, + Lint: NewCrlAcceptableReasonCodes, + }) +} + +func NewCrlAcceptableReasonCodes() lint.RevocationListLintInterface { + return &crlAcceptableReasonCodes{} +} + +func (l *crlAcceptableReasonCodes) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlAcceptableReasonCodes) Execute(c *x509.RevocationList) *lint.LintResult { + for _, rc := range c.RevokedCertificates { + if rc.ReasonCode == nil { + continue + } + switch *rc.ReasonCode { + case 1: // keyCompromise + case 3: // affiliationChanged + case 4: // superseded + case 5: // cessationOfOperation + case 9: // privilegeWithdrawn + continue + default: + return &lint.LintResult{ + Status: lint.Error, + Details: "CRLs MUST NOT include reasonCodes other than 1, 3, 4, 5, and 9", + } + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes_test.go new file mode 100644 index 000000000..1ab8f08ab --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_acceptable_reason_codes_test.go @@ -0,0 +1,87 @@ +package cabfbr + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlAcceptableReasonCodes(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + // crl_good.pem contains a revocation entry with no reason code extension. + name: "good", + want: lint.Pass, + }, + { + name: "reason_0", + want: lint.Error, + wantSubStr: "MUST NOT include reasonCodes other than", + }, + { + name: "reason_1", + want: lint.Pass, + }, + { + name: "reason_2", + want: lint.Error, + wantSubStr: "MUST NOT include reasonCodes other than", + }, + { + name: "reason_3", + want: lint.Pass, + }, + { + name: "reason_4", + want: lint.Pass, + }, + { + name: "reason_5", + want: lint.Pass, + }, + { + name: "reason_6", + want: lint.Error, + wantSubStr: "MUST NOT include reasonCodes other than", + }, + { + name: "reason_8", + want: lint.Error, + wantSubStr: "MUST NOT include reasonCodes other than", + }, + { + name: "reason_9", + want: lint.Pass, + }, + { + name: "reason_10", + want: lint.Error, + wantSubStr: "MUST NOT include reasonCodes other than", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlAcceptableReasonCodes() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes.go new file mode 100644 index 000000000..c1950ab01 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes.go @@ -0,0 +1,51 @@ +package cabfbr + +import ( + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" +) + +type crlCriticalReasonCodes struct{} + +/************************************************ +Baseline Requirements: 7.2.2.1: +If present, [the reasonCode] extension MUST NOT be marked critical. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_no_critical_reason_codes", + Description: "CRL entry reasonCode extension MUST NOT be marked critical", + Citation: "BRs: 7.2.2.1", + Source: lint.CABFBaselineRequirements, + EffectiveDate: util.CABFBRs_1_8_0_Date, + }, + Lint: NewCrlCriticalReasonCodes, + }) +} + +func NewCrlCriticalReasonCodes() lint.RevocationListLintInterface { + return &crlCriticalReasonCodes{} +} + +func (l *crlCriticalReasonCodes) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlCriticalReasonCodes) Execute(c *x509.RevocationList) *lint.LintResult { + reasonCodeOID := asn1.ObjectIdentifier{2, 5, 29, 21} // id-ce-reasonCode + for _, rc := range c.RevokedCertificates { + for _, ext := range rc.Extensions { + if ext.Id.Equal(reasonCodeOID) && ext.Critical { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRL entry reasonCode extension MUST NOT be marked critical", + } + } + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes_test.go new file mode 100644 index 000000000..8dc6d95fa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_no_critical_reason_codes_test.go @@ -0,0 +1,46 @@ +package cabfbr + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlCriticalReasonCodes(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "critical_reason", + want: lint.Error, + wantSubStr: "reasonCode extension MUST NOT be marked critical", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlCriticalReasonCodes() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period.go new file mode 100644 index 000000000..853e8376f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period.go @@ -0,0 +1,141 @@ +package cabfbr + +import ( + "fmt" + "time" + + "github.com/letsencrypt/boulder/linter/lints" + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + "golang.org/x/crypto/cryptobyte" + + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +type crlValidityPeriod struct{} + +/************************************************ +Baseline Requirements, Section 4.9.7: +* For the status of Subscriber Certificates [...] the value of the nextUpdate + field MUST NOT be more than ten days beyond the value of the thisUpdate field. +* For the status of Subordinate CA Certificates [...]. The value of the + nextUpdate field MUST NOT be more than twelve months beyond the value of the + thisUpdatefield. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_validity_period", + Description: "Let's Encrypt CRLs must have an acceptable validity period", + Citation: "BRs: 4.9.7", + Source: lint.CABFBaselineRequirements, + EffectiveDate: util.CABFBRs_1_2_1_Date, + }, + Lint: NewCrlValidityPeriod, + }) +} + +func NewCrlValidityPeriod() lint.RevocationListLintInterface { + return &crlValidityPeriod{} +} + +func (l *crlValidityPeriod) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlValidityPeriod) Execute(c *x509.RevocationList) *lint.LintResult { + /* + Let's Encrypt issues two kinds of CRLs: + + 1) CRLs containing subscriber certificates, created by crl-updater. + These assert the distributionPoint and onlyContainsUserCerts + boolean. + 2) CRLs containing issuer CRLs, created by the ceremony tool. These + assert the onlyContainsCACerts boolean. + + We use the presence of these booleans to determine which BR-mandated + lifetime to enforce. + */ + + // The only way to determine which type of CRL we're dealing with. The + // issuingDistributionPoint must be parsed and the internal fields + // inspected. + idpOID := asn1.ObjectIdentifier{2, 5, 29, 28} // id-ce-issuingDistributionPoint + idpe := lints.GetExtWithOID(c.Extensions, idpOID) + if idpe == nil { + return &lint.LintResult{ + Status: lint.Warn, + Details: "CRL missing IssuingDistributionPoint", + } + } + + // Step inside the outer issuingDistributionPoint sequence to get access to + // its constituent fields. + idpv := cryptobyte.String(idpe.Value) + if !idpv.ReadASN1(&idpv, cryptobyte_asn1.SEQUENCE) { + return &lint.LintResult{ + Status: lint.Warn, + Details: "Failed to read IssuingDistributionPoint distributionPoint", + } + } + + // Throw distributionPoint away. + distributionPointTag := cryptobyte_asn1.Tag(0).ContextSpecific().Constructed() + _ = idpv.SkipOptionalASN1(distributionPointTag) + + // Parse IssuingDistributionPoint OPTIONAL BOOLEANS to eventually perform + // sanity checks. + idp := lints.NewIssuingDistributionPoint() + onlyContainsUserCertsTag := cryptobyte_asn1.Tag(1).ContextSpecific() + if !lints.ReadOptionalASN1BooleanWithTag(&idpv, &idp.OnlyContainsUserCerts, onlyContainsUserCertsTag, false) { + return &lint.LintResult{ + Status: lint.Warn, + Details: "Failed to read IssuingDistributionPoint onlyContainsUserCerts", + } + } + + onlyContainsCACertsTag := cryptobyte_asn1.Tag(2).ContextSpecific() + if !lints.ReadOptionalASN1BooleanWithTag(&idpv, &idp.OnlyContainsCACerts, onlyContainsCACertsTag, false) { + return &lint.LintResult{ + Status: lint.Warn, + Details: "Failed to read IssuingDistributionPoint onlyContainsCACerts", + } + } + + // Basic sanity check so that later on we can determine what type of CRL we + // issued based on the presence of one of these fields. If both fields exist + // then 1) it's a problem and 2) the real validity period is unknown. + if idp.OnlyContainsUserCerts && idp.OnlyContainsCACerts { + return &lint.LintResult{ + Status: lint.Error, + Details: "IssuingDistributionPoint should not have both onlyContainsUserCerts: TRUE and onlyContainsCACerts: TRUE", + } + } + + // Default to subscriber cert CRL. + var BRValidity = 10 * 24 * time.Hour + var validityString = "10 days" + if idp.OnlyContainsCACerts { + BRValidity = 365 * lints.BRDay + validityString = "365 days" + } + + parsedValidity := c.NextUpdate.Sub(c.ThisUpdate) + if parsedValidity <= 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRL has NextUpdate at or before ThisUpdate", + } + } + + if parsedValidity > BRValidity { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("CRL has validity period greater than %s", validityString), + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period_test.go new file mode 100644 index 000000000..39e16ff80 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/lint_crl_validity_period_test.go @@ -0,0 +1,83 @@ +package cabfbr + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlValidityPeriod(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", // CRL for subscriber certs + want: lint.Pass, + }, + { + name: "good_subordinate_ca", + want: lint.Pass, + }, + { + name: "idp_distributionPoint_and_onlyUser_and_onlyCA", // What type of CRL is it (besides horrible)?!!??! + want: lint.Error, + wantSubStr: "IssuingDistributionPoint should not have both onlyContainsUserCerts: TRUE and onlyContainsCACerts: TRUE", + }, + { + name: "negative_validity", + want: lint.Warn, + wantSubStr: "CRL missing IssuingDistributionPoint", + }, + { + name: "negative_validity_subscriber_cert", + want: lint.Error, + wantSubStr: "at or before", + }, + { + name: "negative_validity_subordinate_ca", + want: lint.Error, + wantSubStr: "at or before", + }, + { + name: "long_validity_subscriber_cert", // 10 days + 1 second + want: lint.Error, + wantSubStr: "CRL has validity period greater than 10 days", + }, + { + name: "long_validity_subordinate_ca", // 1 year + 1 second + want: lint.Error, + wantSubStr: "CRL has validity period greater than 365 days", + }, + { + // Technically this CRL is incorrect because Let's Encrypt does not + // (yet) issue CRLs containing both the distributionPoint and + // optional onlyContainsCACerts boolean, but we're still parsing the + // correct BR validity in this lint. + name: "long_validity_distributionPoint_and_subordinate_ca", + want: lint.Pass, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlValidityPeriod() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_critical_reason.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_critical_reason.pem new file mode 100644 index 000000000..91f0732e0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_critical_reason.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBVjCB3gIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjAsMCoCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMA8wDQYDVR0VAQH/BAMKAQGgNjA0MB8GA1UdIwQYMBaAFAHa +u3rLJSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQD +AwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD +6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDq +KD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good.pem new file mode 100644 index 000000000..8b383d0a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good_subordinate_ca.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good_subordinate_ca.pem new file mode 100644 index 000000000..a476c16fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_good_subordinate_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTAPBgNVHRwBAf8EBTAD +ggH/MAoGCCqGSM49BAMDA2cAMGQCMC8OQhSdNhq8nqHzrTowPIWHa7D9wX45Wczi +wTydR0bLRdiDSEZ9tHgxj6RHFFBrIgIwV5A+lykivTOBek/qVRdTStwtK9q25p5B +JWvbicaNns/LS9z3jDSfuJ1nzCN7n78z +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem new file mode 100644 index 000000000..2513e3c7f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmzCCASICAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoH0wezAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwRQYDVR0cAQH/BDsw +OaAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/4IB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw +/cF+OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rc +LSvatuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity.pem new file mode 100644 index 000000000..cb745bfa7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE2MTY0MzM5WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_distributionPoint_and_subordinate_ca.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_distributionPoint_and_subordinate_ca.pem new file mode 100644 index 000000000..50b194c9c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_distributionPoint_and_subordinate_ca.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIIB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subordinate_ca.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subordinate_ca.pem new file mode 100644 index 000000000..b42108632 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subordinate_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjMwNzE2MTY0MzM5WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAPBgNVHRwBAf8EBTAD +ggH/MAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzYbdPAi7AEEr53OIulDND4 +vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wnxjuxc+jdK7iEyJxbbegQ +0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subscriber_cert.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subscriber_cert.pem new file mode 100644 index 000000000..0a0b36112 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_long_validity_subscriber_cert.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMDcwNjE2NDMzOFoXDTIyMDcxNjE2NDMzOVowKTAnAggDrlHbURVaPBcN +MjIwNzA2MTU0MzM4WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFv9LJt+yGA8wQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziL +pQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMic +W23oENHmLNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity.pem new file mode 100644 index 000000000..fc16812d6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzA2MTY0MzM3WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subordinate_ca.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subordinate_ca.pem new file mode 100644 index 000000000..e13ef6bfb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subordinate_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzA2MTY0MzM3WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAPBgNVHRwBAf8EBTAD +ggH/MAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzYbdPAi7AEEr53OIulDND4 +vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wnxjuxc+jdK7iEyJxbbegQ +0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subscriber_cert.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subscriber_cert.pem new file mode 100644 index 000000000..d41cedf29 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_negative_validity_subscriber_cert.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMDcwNjE2NDMzOFoXDTIyMDcwNjE2NDMzN1owKTAnAggDrlHbURVaPBcN +MjIwNzA2MTU0MzM4WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFv9LJt+yGA8wQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziL +pQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMic +W23oENHmLNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_0.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_0.pem new file mode 100644 index 000000000..308fd94d9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_0.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQCgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_1.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_1.pem new file mode 100644 index 000000000..0331fa9a8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_1.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_10.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_10.pem new file mode 100644 index 000000000..86c791916 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_10.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQqgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_2.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_2.pem new file mode 100644 index 000000000..bbeaaee00 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_2.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQKgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_3.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_3.pem new file mode 100644 index 000000000..66d2fae7d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_3.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQOgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_4.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_4.pem new file mode 100644 index 000000000..62e2d1456 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_4.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQSgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_5.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_5.pem new file mode 100644 index 000000000..879783e1b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_5.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQWgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_6.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_6.pem new file mode 100644 index 000000000..cc91f53f3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_6.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQagNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_8.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_8.pem new file mode 100644 index 000000000..4d1ff3e8d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_8.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQigNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_9.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_9.pem new file mode 100644 index 000000000..ae24a3d5f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cabf_br/testdata/crl_reason_9.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQmgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNn +ADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQ +sHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E +4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/chrome/e_scts_from_same_operator.go b/third-party/github.com/letsencrypt/boulder/linter/lints/chrome/e_scts_from_same_operator.go new file mode 100644 index 000000000..eb50e43c8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/chrome/e_scts_from_same_operator.go @@ -0,0 +1,88 @@ +package chrome + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/ct" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/ctpolicy/loglist" + "github.com/letsencrypt/boulder/linter/lints" +) + +type sctsFromSameOperator struct { + logList loglist.List +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_scts_from_same_operator", + Description: "Let's Encrypt Subscriber Certificates have two SCTs from logs run by different operators", + Citation: "Chrome CT Policy", + Source: lints.ChromeCTPolicy, + EffectiveDate: time.Date(2022, time.April, 15, 0, 0, 0, 0, time.UTC), + }, + Lint: NewSCTsFromSameOperator, + }) +} + +func NewSCTsFromSameOperator() lint.CertificateLintInterface { + return &sctsFromSameOperator{logList: loglist.GetLintList()} +} + +func (l *sctsFromSameOperator) CheckApplies(c *x509.Certificate) bool { + return util.IsSubscriberCert(c) && !util.IsExtInCert(c, util.CtPoisonOID) +} + +func (l *sctsFromSameOperator) Execute(c *x509.Certificate) *lint.LintResult { + if len(l.logList) == 0 { + return &lint.LintResult{ + Status: lint.NE, + Details: "Failed to load log list, unable to check Certificate SCTs.", + } + } + + if len(c.SignedCertificateTimestampList) < 2 { + return &lint.LintResult{ + Status: lint.Error, + Details: "Certificate had too few embedded SCTs; browser policy requires 2.", + } + } + + logIDs := make(map[ct.SHA256Hash]struct{}) + for _, sct := range c.SignedCertificateTimestampList { + logIDs[sct.LogID] = struct{}{} + } + + if len(logIDs) < 2 { + return &lint.LintResult{ + Status: lint.Error, + Details: "Certificate SCTs from too few distinct logs; browser policy requires 2.", + } + } + + operatorNames := make(map[string]struct{}) + for logID := range logIDs { + operator, err := l.logList.OperatorForLogID(logID.Base64String()) + if err != nil { + // This certificate *may* have more than 2 SCTs, so missing one now isn't + // a problem. + continue + } + operatorNames[operator] = struct{}{} + } + + if len(operatorNames) < 2 { + return &lint.LintResult{ + Status: lint.Error, + Details: "Certificate SCTs from too few distinct log operators; browser policy requires 2.", + } + } + + return &lint.LintResult{ + Status: lint.Pass, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/common.go b/third-party/github.com/letsencrypt/boulder/linter/lints/common.go new file mode 100644 index 000000000..4efe48286 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/common.go @@ -0,0 +1,134 @@ +package lints + +import ( + "bytes" + "net/url" + "time" + + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509/pkix" + "github.com/zmap/zlint/v3/lint" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +const ( + // CABF Baseline Requirements 6.3.2 Certificate operational periods: + // For the purpose of calculations, a day is measured as 86,400 seconds. + // Any amount of time greater than this, including fractional seconds and/or + // leap seconds, shall represent an additional day. + BRDay time.Duration = 86400 * time.Second + + // Declare our own Sources for use in zlint registry filtering. + LetsEncryptCPS lint.LintSource = "LECPS" + ChromeCTPolicy lint.LintSource = "ChromeCT" +) + +var ( + CPSV33Date = time.Date(2021, time.June, 8, 0, 0, 0, 0, time.UTC) + MozillaPolicy281Date = time.Date(2023, time.February, 15, 0, 0, 0, 0, time.UTC) +) + +// IssuingDistributionPoint stores the IA5STRING value(s) of the optional +// distributionPoint, and the (implied OPTIONAL) BOOLEAN values of +// onlyContainsUserCerts and onlyContainsCACerts. +// +// RFC 5280 +// * Section 5.2.5 +// IssuingDistributionPoint ::= SEQUENCE { +// distributionPoint [0] DistributionPointName OPTIONAL, +// onlyContainsUserCerts [1] BOOLEAN DEFAULT FALSE, +// onlyContainsCACerts [2] BOOLEAN DEFAULT FALSE, +// ... +// } +// +// * Section 4.2.1.13 +// DistributionPointName ::= CHOICE { +// fullName [0] GeneralNames, +// ... } +// +// * Appendix A.1, Page 128 +// GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName +// GeneralName ::= CHOICE { +// ... +// uniformResourceIdentifier [6] IA5String, +// ... } +// +// Because this struct is used by cryptobyte (not by encoding/asn1), and because +// we only care about the uniformResourceIdentifier flavor of GeneralName, we +// are able to flatten the DistributionPointName down into a slice of URIs. +type IssuingDistributionPoint struct { + DistributionPointURIs []*url.URL + OnlyContainsUserCerts bool + OnlyContainsCACerts bool +} + +// NewIssuingDistributionPoint is a constructor which returns an +// IssuingDistributionPoint with each field set to zero values. +func NewIssuingDistributionPoint() *IssuingDistributionPoint { + return &IssuingDistributionPoint{} +} + +// GetExtWithOID is a helper for several of our custom lints. It returns the +// extension with the given OID if it exists, or nil otherwise. +func GetExtWithOID(exts []pkix.Extension, oid asn1.ObjectIdentifier) *pkix.Extension { + for _, ext := range exts { + if ext.Id.Equal(oid) { + return &ext + } + } + return nil +} + +// ReadOptionalASN1BooleanWithTag attempts to read and advance incoming to +// search for an optional DER-encoded ASN.1 element tagged with the given tag. +// Unless out is nil, it stores whether an element with the tag was found in +// out, otherwise out will take the default value. It reports whether all reads +// were successful. +func ReadOptionalASN1BooleanWithTag(incoming *cryptobyte.String, out *bool, tag cryptobyte_asn1.Tag, defaultValue bool) bool { + // ReadOptionalASN1 performs a peek and will not advance if the tag is + // missing, meaning that incoming will retain bytes. + var valuePresent bool + var valueBytes cryptobyte.String + if !incoming.ReadOptionalASN1(&valueBytes, &valuePresent, tag) { + return false + } + val := defaultValue + if valuePresent { + /* + X.690 (07/2002) + https://www.itu.int/rec/T-REC-X.690-200207-S/en + + Section 8.2.2: + If the boolean value is: + FALSE + the octet shall be zero. + If the boolean value is + TRUE + the octet shall have any non-zero value, as a sender's option. + + Section 11.1 Boolean values: + If the encoding represents the boolean value TRUE, its single contents octet shall have all eight + bits set to one. (Contrast with 8.2.2.) + + Succinctly, BER encoding states any nonzero value is TRUE. The DER + encoding restricts the value 0xFF as TRUE and any other: 0x01, + 0x23, 0xFE, etc as invalid encoding. + */ + boolBytes := []byte(valueBytes) + if bytes.Equal(boolBytes, []byte{0xFF}) { + val = true + } else if bytes.Equal(boolBytes, []byte{0x00}) { + val = false + } else { + // Unrecognized DER encoding of boolean! + return false + } + } + if out != nil { + *out = val + } + + // All reads were successful. + return true +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/common_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/common_test.go new file mode 100644 index 000000000..a09e3ff69 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/common_test.go @@ -0,0 +1,100 @@ +package lints + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" +) + +var onlyContainsUserCertsTag = asn1.Tag(1).ContextSpecific() +var onlyContainsCACertsTag = asn1.Tag(2).ContextSpecific() + +func TestReadOptionalASN1BooleanWithTag(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + // incoming will be mutated by the function under test + incoming []byte + out bool + defaultValue bool + asn1Tag asn1.Tag + expectedOk bool + // expectedTrailer counts the remaining bytes from incoming after having + // been advanced by the function under test + expectedTrailer int + expectedOut bool + }{ + { + name: "Good: onlyContainsUserCerts", + incoming: cryptobyte.String([]byte{0x81, 0x01, 0xFF}), + asn1Tag: onlyContainsUserCertsTag, + expectedOk: true, + expectedTrailer: 0, + expectedOut: true, + }, + { + name: "Good: onlyContainsCACerts", + incoming: cryptobyte.String([]byte{0x82, 0x01, 0xFF}), + asn1Tag: onlyContainsCACertsTag, + expectedOk: true, + expectedTrailer: 0, + expectedOut: true, + }, + { + name: "Good: Bytes are read and trailer remains", + incoming: cryptobyte.String([]byte{0x82, 0x01, 0xFF, 0xC0, 0xFF, 0xEE, 0xCA, 0xFE}), + asn1Tag: onlyContainsCACertsTag, + expectedOk: true, + expectedTrailer: 5, + expectedOut: true, + }, + { + name: "Bad: Read the tag, but out should be false, no trailer", + incoming: cryptobyte.String([]byte{0x82, 0x01, 0x00}), + asn1Tag: onlyContainsCACertsTag, + expectedOk: true, + expectedTrailer: 0, + expectedOut: false, + }, + { + name: "Bad: Read the tag, but out should be false, trailer remains", + incoming: cryptobyte.String([]byte{0x82, 0x01, 0x00, 0x99}), + asn1Tag: onlyContainsCACertsTag, + expectedOk: true, + expectedTrailer: 1, + expectedOut: false, + }, + { + name: "Bad: Wrong asn1Tag compared to incoming bytes, no bytes read", + incoming: cryptobyte.String([]byte{0x81, 0x01, 0xFF}), + asn1Tag: onlyContainsCACertsTag, + expectedOk: true, + expectedTrailer: 3, + expectedOut: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // ReadOptionalASN1BooleanWithTag accepts nil as a valid outParam to + // maintain the style of upstream x/crypto/cryptobyte, but we + // currently don't pass nil. Instead we use a reference to a + // pre-existing boolean here and in the lint code. Passing in nil + // will _do the wrong thing (TM)_ in our CRL lints. + var outParam bool + ok := ReadOptionalASN1BooleanWithTag((*cryptobyte.String)(&tc.incoming), &outParam, tc.asn1Tag, false) + t.Log("Check if reading the tag was successful:") + test.AssertEquals(t, ok, tc.expectedOk) + t.Log("Check value of the optional boolean:") + test.AssertEquals(t, outParam, tc.expectedOut) + t.Log("Bytes should be popped off of incoming as they're successfully read:") + test.AssertEquals(t, len(tc.incoming), tc.expectedTrailer) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp.go new file mode 100644 index 000000000..7cf3fa221 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp.go @@ -0,0 +1,203 @@ +package cpcps + +import ( + "net/url" + + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlHasIDP struct{} + +/************************************************ +Various root programs (and the BRs, after Ballot SC-063 passes) require that +sharded/partitioned CRLs have a specifically-encoded Issuing Distribution Point +extension. Since there's no way to tell from the CRL itself whether or not it +is sharded, we apply this lint universally to all CRLs, but as part of the Let's +Encrypt-specific suite of lints. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_idp", + Description: "Let's Encrypt CRLs must have the Issuing Distribution Point extension with appropriate contents", + Citation: "", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewCrlHasIDP, + }) +} + +func NewCrlHasIDP() lint.RevocationListLintInterface { + return &crlHasIDP{} +} + +func (l *crlHasIDP) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasIDP) Execute(c *x509.RevocationList) *lint.LintResult { + /* + Let's Encrypt issues CRLs for two distinct purposes: + 1) CRLs containing subscriber certificates created by the + crl-updater. These CRLs must have only the distributionPoint and + onlyContainsUserCerts fields set. + 2) CRLs containing subordinate CA certificates created by the + ceremony tool. These CRLs must only have the onlyContainsCACerts + field set. + */ + + idpOID := asn1.ObjectIdentifier{2, 5, 29, 28} // id-ce-issuingDistributionPoint + idpe := lints.GetExtWithOID(c.Extensions, idpOID) + if idpe == nil { + return &lint.LintResult{ + Status: lint.Warn, + Details: "CRL missing IssuingDistributionPoint", + } + } + if !idpe.Critical { + return &lint.LintResult{ + Status: lint.Error, + Details: "IssuingDistributionPoint MUST be critical", + } + } + + // Step inside the outer issuingDistributionPoint sequence to get access to + // its constituent fields: distributionPoint [0], + // onlyContainsUserCerts [1], and onlyContainsCACerts [2]. + idpv := cryptobyte.String(idpe.Value) + if !idpv.ReadASN1(&idpv, cryptobyte_asn1.SEQUENCE) { + return &lint.LintResult{ + Status: lint.Warn, + Details: "Failed to read issuingDistributionPoint", + } + } + + var dpName cryptobyte.String + var distributionPointExists bool + distributionPointTag := cryptobyte_asn1.Tag(0).ContextSpecific().Constructed() + if !idpv.ReadOptionalASN1(&dpName, &distributionPointExists, distributionPointTag) { + return &lint.LintResult{ + Status: lint.Warn, + Details: "Failed to read IssuingDistributionPoint distributionPoint", + } + } + + idp := lints.NewIssuingDistributionPoint() + if distributionPointExists { + lintErr := parseDistributionPointName(&dpName, idp) + if lintErr != nil { + return lintErr + } + } + + onlyContainsUserCertsTag := cryptobyte_asn1.Tag(1).ContextSpecific() + if !lints.ReadOptionalASN1BooleanWithTag(&idpv, &idp.OnlyContainsUserCerts, onlyContainsUserCertsTag, false) { + return &lint.LintResult{ + Status: lint.Error, + Details: "Failed to read IssuingDistributionPoint onlyContainsUserCerts", + } + } + + onlyContainsCACertsTag := cryptobyte_asn1.Tag(2).ContextSpecific() + if !lints.ReadOptionalASN1BooleanWithTag(&idpv, &idp.OnlyContainsCACerts, onlyContainsCACertsTag, false) { + return &lint.LintResult{ + Status: lint.Error, + Details: "Failed to read IssuingDistributionPoint onlyContainsCACerts", + } + } + + if !idpv.Empty() { + return &lint.LintResult{ + Status: lint.Error, + Details: "Unexpected IssuingDistributionPoint fields were found", + } + } + + if idp.OnlyContainsUserCerts && idp.OnlyContainsCACerts { + return &lint.LintResult{ + Status: lint.Error, + Details: "IssuingDistributionPoint should not have both onlyContainsUserCerts: TRUE and onlyContainsCACerts: TRUE", + } + } else if idp.OnlyContainsUserCerts { + if len(idp.DistributionPointURIs) == 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "User certificate CRLs MUST have at least one DistributionPointName FullName", + } + } + } else if idp.OnlyContainsCACerts { + if len(idp.DistributionPointURIs) != 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "CA certificate CRLs SHOULD NOT have a DistributionPointName FullName", + } + } + } else { + return &lint.LintResult{ + Status: lint.Error, + Details: "Neither onlyContainsUserCerts nor onlyContainsCACerts was set", + } + } + + return &lint.LintResult{Status: lint.Pass} +} + +// parseDistributionPointName examines the provided distributionPointName +// and updates idp with the URI if it is found. The distribution point name is +// checked for validity and returns a non-nil LintResult if there were any +// problems. +func parseDistributionPointName(distributionPointName *cryptobyte.String, idp *lints.IssuingDistributionPoint) *lint.LintResult { + fullNameTag := cryptobyte_asn1.Tag(0).ContextSpecific().Constructed() + if !distributionPointName.ReadASN1(distributionPointName, fullNameTag) { + return &lint.LintResult{ + Status: lint.Error, + Details: "Failed to read IssuingDistributionPoint distributionPoint fullName", + } + } + + for !distributionPointName.Empty() { + var uriBytes []byte + uriTag := cryptobyte_asn1.Tag(6).ContextSpecific() + if !distributionPointName.ReadASN1Bytes(&uriBytes, uriTag) { + return &lint.LintResult{ + Status: lint.Error, + Details: "Failed to read IssuingDistributionPoint URI", + } + } + uri, err := url.Parse(string(uriBytes)) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: "Failed to parse IssuingDistributionPoint URI", + } + } + if uri.Scheme != "http" { + return &lint.LintResult{ + Status: lint.Error, + Details: "IssuingDistributionPoint URI MUST use http scheme", + } + } + idp.DistributionPointURIs = append(idp.DistributionPointURIs, uri) + } + if len(idp.DistributionPointURIs) == 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "IssuingDistributionPoint FullName URI MUST be present", + } + } else if len(idp.DistributionPointURIs) > 1 { + return &lint.LintResult{ + Status: lint.Notice, + Details: "IssuingDistributionPoint unexpectedly has more than one FullName", + } + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp_test.go new file mode 100644 index 000000000..ff93b7090 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_idp_test.go @@ -0,0 +1,95 @@ +package cpcps + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + linttest "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasIDP(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", // CRL for subscriber certs + want: lint.Pass, + }, + { + name: "good_subordinate_ca", + want: lint.Pass, + }, + { + name: "no_idp", + want: lint.Warn, + wantSubStr: "CRL missing IssuingDistributionPoint", + }, + { + name: "idp_no_dpn", + want: lint.Error, + wantSubStr: "User certificate CRLs MUST have at least one DistributionPointName FullName", + }, + { + name: "idp_no_fullname", + want: lint.Error, + wantSubStr: "Failed to read IssuingDistributionPoint distributionPoint fullName", + }, + { + name: "idp_no_uris", + want: lint.Error, + wantSubStr: "IssuingDistributionPoint FullName URI MUST be present", + }, + { + name: "idp_two_uris", + want: lint.Notice, + wantSubStr: "IssuingDistributionPoint unexpectedly has more than one FullName", + }, + { + name: "idp_https", + want: lint.Error, + wantSubStr: "IssuingDistributionPoint URI MUST use http scheme", + }, + { + name: "idp_no_usercerts", + want: lint.Error, + wantSubStr: "Neither onlyContainsUserCerts nor onlyContainsCACerts was set", + }, + { + name: "idp_some_reasons", // Subscriber cert + want: lint.Error, + wantSubStr: "Unexpected IssuingDistributionPoint fields were found", + }, + { + name: "idp_distributionPoint_and_onlyCA", + want: lint.Error, + wantSubStr: "CA certificate CRLs SHOULD NOT have a DistributionPointName FullName", + }, + { + name: "idp_distributionPoint_and_onlyUser_and_onlyCA", + want: lint.Error, + wantSubStr: "IssuingDistributionPoint should not have both onlyContainsUserCerts: TRUE and onlyContainsCACerts: TRUE", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasIDP() + c := linttest.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia.go new file mode 100644 index 000000000..43f08976d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia.go @@ -0,0 +1,51 @@ +package cpcps + +import ( + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlHasNoAIA struct{} + +/************************************************ +RFC 5280: 5.2.7 + +The requirements around the Authority Information Access extension are extensive. +Therefore we do not include one. +Conforming CRL issuers MUST include the nextUpdate field in all CRLs. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_no_aia", + Description: "Let's Encrypt does not include the CRL AIA extension", + Citation: "", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewCrlHasNoAIA, + }) +} + +func NewCrlHasNoAIA() lint.RevocationListLintInterface { + return &crlHasNoAIA{} +} + +func (l *crlHasNoAIA) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasNoAIA) Execute(c *x509.RevocationList) *lint.LintResult { + aiaOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 1} // id-pe-authorityInfoAccess + if lints.GetExtWithOID(c.Extensions, aiaOID) != nil { + return &lint.LintResult{ + Status: lint.Notice, + Details: "CRL has an Authority Information Access url", + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia_test.go new file mode 100644 index 000000000..679bfe7ba --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_aia_test.go @@ -0,0 +1,46 @@ +package cpcps + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasNoAIA(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "aia", + want: lint.Notice, + wantSubStr: "Authority Information Access", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasNoAIA() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers.go new file mode 100644 index 000000000..61bed1fbb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers.go @@ -0,0 +1,54 @@ +package cpcps + +import ( + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlHasNoCertIssuers struct{} + +/************************************************ +RFC 5280: 5.3.3 + +Section 5.3.3 defines the Certificate Issuer entry extension. The presence of +this extension means that the CRL is an "indirect CRL", including certificates +which were issued by a different issuer than the one issuing the CRL itself. +We do not issue indirect CRLs, so our CRL entries should not have this extension. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_no_cert_issuers", + Description: "Let's Encrypt does not issue indirect CRLs", + Citation: "", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewCrlHasNoCertIssuers, + }) +} + +func NewCrlHasNoCertIssuers() lint.RevocationListLintInterface { + return &crlHasNoCertIssuers{} +} + +func (l *crlHasNoCertIssuers) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasNoCertIssuers) Execute(c *x509.RevocationList) *lint.LintResult { + certIssuerOID := asn1.ObjectIdentifier{2, 5, 29, 29} // id-ce-certificateIssuer + for _, entry := range c.RevokedCertificates { + if lints.GetExtWithOID(entry.Extensions, certIssuerOID) != nil { + return &lint.LintResult{ + Status: lint.Notice, + Details: "CRL has an entry with a Certificate Issuer extension", + } + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers_test.go new file mode 100644 index 000000000..c2710ad58 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_has_no_cert_issuers_test.go @@ -0,0 +1,45 @@ +package cpcps + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasNoCertIssuers(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "cert_issuer", + want: lint.Notice, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasNoCertIssuers() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta.go new file mode 100644 index 000000000..eaa588c44 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta.go @@ -0,0 +1,65 @@ +package cpcps + +import ( + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlIsNotDelta struct{} + +/************************************************ +RFC 5280: 5.2.4 + +Section 5.2.4 defines a Delta CRL, and all the requirements that come with it. +These requirements are complex and do not serve our purpose, so we ensure that +we never issue a CRL which could be construed as a Delta CRL. + +RFC 5280: 5.2.6 + +Similarly, Section 5.2.6 defines the Freshest CRL extension, which is only +applicable in the case that the CRL is a Delta CRL. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_is_not_delta", + Description: "Let's Encrypt does not issue delta CRLs", + Citation: "", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewCrlIsNotDelta, + }) +} + +func NewCrlIsNotDelta() lint.RevocationListLintInterface { + return &crlIsNotDelta{} +} + +func (l *crlIsNotDelta) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlIsNotDelta) Execute(c *x509.RevocationList) *lint.LintResult { + deltaCRLIndicatorOID := asn1.ObjectIdentifier{2, 5, 29, 27} // id-ce-deltaCRLIndicator + if lints.GetExtWithOID(c.Extensions, deltaCRLIndicatorOID) != nil { + return &lint.LintResult{ + Status: lint.Notice, + Details: "CRL is a Delta CRL", + } + } + + freshestCRLOID := asn1.ObjectIdentifier{2, 5, 29, 46} // id-ce-freshestCRL + if lints.GetExtWithOID(c.Extensions, freshestCRLOID) != nil { + return &lint.LintResult{ + Status: lint.Notice, + Details: "CRL has a Freshest CRL url", + } + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta_test.go new file mode 100644 index 000000000..23137d9d6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_crl_is_not_delta_test.go @@ -0,0 +1,51 @@ +package cpcps + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlIsNotDelta(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "delta", + want: lint.Notice, + wantSubStr: "Delta", + }, + { + name: "freshest", + want: lint.Notice, + wantSubStr: "Freshest", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlIsNotDelta() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go new file mode 100644 index 000000000..a963cf195 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go @@ -0,0 +1,49 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type rootCACertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_root_ca_cert_validity_period_greater_than_25_years", + Description: "Let's Encrypt Root CA Certificates have Validity Periods of up to 25 years", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewRootCACertValidityTooLong, + }) +} + +func NewRootCACertValidityTooLong() lint.CertificateLintInterface { + return &rootCACertValidityTooLong{} +} + +func (l *rootCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsRootCA(c) +} + +func (l *rootCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "Root CA Certificate Validity Period: Up to 25 years." + maxValidity := 25 * 365 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go new file mode 100644 index 000000000..fdf5906c9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go @@ -0,0 +1,49 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type subordinateCACertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_validity_period_greater_than_8_years", + Description: "Let's Encrypt Intermediate CA Certificates have Validity Periods of up to 8 years", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewSubordinateCACertValidityTooLong, + }) +} + +func NewSubordinateCACertValidityTooLong() lint.CertificateLintInterface { + return &subordinateCACertValidityTooLong{} +} + +func (l *subordinateCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsSubCA(c) +} + +func (l *subordinateCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "Intermediate CA Certificate Validity Period: Up to 8 years." + maxValidity := 8 * 365 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go new file mode 100644 index 000000000..e91e187c4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go @@ -0,0 +1,49 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type subscriberCertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_subscriber_cert_validity_period_greater_than_100_days", + Description: "Let's Encrypt Subscriber Certificates have Validity Periods of up to 100 days", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewSubscriberCertValidityTooLong, + }) +} + +func NewSubscriberCertValidityTooLong() lint.CertificateLintInterface { + return &subscriberCertValidityTooLong{} +} + +func (l *subscriberCertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsServerAuthCert(c) && !c.IsCA +} + +func (l *subscriberCertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "DV SSL End Entity Certificate Validity Period: Up to 100 days." + maxValidity := 100 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_validity_period_has_extra_second.go b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_validity_period_has_extra_second.go new file mode 100644 index 000000000..e8ea34831 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/lint_validity_period_has_extra_second.go @@ -0,0 +1,45 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type certValidityNotRound struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "w_validity_period_has_extra_second", + Description: "Let's Encrypt Certificates have Validity Periods that are a round number of seconds", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + }, + Lint: NewCertValidityNotRound, + }) +} + +func NewCertValidityNotRound() lint.CertificateLintInterface { + return &certValidityNotRound{} +} + +func (l *certValidityNotRound) CheckApplies(c *x509.Certificate) bool { + return true +} + +func (l *certValidityNotRound) Execute(c *x509.Certificate) *lint.LintResult { + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity%60 == 0 { + return &lint.LintResult{Status: lint.Pass} + } + + return &lint.LintResult{Status: lint.Error} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_aia.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_aia.pem new file mode 100644 index 000000000..406305d85 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_aia.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBgDCCAQcCAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMDcwNjE2NDMzOFoXDTIyMDcxNTE2NDMzOFowKTAnAggDrlHbURVaPBcN +MjIwNzA2MTU0MzM4WjAMMAoGA1UdFQQDCgEBoGIwYDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFv9LJt+yGA8wKgYIKwYBBQUHAQEE +HjAcMBoGCCsGAQUFBzABgg5lMS5vLmxlbmNyLm9yZzAKBggqhkjOPQQDAwNnADBk +AjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqk +qEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E4c5Z +HFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_cert_issuer.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_cert_issuer.pem new file mode 100644 index 000000000..3ff128cfa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_cert_issuer.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBczCB+wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjBJMEcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMCwwCgYDVR0VBAMKAQEwHgYDVR0dBBcwFYITaW50LWUxLmJv +dWxkZXIudGVzdKA2MDQwHwYDVR0jBBgwFoAUAdq7esslII5eedb5lkIvAkEpB74w +EQYDVR0UBAoCCBb/SybfshgPMAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzY +bdPAi7AEEr53OIulDND4vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wn +xjuxc+jdK7iEyJxbbegQ0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_delta.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_delta.pem new file mode 100644 index 000000000..3019facec --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_delta.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZjCB7gIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgSTBHMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzARBgNVHRsECgIIFv9L +Jt+yGA4wCgYIKoZIzj0EAwMDZwAwZAIwVrITRYutGjFpfNht08CLsAQSvnc4i6UM +0Pi8+U3T8DRHImIiuB9cQ+qxULB6pKhBAjBbuGCwTop7vCfGO7Fz6N0ruITInFtt +6BDR5izWUMfXXa7mXhSQ6ig9hOHOWRxR00I= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_freshest.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_freshest.pem new file mode 100644 index 000000000..196871fa1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_freshest.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBdjCB/gIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgWTBXMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggW/0sm37IYDzAhBgNVHS4EGjAYMBaA +FIASMBCCDmUxLmMubGVuY3Iub3JnMAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRox +aXzYbdPAi7AEEr53OIulDND4vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6K +e7wnxjuxc+jdK7iEyJxbbegQ0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good.pem new file mode 100644 index 000000000..8b383d0a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good_subordinate_ca.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good_subordinate_ca.pem new file mode 100644 index 000000000..a476c16fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_good_subordinate_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTAPBgNVHRwBAf8EBTAD +ggH/MAoGCCqGSM49BAMDA2cAMGQCMC8OQhSdNhq8nqHzrTowPIWHa7D9wX45Wczi +wTydR0bLRdiDSEZ9tHgxj6RHFFBrIgIwV5A+lykivTOBek/qVRdTStwtK9q25p5B +JWvbicaNns/LS9z3jDSfuJ1nzCN7n78z +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyCA.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyCA.pem new file mode 100644 index 000000000..50b194c9c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyCA.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIIB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem new file mode 100644 index 000000000..2513e3c7f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_distributionPoint_and_onlyUser_and_onlyCA.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmzCCASICAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoH0wezAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwRQYDVR0cAQH/BDsw +OaAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/4IB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw +/cF+OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rc +LSvatuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_https.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_https.pem new file mode 100644 index 000000000..3a5bdfa3a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_https.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmTCCASACAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHsweTAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQwYDVR0cAQH/BDkw +N6AyoDCGLmh0dHBzOi8vYy5ib3VsZGVyLnRlc3QvNjYyODM3NTY5MTM1ODgyODgv +MC5jcmyBAf8wCgYIKoZIzj0EAwMDZwAwZAIwLw5CFJ02GryeofOtOjA8hYdrsP3B +fjlZzOLBPJ1HRstF2INIRn20eDGPpEcUUGsiAjBXkD6XKSK9M4F6T+pVF1NK3C0r +2rbmnkEla9uJxo2ez8tL3PeMNJ+4nWfMI3ufvzM= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_dpn.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_dpn.pem new file mode 100644 index 000000000..ddfcb136b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_dpn.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTAPBgNVHRwBAf8EBTAD +gQH/MAoGCCqGSM49BAMDA2cAMGQCMC8OQhSdNhq8nqHzrTowPIWHa7D9wX45Wczi +wTydR0bLRdiDSEZ9tHgxj6RHFFBrIgIwV5A+lykivTOBek/qVRdTStwtK9q25p5B +JWvbicaNns/LS9z3jDSfuJ1nzCN7n78z +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_fullname.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_fullname.pem new file mode 100644 index 000000000..036dbbca0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_fullname.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBZjCB7gIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgSTBHMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTARBgNVHRwBAf8EBzAF +oACBAf8wCgYIKoZIzj0EAwMDZwAwZAIwLw5CFJ02GryeofOtOjA8hYdrsP3BfjlZ +zOLBPJ1HRstF2INIRn20eDGPpEcUUGsiAjBXkD6XKSK9M4F6T+pVF1NK3C0r2rbm +nkEla9uJxo2ez8tL3PeMNJ+4nWfMI3ufvzM= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_uris.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_uris.pem new file mode 100644 index 000000000..117d36bda --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_uris.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBaDCB8AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgSzBJMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTATBgNVHRwBAf8ECTAH +oAKgAIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_usercerts.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_usercerts.pem new file mode 100644 index 000000000..ff95bd9f7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_no_usercerts.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBlTCCARwCAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHcwdTAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwPwYDVR0cAQH/BDUw +M6AxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybDAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+OVnM +4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSvatuae +QSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_some_reasons.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_some_reasons.pem new file mode 100644 index 000000000..e8eb97133 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_some_reasons.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBnDCCASMCAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoH4wfDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwRgYDVR0cAQH/BDww +OqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/6MCBkAwCgYIKoZIzj0EAwMDZwAwZAIwLw5CFJ02GryeofOtOjA8hYdr +sP3BfjlZzOLBPJ1HRstF2INIRn20eDGPpEcUUGsiAjBXkD6XKSK9M4F6T+pVF1NK +3C0r2rbmnkEla9uJxo2ez8tL3PeMNJ+4nWfMI3ufvzM= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_two_uris.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_two_uris.pem new file mode 100644 index 000000000..4294a2526 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_idp_two_uris.pem @@ -0,0 +1,12 @@ +-----BEGIN X509 CRL----- +MIIByTCCAVACAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoIGqMIGnMB8GA1UdIwQYMBaAFAHa +u3rLJSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTBxBgNVHRwBAf8E +ZzBloGCgXoYtaHR0cDovL2MuYm91bGRlci50ZXN0LzY2MjgzNzU2OTEzNTg4Mjg4 +LzAuY3Jshi1odHRwOi8vYy5ib3VsZGVyLnRlc3QvNjYyODM3NTY5MTM1ODgyODgv +MS5jcmyBAf8wCgYIKoZIzj0EAwMDZwAwZAIwLw5CFJ02GryeofOtOjA8hYdrsP3B +fjlZzOLBPJ1HRstF2INIRn20eDGPpEcUUGsiAjBXkD6XKSK9M4F6T+pVF1NK3C0r +2rbmnkEla9uJxo2ez8tL3PeMNJ+4nWfMI3ufvzM= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_no_idp.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_no_idp.pem new file mode 100644 index 000000000..18470cca0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/cpcps/testdata/crl_no_idp.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBUzCB2wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y +MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgNjA0MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTAKBggqhkjOPQQDAwNn +ADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+OVnM4sE8nUdGy0XYg0hGfbR4MY+k +RxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSvatuaeQSVr24nGjZ7Py0vc94w0n7id +Z8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_cert_via_pkilint.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_cert_via_pkilint.go new file mode 100644 index 000000000..6a0dbd3d5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_cert_via_pkilint.go @@ -0,0 +1,156 @@ +package rfc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" +) + +type certViaPKILint struct { + PKILintAddr string `toml:"pkilint_addr" comment:"The address where a pkilint REST API can be reached."` + PKILintTimeout time.Duration `toml:"pkilint_timeout" comment:"How long, in nanoseconds, to wait before giving up."` + IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_pkilint_lint_cabf_serverauth_cert", + Description: "Runs pkilint's suite of cabf serverauth certificate lints", + Citation: "https://github.com/digicert/pkilint", + Source: lint.Community, + EffectiveDate: util.CABEffectiveDate, + }, + Lint: NewCertValidityNotRound, + }) +} + +func NewCertValidityNotRound() lint.CertificateLintInterface { + return &certViaPKILint{} +} + +func (l *certViaPKILint) Configure() interface{} { + return l +} + +func (l *certViaPKILint) CheckApplies(c *x509.Certificate) bool { + // This lint applies to all certificates issued by Boulder, as long as it has + // been configured with an address to reach out to. If not, skip it. + return l.PKILintAddr != "" +} + +type PKILintResponse struct { + Results []struct { + Validator string `json:"validator"` + NodePath string `json:"node_path"` + FindingDescriptions []struct { + Severity string `json:"severity"` + Code string `json:"code"` + Message string `json:"message,omitempty"` + } `json:"finding_descriptions"` + } `json:"results"` + Linter struct { + Name string `json:"name"` + } `json:"linter"` +} + +func (l *certViaPKILint) Execute(c *x509.Certificate) *lint.LintResult { + timeout := l.PKILintTimeout + if timeout == 0 { + timeout = 100 * time.Millisecond + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + reqJSON, err := json.Marshal(struct { + B64 string `json:"b64"` + }{ + B64: base64.StdEncoding.EncodeToString(c.Raw), + }) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("marshalling pkilint request: %s", err), + } + } + + url := fmt.Sprintf("%s/certificate/cabf-serverauth", l.PKILintAddr) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqJSON)) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("creating pkilint request: %s", err), + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("making POST request to pkilint API: %s", err), + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("got status %d (%s) from pkilint API", resp.StatusCode, resp.Status), + } + } + + res, err := io.ReadAll(resp.Body) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("reading response from pkilint API: %s", err), + } + } + + var jsonResult PKILintResponse + err = json.Unmarshal(res, &jsonResult) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("parsing response from pkilint API: %s", err), + } + } + + var findings []string + for _, validator := range jsonResult.Results { + for _, finding := range validator.FindingDescriptions { + id := fmt.Sprintf("%s:%s", validator.Validator, finding.Code) + if slices.Contains(l.IgnoreLints, id) { + continue + } + desc := fmt.Sprintf("%s from %s at %s", finding.Severity, id, validator.NodePath) + if finding.Message != "" { + desc = fmt.Sprintf("%s: %s", desc, finding.Message) + } + findings = append(findings, desc) + } + } + + if len(findings) != 0 { + // Group the findings by severity, for human readers. + slices.Sort(findings) + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("got %d lint findings from pkilint API: %s", len(findings), strings.Join(findings, "; ")), + } + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki.go new file mode 100644 index 000000000..58e7b5c00 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki.go @@ -0,0 +1,62 @@ +package rfc + +import ( + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +type crlHasAKI struct{} + +/************************************************ +RFC 5280: 5.2.1 +Conforming CRL issuers MUST use the key identifier method, and MUST include this +extension in all CRLs issued. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_aki", + Description: "Conforming", + Citation: "RFC 5280: 5.2.1", + Source: lint.RFC5280, + EffectiveDate: util.RFC5280Date, + }, + Lint: NewCrlHasAKI, + }) +} + +func NewCrlHasAKI() lint.RevocationListLintInterface { + return &crlHasAKI{} +} + +func (l *crlHasAKI) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasAKI) Execute(c *x509.RevocationList) *lint.LintResult { + if len(c.AuthorityKeyId) == 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRLs MUST include the authority key identifier extension", + } + } + aki := cryptobyte.String(c.AuthorityKeyId) + var akiBody cryptobyte.String + if !aki.ReadASN1(&akiBody, cryptobyte_asn1.SEQUENCE) { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRL has a malformed authority key identifier extension", + } + } + if !akiBody.PeekASN1Tag(cryptobyte_asn1.Tag(0).ContextSpecific()) { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRLs MUST use the key identifier method in the authority key identifier extension", + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki_test.go new file mode 100644 index 000000000..776727df4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_aki_test.go @@ -0,0 +1,51 @@ +package rfc + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasAKI(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "no_aki", + want: lint.Error, + wantSubStr: "MUST include the authority key identifier", + }, + { + name: "aki_name_and_serial", + want: lint.Error, + wantSubStr: "MUST use the key identifier method", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasAKI() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name.go new file mode 100644 index 000000000..192d0ebd8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name.go @@ -0,0 +1,50 @@ +package rfc + +import ( + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" +) + +type crlHasIssuerName struct{} + +/************************************************ +RFC 5280: 5.1.2.3 +The issuer field MUST contain a non-empty X.500 distinguished name (DN). + +This lint does not enforce that the issuer field complies with the rest of +the encoding rules of a certificate issuer name, because it (perhaps wrongly) +assumes that those were checked when the issuer was itself issued, and on all +certificates issued by this CRL issuer. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_issuer_name", + Description: "The CRL Issuer field MUST contain a non-empty X.500 distinguished name", + Citation: "RFC 5280: 5.1.2.3", + Source: lint.RFC5280, + EffectiveDate: util.RFC5280Date, + }, + Lint: NewCrlHasIssuerName, + }) +} + +func NewCrlHasIssuerName() lint.RevocationListLintInterface { + return &crlHasIssuerName{} +} + +func (l *crlHasIssuerName) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasIssuerName) Execute(c *x509.RevocationList) *lint.LintResult { + if len(c.Issuer.Names) == 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "The CRL Issuer field MUST contain a non-empty X.500 distinguished name", + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name_test.go new file mode 100644 index 000000000..ef6dcf38d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_issuer_name_test.go @@ -0,0 +1,46 @@ +package rfc + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasIssuerName(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "no_issuer_name", + want: lint.Error, + wantSubStr: "MUST contain a non-empty X.500 distinguished name", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasIssuerName() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number.go new file mode 100644 index 000000000..3120abd11 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number.go @@ -0,0 +1,67 @@ +package rfc + +import ( + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crlHasNumber struct{} + +/************************************************ +RFC 5280: 5.2.3 +CRL issuers conforming to this profile MUST include this extension in all CRLs +and MUST mark this extension as non-critical. Conforming CRL issuers MUST NOT +use CRLNumber values longer than 20 octets. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_number", + Description: "CRLs must have a well-formed CRL Number extension", + Citation: "RFC 5280: 5.2.3", + Source: lint.RFC5280, + EffectiveDate: util.RFC5280Date, + }, + Lint: NewCrlHasNumber, + }) +} + +func NewCrlHasNumber() lint.RevocationListLintInterface { + return &crlHasNumber{} +} + +func (l *crlHasNumber) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasNumber) Execute(c *x509.RevocationList) *lint.LintResult { + if c.Number == nil { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRLs MUST include the CRL number extension", + } + } + + crlNumberOID := asn1.ObjectIdentifier{2, 5, 29, 20} // id-ce-cRLNumber + ext := lints.GetExtWithOID(c.Extensions, crlNumberOID) + if ext != nil && ext.Critical { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRL Number MUST NOT be marked critical", + } + } + + numBytes := c.Number.Bytes() + if len(numBytes) > 20 || (len(numBytes) == 20 && numBytes[0]&0x80 != 0) { + return &lint.LintResult{ + Status: lint.Error, + Details: "CRL Number MUST NOT be longer than 20 octets", + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number_test.go new file mode 100644 index 000000000..a9225aeac --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_number_test.go @@ -0,0 +1,56 @@ +package rfc + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasNumber(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "no_number", + want: lint.Error, + wantSubStr: "MUST include the CRL number", + }, + { + name: "critical_number", + want: lint.Error, + wantSubStr: "MUST NOT be marked critical", + }, + { + name: "long_number", + want: lint.Error, + wantSubStr: "MUST NOT be longer than 20 octets", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasNumber() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps.go new file mode 100644 index 000000000..0546d62c5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps.go @@ -0,0 +1,230 @@ +package rfc + +import ( + "errors" + "fmt" + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +const ( + utcTimeFormat = "YYMMDDHHMMSSZ" + generalizedTimeFormat = "YYYYMMDDHHMMSSZ" +) + +type crlHasValidTimestamps struct{} + +/************************************************ +RFC 5280: 5.1.2.4 +CRL issuers conforming to this profile MUST encode thisUpdate as UTCTime for +dates through the year 2049. CRL issuers conforming to this profile MUST encode +thisUpdate as GeneralizedTime for dates in the year 2050 or later. Conforming +applications MUST be able to process dates that are encoded in either UTCTime or +GeneralizedTime. + +Where encoded as UTCTime, thisUpdate MUST be specified and interpreted as +defined in Section 4.1.2.5.1. Where encoded as GeneralizedTime, thisUpdate MUST +be specified and interpreted as defined in Section 4.1.2.5.2. + +RFC 5280: 5.1.2.5 +CRL issuers conforming to this profile MUST encode nextUpdate as UTCTime for +dates through the year 2049. CRL issuers conforming to this profile MUST encode +nextUpdate as GeneralizedTime for dates in the year 2050 or later. Conforming +applications MUST be able to process dates that are encoded in either UTCTime or +GeneralizedTime. + +Where encoded as UTCTime, nextUpdate MUST be specified and interpreted as +defined in Section 4.1.2.5.1. Where encoded as GeneralizedTime, nextUpdate MUST +be specified and interpreted as defined in Section 4.1.2.5.2. + +RFC 5280: 5.1.2.6 +The time for revocationDate MUST be expressed as described in Section 5.1.2.4. + +RFC 5280: 4.1.2.5.1 +UTCTime values MUST be expressed in Greenwich Mean Time (Zulu) and MUST include +seconds (i.e., times are YYMMDDHHMMSSZ), even where the number of seconds is +zero. + +RFC 5280: 4.1.2.5.2 +GeneralizedTime values MUST be expressed in Greenwich Mean Time (Zulu) and MUST +include seconds (i.e., times are YYYYMMDDHHMMSSZ), even where the number of +seconds is zero. GeneralizedTime values MUST NOT include fractional seconds. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_has_valid_timestamps", + Description: "CRL thisUpdate, nextUpdate, and revocationDates must be properly encoded", + Citation: "RFC 5280: 5.1.2.4, 5.1.2.5, and 5.1.2.6", + Source: lint.RFC5280, + EffectiveDate: util.RFC5280Date, + }, + Lint: NewCrlHasValidTimestamps, + }) +} + +func NewCrlHasValidTimestamps() lint.RevocationListLintInterface { + return &crlHasValidTimestamps{} +} + +func (l *crlHasValidTimestamps) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlHasValidTimestamps) Execute(c *x509.RevocationList) *lint.LintResult { + input := cryptobyte.String(c.RawTBSRevocationList) + lintFail := lint.LintResult{ + Status: lint.Error, + Details: "Failed to re-parse tbsCertList during linting", + } + + // Read tbsCertList. + var tbs cryptobyte.String + if !input.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + // Skip (optional) version. + if !tbs.SkipOptionalASN1(cryptobyte_asn1.INTEGER) { + return &lintFail + } + + // Skip signature. + if !tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + // Skip issuer. + if !tbs.SkipASN1(cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + // Read thisUpdate. + var thisUpdate cryptobyte.String + var thisUpdateTag cryptobyte_asn1.Tag + if !tbs.ReadAnyASN1Element(&thisUpdate, &thisUpdateTag) { + return &lintFail + } + + // Lint thisUpdate. + err := lintTimestamp(&thisUpdate, thisUpdateTag) + if err != nil { + return &lint.LintResult{Status: lint.Error, Details: err.Error()} + } + + // Peek (optional) nextUpdate. + if tbs.PeekASN1Tag(cryptobyte_asn1.UTCTime) || tbs.PeekASN1Tag(cryptobyte_asn1.GeneralizedTime) { + // Read nextUpdate. + var nextUpdate cryptobyte.String + var nextUpdateTag cryptobyte_asn1.Tag + if !tbs.ReadAnyASN1Element(&nextUpdate, &nextUpdateTag) { + return &lintFail + } + + // Lint nextUpdate. + err = lintTimestamp(&nextUpdate, nextUpdateTag) + if err != nil { + return &lint.LintResult{Status: lint.Error, Details: err.Error()} + } + } + + // Peek (optional) revokedCertificates. + if tbs.PeekASN1Tag(cryptobyte_asn1.SEQUENCE) { + // Read sequence of revokedCertificate. + var revokedSeq cryptobyte.String + if !tbs.ReadASN1(&revokedSeq, cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + // Iterate over each revokedCertificate sequence. + for !revokedSeq.Empty() { + // Read revokedCertificate. + var certSeq cryptobyte.String + if !revokedSeq.ReadASN1Element(&certSeq, cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + if !certSeq.ReadASN1(&certSeq, cryptobyte_asn1.SEQUENCE) { + return &lintFail + } + + // Skip userCertificate (serial number). + if !certSeq.SkipASN1(cryptobyte_asn1.INTEGER) { + return &lintFail + } + + // Read revocationDate. + var revocationDate cryptobyte.String + var revocationDateTag cryptobyte_asn1.Tag + if !certSeq.ReadAnyASN1Element(&revocationDate, &revocationDateTag) { + return &lintFail + } + + // Lint revocationDate. + err = lintTimestamp(&revocationDate, revocationDateTag) + if err != nil { + return &lint.LintResult{Status: lint.Error, Details: err.Error()} + } + } + } + return &lint.LintResult{Status: lint.Pass} +} + +func lintTimestamp(der *cryptobyte.String, tag cryptobyte_asn1.Tag) error { + // Preserve the original timestamp for length checking. + derBytes := *der + var tsBytes cryptobyte.String + if !derBytes.ReadASN1(&tsBytes, tag) { + return errors.New("failed to read timestamp") + } + tsLen := len(string(tsBytes)) + + var parsedTime time.Time + switch tag { + case cryptobyte_asn1.UTCTime: + // Verify that the timestamp is properly formatted. + if tsLen != len(utcTimeFormat) { + return fmt.Errorf("timestamps encoded using UTCTime MUST be specified in the format %q", utcTimeFormat) + } + + if !der.ReadASN1UTCTime(&parsedTime) { + return errors.New("failed to read timestamp encoded using UTCTime") + } + + // Verify that the timestamp is prior to the year 2050. This should + // really never happen. + if parsedTime.Year() > 2049 { + return errors.New("ReadASN1UTCTime returned a UTCTime after 2049") + } + case cryptobyte_asn1.GeneralizedTime: + // Verify that the timestamp is properly formatted. + if tsLen != len(generalizedTimeFormat) { + return fmt.Errorf( + "timestamps encoded using GeneralizedTime MUST be specified in the format %q", generalizedTimeFormat, + ) + } + + if !der.ReadASN1GeneralizedTime(&parsedTime) { + return fmt.Errorf("failed to read timestamp encoded using GeneralizedTime") + } + + // Verify that the timestamp occurred after the year 2049. + if parsedTime.Year() < 2050 { + return errors.New("timestamps prior to 2050 MUST be encoded using UTCTime") + } + default: + return errors.New("unsupported time format") + } + + // Verify that the location is UTC. + if parsedTime.Location() != time.UTC { + return errors.New("time must be in UTC") + } + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps_test.go new file mode 100644 index 000000000..137ab89fa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_has_valid_timestamps_test.go @@ -0,0 +1,64 @@ +package rfc + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlHasValidTimestamps(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "good_utctime_1950", + want: lint.Pass, + }, + { + name: "good_gentime_2050", + want: lint.Pass, + }, + { + name: "gentime_2049", + want: lint.Error, + wantSubStr: "timestamps prior to 2050 MUST be encoded using UTCTime", + }, + { + name: "utctime_no_seconds", + want: lint.Error, + wantSubStr: "timestamps encoded using UTCTime MUST be specified in the format \"YYMMDDHHMMSSZ\"", + }, + { + name: "gentime_revoked_2049", + want: lint.Error, + wantSubStr: "timestamps prior to 2050 MUST be encoded using UTCTime", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlHasValidTimestamps() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list.go new file mode 100644 index 000000000..053da88b8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list.go @@ -0,0 +1,46 @@ +package rfc + +import ( + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" +) + +type crlNoEmptyRevokedCertsList struct{} + +/************************************************ +RFC 5280: 5.1.2.6 +When there are no revoked certificates, the revoked certificates list MUST be +absent. +************************************************/ + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_crl_no_empty_revoked_certificates_list", + Description: "When there are no revoked certificates, the revoked certificates list MUST be absent.", + Citation: "RFC 5280: 5.1.2.6", + Source: lint.RFC5280, + EffectiveDate: util.RFC5280Date, + }, + Lint: NewCrlNoEmptyRevokedCertsList, + }) +} + +func NewCrlNoEmptyRevokedCertsList() lint.RevocationListLintInterface { + return &crlNoEmptyRevokedCertsList{} +} + +func (l *crlNoEmptyRevokedCertsList) CheckApplies(c *x509.RevocationList) bool { + return true +} + +func (l *crlNoEmptyRevokedCertsList) Execute(c *x509.RevocationList) *lint.LintResult { + if c.RevokedCertificates != nil && len(c.RevokedCertificates) == 0 { + return &lint.LintResult{ + Status: lint.Error, + Details: "If the revokedCertificates list is empty, it must not be present", + } + } + return &lint.LintResult{Status: lint.Pass} +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list_test.go b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list_test.go new file mode 100644 index 000000000..d0361a812 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/lint_crl_no_empty_revoked_certificates_list_test.go @@ -0,0 +1,50 @@ +package rfc + +import ( + "fmt" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/test" +) + +func TestCrlNoEmptyRevokedCertsList(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "none_revoked", + want: lint.Pass, + }, + { + name: "empty_revoked", + want: lint.Error, + wantSubStr: "must not be present", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := NewCrlNoEmptyRevokedCertsList() + c := test.LoadPEMCRL(t, fmt.Sprintf("testdata/crl_%s.pem", tc.name)) + r := l.Execute(c) + + if r.Status != tc.want { + t.Errorf("expected %q, got %q", tc.want, r.Status) + } + if !strings.Contains(r.Details, tc.wantSubStr) { + t.Errorf("expected %q, got %q", tc.wantSubStr, r.Details) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_aki_name_and_serial.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_aki_name_and_serial.pem new file mode 100644 index 000000000..f223479e2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_aki_name_and_serial.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBazCB8wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgTjBMMDcGA1UdIwQwMC6BFzAVghNp +bnQtZTEuYm91bGRlci50ZXN0ghMCEQChCjEx4ZnD1S6gsNFjWXmlMBEGA1UdFAQK +AggW/0sm37IYDzAKBggqhkjOPQQDAwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+ +dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4 +hMicW23oENHmLNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_critical_number.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_critical_number.pem new file mode 100644 index 000000000..1fdccc98d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_critical_number.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBVjCB3gIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgOTA3MB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MBQGA1UdFAEB/wQKAggW/0sm37IYDzAKBggqhkjOPQQD +AwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD +6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDq +KD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_empty_revoked.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_empty_revoked.pem new file mode 100644 index 000000000..874518ce7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_empty_revoked.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBKjCBsgIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjAAoDYwNDAfBgNVHSMEGDAW +gBQB2rt6yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFv9LJt+yGA8wCgYIKoZI +zj0EAwMDZwAwZAIwVrITRYutGjFpfNht08CLsAQSvnc4i6UM0Pi8+U3T8DRHImIi +uB9cQ+qxULB6pKhBAjBbuGCwTop7vCfGO7Fz6N0ruITInFtt6BDR5izWUMfXXa7m +XhSQ6ig9hOHOWRxR00I= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_2049.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_2049.pem new file mode 100644 index 000000000..9f4140463 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_2049.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBRzCBzwIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRgPMjA0OTA3MDYxNjQzMzhaFw0yMjA3MTUxNjQzMzhaMBswGQIIA65R21EVWjwX +DTIyMDcwNjE1NDMzOFqgNjA0MB8GA1UdIwQYMBaAFAHau3rLJSCOXnnW+ZZCLwJB +KQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNnADBkAjBWshNFi60a +MWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBO +inu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_revoked_2049.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_revoked_2049.pem new file mode 100644 index 000000000..1d411184d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_gentime_revoked_2049.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBSzCB0wIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRgPMjA1MDA3MDYxNjQzMzhaGA8yMDUwMDcxNTE2NDMzOFowHTAbAggDrlHbURVa +PBgPMjA0OTA3MDYxNTQzMzhaoDYwNDAfBgNVHSMEGDAWgBQB2rt6yyUgjl551vmW +Qi8CQSkHvjARBgNVHRQECgIIFv9LJt+yGA8wCgYIKoZIzj0EAwMDZwAwZAIwVrIT +RYutGjFpfNht08CLsAQSvnc4i6UM0Pi8+U3T8DRHImIiuB9cQ+qxULB6pKhBAjBb +uGCwTop7vCfGO7Fz6N0ruITInFtt6BDR5izWUMfXXa7mXhSQ6ig9hOHOWRxR00I= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good.pem new file mode 100644 index 000000000..8b383d0a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_gentime_2050.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_gentime_2050.pem new file mode 100644 index 000000000..b837453a6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_gentime_2050.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBRzCBzwIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRgPMjA1MDA3MDYxNjQzMzhaFw0yMjA3MTUxNjQzMzhaMBswGQIIA65R21EVWjwX +DTIyMDcwNjE1NDMzOFqgNjA0MB8GA1UdIwQYMBaAFAHau3rLJSCOXnnW+ZZCLwJB +KQe+MBEGA1UdFAQKAggW/0sm37IYDzAKBggqhkjOPQQDAwNnADBkAjBWshNFi60a +MWl82G3TwIuwBBK+dziLpQzQ+Lz5TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBO +inu8J8Y7sXPo3Su4hMicW23oENHmLNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_utctime_1950.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_utctime_1950.pem new file mode 100644 index 000000000..a70089443 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_good_utctime_1950.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBRTCBzQIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNNTAwNzA2MTY0MzM4WhcNNTAwNzE1MTY0MzM4WjAbMBkCCAOuUdtRFVo8Fw01 +MDA3MDYxNTQzMzhaoDYwNDAfBgNVHSMEGDAWgBQB2rt6yyUgjl551vmWQi8CQSkH +vjARBgNVHRQECgIIFv9LJt+yGA8wCgYIKoZIzj0EAwMDZwAwZAIwVrITRYutGjFp +fNht08CLsAQSvnc4i6UM0Pi8+U3T8DRHImIiuB9cQ+qxULB6pKhBAjBbuGCwTop7 +vCfGO7Fz6N0ruITInFtt6BDR5izWUMfXXa7mXhSQ6ig9hOHOWRxR00I= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_long_number.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_long_number.pem new file mode 100644 index 000000000..e8b855dbe --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_long_number.pem @@ -0,0 +1,10 @@ +-----BEGIN X509 CRL----- +MIIBYDCB6AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgQzBBMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MB4GA1UdFAQXAhUW/0sm37IYDxb/SybfshgPFv9LJt8w +CgYIKoZIzj0EAwMDZwAwZAIwVrITRYutGjFpfNht08CLsAQSvnc4i6UM0Pi8+U3T +8DRHImIiuB9cQ+qxULB6pKhBAjBbuGCwTop7vCfGO7Fz6N0ruITInFtt6BDR5izW +UMfXXa7mXhSQ6ig9hOHOWRxR00I= +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_aki.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_aki.pem new file mode 100644 index 000000000..a1fdf6e43 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_aki.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBMjCBugIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgFTATMBEGA1UdFAQKAggW/0sm37IY +DzAKBggqhkjOPQQDAwNnADBkAjBWshNFi60aMWl82G3TwIuwBBK+dziLpQzQ+Lz5 +TdPwNEciYiK4H1xD6rFQsHqkqEECMFu4YLBOinu8J8Y7sXPo3Su4hMicW23oENHm +LNZQx9ddruZeFJDqKD2E4c5ZHFHTQg== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_issuer_name.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_issuer_name.pem new file mode 100644 index 000000000..c45c428c0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_issuer_name.pem @@ -0,0 +1,8 @@ +-----BEGIN X509 CRL----- +MIIBCjCBkgIBATAKBggqhkjOPQQDAzAAFw0yMjA3MDYxNjQzMzhaFw0yMjA3MTUx +NjQzMzhaMCkwJwIIA65R21EVWjwXDTIyMDcwNjE1NDMzOFowDDAKBgNVHRUEAwoB +AaA2MDQwHwYDVR0jBBgwFoAUAdq7esslII5eedb5lkIvAkEpB74wEQYDVR0UBAoC +CBb/SybfshgPMAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzYbdPAi7AEEr53 +OIulDND4vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wnxjuxc+jdK7iE +yJxbbegQ0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_number.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_number.pem new file mode 100644 index 000000000..65578de8b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_no_number.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBQDCByAIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjApMCcCCAOuUdtRFVo8Fw0y +MjA3MDYxNTQzMzhaMAwwCgYDVR0VBAMKAQGgIzAhMB8GA1UdIwQYMBaAFAHau3rL +JSCOXnnW+ZZCLwJBKQe+MAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzYbdPA +i7AEEr53OIulDND4vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wnxjux +c+jdK7iEyJxbbegQ0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_none_revoked.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_none_revoked.pem new file mode 100644 index 000000000..b73885ddb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_none_revoked.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBKDCBsAIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WqA2MDQwHwYDVR0jBBgwFoAU +Adq7esslII5eedb5lkIvAkEpB74wEQYDVR0UBAoCCBb/SybfshgPMAoGCCqGSM49 +BAMDA2cAMGQCMFayE0WLrRoxaXzYbdPAi7AEEr53OIulDND4vPlN0/A0RyJiIrgf +XEPqsVCweqSoQQIwW7hgsE6Ke7wnxjuxc+jdK7iEyJxbbegQ0eYs1lDH112u5l4U +kOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_utctime_no_seconds.pem b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_utctime_no_seconds.pem new file mode 100644 index 000000000..af07a12bd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/rfc/testdata/crl_utctime_no_seconds.pem @@ -0,0 +1,9 @@ +-----BEGIN X509 CRL----- +MIIBQzCBywIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM +Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF +MRcNMjIwNzA2MTY0MzM4WhcLMjIwNzE1MTY0M1owGzAZAggDrlHbURVaPBcNMjIw +NzA2MTU0MzM4WqA2MDQwHwYDVR0jBBgwFoAUAdq7esslII5eedb5lkIvAkEpB74w +EQYDVR0UBAoCCBb/SybfshgPMAoGCCqGSM49BAMDA2cAMGQCMFayE0WLrRoxaXzY +bdPAi7AEEr53OIulDND4vPlN0/A0RyJiIrgfXEPqsVCweqSoQQIwW7hgsE6Ke7wn +xjuxc+jdK7iEyJxbbegQ0eYs1lDH112u5l4UkOooPYThzlkcUdNC +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/test/README.md b/third-party/github.com/letsencrypt/boulder/linter/lints/test/README.md new file mode 100644 index 000000000..07b0e0e31 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/test/README.md @@ -0,0 +1,35 @@ +# Test Lint CRLs + +The contents of this directory are a variety of PEM-encoded CRLs uses to test +the CRL linting functions in the parent directory. + +To create a new test CRL to exercise a new lint: + +1. Install the `der2text` and `text2der` tools: + + ```sh + $ go install github.com/syncsynchalt/der2text/cmds/text2der@latest + $ go install github.com/syncsynchalt/der2text/cmds/der2text@latest + ``` + +2. Use `der2text` to create an editable version of CRL you want to start with, usually `crl_good.pem`: + + ```sh + $ der2text crl_good.pem > my_new_crl.txt + ``` + +3. Edit the text file. See [the der2text readme](https://github.com/syncsynchalt/der2text) for details about the file format. + +4. Write the new PEM file and run the tests to see if it works! Repeat steps 3 and 4 as necessary until you get the correct result. + + ```sh + $ text2der my_new_crl.txt >| my_new_crl.pem + $ go test .. + ``` + +5. Remove the text file and commit your new CRL. + + ```sh + $ rm my_new_crl.txt + $ git add . + ``` diff --git a/third-party/github.com/letsencrypt/boulder/linter/lints/test/helpers.go b/third-party/github.com/letsencrypt/boulder/linter/lints/test/helpers.go new file mode 100644 index 000000000..55badf8be --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/linter/lints/test/helpers.go @@ -0,0 +1,23 @@ +package test + +import ( + "encoding/pem" + "os" + "testing" + + "github.com/zmap/zcrypto/x509" + + "github.com/letsencrypt/boulder/test" +) + +func LoadPEMCRL(t *testing.T, filename string) *x509.RevocationList { + t.Helper() + file, err := os.ReadFile(filename) + test.AssertNotError(t, err, "reading CRL file") + block, rest := pem.Decode(file) + test.AssertEquals(t, block.Type, "X509 CRL") + test.AssertEquals(t, len(rest), 0) + crl, err := x509.ParseRevocationList(block.Bytes) + test.AssertNotError(t, err, "parsing CRL bytes") + return crl +} diff --git a/third-party/github.com/letsencrypt/boulder/log/log.go b/third-party/github.com/letsencrypt/boulder/log/log.go new file mode 100644 index 000000000..f6172cc22 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/log.go @@ -0,0 +1,360 @@ +package log + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "io" + "log/syslog" + "os" + "strings" + "sync" + + "github.com/jmhodges/clock" + "golang.org/x/term" + + "github.com/letsencrypt/boulder/core" +) + +// A Logger logs messages with explicit priority levels. It is +// implemented by a logging back-end as provided by New() or +// NewMock(). Any additions to this interface with format strings should be +// added to the govet configuration in .golangci.yml +type Logger interface { + Err(msg string) + Errf(format string, a ...interface{}) + Warning(msg string) + Warningf(format string, a ...interface{}) + Info(msg string) + Infof(format string, a ...interface{}) + InfoObject(string, interface{}) + Debug(msg string) + Debugf(format string, a ...interface{}) + AuditInfo(msg string) + AuditInfof(format string, a ...interface{}) + AuditObject(string, interface{}) + AuditErr(string) + AuditErrf(format string, a ...interface{}) +} + +// impl implements Logger. +type impl struct { + w writer +} + +// singleton defines the object of a Singleton pattern +type singleton struct { + once sync.Once + log Logger +} + +// _Singleton is the single impl entity in memory +var _Singleton singleton + +// The constant used to identify audit-specific messages +const auditTag = "[AUDIT]" + +// New returns a new Logger that uses the given syslog.Writer as a backend +// and also writes to stdout/stderr. It is safe for concurrent use. +func New(log *syslog.Writer, stdoutLogLevel int, syslogLogLevel int) (Logger, error) { + if log == nil { + return nil, errors.New("Attempted to use a nil System Logger") + } + return &impl{ + &bothWriter{ + sync.Mutex{}, + log, + newStdoutWriter(stdoutLogLevel), + syslogLogLevel, + }, + }, nil +} + +// StdoutLogger returns a Logger that writes solely to stdout and stderr. +// It is safe for concurrent use. +func StdoutLogger(level int) Logger { + return &impl{newStdoutWriter(level)} +} + +func newStdoutWriter(level int) *stdoutWriter { + prefix, clkFormat := getPrefix() + return &stdoutWriter{ + prefix: prefix, + level: level, + clkFormat: clkFormat, + clk: clock.New(), + stdout: os.Stdout, + stderr: os.Stderr, + isatty: term.IsTerminal(int(os.Stdout.Fd())), + } +} + +// initialize is used in unit tests and called by `Get` before the logger +// is fully set up. +func initialize() { + const defaultPriority = syslog.LOG_INFO | syslog.LOG_LOCAL0 + syslogger, err := syslog.Dial("", "", defaultPriority, "test") + if err != nil { + panic(err) + } + logger, err := New(syslogger, int(syslog.LOG_DEBUG), int(syslog.LOG_DEBUG)) + if err != nil { + panic(err) + } + + _ = Set(logger) +} + +// Set configures the singleton Logger. This method +// must only be called once, and before calling Get the +// first time. +func Set(logger Logger) (err error) { + if _Singleton.log != nil { + err = errors.New("You may not call Set after it has already been implicitly or explicitly set") + _Singleton.log.Warning(err.Error()) + } else { + _Singleton.log = logger + } + return +} + +// Get obtains the singleton Logger. If Set has not been called first, this +// method initializes with basic defaults. The basic defaults cannot error, and +// subsequent access to an already-set Logger also cannot error, so this method is +// error-safe. +func Get() Logger { + _Singleton.once.Do(func() { + if _Singleton.log == nil { + initialize() + } + }) + + return _Singleton.log +} + +type writer interface { + logAtLevel(syslog.Priority, string, ...interface{}) +} + +// bothWriter implements writer and writes to both syslog and stdout. +type bothWriter struct { + sync.Mutex + *syslog.Writer + *stdoutWriter + syslogLevel int +} + +// stdoutWriter implements writer and writes just to stdout. +type stdoutWriter struct { + // prefix is a set of information that is the same for every log line, + // imitating what syslog emits for us when we use the syslog writer. + prefix string + level int + clkFormat string + clk clock.Clock + stdout io.Writer + stderr io.Writer + isatty bool +} + +func LogLineChecksum(line string) string { + crc := crc32.ChecksumIEEE([]byte(line)) + // Using the hash.Hash32 doesn't make this any easier + // as it also returns a uint32 rather than []byte + buf := make([]byte, binary.MaxVarintLen32) + binary.PutUvarint(buf, uint64(crc)) + return base64.RawURLEncoding.EncodeToString(buf) +} + +func checkSummed(msg string) string { + return fmt.Sprintf("%s %s", LogLineChecksum(msg), msg) +} + +// logAtLevel logs the provided message at the appropriate level, writing to +// both stdout and the Logger +func (w *bothWriter) logAtLevel(level syslog.Priority, msg string, a ...interface{}) { + var err error + + // Apply conditional formatting for f functions + if a != nil { + msg = fmt.Sprintf(msg, a...) + } + + // Since messages are delimited by newlines, we have to escape any internal or + // trailing newlines before generating the checksum or outputting the message. + msg = strings.Replace(msg, "\n", "\\n", -1) + + w.Lock() + defer w.Unlock() + + switch syslogAllowed := int(level) <= w.syslogLevel; level { + case syslog.LOG_ERR: + if syslogAllowed { + err = w.Err(checkSummed(msg)) + } + case syslog.LOG_WARNING: + if syslogAllowed { + err = w.Warning(checkSummed(msg)) + } + case syslog.LOG_INFO: + if syslogAllowed { + err = w.Info(checkSummed(msg)) + } + case syslog.LOG_DEBUG: + if syslogAllowed { + err = w.Debug(checkSummed(msg)) + } + default: + err = w.Err(fmt.Sprintf("%s (unknown logging level: %d)", checkSummed(msg), int(level))) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to write to syslog: %d %s (%s)\n", int(level), checkSummed(msg), err) + } + + w.stdoutWriter.logAtLevel(level, msg) +} + +// logAtLevel logs the provided message to stdout, or stderr if it is at Warning or Error level. +func (w *stdoutWriter) logAtLevel(level syslog.Priority, msg string, a ...interface{}) { + if int(level) <= w.level { + output := w.stdout + if int(level) <= int(syslog.LOG_WARNING) { + output = w.stderr + } + + // Apply conditional formatting for f functions + if a != nil { + msg = fmt.Sprintf(msg, a...) + } + + msg = strings.Replace(msg, "\n", "\\n", -1) + + var color string + var reset string + + const red = "\033[31m\033[1m" + const yellow = "\033[33m" + const gray = "\033[37m\033[2m" + + if w.isatty { + if int(level) == int(syslog.LOG_DEBUG) { + color = gray + reset = "\033[0m" + } else if int(level) == int(syslog.LOG_WARNING) { + color = yellow + reset = "\033[0m" + } else if int(level) <= int(syslog.LOG_ERR) { + color = red + reset = "\033[0m" + } + } + + if _, err := fmt.Fprintf(output, "%s%s %s%d %s %s%s\n", + color, + w.clk.Now().UTC().Format(w.clkFormat), + w.prefix, + int(level), + core.Command(), + checkSummed(msg), + reset); err != nil { + panic(fmt.Sprintf("failed to write to stdout: %v\n", err)) + } + } +} + +func (log *impl) auditAtLevel(level syslog.Priority, msg string, a ...interface{}) { + msg = fmt.Sprintf("%s %s", auditTag, msg) + log.w.logAtLevel(level, msg, a...) +} + +// Err level messages are always marked with the audit tag, for special handling +// at the upstream system logger. +func (log *impl) Err(msg string) { + log.Errf(msg) +} + +// Errf level messages are always marked with the audit tag, for special handling +// at the upstream system logger. +func (log *impl) Errf(format string, a ...interface{}) { + log.auditAtLevel(syslog.LOG_ERR, format, a...) +} + +// Warning level messages pass through normally. +func (log *impl) Warning(msg string) { + log.Warningf(msg) +} + +// Warningf level messages pass through normally. +func (log *impl) Warningf(format string, a ...interface{}) { + log.w.logAtLevel(syslog.LOG_WARNING, format, a...) +} + +// Info level messages pass through normally. +func (log *impl) Info(msg string) { + log.Infof(msg) +} + +// Infof level messages pass through normally. +func (log *impl) Infof(format string, a ...interface{}) { + log.w.logAtLevel(syslog.LOG_INFO, format, a...) +} + +// InfoObject logs an INFO level JSON-serialized object message. +func (log *impl) InfoObject(msg string, obj interface{}) { + jsonObj, err := json.Marshal(obj) + if err != nil { + log.auditAtLevel(syslog.LOG_ERR, fmt.Sprintf("Object for msg %q could not be serialized to JSON. Raw: %+v", msg, obj)) + return + } + + log.Infof("%s JSON=%s", msg, jsonObj) +} + +// Debug level messages pass through normally. +func (log *impl) Debug(msg string) { + log.Debugf(msg) + +} + +// Debugf level messages pass through normally. +func (log *impl) Debugf(format string, a ...interface{}) { + log.w.logAtLevel(syslog.LOG_DEBUG, format, a...) +} + +// AuditInfo sends an INFO-severity message that is prefixed with the +// audit tag, for special handling at the upstream system logger. +func (log *impl) AuditInfo(msg string) { + log.AuditInfof(msg) +} + +// AuditInfof sends an INFO-severity message that is prefixed with the +// audit tag, for special handling at the upstream system logger. +func (log *impl) AuditInfof(format string, a ...interface{}) { + log.auditAtLevel(syslog.LOG_INFO, format, a...) +} + +// AuditObject sends an INFO-severity JSON-serialized object message that is prefixed +// with the audit tag, for special handling at the upstream system logger. +func (log *impl) AuditObject(msg string, obj interface{}) { + jsonObj, err := json.Marshal(obj) + if err != nil { + log.auditAtLevel(syslog.LOG_ERR, fmt.Sprintf("Object for msg %q could not be serialized to JSON. Raw: %+v", msg, obj)) + return + } + + log.auditAtLevel(syslog.LOG_INFO, fmt.Sprintf("%s JSON=%s", msg, jsonObj)) +} + +// AuditErr can format an error for auditing; it does so at ERR level. +func (log *impl) AuditErr(msg string) { + log.AuditErrf(msg) +} + +// AuditErrf can format an error for auditing; it does so at ERR level. +func (log *impl) AuditErrf(format string, a ...interface{}) { + log.auditAtLevel(syslog.LOG_ERR, format, a...) +} diff --git a/third-party/github.com/letsencrypt/boulder/log/log_test.go b/third-party/github.com/letsencrypt/boulder/log/log_test.go new file mode 100644 index 000000000..fad3fcf3d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/log_test.go @@ -0,0 +1,344 @@ +package log + +import ( + "bytes" + "fmt" + "log/syslog" + "net" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/test" +) + +const stdoutLevel = 7 +const syslogLevel = 7 + +func setup(t *testing.T) *impl { + // Write all logs to UDP on a high port so as to not bother the system + // which is running the test + writer, err := syslog.Dial("udp", "127.0.0.1:65530", syslog.LOG_INFO|syslog.LOG_LOCAL0, "") + test.AssertNotError(t, err, "Could not construct syslog object") + + logger, err := New(writer, stdoutLevel, syslogLevel) + test.AssertNotError(t, err, "Could not construct syslog object") + impl, ok := logger.(*impl) + if !ok { + t.Fatalf("Wrong type returned from New: %T", logger) + } + return impl +} + +func TestConstruction(t *testing.T) { + t.Parallel() + _ = setup(t) +} + +func TestSingleton(t *testing.T) { + t.Parallel() + log1 := Get() + test.AssertNotNil(t, log1, "Logger shouldn't be nil") + + log2 := Get() + test.AssertEquals(t, log1, log2) + + audit := setup(t) + + // Should not work + err := Set(audit) + test.AssertError(t, err, "Can't re-set") + + // Verify no change + log4 := Get() + + // Verify that log4 != log3 + test.AssertNotEquals(t, log4, audit) + + // Verify that log4 == log2 == log1 + test.AssertEquals(t, log4, log2) + test.AssertEquals(t, log4, log1) +} + +func TestConstructionNil(t *testing.T) { + t.Parallel() + _, err := New(nil, stdoutLevel, syslogLevel) + test.AssertError(t, err, "Nil shouldn't be permitted.") +} + +func TestEmit(t *testing.T) { + t.Parallel() + log := setup(t) + + log.AuditInfo("test message") +} + +func TestEmitEmpty(t *testing.T) { + t.Parallel() + log := setup(t) + + log.AuditInfo("") +} + +func TestStdoutLogger(t *testing.T) { + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + logger := &impl{ + &stdoutWriter{ + prefix: "prefix ", + level: 7, + clkFormat: "2006-01-02", + clk: clock.NewFake(), + stdout: stdout, + stderr: stderr, + }, + } + + logger.AuditErr("Error Audit") + logger.Warning("Warning log") + logger.Info("Info log") + + test.AssertEquals(t, stdout.String(), "1970-01-01 prefix 6 log.test pcbo7wk Info log\n") + test.AssertEquals(t, stderr.String(), "1970-01-01 prefix 3 log.test 46_ghQg [AUDIT] Error Audit\n1970-01-01 prefix 4 log.test 97r2xAw Warning log\n") +} + +func TestSyslogMethods(t *testing.T) { + t.Parallel() + impl := setup(t) + + impl.AuditInfo("audit-logger_test.go: audit-info") + impl.AuditErr("audit-logger_test.go: audit-err") + impl.Debug("audit-logger_test.go: debug") + impl.Err("audit-logger_test.go: err") + impl.Info("audit-logger_test.go: info") + impl.Warning("audit-logger_test.go: warning") + impl.AuditInfof("audit-logger_test.go: %s", "audit-info") + impl.AuditErrf("audit-logger_test.go: %s", "audit-err") + impl.Debugf("audit-logger_test.go: %s", "debug") + impl.Errf("audit-logger_test.go: %s", "err") + impl.Infof("audit-logger_test.go: %s", "info") + impl.Warningf("audit-logger_test.go: %s", "warning") +} + +func TestAuditObject(t *testing.T) { + t.Parallel() + + log := NewMock() + + // Test a simple object + log.AuditObject("Prefix", "String") + if len(log.GetAllMatching("[AUDIT]")) != 1 { + t.Errorf("Failed to audit log simple object") + } + + // Test a system object + log.Clear() + log.AuditObject("Prefix", t) + if len(log.GetAllMatching("[AUDIT]")) != 1 { + t.Errorf("Failed to audit log system object") + } + + // Test a complex object + log.Clear() + type validObj struct { + A string + B string + } + var valid = validObj{A: "B", B: "C"} + log.AuditObject("Prefix", valid) + if len(log.GetAllMatching("[AUDIT]")) != 1 { + t.Errorf("Failed to audit log complex object") + } + + // Test logging an unserializable object + log.Clear() + type invalidObj struct { + A chan string + } + + var invalid = invalidObj{A: make(chan string)} + log.AuditObject("Prefix", invalid) + if len(log.GetAllMatching("[AUDIT]")) != 1 { + t.Errorf("Failed to audit log unserializable object %v", log.GetAllMatching("[AUDIT]")) + } +} + +func TestTransmission(t *testing.T) { + t.Parallel() + + l, err := newUDPListener("127.0.0.1:0") + test.AssertNotError(t, err, "Failed to open log server") + defer func() { + err = l.Close() + test.AssertNotError(t, err, "listener.Close returned error") + }() + + fmt.Printf("Going to %s\n", l.LocalAddr().String()) + writer, err := syslog.Dial("udp", l.LocalAddr().String(), syslog.LOG_INFO|syslog.LOG_LOCAL0, "") + test.AssertNotError(t, err, "Failed to find connect to log server") + + impl, err := New(writer, stdoutLevel, syslogLevel) + test.AssertNotError(t, err, "Failed to construct audit logger") + + data := make([]byte, 128) + + impl.AuditInfo("audit-logger_test.go: audit-info") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.AuditErr("audit-logger_test.go: audit-err") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Debug("audit-logger_test.go: debug") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Err("audit-logger_test.go: err") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Info("audit-logger_test.go: info") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Warning("audit-logger_test.go: warning") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.AuditInfof("audit-logger_test.go: %s", "audit-info") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.AuditErrf("audit-logger_test.go: %s", "audit-err") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Debugf("audit-logger_test.go: %s", "debug") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Errf("audit-logger_test.go: %s", "err") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Infof("audit-logger_test.go: %s", "info") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + + impl.Warningf("audit-logger_test.go: %s", "warning") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") +} + +func TestSyslogLevels(t *testing.T) { + t.Parallel() + + l, err := newUDPListener("127.0.0.1:0") + test.AssertNotError(t, err, "Failed to open log server") + defer func() { + err = l.Close() + test.AssertNotError(t, err, "listener.Close returned error") + }() + + fmt.Printf("Going to %s\n", l.LocalAddr().String()) + writer, err := syslog.Dial("udp", l.LocalAddr().String(), syslog.LOG_INFO|syslog.LOG_LOCAL0, "") + test.AssertNotError(t, err, "Failed to find connect to log server") + + // create a logger with syslog level debug + impl, err := New(writer, stdoutLevel, int(syslog.LOG_DEBUG)) + test.AssertNotError(t, err, "Failed to construct audit logger") + + data := make([]byte, 512) + + // debug messages should be sent to the logger + impl.Debug("log_test.go: debug") + _, _, err = l.ReadFrom(data) + test.AssertNotError(t, err, "Failed to find packet") + test.Assert(t, strings.Contains(string(data), "log_test.go: debug"), "Failed to find log message") + + // create a logger with syslog level info + impl, err = New(writer, stdoutLevel, int(syslog.LOG_INFO)) + test.AssertNotError(t, err, "Failed to construct audit logger") + + // debug messages should not be sent to the logger + impl.Debug("log_test.go: debug") + n, _, err := l.ReadFrom(data) + if n != 0 && err == nil { + t.Error("Failed to withhold debug log message") + } +} + +func newUDPListener(addr string) (*net.UDPConn, error) { + l, err := net.ListenPacket("udp", addr) + if err != nil { + return nil, err + } + err = l.SetDeadline(time.Now().Add(100 * time.Millisecond)) + if err != nil { + return nil, err + } + err = l.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + if err != nil { + return nil, err + } + err = l.SetWriteDeadline(time.Now().Add(100 * time.Millisecond)) + if err != nil { + return nil, err + } + return l.(*net.UDPConn), nil +} + +// TestStdoutFailure tests that audit logging with a bothWriter panics if stdout +// becomes unavailable. +func TestStdoutFailure(t *testing.T) { + // Save the stdout fd so we can restore it later + saved := os.Stdout + + // Create a throw-away pipe FD to replace stdout with + _, w, err := os.Pipe() + test.AssertNotError(t, err, "failed to create pipe") + os.Stdout = w + + // Setup the logger + log := setup(t) + + // Close Stdout so that the fmt.Printf in bothWriter's logAtLevel + // function will return an err on next log. + err = os.Stdout.Close() + test.AssertNotError(t, err, "failed to close stdout") + + // Defer a function that will check if there was a panic to recover from. If + // there wasn't then the test should fail, we were able to AuditInfo when + // Stdout was inoperable. + defer func() { + if recovered := recover(); recovered == nil { + t.Errorf("log.AuditInfo with Stdout closed did not panic") + } + + // Restore stdout so that subsequent tests don't fail + os.Stdout = saved + }() + + // Try to audit log something + log.AuditInfo("This should cause a panic, stdout is closed!") +} + +func TestLogAtLevelEscapesNewlines(t *testing.T) { + var buf bytes.Buffer + w := &bothWriter{sync.Mutex{}, + nil, + &stdoutWriter{ + stdout: &buf, + clk: clock.NewFake(), + level: 6, + }, + -1, + } + w.logAtLevel(6, "foo\nbar") + + test.Assert(t, strings.Contains(buf.String(), "foo\\nbar"), "failed to escape newline") +} diff --git a/third-party/github.com/letsencrypt/boulder/log/mock.go b/third-party/github.com/letsencrypt/boulder/log/mock.go new file mode 100644 index 000000000..88aa50f4b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/mock.go @@ -0,0 +1,168 @@ +package log + +import ( + "fmt" + "log/syslog" + "regexp" + "strings" + "time" +) + +// UseMock sets a mock logger as the default logger, and returns it. +func UseMock() *Mock { + m := NewMock() + _ = Set(m) + return m +} + +// NewMock creates a mock logger. +func NewMock() *Mock { + return &Mock{impl{newMockWriter()}} +} + +// NewWaitingMock creates a mock logger implementing the writer interface. +// It stores all logged messages in a buffer for inspection by test +// functions. +func NewWaitingMock() *WaitingMock { + return &WaitingMock{impl{newWaitingMockWriter()}} +} + +// Mock is a logger that stores all log messages in memory to be examined by a +// test. +type Mock struct { + impl +} + +// WaitingMock is a logger that stores all messages in memory to be examined by a test with methods +type WaitingMock struct { + impl +} + +// Mock implements the writer interface. It +// stores all logged messages in a buffer for inspection by test +// functions (via GetAll()) instead of sending them to syslog. +type mockWriter struct { + logged []string + msgChan chan<- string + getChan <-chan []string + clearChan chan<- struct{} + closeChan chan<- struct{} +} + +var levelName = map[syslog.Priority]string{ + syslog.LOG_ERR: "ERR", + syslog.LOG_WARNING: "WARNING", + syslog.LOG_INFO: "INFO", + syslog.LOG_DEBUG: "DEBUG", +} + +func (w *mockWriter) logAtLevel(p syslog.Priority, msg string, a ...interface{}) { + w.msgChan <- fmt.Sprintf("%s: %s", levelName[p&7], fmt.Sprintf(msg, a...)) +} + +// newMockWriter returns a new mockWriter +func newMockWriter() *mockWriter { + msgChan := make(chan string) + getChan := make(chan []string) + clearChan := make(chan struct{}) + closeChan := make(chan struct{}) + w := &mockWriter{ + logged: []string{}, + msgChan: msgChan, + getChan: getChan, + clearChan: clearChan, + closeChan: closeChan, + } + go func() { + for { + select { + case logMsg := <-msgChan: + w.logged = append(w.logged, logMsg) + case getChan <- w.logged: + case <-clearChan: + w.logged = []string{} + case <-closeChan: + close(getChan) + return + } + } + }() + return w +} + +// GetAll returns all messages logged since instantiation or the last call to +// Clear(). +// +// The caller must not modify the returned slice or its elements. +func (m *Mock) GetAll() []string { + w := m.w.(*mockWriter) + return <-w.getChan +} + +// GetAllMatching returns all messages logged since instantiation or the last +// Clear() whose text matches the given regexp. The regexp is +// accepted as a string and compiled on the fly, because convenience +// is more important than performance. +// +// The caller must not modify the elements of the returned slice. +func (m *Mock) GetAllMatching(reString string) []string { + var matches []string + w := m.w.(*mockWriter) + re := regexp.MustCompile(reString) + for _, logMsg := range <-w.getChan { + if re.MatchString(logMsg) { + matches = append(matches, logMsg) + } + } + return matches +} + +func (m *Mock) ExpectMatch(reString string) error { + results := m.GetAllMatching(reString) + if len(results) == 0 { + return fmt.Errorf("expected log line %q, got %q", reString, strings.Join(m.GetAll(), "\n")) + } + return nil +} + +// Clear resets the log buffer. +func (m *Mock) Clear() { + w := m.w.(*mockWriter) + w.clearChan <- struct{}{} +} + +type waitingMockWriter struct { + logChan chan string +} + +// newWaitingMockWriter returns a new waitingMockWriter +func newWaitingMockWriter() *waitingMockWriter { + logChan := make(chan string, 1000) + return &waitingMockWriter{ + logChan, + } +} + +func (m *waitingMockWriter) logAtLevel(p syslog.Priority, msg string, a ...interface{}) { + m.logChan <- fmt.Sprintf("%s: %s", levelName[p&7], fmt.Sprintf(msg, a...)) +} + +// WaitForMatch returns the first log line matching a regex. It accepts a +// regexp string and timeout. If the timeout value is met before the +// matching pattern is read from the channel, an error is returned. +func (m *WaitingMock) WaitForMatch(reString string, timeout time.Duration) (string, error) { + w := m.w.(*waitingMockWriter) + deadline := time.After(timeout) + re := regexp.MustCompile(reString) + for { + select { + case logLine := <-w.logChan: + if re.MatchString(logLine) { + close(w.logChan) + return logLine, nil + } + case <-deadline: + return "", fmt.Errorf("timeout waiting for match: %q", reString) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/log/prod_prefix.go b/third-party/github.com/letsencrypt/boulder/log/prod_prefix.go new file mode 100644 index 000000000..b4cf55daf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/prod_prefix.go @@ -0,0 +1,31 @@ +//go:build !integration + +package log + +import ( + "fmt" + "os" + "strings" + + "github.com/letsencrypt/boulder/core" +) + +// getPrefix returns the prefix and clkFormat that should be used by the +// stdout logger. +func getPrefix() (string, string) { + shortHostname := "unknown" + datacenter := "unknown" + hostname, err := os.Hostname() + if err == nil { + splits := strings.SplitN(hostname, ".", 3) + shortHostname = splits[0] + if len(splits) > 1 { + datacenter = splits[1] + } + } + + prefix := fmt.Sprintf("%s %s %s[%d]: ", shortHostname, datacenter, core.Command(), os.Getpid()) + clkFormat := "2006-01-02T15:04:05.000000+00:00Z" + + return prefix, clkFormat +} diff --git a/third-party/github.com/letsencrypt/boulder/log/test_prefix.go b/third-party/github.com/letsencrypt/boulder/log/test_prefix.go new file mode 100644 index 000000000..d1fb89491 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/test_prefix.go @@ -0,0 +1,9 @@ +//go:build integration + +package log + +// getPrefix returns the prefix and clkFormat that should be used by the +// stdout logger. +func getPrefix() (string, string) { + return "", "15:04:05.000000" +} diff --git a/third-party/github.com/letsencrypt/boulder/log/validator/tail_logger.go b/third-party/github.com/letsencrypt/boulder/log/validator/tail_logger.go new file mode 100644 index 000000000..ba8fdd0d3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/validator/tail_logger.go @@ -0,0 +1,40 @@ +package validator + +import ( + "fmt" + + "github.com/letsencrypt/boulder/log" +) + +// tailLogger is an adapter to the nxadm/tail module's logging interface. +type tailLogger struct { + log.Logger +} + +func (tl tailLogger) Fatal(v ...interface{}) { + tl.AuditErr(fmt.Sprint(v...)) +} +func (tl tailLogger) Fatalf(format string, v ...interface{}) { + tl.AuditErrf(format, v...) +} +func (tl tailLogger) Fatalln(v ...interface{}) { + tl.AuditErr(fmt.Sprint(v...) + "\n") +} +func (tl tailLogger) Panic(v ...interface{}) { + tl.AuditErr(fmt.Sprint(v...)) +} +func (tl tailLogger) Panicf(format string, v ...interface{}) { + tl.AuditErrf(format, v...) +} +func (tl tailLogger) Panicln(v ...interface{}) { + tl.AuditErr(fmt.Sprint(v...) + "\n") +} +func (tl tailLogger) Print(v ...interface{}) { + tl.Info(fmt.Sprint(v...)) +} +func (tl tailLogger) Printf(format string, v ...interface{}) { + tl.Infof(format, v...) +} +func (tl tailLogger) Println(v ...interface{}) { + tl.Info(fmt.Sprint(v...) + "\n") +} diff --git a/third-party/github.com/letsencrypt/boulder/log/validator/validator.go b/third-party/github.com/letsencrypt/boulder/log/validator/validator.go new file mode 100644 index 000000000..a73330cb3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/validator/validator.go @@ -0,0 +1,235 @@ +package validator + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/nxadm/tail" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/log" +) + +var errInvalidChecksum = errors.New("invalid checksum length") + +type Validator struct { + // mu guards patterns and tailers to prevent Shutdown racing monitor + mu sync.Mutex + + // patterns is the list of glob patterns to monitor with filepath.Glob for logs + patterns []string + + // tailers is a map of filenames to the tailer which are currently being tailed + tailers map[string]*tail.Tail + + // monitorCancel cancels the monitor's context, so it exits + monitorCancel context.CancelFunc + + lineCounter *prometheus.CounterVec + log log.Logger +} + +// New Validator monitoring paths, which is a list of file globs. +func New(patterns []string, logger log.Logger, stats prometheus.Registerer) *Validator { + lineCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "log_lines", + Help: "A counter of log lines processed, with status", + }, []string{"filename", "status"}) + stats.MustRegister(lineCounter) + + monitorContext, monitorCancel := context.WithCancel(context.Background()) + + v := &Validator{ + patterns: patterns, + tailers: map[string]*tail.Tail{}, + log: logger, + monitorCancel: monitorCancel, + lineCounter: lineCounter, + } + + go v.monitor(monitorContext) + + return v +} + +// pollPaths expands v.patterns and calls v.tailValidateFile on each resulting file +func (v *Validator) pollPaths() { + v.mu.Lock() + defer v.mu.Unlock() + for _, pattern := range v.patterns { + paths, err := filepath.Glob(pattern) + if err != nil { + v.log.Err(err.Error()) + } + + for _, path := range paths { + if _, ok := v.tailers[path]; ok { + // We are already tailing this file + continue + } + + t, err := tail.TailFile(path, tail.Config{ + ReOpen: true, + MustExist: false, // sometimes files won't exist, so we must tolerate that + Follow: true, + Logger: tailLogger{v.log}, + CompleteLines: true, + }) + if err != nil { + // TailFile shouldn't error when MustExist is false + v.log.Errf("unexpected error from TailFile: %v", err) + } + + go v.tailValidate(path, t.Lines) + + v.tailers[path] = t + } + } +} + +// Monitor calls v.pollPaths every minute until its context is cancelled +func (v *Validator) monitor(ctx context.Context) { + for { + v.pollPaths() + + // Wait a minute, unless cancelled + timer := time.NewTimer(time.Minute) + select { + case <-ctx.Done(): + return + case <-timer.C: + } + } +} + +func (v *Validator) tailValidate(filename string, lines chan *tail.Line) { + // Emit no more than 1 error line per second. This prevents consuming large + // amounts of disk space in case there is problem that causes all log lines to + // be invalid. + outputLimiter := time.NewTicker(time.Second) + defer outputLimiter.Stop() + + for line := range lines { + if line.Err != nil { + v.log.Errf("error while tailing %s: %s", filename, line.Err) + continue + } + err := lineValid(line.Text) + if err != nil { + if errors.Is(err, errInvalidChecksum) { + v.lineCounter.WithLabelValues(filename, "invalid checksum length").Inc() + } else { + v.lineCounter.WithLabelValues(filename, "bad").Inc() + } + select { + case <-outputLimiter.C: + v.log.Errf("%s: %s %q", filename, err, line.Text) + default: + } + } else { + v.lineCounter.WithLabelValues(filename, "ok").Inc() + } + } +} + +// Shutdown should be called before process shutdown +func (v *Validator) Shutdown() { + v.mu.Lock() + defer v.mu.Unlock() + + v.monitorCancel() + + for _, t := range v.tailers { + // The tail module seems to have a race condition that will generate + // errors like this on shutdown: + // failed to stop tailing file: : Failed to detect creation of + // : inotify watcher has been closed + // This is probably related to the module's shutdown logic triggering the + // "reopen" code path for files that are removed and then recreated. + // These errors are harmless so we ignore them to allow clean shutdown. + _ = t.Stop() + t.Cleanup() + } +} + +func lineValid(text string) error { + // Line format should match the following rsyslog omfile template: + // + // template( name="LELogFormat" type="list" ) { + // property(name="timereported" dateFormat="rfc3339") + // constant(value=" ") + // property(name="hostname" field.delimiter="46" field.number="1") + // constant(value=" datacenter ") + // property(name="syslogseverity") + // constant(value=" ") + // property(name="syslogtag") + // property(name="msg" spifno1stsp="on" ) + // property(name="msg" droplastlf="on" ) + // constant(value="\n") + // } + // + // This should result in a log line that looks like this: + // timestamp hostname datacenter syslogseverity binary-name[pid]: checksum msg + + fields := strings.Split(text, " ") + const errorPrefix = "log-validator:" + // Extract checksum from line + if len(fields) < 6 { + return fmt.Errorf("%s line doesn't match expected format", errorPrefix) + } + checksum := fields[5] + _, err := base64.RawURLEncoding.DecodeString(checksum) + if err != nil || len(checksum) != 7 { + return fmt.Errorf( + "%s expected a 7 character base64 raw URL decodable string, got %q: %w", + errorPrefix, + checksum, + errInvalidChecksum, + ) + } + + // Reconstruct just the message portion of the line + line := strings.Join(fields[6:], " ") + + // If we are fed our own output, treat it as always valid. This + // prevents runaway scenarios where we generate ever-longer output. + if strings.Contains(text, errorPrefix) { + return nil + } + // Check the extracted checksum against the computed checksum + if computedChecksum := log.LogLineChecksum(line); checksum != computedChecksum { + return fmt.Errorf("%s invalid checksum (expected %q, got %q)", errorPrefix, computedChecksum, checksum) + } + return nil +} + +// ValidateFile validates a single file and returns +func ValidateFile(filename string) error { + file, err := os.ReadFile(filename) + if err != nil { + return err + } + badFile := false + for i, line := range strings.Split(string(file), "\n") { + if line == "" { + continue + } + err := lineValid(line) + if err != nil { + badFile = true + fmt.Fprintf(os.Stderr, "[line %d] %s: %s\n", i+1, err, line) + } + } + + if badFile { + return errors.New("file contained invalid lines") + } + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/log/validator/validator_test.go b/third-party/github.com/letsencrypt/boulder/log/validator/validator_test.go new file mode 100644 index 000000000..fc543b652 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/log/validator/validator_test.go @@ -0,0 +1,32 @@ +package validator + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestLineValidAccepts(t *testing.T) { + err := lineValid("2020-07-06T18:07:43.109389+00:00 70877f679c72 datacenter 6 boulder-wfe[1595]: kKG6cwA Caught SIGTERM") + test.AssertNotError(t, err, "errored on valid checksum") +} + +func TestLineValidRejects(t *testing.T) { + err := lineValid("2020-07-06T18:07:43.109389+00:00 70877f679c72 datacenter 6 boulder-wfe[1595]: xxxxxxx Caught SIGTERM") + test.AssertError(t, err, "didn't error on invalid checksum") +} + +func TestLineValidRejectsNotAChecksum(t *testing.T) { + err := lineValid("2020-07-06T18:07:43.109389+00:00 70877f679c72 datacenter 6 boulder-wfe[1595]: xxxx Caught SIGTERM") + test.AssertError(t, err, "didn't error on invalid checksum") + test.AssertErrorIs(t, err, errInvalidChecksum) +} + +func TestLineValidNonOurobouros(t *testing.T) { + err := lineValid("2020-07-06T18:07:43.109389+00:00 70877f679c72 datacenter 6 boulder-wfe[1595]: xxxxxxx Caught SIGTERM") + test.AssertError(t, err, "didn't error on invalid checksum") + + selfOutput := "2020-07-06T18:07:43.109389+00:00 70877f679c72 datacenter 6 log-validator[1337]: xxxxxxx " + err.Error() + err2 := lineValid(selfOutput) + test.AssertNotError(t, err2, "expected no error when feeding lineValid's error output into itself") +} diff --git a/third-party/github.com/letsencrypt/boulder/mail/mailer.go b/third-party/github.com/letsencrypt/boulder/mail/mailer.go new file mode 100644 index 000000000..31ebd40b1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mail/mailer.go @@ -0,0 +1,430 @@ +package mail + +import ( + "bytes" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "math" + "math/big" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "strconv" + "strings" + "syscall" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" +) + +type idGenerator interface { + generate() *big.Int +} + +var maxBigInt = big.NewInt(math.MaxInt64) + +type realSource struct{} + +func (s realSource) generate() *big.Int { + randInt, err := rand.Int(rand.Reader, maxBigInt) + if err != nil { + panic(err) + } + return randInt +} + +// Mailer is an interface that allows creating Conns. Implementations must +// be safe for concurrent use. +type Mailer interface { + Connect() (Conn, error) +} + +// Conn is an interface that allows sending mail. When you are done with a +// Conn, call Close(). Implementations are not required to be safe for +// concurrent use. +type Conn interface { + SendMail([]string, string, string) error + Close() error +} + +// connImpl represents a single connection to a mail server. It is not safe +// for concurrent use. +type connImpl struct { + config + client smtpClient +} + +// mailerImpl defines a mail transfer agent to use for sending mail. It is +// safe for concurrent us. +type mailerImpl struct { + config +} + +type config struct { + log blog.Logger + dialer dialer + from mail.Address + clk clock.Clock + csprgSource idGenerator + reconnectBase time.Duration + reconnectMax time.Duration + sendMailAttempts *prometheus.CounterVec +} + +type dialer interface { + Dial() (smtpClient, error) +} + +type smtpClient interface { + Mail(string) error + Rcpt(string) error + Data() (io.WriteCloser, error) + Reset() error + Close() error +} + +type dryRunClient struct { + log blog.Logger +} + +func (d dryRunClient) Dial() (smtpClient, error) { + return d, nil +} + +func (d dryRunClient) Mail(from string) error { + d.log.Debugf("MAIL FROM:<%s>", from) + return nil +} + +func (d dryRunClient) Rcpt(to string) error { + d.log.Debugf("RCPT TO:<%s>", to) + return nil +} + +func (d dryRunClient) Close() error { + return nil +} + +func (d dryRunClient) Data() (io.WriteCloser, error) { + return d, nil +} + +func (d dryRunClient) Write(p []byte) (n int, err error) { + for _, line := range strings.Split(string(p), "\n") { + d.log.Debugf("data: %s", line) + } + return len(p), nil +} + +func (d dryRunClient) Reset() (err error) { + d.log.Debugf("RESET") + return nil +} + +// New constructs a Mailer to represent an account on a particular mail +// transfer agent. +func New( + server, + port, + username, + password string, + rootCAs *x509.CertPool, + from mail.Address, + logger blog.Logger, + stats prometheus.Registerer, + reconnectBase time.Duration, + reconnectMax time.Duration) *mailerImpl { + + sendMailAttempts := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "send_mail_attempts", + Help: "A counter of send mail attempts labelled by result", + }, []string{"result", "error"}) + stats.MustRegister(sendMailAttempts) + + return &mailerImpl{ + config: config{ + dialer: &dialerImpl{ + username: username, + password: password, + server: server, + port: port, + rootCAs: rootCAs, + }, + log: logger, + from: from, + clk: clock.New(), + csprgSource: realSource{}, + reconnectBase: reconnectBase, + reconnectMax: reconnectMax, + sendMailAttempts: sendMailAttempts, + }, + } +} + +// NewDryRun constructs a Mailer suitable for doing a dry run. It simply logs +// each command that would have been run, at debug level. +func NewDryRun(from mail.Address, logger blog.Logger) *mailerImpl { + return &mailerImpl{ + config: config{ + dialer: dryRunClient{logger}, + from: from, + clk: clock.New(), + csprgSource: realSource{}, + sendMailAttempts: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "send_mail_attempts", + Help: "A counter of send mail attempts labelled by result", + }, []string{"result", "error"}), + }, + } +} + +func (c config) generateMessage(to []string, subject, body string) ([]byte, error) { + mid := c.csprgSource.generate() + now := c.clk.Now().UTC() + addrs := []string{} + for _, a := range to { + if !core.IsASCII(a) { + return nil, fmt.Errorf("Non-ASCII email address") + } + addrs = append(addrs, strconv.Quote(a)) + } + headers := []string{ + fmt.Sprintf("To: %s", strings.Join(addrs, ", ")), + fmt.Sprintf("From: %s", c.from.String()), + fmt.Sprintf("Subject: %s", subject), + fmt.Sprintf("Date: %s", now.Format(time.RFC822)), + fmt.Sprintf("Message-Id: <%s.%s.%s>", now.Format("20060102T150405"), mid.String(), c.from.Address), + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: quoted-printable", + } + for i := range headers[1:] { + // strip LFs + headers[i] = strings.Replace(headers[i], "\n", "", -1) + } + bodyBuf := new(bytes.Buffer) + mimeWriter := quotedprintable.NewWriter(bodyBuf) + _, err := mimeWriter.Write([]byte(body)) + if err != nil { + return nil, err + } + err = mimeWriter.Close() + if err != nil { + return nil, err + } + return []byte(fmt.Sprintf( + "%s\r\n\r\n%s\r\n", + strings.Join(headers, "\r\n"), + bodyBuf.String(), + )), nil +} + +func (c *connImpl) reconnect() { + for i := 0; ; i++ { + sleepDuration := core.RetryBackoff(i, c.reconnectBase, c.reconnectMax, 2) + c.log.Infof("sleeping for %s before reconnecting mailer", sleepDuration) + c.clk.Sleep(sleepDuration) + c.log.Info("attempting to reconnect mailer") + client, err := c.dialer.Dial() + if err != nil { + c.log.Warningf("reconnect error: %s", err) + continue + } + c.client = client + break + } + c.log.Info("reconnected successfully") +} + +// Connect opens a connection to the specified mail server. It must be called +// before SendMail. +func (m *mailerImpl) Connect() (Conn, error) { + client, err := m.dialer.Dial() + if err != nil { + return nil, err + } + return &connImpl{m.config, client}, nil +} + +type dialerImpl struct { + username, password, server, port string + rootCAs *x509.CertPool +} + +func (di *dialerImpl) Dial() (smtpClient, error) { + hostport := net.JoinHostPort(di.server, di.port) + var conn net.Conn + var err error + conn, err = tls.Dial("tcp", hostport, &tls.Config{ + RootCAs: di.rootCAs, + }) + if err != nil { + return nil, err + } + client, err := smtp.NewClient(conn, di.server) + if err != nil { + return nil, err + } + auth := smtp.PlainAuth("", di.username, di.password, di.server) + if err = client.Auth(auth); err != nil { + return nil, err + } + return client, nil +} + +// resetAndError resets the current mail transaction and then returns its +// argument as an error. If the reset command also errors, it combines both +// errors and returns them. Without this we would get `nested MAIL command`. +// https://github.com/letsencrypt/boulder/issues/3191 +func (c *connImpl) resetAndError(err error) error { + if err == io.EOF { + return err + } + if err2 := c.client.Reset(); err2 != nil { + return fmt.Errorf("%s (also, on sending RSET: %s)", err, err2) + } + return err +} + +func (c *connImpl) sendOne(to []string, subject, msg string) error { + if c.client == nil { + return errors.New("call Connect before SendMail") + } + body, err := c.generateMessage(to, subject, msg) + if err != nil { + return err + } + if err = c.client.Mail(c.from.String()); err != nil { + return err + } + for _, t := range to { + if err = c.client.Rcpt(t); err != nil { + return c.resetAndError(err) + } + } + w, err := c.client.Data() + if err != nil { + return c.resetAndError(err) + } + _, err = w.Write(body) + if err != nil { + return c.resetAndError(err) + } + err = w.Close() + if err != nil { + return c.resetAndError(err) + } + return nil +} + +// BadAddressSMTPError is returned by SendMail when the server rejects a message +// but for a reason that doesn't prevent us from continuing to send mail. The +// error message contains the error code and the error message returned from the +// server. +type BadAddressSMTPError struct { + Message string +} + +func (e BadAddressSMTPError) Error() string { + return e.Message +} + +// Based on reading of various SMTP documents these are a handful +// of errors we are likely to be able to continue sending mail after +// receiving. The majority of these errors boil down to 'bad address'. +var badAddressErrorCodes = map[int]bool{ + 401: true, // Invalid recipient + 422: true, // Recipient mailbox is full + 441: true, // Recipient server is not responding + 450: true, // User's mailbox is not available + 501: true, // Bad recipient address syntax + 510: true, // Invalid recipient + 511: true, // Invalid recipient + 513: true, // Address type invalid + 541: true, // Recipient rejected message + 550: true, // Non-existent address + 553: true, // Non-existent address +} + +// SendMail sends an email to the provided list of recipients. The email body +// is simple text. +func (c *connImpl) SendMail(to []string, subject, msg string) error { + var protoErr *textproto.Error + for { + err := c.sendOne(to, subject, msg) + if err == nil { + // If the error is nil, we sent the mail without issue. nice! + break + } else if err == io.EOF { + c.sendMailAttempts.WithLabelValues("failure", "EOF").Inc() + // If the error is an EOF, we should try to reconnect on a backoff + // schedule, sleeping between attempts. + c.reconnect() + // After reconnecting, loop around and try `sendOne` again. + continue + } else if errors.Is(err, syscall.ECONNRESET) { + c.sendMailAttempts.WithLabelValues("failure", "TCP RST").Inc() + // If the error is `syscall.ECONNRESET`, we should try to reconnect on a backoff + // schedule, sleeping between attempts. + c.reconnect() + // After reconnecting, loop around and try `sendOne` again. + continue + } else if errors.Is(err, syscall.EPIPE) { + // EPIPE also seems to be a common way to signal TCP RST. + c.sendMailAttempts.WithLabelValues("failure", "EPIPE").Inc() + c.reconnect() + continue + } else if errors.As(err, &protoErr) && protoErr.Code == 421 { + c.sendMailAttempts.WithLabelValues("failure", "SMTP 421").Inc() + /* + * If the error is an instance of `textproto.Error` with a SMTP error code, + * and that error code is 421 then treat this as a reconnect-able event. + * + * The SMTP RFC defines this error code as: + * 421 Service not available, closing transmission channel + * (This may be a reply to any command if the service knows it + * must shut down) + * + * In practice we see this code being used by our production SMTP server + * when the connection has gone idle for too long. For more information + * see issue #2249[0]. + * + * [0] - https://github.com/letsencrypt/boulder/issues/2249 + */ + c.reconnect() + // After reconnecting, loop around and try `sendOne` again. + continue + } else if errors.As(err, &protoErr) && badAddressErrorCodes[protoErr.Code] { + c.sendMailAttempts.WithLabelValues("failure", fmt.Sprintf("SMTP %d", protoErr.Code)).Inc() + return BadAddressSMTPError{fmt.Sprintf("%d: %s", protoErr.Code, protoErr.Msg)} + } else { + // If it wasn't an EOF error or a recoverable SMTP error it is unexpected and we + // return from SendMail() with the error + c.sendMailAttempts.WithLabelValues("failure", "unexpected").Inc() + return err + } + } + + c.sendMailAttempts.WithLabelValues("success", "").Inc() + return nil +} + +// Close closes the connection. +func (c *connImpl) Close() error { + err := c.client.Close() + if err != nil { + return err + } + c.client = nil + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/mail/mailer_test.go b/third-party/github.com/letsencrypt/boulder/mail/mailer_test.go new file mode 100644 index 000000000..241412051 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mail/mailer_test.go @@ -0,0 +1,545 @@ +package mail + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "fmt" + "math/big" + "net" + "net/mail" + "net/textproto" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +var ( + // These variables are populated by init(), and then referenced by setup() and + // listenForever(). smtpCert is the TLS certificate which will be served by + // the fake SMTP server, and smtpRoot is the issuer of that certificate which + // will be trusted by the SMTP client under test. + smtpRoot *x509.CertPool + smtpCert *tls.Certificate +) + +func init() { + // Populate the global smtpRoot and smtpCert variables. We use a single self + // signed cert for both, for ease of generation. It has to assert the name + // localhost to appease the mailer, which is connecting to localhost. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + fmt.Println(err) + template := x509.Certificate{ + DNSNames: []string{"localhost"}, + SerialNumber: big.NewInt(123), + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) + fmt.Println(err) + cert, err := x509.ParseCertificate(certDER) + fmt.Println(err) + + smtpRoot = x509.NewCertPool() + smtpRoot.AddCert(cert) + + smtpCert = &tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + Leaf: cert, + } +} + +type fakeSource struct{} + +func (f fakeSource) generate() *big.Int { + return big.NewInt(1991) +} + +func TestGenerateMessage(t *testing.T) { + fc := clock.NewFake() + fromAddress, _ := mail.ParseAddress("happy sender ") + log := blog.UseMock() + m := New("", "", "", "", nil, *fromAddress, log, metrics.NoopRegisterer, 0, 0) + m.clk = fc + m.csprgSource = fakeSource{} + messageBytes, err := m.generateMessage([]string{"recv@email.com"}, "test subject", "this is the body\n") + test.AssertNotError(t, err, "Failed to generate email body") + message := string(messageBytes) + fields := strings.Split(message, "\r\n") + test.AssertEquals(t, len(fields), 12) + fmt.Println(message) + test.AssertEquals(t, fields[0], "To: \"recv@email.com\"") + test.AssertEquals(t, fields[1], "From: \"happy sender\" ") + test.AssertEquals(t, fields[2], "Subject: test subject") + test.AssertEquals(t, fields[3], "Date: 01 Jan 70 00:00 UTC") + test.AssertEquals(t, fields[4], "Message-Id: <19700101T000000.1991.send@email.com>") + test.AssertEquals(t, fields[5], "MIME-Version: 1.0") + test.AssertEquals(t, fields[6], "Content-Type: text/plain; charset=UTF-8") + test.AssertEquals(t, fields[7], "Content-Transfer-Encoding: quoted-printable") + test.AssertEquals(t, fields[8], "") + test.AssertEquals(t, fields[9], "this is the body") +} + +func TestFailNonASCIIAddress(t *testing.T) { + log := blog.UseMock() + fromAddress, _ := mail.ParseAddress("send@email.com") + m := New("", "", "", "", nil, *fromAddress, log, metrics.NoopRegisterer, 0, 0) + _, err := m.generateMessage([]string{"遗憾@email.com"}, "test subject", "this is the body\n") + test.AssertError(t, err, "Allowed a non-ASCII to address incorrectly") +} + +func expect(t *testing.T, buf *bufio.Reader, expected string) error { + line, _, err := buf.ReadLine() + if err != nil { + t.Errorf("readline: %s expected: %s\n", err, expected) + return err + } + if string(line) != expected { + t.Errorf("Expected %s, got %s", expected, line) + return fmt.Errorf("Expected %s, got %s", expected, line) + } + return nil +} + +type connHandler func(int, *testing.T, net.Conn, *net.TCPConn) + +func listenForever(l *net.TCPListener, t *testing.T, handler connHandler) { + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{*smtpCert}, + } + connID := 0 + for { + tcpConn, err := l.AcceptTCP() + if err != nil { + return + } + + tlsConn := tls.Server(tcpConn, tlsConf) + connID++ + go handler(connID, t, tlsConn, tcpConn) + } +} + +func authenticateClient(t *testing.T, conn net.Conn) { + buf := bufio.NewReader(conn) + // we can ignore write errors because any + // failures will be caught on the connecting + // side + _, _ = conn.Write([]byte("220 smtp.example.com ESMTP\n")) + err := expect(t, buf, "EHLO localhost") + if err != nil { + return + } + + _, _ = conn.Write([]byte("250-PIPELINING\n")) + _, _ = conn.Write([]byte("250-AUTH PLAIN LOGIN\n")) + _, _ = conn.Write([]byte("250 8BITMIME\n")) + // Base64 encoding of "\0user@example.com\0passwd" + err = expect(t, buf, "AUTH PLAIN AHVzZXJAZXhhbXBsZS5jb20AcGFzc3dk") + if err != nil { + return + } + _, _ = conn.Write([]byte("235 2.7.0 Authentication successful\n")) +} + +// The normal handler authenticates the client and then disconnects without +// further command processing. It is sufficient for TestConnect() +func normalHandler(connID int, t *testing.T, tlsConn net.Conn, tcpConn *net.TCPConn) { + defer func() { + err := tlsConn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, tlsConn) +} + +// The disconnectHandler authenticates the client like the normalHandler but +// additionally processes an email flow (e.g. MAIL, RCPT and DATA commands). +// When the `connID` is <= `closeFirst` the connection is closed immediately +// after the MAIL command is received and prior to issuing a 250 response. If +// a `goodbyeMsg` is provided, it is written to the client immediately before +// closing. In this way the first `closeFirst` connections will not complete +// normally and can be tested for reconnection logic. +func disconnectHandler(closeFirst int, goodbyeMsg string) connHandler { + return func(connID int, t *testing.T, conn net.Conn, _ *net.TCPConn) { + defer func() { + err := conn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, conn) + + buf := bufio.NewReader(conn) + err := expect(t, buf, "MAIL FROM:<> BODY=8BITMIME") + if err != nil { + return + } + + if connID <= closeFirst { + // If there was a `goodbyeMsg` specified, write it to the client before + // closing the connection. This is a good way to deliver a SMTP error + // before closing + if goodbyeMsg != "" { + _, _ = fmt.Fprintf(conn, "%s\r\n", goodbyeMsg) + t.Logf("Wrote goodbye msg: %s", goodbyeMsg) + } + t.Log("Cutting off client early") + return + } + _, _ = conn.Write([]byte("250 Sure. Go on. \r\n")) + + err = expect(t, buf, "RCPT TO:") + if err != nil { + return + } + _, _ = conn.Write([]byte("250 Tell Me More \r\n")) + + err = expect(t, buf, "DATA") + if err != nil { + return + } + _, _ = conn.Write([]byte("354 Cool Data\r\n")) + _, _ = conn.Write([]byte("250 Peace Out\r\n")) + } +} + +func badEmailHandler(messagesToProcess int) connHandler { + return func(_ int, t *testing.T, conn net.Conn, _ *net.TCPConn) { + defer func() { + err := conn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, conn) + + buf := bufio.NewReader(conn) + err := expect(t, buf, "MAIL FROM:<> BODY=8BITMIME") + if err != nil { + return + } + + _, _ = conn.Write([]byte("250 Sure. Go on. \r\n")) + + err = expect(t, buf, "RCPT TO:") + if err != nil { + return + } + _, _ = conn.Write([]byte("401 4.1.3 Bad recipient address syntax\r\n")) + err = expect(t, buf, "RSET") + if err != nil { + return + } + _, _ = conn.Write([]byte("250 Ok yr rset now\r\n")) + } +} + +// The rstHandler authenticates the client like the normalHandler but +// additionally processes an email flow (e.g. MAIL, RCPT and DATA +// commands). When the `connID` is <= `rstFirst` the socket of the +// listening connection is set to abruptively close (sends TCP RST but +// no FIN). The listening connection is closed immediately after the +// MAIL command is received and prior to issuing a 250 response. In this +// way the first `rstFirst` connections will not complete normally and +// can be tested for reconnection logic. +func rstHandler(rstFirst int) connHandler { + return func(connID int, t *testing.T, tlsConn net.Conn, tcpConn *net.TCPConn) { + defer func() { + err := tcpConn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, tlsConn) + + buf := bufio.NewReader(tlsConn) + err := expect(t, buf, "MAIL FROM:<> BODY=8BITMIME") + if err != nil { + return + } + // Set the socket of the listening connection to abruptively + // close. + if connID <= rstFirst { + err := tcpConn.SetLinger(0) + if err != nil { + t.Error(err) + return + } + t.Log("Socket set for abruptive close. Cutting off client early") + return + } + _, _ = tlsConn.Write([]byte("250 Sure. Go on. \r\n")) + + err = expect(t, buf, "RCPT TO:") + if err != nil { + return + } + _, _ = tlsConn.Write([]byte("250 Tell Me More \r\n")) + + err = expect(t, buf, "DATA") + if err != nil { + return + } + _, _ = tlsConn.Write([]byte("354 Cool Data\r\n")) + _, _ = tlsConn.Write([]byte("250 Peace Out\r\n")) + } +} + +func setup(t *testing.T) (*mailerImpl, *net.TCPListener, func()) { + fromAddress, _ := mail.ParseAddress("you-are-a-winner@example.com") + log := blog.UseMock() + + // Listen on port 0 to get any free available port + tcpAddr, err := net.ResolveTCPAddr("tcp", ":0") + if err != nil { + t.Fatalf("resolving tcp addr: %s", err) + } + tcpl, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + t.Fatalf("listen: %s", err) + } + + cleanUp := func() { + err := tcpl.Close() + if err != nil { + t.Errorf("listen.Close: %s", err) + } + } + + // We can look at the listener Addr() to figure out which free port was + // assigned by the operating system + + _, port, err := net.SplitHostPort(tcpl.Addr().String()) + if err != nil { + t.Fatal("failed parsing port from tcp listen") + } + + m := New( + "localhost", + port, + "user@example.com", + "passwd", + smtpRoot, + *fromAddress, + log, + metrics.NoopRegisterer, + time.Second*2, time.Second*10) + + return m, tcpl, cleanUp +} + +func TestConnect(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + + go listenForever(l, t, normalHandler) + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + err = conn.Close() + if err != nil { + t.Errorf("Failed to clean up: %s", err) + } +} + +func TestReconnectSuccess(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + const closedConns = 5 + + // Configure a test server that will disconnect the first `closedConns` + // connections after the MAIL cmd + go listenForever(l, t, disconnectHandler(closedConns, "")) + + // With a mailer client that has a max attempt > `closedConns` we expect no + // error. The message should be delivered after `closedConns` reconnect + // attempts. + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + if err != nil { + t.Errorf("Expected SendMail() to not fail. Got err: %s", err) + } +} + +func TestBadEmailError(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + const messages = 3 + + go listenForever(l, t, badEmailHandler(messages)) + + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + // We expect there to be an error + if err == nil { + t.Errorf("Expected SendMail() to return an BadAddressSMTPError, got nil") + } + expected := "401: 4.1.3 Bad recipient address syntax" + var badAddrErr BadAddressSMTPError + test.AssertErrorWraps(t, err, &badAddrErr) + test.AssertEquals(t, badAddrErr.Message, expected) +} + +func TestReconnectSMTP421(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + const closedConns = 5 + + // A SMTP 421 can be generated when the server times out an idle connection. + // For more information see https://github.com/letsencrypt/boulder/issues/2249 + smtp421 := "421 1.2.3 green.eggs.and.spam Error: timeout exceeded" + + // Configure a test server that will disconnect the first `closedConns` + // connections after the MAIL cmd with a SMTP 421 error + go listenForever(l, t, disconnectHandler(closedConns, smtp421)) + + // With a mailer client that has a max attempt > `closedConns` we expect no + // error. The message should be delivered after `closedConns` reconnect + // attempts. + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + if err != nil { + t.Errorf("Expected SendMail() to not fail. Got err: %s", err) + } +} + +func TestOtherError(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + + go listenForever(l, t, func(_ int, t *testing.T, conn net.Conn, _ *net.TCPConn) { + defer func() { + err := conn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, conn) + + buf := bufio.NewReader(conn) + err := expect(t, buf, "MAIL FROM:<> BODY=8BITMIME") + if err != nil { + return + } + + _, _ = conn.Write([]byte("250 Sure. Go on. \r\n")) + + err = expect(t, buf, "RCPT TO:") + if err != nil { + return + } + + _, _ = conn.Write([]byte("999 1.1.1 This would probably be bad?\r\n")) + + err = expect(t, buf, "RSET") + if err != nil { + return + } + + _, _ = conn.Write([]byte("250 Ok yr rset now\r\n")) + }) + + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + // We expect there to be an error + if err == nil { + t.Errorf("Expected SendMail() to return an error, got nil") + } + expected := "999 1.1.1 This would probably be bad?" + var rcptErr *textproto.Error + test.AssertErrorWraps(t, err, &rcptErr) + test.AssertEquals(t, rcptErr.Error(), expected) + + m, l, cleanUp = setup(t) + defer cleanUp() + + go listenForever(l, t, func(_ int, t *testing.T, conn net.Conn, _ *net.TCPConn) { + defer func() { + err := conn.Close() + if err != nil { + t.Errorf("conn.Close: %s", err) + } + }() + authenticateClient(t, conn) + + buf := bufio.NewReader(conn) + err := expect(t, buf, "MAIL FROM:<> BODY=8BITMIME") + if err != nil { + return + } + + _, _ = conn.Write([]byte("250 Sure. Go on. \r\n")) + + err = expect(t, buf, "RCPT TO:") + if err != nil { + return + } + + _, _ = conn.Write([]byte("999 1.1.1 This would probably be bad?\r\n")) + + err = expect(t, buf, "RSET") + if err != nil { + return + } + + _, _ = conn.Write([]byte("nop\r\n")) + }) + conn, err = m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + // We expect there to be an error + test.AssertError(t, err, "SendMail didn't fail as expected") + test.AssertEquals(t, err.Error(), "999 1.1.1 This would probably be bad? (also, on sending RSET: short response: nop)") +} + +func TestReconnectAfterRST(t *testing.T) { + m, l, cleanUp := setup(t) + defer cleanUp() + const rstConns = 5 + + // Configure a test server that will RST and disconnect the first + // `closedConns` connections + go listenForever(l, t, rstHandler(rstConns)) + + // With a mailer client that has a max attempt > `closedConns` we expect no + // error. The message should be delivered after `closedConns` reconnect + // attempts. + conn, err := m.Connect() + if err != nil { + t.Errorf("Failed to connect: %s", err) + } + err = conn.SendMail([]string{"hi@bye.com"}, "You are already a winner!", "Just kidding") + if err != nil { + t.Errorf("Expected SendMail() to not fail. Got err: %s", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http.go b/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http.go new file mode 100644 index 000000000..ecd50b284 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http.go @@ -0,0 +1,91 @@ +package measured_http + +import ( + "net/http" + "strconv" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// responseWriterWithStatus satisfies http.ResponseWriter, but keeps track of the +// status code for gathering stats. +type responseWriterWithStatus struct { + http.ResponseWriter + code int +} + +// WriteHeader stores a status code for generating stats. +func (r *responseWriterWithStatus) WriteHeader(code int) { + r.code = code + r.ResponseWriter.WriteHeader(code) +} + +// Write writes the body and sets the status code to 200 if a status code +// has not already been set. +func (r *responseWriterWithStatus) Write(body []byte) (int, error) { + if r.code == 0 { + r.code = http.StatusOK + } + return r.ResponseWriter.Write(body) +} + +// serveMux is a partial interface wrapper for the method http.ServeMux +// exposes that we use. This is needed so that we can replace the default +// http.ServeMux in ocsp-responder where we don't want to use its path +// canonicalization. +type serveMux interface { + Handler(*http.Request) (http.Handler, string) +} + +// MeasuredHandler wraps an http.Handler and records prometheus stats +type MeasuredHandler struct { + serveMux + clk clock.Clock + // Normally this is always responseTime, but we override it for testing. + stat *prometheus.HistogramVec +} + +func New(m serveMux, clk clock.Clock, stats prometheus.Registerer, opts ...otelhttp.Option) http.Handler { + responseTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "response_time", + Help: "Time taken to respond to a request", + }, + []string{"endpoint", "method", "code"}) + stats.MustRegister(responseTime) + return otelhttp.NewHandler(&MeasuredHandler{ + serveMux: m, + clk: clk, + stat: responseTime, + }, "server", opts...) +} + +func (h *MeasuredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + begin := h.clk.Now() + rwws := &responseWriterWithStatus{w, 0} + + // Use the method string only if it's a recognized HTTP method. This avoids + // ballooning timeseries with invalid methods from public input. + var method string + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodConnect, + http.MethodOptions, http.MethodTrace: + method = r.Method + default: + method = "unknown" + } + + subHandler, pattern := h.Handler(r) + defer func() { + h.stat.With(prometheus.Labels{ + "endpoint": pattern, + "method": method, + "code": strconv.Itoa(rwws.code), + }).Observe(h.clk.Since(begin).Seconds()) + }() + + subHandler.ServeHTTP(rwws, r) +} diff --git a/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http_test.go b/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http_test.go new file mode 100644 index 000000000..ee435c353 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/metrics/measured_http/http_test.go @@ -0,0 +1,210 @@ +package measured_http + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + io_prometheus_client "github.com/prometheus/client_model/go" +) + +type sleepyHandler struct { + clk clock.FakeClock +} + +func (h sleepyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.clk.Sleep(999 * time.Second) + w.WriteHeader(302) +} + +func collect(m prometheus.Collector) *io_prometheus_client.Metric { + ch := make(chan prometheus.Metric, 10) + m.Collect(ch) + result := <-ch + var iom = new(io_prometheus_client.Metric) + _ = result.Write(iom) + return iom +} + +func TestMeasuring(t *testing.T) { + clk := clock.NewFake() + + // Create a local histogram stat with the same labels as the real one, but + // don't register it; we will collect its data here in the test to verify it. + stat := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "fake", + Help: "fake", + }, + []string{"endpoint", "method", "code"}) + + mux := http.NewServeMux() + mux.Handle("/foo", sleepyHandler{clk}) + mh := MeasuredHandler{ + serveMux: mux, + clk: clk, + stat: stat, + } + mh.ServeHTTP(httptest.NewRecorder(), &http.Request{ + URL: &url.URL{Path: "/foo"}, + Method: "GET", + }) + iom := collect(stat) + + hist := iom.Histogram + if *hist.SampleCount != 1 { + t.Errorf("SampleCount = %d (expected 1)", *hist.SampleCount) + } + if *hist.SampleSum != 999 { + t.Errorf("SampleSum = %g (expected 999)", *hist.SampleSum) + } + + expectedLabels := map[string]string{ + "endpoint": "/foo", + "method": "GET", + "code": "302", + } + for _, labelPair := range iom.Label { + if expectedLabels[*labelPair.Name] == "" { + t.Errorf("Unexpected label %s", *labelPair.Name) + } else if expectedLabels[*labelPair.Name] != *labelPair.Value { + t.Errorf("labels[%q] = %q (expected %q)", *labelPair.Name, *labelPair.Value, + expectedLabels[*labelPair.Name]) + } + delete(expectedLabels, *labelPair.Name) + } + if len(expectedLabels) != 0 { + t.Errorf("Some labels were expected, but not observed: %v", expectedLabels) + } +} + +// Make an HTTP request with an unknown method and ensure we use the appropriate +// label value. +func TestUnknownMethod(t *testing.T) { + clk := clock.NewFake() + + // Create a local histogram stat with the same labels as the real one, but + // don't register it; we will collect its data here in the test to verify it. + stat := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "fake", + Help: "fake", + }, + []string{"endpoint", "method", "code"}) + + mux := http.NewServeMux() + mux.Handle("/foo", sleepyHandler{clk}) + mh := MeasuredHandler{ + serveMux: mux, + clk: clk, + stat: stat, + } + mh.ServeHTTP(httptest.NewRecorder(), &http.Request{ + URL: &url.URL{Path: "/foo"}, + Method: "POKE", + }) + iom := collect(stat) + + expectedLabels := map[string]string{ + "endpoint": "/foo", + "method": "unknown", + "code": "302", + } + for _, labelPair := range iom.Label { + if expectedLabels[*labelPair.Name] == "" { + t.Errorf("Unexpected label %s", *labelPair.Name) + } else if expectedLabels[*labelPair.Name] != *labelPair.Value { + t.Errorf("labels[%q] = %q (expected %q)", *labelPair.Name, *labelPair.Value, + expectedLabels[*labelPair.Name]) + } + delete(expectedLabels, *labelPair.Name) + } + if len(expectedLabels) != 0 { + t.Errorf("Some labels were expected, but not observed: %v", expectedLabels) + } +} + +func TestWrite(t *testing.T) { + clk := clock.NewFake() + + // Create a local histogram stat with the same labels as the real one, but + // don't register it; we will collect its data here in the test to verify it. + stat := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "fake", + Help: "fake", + }, + []string{"endpoint", "method", "code"}) + + mux := http.NewServeMux() + mux.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte{}) + }) + mh := MeasuredHandler{ + serveMux: mux, + clk: clk, + stat: stat, + } + mh.ServeHTTP(httptest.NewRecorder(), &http.Request{ + URL: &url.URL{Path: "/foo"}, + Method: "GET", + }) + iom := collect(stat) + + stat = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "fake", + Help: "fake", + }, + []string{"endpoint", "method", "code"}) + mh.stat = stat + expectedLabels := map[string]string{ + "endpoint": "/foo", + "method": "GET", + "code": "200", + } + for _, labelPair := range iom.Label { + if expectedLabels[*labelPair.Name] == "" { + t.Errorf("Unexpected label %s", *labelPair.Name) + } else if expectedLabels[*labelPair.Name] != *labelPair.Value { + t.Errorf("labels[%q] = %q (expected %q)", *labelPair.Name, *labelPair.Value, + expectedLabels[*labelPair.Name]) + } + delete(expectedLabels, *labelPair.Name) + } + if len(expectedLabels) != 0 { + t.Errorf("Some labels were expected, but not observed: %v", expectedLabels) + } + + mux.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(202) + w.Write([]byte{}) + }) + mh.ServeHTTP(httptest.NewRecorder(), &http.Request{ + URL: &url.URL{Path: "/bar"}, + Method: "GET", + }) + iom = collect(stat) + + expectedLabels = map[string]string{ + "endpoint": "/bar", + "method": "GET", + "code": "202", + } + for _, labelPair := range iom.Label { + if expectedLabels[*labelPair.Name] == "" { + t.Errorf("Unexpected label %s", *labelPair.Name) + } else if expectedLabels[*labelPair.Name] != *labelPair.Value { + t.Errorf("labels[%q] = %q (expected %q)", *labelPair.Name, *labelPair.Value, + expectedLabels[*labelPair.Name]) + } + delete(expectedLabels, *labelPair.Name) + } + if len(expectedLabels) != 0 { + t.Errorf("Some labels were expected, but not observed: %v", expectedLabels) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/metrics/scope.go b/third-party/github.com/letsencrypt/boulder/metrics/scope.go new file mode 100644 index 000000000..d99f7232a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/metrics/scope.go @@ -0,0 +1,19 @@ +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +// InternetFacingBuckets are the histogram buckets that should be used when +// measuring latencies that involve traversing the public internet. +var InternetFacingBuckets = []float64{.1, .5, 1, 5, 10, 30, 45} + +// noopRegisterer mocks prometheus.Registerer. It is used when we need to +// register prometheus metrics in tests where multiple registrations would +// cause a panic. +type noopRegisterer struct{} + +func (np *noopRegisterer) MustRegister(_ ...prometheus.Collector) {} + +func (np *noopRegisterer) Register(_ prometheus.Collector) error { return nil } +func (np *noopRegisterer) Unregister(_ prometheus.Collector) bool { return true } + +var NoopRegisterer = &noopRegisterer{} diff --git a/third-party/github.com/letsencrypt/boulder/mocks/ca.go b/third-party/github.com/letsencrypt/boulder/mocks/ca.go new file mode 100644 index 000000000..929c204e7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mocks/ca.go @@ -0,0 +1,69 @@ +package mocks + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "fmt" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + corepb "github.com/letsencrypt/boulder/core/proto" +) + +// MockCA is a mock of a CA that always returns the cert from PEM in response to +// IssueCertificate. +type MockCA struct { + PEM []byte +} + +// IssuePrecertificate is a mock +func (ca *MockCA) IssuePrecertificate(ctx context.Context, req *capb.IssueCertificateRequest, _ ...grpc.CallOption) (*capb.IssuePrecertificateResponse, error) { + if ca.PEM == nil { + return nil, fmt.Errorf("MockCA's PEM field must be set before calling IssueCertificate") + } + block, _ := pem.Decode(ca.PEM) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + profHash := sha256.Sum256([]byte(req.CertProfileName)) + return &capb.IssuePrecertificateResponse{ + DER: cert.Raw, + CertProfileHash: profHash[:8], + CertProfileName: req.CertProfileName, + }, nil +} + +// IssueCertificateForPrecertificate is a mock +func (ca *MockCA) IssueCertificateForPrecertificate(ctx context.Context, req *capb.IssueCertificateForPrecertificateRequest, _ ...grpc.CallOption) (*corepb.Certificate, error) { + now := time.Now() + expires := now.Add(1 * time.Hour) + + return &corepb.Certificate{ + Der: req.DER, + RegistrationID: 1, + Serial: "mock", + Digest: "mock", + Issued: timestamppb.New(now), + Expires: timestamppb.New(expires), + }, nil +} + +type MockOCSPGenerator struct{} + +// GenerateOCSP is a mock +func (ca *MockOCSPGenerator) GenerateOCSP(ctx context.Context, req *capb.GenerateOCSPRequest, _ ...grpc.CallOption) (*capb.OCSPResponse, error) { + return nil, nil +} + +type MockCRLGenerator struct{} + +// GenerateCRL is a mock +func (ca *MockCRLGenerator) GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[capb.GenerateCRLRequest, capb.GenerateCRLResponse], error) { + return nil, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/mocks/grpc.go b/third-party/github.com/letsencrypt/boulder/mocks/grpc.go new file mode 100644 index 000000000..f1c18f2c7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mocks/grpc.go @@ -0,0 +1,31 @@ +package mocks + +import ( + "io" + + "google.golang.org/grpc" +) + +// ServerStreamClient is a mock which satisfies the grpc.ClientStream interface, +// allowing it to be returned by methods where the server returns a stream of +// results. It can be populated with a list of results to return, or an error +// to return. +type ServerStreamClient[T any] struct { + grpc.ClientStream + Results []*T + Err error +} + +// Recv returns the error, if populated. Otherwise it returns the next item from +// the list of results. If it has returned all items already, it returns EOF. +func (c *ServerStreamClient[T]) Recv() (*T, error) { + if c.Err != nil { + return nil, c.Err + } + if len(c.Results) == 0 { + return nil, io.EOF + } + res := c.Results[0] + c.Results = c.Results[1:] + return res, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/mocks/mailer.go b/third-party/github.com/letsencrypt/boulder/mocks/mailer.go new file mode 100644 index 000000000..a6081aebb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mocks/mailer.go @@ -0,0 +1,60 @@ +package mocks + +import ( + "sync" + + "github.com/letsencrypt/boulder/mail" +) + +// Mailer is a mock +type Mailer struct { + sync.Mutex + Messages []MailerMessage +} + +var _ mail.Mailer = &Mailer{} + +// mockMailerConn is a mock that satisfies the mail.Conn interface +type mockMailerConn struct { + parent *Mailer +} + +var _ mail.Conn = &mockMailerConn{} + +// MailerMessage holds the captured emails from SendMail() +type MailerMessage struct { + To string + Subject string + Body string +} + +// Clear removes any previously recorded messages +func (m *Mailer) Clear() { + m.Lock() + defer m.Unlock() + m.Messages = nil +} + +// SendMail is a mock +func (m *mockMailerConn) SendMail(to []string, subject, msg string) error { + m.parent.Lock() + defer m.parent.Unlock() + for _, rcpt := range to { + m.parent.Messages = append(m.parent.Messages, MailerMessage{ + To: rcpt, + Subject: subject, + Body: msg, + }) + } + return nil +} + +// Close is a mock +func (m *mockMailerConn) Close() error { + return nil +} + +// Connect is a mock +func (m *Mailer) Connect() (mail.Conn, error) { + return &mockMailerConn{parent: m}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/mocks/publisher.go b/third-party/github.com/letsencrypt/boulder/mocks/publisher.go new file mode 100644 index 000000000..256215718 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mocks/publisher.go @@ -0,0 +1,19 @@ +package mocks + +import ( + "context" + + "google.golang.org/grpc" + + pubpb "github.com/letsencrypt/boulder/publisher/proto" +) + +// PublisherClient is a mock +type PublisherClient struct { + // empty +} + +// SubmitToSingleCTWithResult is a mock +func (*PublisherClient) SubmitToSingleCTWithResult(_ context.Context, _ *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + return &pubpb.Result{}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/mocks/sa.go b/third-party/github.com/letsencrypt/boulder/mocks/sa.go new file mode 100644 index 000000000..032378d78 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/mocks/sa.go @@ -0,0 +1,622 @@ +package mocks + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "fmt" + "math/rand" + "net" + "os" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/jmhodges/clock" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + berrors "github.com/letsencrypt/boulder/errors" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// StorageAuthorityReadOnly is a mock of sapb.StorageAuthorityReadOnlyClient +type StorageAuthorityReadOnly struct { + clk clock.Clock +} + +// NewStorageAuthorityReadOnly creates a new mock read-only storage authority +// with the given clock. +func NewStorageAuthorityReadOnly(clk clock.Clock) *StorageAuthorityReadOnly { + return &StorageAuthorityReadOnly{clk} +} + +// StorageAuthority is a mock of sapb.StorageAuthorityClient +type StorageAuthority struct { + StorageAuthorityReadOnly +} + +// NewStorageAuthority creates a new mock storage authority +// with the given clock. +func NewStorageAuthority(clk clock.Clock) *StorageAuthority { + return &StorageAuthority{StorageAuthorityReadOnly{clk}} +} + +const ( + test1KeyPublicJSON = `{"kty":"RSA","n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ","e":"AQAB"}` + test2KeyPublicJSON = `{"kty":"RSA","n":"qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw","e":"AQAB"}` + testE1KeyPublicJSON = `{"kty":"EC","crv":"P-256","x":"FwvSZpu06i3frSk_mz9HcD9nETn4wf3mQ-zDtG21Gao","y":"S8rR-0dWa8nAcw1fbunF_ajS3PQZ-QwLps-2adgLgPk"}` + testE2KeyPublicJSON = `{"kty":"EC","crv":"P-256","x":"S8FOmrZ3ywj4yyFqt0etAD90U-EnkNaOBSLfQmf7pNg","y":"vMvpDyqFDRHjGfZ1siDOm5LS6xNdR5xTpyoQGLDOX2Q"}` + test3KeyPublicJSON = `{"kty":"RSA","n":"uTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0hSx2mPP7gBvis2lizZ9r-y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq-XhHZbFrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx-3uSvgZOuQA5ffEn5L38Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W-nV0WL17o7v8aDgV_t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9-g6rWnTqPbY3knffhp4m0scLD6e33k8MtzxDX_D7vHsg0_X1w","e":"AQAB"}` + test4KeyPublicJSON = `{"kty":"RSA","n":"qih-cx32M0wq8MhhN-kBi2xPE-wnw4_iIg1hWO5wtBfpt2PtWikgPuBT6jvK9oyQwAWbSfwqlVZatMPY_-3IyytMNb9R9OatNr6o5HROBoyZnDVSiC4iMRd7bRl_PWSIqj_MjhPNa9cYwBdW5iC3jM5TaOgmp0-YFm4tkLGirDcIBDkQYlnv9NKILvuwqkapZ7XBixeqdCcikUcTRXW5unqygO6bnapzw-YtPsPPlj4Ih3SvK4doyziPV96U8u5lbNYYEzYiW1mbu9n0KLvmKDikGcdOpf6-yRa_10kMZyYQatY1eclIKI0xb54kbluEl0GQDaL5FxLmiKeVnsapzw","e":"AQAB"}` + + agreementURL = "http://example.invalid/terms" +) + +// GetRegistration is a mock +func (sa *StorageAuthorityReadOnly) GetRegistration(_ context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { + if req.Id == 100 { + // Tag meaning "Missing" + return nil, errors.New("missing") + } + if req.Id == 101 { + // Tag meaning "Malformed" + return &corepb.Registration{}, nil + } + if req.Id == 102 { + // Tag meaning "Not Found" + return nil, berrors.NotFoundError("Dave's not here man") + } + + goodReg := &corepb.Registration{ + Id: req.Id, + Key: []byte(test1KeyPublicJSON), + Agreement: agreementURL, + Contact: []string{"mailto:person@mail.com"}, + ContactsPresent: true, + Status: string(core.StatusValid), + } + + // Return a populated registration with contacts for ID == 1 or ID == 5 + if req.Id == 1 || req.Id == 5 { + return goodReg, nil + } + + // Return a populated registration with a different key for ID == 2 + if req.Id == 2 { + goodReg.Key = []byte(test2KeyPublicJSON) + return goodReg, nil + } + + // Return a deactivated registration with a different key for ID == 3 + if req.Id == 3 { + goodReg.Key = []byte(test3KeyPublicJSON) + goodReg.Status = string(core.StatusDeactivated) + return goodReg, nil + } + + // Return a populated registration with a different key for ID == 4 + if req.Id == 4 { + goodReg.Key = []byte(test4KeyPublicJSON) + return goodReg, nil + } + + // Return a registration without the agreement set for ID == 6 + if req.Id == 6 { + goodReg.Agreement = "" + return goodReg, nil + } + + goodReg.InitialIP, _ = net.ParseIP("5.6.7.8").MarshalText() + goodReg.CreatedAt = timestamppb.New(time.Date(2003, 9, 27, 0, 0, 0, 0, time.UTC)) + return goodReg, nil +} + +// GetRegistrationByKey is a mock +func (sa *StorageAuthorityReadOnly) GetRegistrationByKey(_ context.Context, req *sapb.JSONWebKey, _ ...grpc.CallOption) (*corepb.Registration, error) { + test5KeyBytes, err := os.ReadFile("../test/test-key-5.der") + if err != nil { + return nil, err + } + test5KeyPriv, err := x509.ParsePKCS1PrivateKey(test5KeyBytes) + if err != nil { + return nil, err + } + test5KeyPublic := jose.JSONWebKey{Key: test5KeyPriv.Public()} + test5KeyPublicJSON, err := test5KeyPublic.MarshalJSON() + if err != nil { + return nil, err + } + + contacts := []string{"mailto:person@mail.com"} + + if bytes.Equal(req.Jwk, []byte(test1KeyPublicJSON)) { + return &corepb.Registration{ + Id: 1, + Key: req.Jwk, + Agreement: agreementURL, + Contact: contacts, + ContactsPresent: true, + Status: string(core.StatusValid), + }, nil + } + + if bytes.Equal(req.Jwk, []byte(test2KeyPublicJSON)) { + // No key found + return &corepb.Registration{Id: 2}, berrors.NotFoundError("reg not found") + } + + if bytes.Equal(req.Jwk, []byte(test4KeyPublicJSON)) { + // No key found + return &corepb.Registration{Id: 5}, berrors.NotFoundError("reg not found") + } + + if bytes.Equal(req.Jwk, test5KeyPublicJSON) { + // No key found + return &corepb.Registration{Id: 5}, berrors.NotFoundError("reg not found") + } + + if bytes.Equal(req.Jwk, []byte(testE1KeyPublicJSON)) { + return &corepb.Registration{Id: 3, Key: req.Jwk, Agreement: agreementURL}, nil + } + + if bytes.Equal(req.Jwk, []byte(testE2KeyPublicJSON)) { + return &corepb.Registration{Id: 4}, berrors.NotFoundError("reg not found") + } + + if bytes.Equal(req.Jwk, []byte(test3KeyPublicJSON)) { + // deactivated registration + return &corepb.Registration{ + Id: 2, + Key: req.Jwk, + Agreement: agreementURL, + Contact: contacts, + ContactsPresent: true, + Status: string(core.StatusDeactivated), + }, nil + } + + // Return a fake registration. Make sure to fill the key field to avoid marshaling errors. + return &corepb.Registration{ + Id: 1, + Key: []byte(test1KeyPublicJSON), + Agreement: agreementURL, + Status: string(core.StatusValid), + }, nil +} + +// GetSerialMetadata is a mock +func (sa *StorageAuthorityReadOnly) GetSerialMetadata(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.SerialMetadata, error) { + now := sa.clk.Now() + created := now.Add(-1 * time.Hour) + expires := now.Add(2159 * time.Hour) + return &sapb.SerialMetadata{ + Serial: req.Serial, + RegistrationID: 1, + Created: timestamppb.New(created), + Expires: timestamppb.New(expires), + }, nil +} + +// GetCertificate is a mock +func (sa *StorageAuthorityReadOnly) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + if req.Serial == "000000000000000000000000000000626164" { + return nil, errors.New("bad") + } else { + return nil, berrors.NotFoundError("No cert") + } +} + +// GetLintPrecertificate is a mock +func (sa *StorageAuthorityReadOnly) GetLintPrecertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, berrors.NotFoundError("No cert") +} + +// GetCertificateStatus is a mock +func (sa *StorageAuthorityReadOnly) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + return nil, errors.New("no cert status") +} + +func (sa *StorageAuthorityReadOnly) SetCertificateStatusReady(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "unimplemented mock") + +} + +// GetRevocationStatus is a mock +func (sa *StorageAuthorityReadOnly) GetRevocationStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.RevocationStatus, error) { + return nil, nil +} + +// SerialsForIncident is a mock +func (sa *StorageAuthorityReadOnly) SerialsForIncident(ctx context.Context, _ *sapb.SerialsForIncidentRequest, _ ...grpc.CallOption) (sapb.StorageAuthorityReadOnly_SerialsForIncidentClient, error) { + return &ServerStreamClient[sapb.IncidentSerial]{}, nil +} + +// SerialsForIncident is a mock +func (sa *StorageAuthority) SerialsForIncident(ctx context.Context, _ *sapb.SerialsForIncidentRequest, _ ...grpc.CallOption) (sapb.StorageAuthority_SerialsForIncidentClient, error) { + return &ServerStreamClient[sapb.IncidentSerial]{}, nil +} + +// CheckIdentifiersPaused is a mock +func (sa *StorageAuthorityReadOnly) CheckIdentifiersPaused(_ context.Context, _ *sapb.PauseRequest, _ ...grpc.CallOption) (*sapb.Identifiers, error) { + return nil, nil +} + +// CheckIdentifiersPaused is a mock +func (sa *StorageAuthority) CheckIdentifiersPaused(_ context.Context, _ *sapb.PauseRequest, _ ...grpc.CallOption) (*sapb.Identifiers, error) { + return nil, nil +} + +// GetPausedIdentifiers is a mock +func (sa *StorageAuthorityReadOnly) GetPausedIdentifiers(_ context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (*sapb.Identifiers, error) { + return nil, nil +} + +// GetPausedIdentifiers is a mock +func (sa *StorageAuthority) GetPausedIdentifiers(_ context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (*sapb.Identifiers, error) { + return nil, nil +} + +// GetRevokedCerts is a mock +func (sa *StorageAuthorityReadOnly) GetRevokedCerts(ctx context.Context, _ *sapb.GetRevokedCertsRequest, _ ...grpc.CallOption) (sapb.StorageAuthorityReadOnly_GetRevokedCertsClient, error) { + return &ServerStreamClient[corepb.CRLEntry]{}, nil +} + +// GetRevokedCerts is a mock +func (sa *StorageAuthority) GetRevokedCerts(ctx context.Context, _ *sapb.GetRevokedCertsRequest, _ ...grpc.CallOption) (sapb.StorageAuthority_GetRevokedCertsClient, error) { + return &ServerStreamClient[corepb.CRLEntry]{}, nil +} + +// GetMaxExpiration is a mock +func (sa *StorageAuthorityReadOnly) GetMaxExpiration(_ context.Context, req *emptypb.Empty, _ ...grpc.CallOption) (*timestamppb.Timestamp, error) { + return nil, nil +} + +// AddPrecertificate is a mock +func (sa *StorageAuthority) AddPrecertificate(ctx context.Context, req *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +// AddSerial is a mock +func (sa *StorageAuthority) AddSerial(ctx context.Context, req *sapb.AddSerialRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +// AddCertificate is a mock +func (sa *StorageAuthority) AddCertificate(_ context.Context, _ *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +// NewRegistration is a mock +func (sa *StorageAuthority) NewRegistration(_ context.Context, _ *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) { + return &corepb.Registration{}, nil +} + +// UpdateRegistration is a mock +func (sa *StorageAuthority) UpdateRegistration(_ context.Context, _ *corepb.Registration, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// CountFQDNSets is a mock +func (sa *StorageAuthorityReadOnly) CountFQDNSets(_ context.Context, _ *sapb.CountFQDNSetsRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +// FQDNSetTimestampsForWindow is a mock +func (sa *StorageAuthorityReadOnly) FQDNSetTimestampsForWindow(_ context.Context, _ *sapb.CountFQDNSetsRequest, _ ...grpc.CallOption) (*sapb.Timestamps, error) { + return &sapb.Timestamps{}, nil +} + +// FQDNSetExists is a mock +func (sa *StorageAuthorityReadOnly) FQDNSetExists(_ context.Context, _ *sapb.FQDNSetExistsRequest, _ ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: false}, nil +} + +// CountCertificatesByNames is a mock +func (sa *StorageAuthorityReadOnly) CountCertificatesByNames(_ context.Context, _ *sapb.CountCertificatesByNamesRequest, _ ...grpc.CallOption) (*sapb.CountByNames, error) { + return &sapb.CountByNames{}, nil +} + +// CountRegistrationsByIP is a mock +func (sa *StorageAuthorityReadOnly) CountRegistrationsByIP(_ context.Context, _ *sapb.CountRegistrationsByIPRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +// CountRegistrationsByIPRange is a mock +func (sa *StorageAuthorityReadOnly) CountRegistrationsByIPRange(_ context.Context, _ *sapb.CountRegistrationsByIPRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +// CountOrders is a mock +func (sa *StorageAuthorityReadOnly) CountOrders(_ context.Context, _ *sapb.CountOrdersRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +// DeactivateRegistration is a mock +func (sa *StorageAuthority) DeactivateRegistration(_ context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// NewOrderAndAuthzs is a mock +func (sa *StorageAuthority) NewOrderAndAuthzs(_ context.Context, req *sapb.NewOrderAndAuthzsRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + response := &corepb.Order{ + // Fields from the input new order request. + RegistrationID: req.NewOrder.RegistrationID, + Expires: req.NewOrder.Expires, + Names: req.NewOrder.Names, + V2Authorizations: req.NewOrder.V2Authorizations, + // Mock new fields generated by the database transaction. + Id: rand.Int63(), + Created: timestamppb.Now(), + // A new order is never processing because it can't have been finalized yet. + BeganProcessing: false, + Status: string(core.StatusPending), + CertificateProfileName: req.NewOrder.CertificateProfileName, + } + return response, nil +} + +// SetOrderProcessing is a mock +func (sa *StorageAuthority) SetOrderProcessing(_ context.Context, req *sapb.OrderRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// SetOrderError is a mock +func (sa *StorageAuthority) SetOrderError(_ context.Context, req *sapb.SetOrderErrorRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// FinalizeOrder is a mock +func (sa *StorageAuthority) FinalizeOrder(_ context.Context, req *sapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// GetOrder is a mock +func (sa *StorageAuthorityReadOnly) GetOrder(_ context.Context, req *sapb.OrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + if req.Id == 2 { + return nil, berrors.NotFoundError("bad") + } else if req.Id == 3 { + return nil, errors.New("very bad") + } + + now := sa.clk.Now() + created := now.AddDate(-30, 0, 0) + exp := now.AddDate(30, 0, 0) + validOrder := &corepb.Order{ + Id: req.Id, + RegistrationID: 1, + Created: timestamppb.New(created), + Expires: timestamppb.New(exp), + Names: []string{"example.com"}, + Status: string(core.StatusValid), + V2Authorizations: []int64{1}, + CertificateSerial: "serial", + Error: nil, + CertificateProfileName: "defaultBoulderCertificateProfile", + } + + // Order ID doesn't have a certificate serial yet + if req.Id == 4 { + validOrder.Status = string(core.StatusPending) + validOrder.Id = req.Id + validOrder.CertificateSerial = "" + validOrder.Error = nil + return validOrder, nil + } + + // Order ID 6 belongs to reg ID 6 + if req.Id == 6 { + validOrder.Id = 6 + validOrder.RegistrationID = 6 + } + + // Order ID 7 is ready, but expired + if req.Id == 7 { + validOrder.Status = string(core.StatusReady) + validOrder.Expires = timestamppb.New(now.AddDate(-30, 0, 0)) + } + + if req.Id == 8 { + validOrder.Status = string(core.StatusReady) + } + + // Order 9 is fresh + if req.Id == 9 { + validOrder.Created = timestamppb.New(now.AddDate(0, 0, 1)) + } + + // Order 10 is processing + if req.Id == 10 { + validOrder.Status = string(core.StatusProcessing) + } + + return validOrder, nil +} + +func (sa *StorageAuthorityReadOnly) GetOrderForNames(_ context.Context, _ *sapb.GetOrderForNamesRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + return nil, nil +} + +func (sa *StorageAuthority) FinalizeAuthorization2(ctx context.Context, req *sapb.FinalizeAuthorizationRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (sa *StorageAuthority) DeactivateAuthorization2(ctx context.Context, req *sapb.AuthorizationID2, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +func (sa *StorageAuthorityReadOnly) CountPendingAuthorizations2(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +func (sa *StorageAuthorityReadOnly) GetValidOrderAuthorizations2(ctx context.Context, req *sapb.GetValidOrderAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + return nil, nil +} + +func (sa *StorageAuthorityReadOnly) CountInvalidAuthorizations2(ctx context.Context, req *sapb.CountInvalidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{}, nil +} + +func (sa *StorageAuthorityReadOnly) GetValidAuthorizations2(ctx context.Context, req *sapb.GetValidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + if req.RegistrationID != 1 && req.RegistrationID != 5 && req.RegistrationID != 4 { + return &sapb.Authorizations{}, nil + } + now := req.Now.AsTime() + auths := &sapb.Authorizations{} + for _, name := range req.Domains { + exp := now.AddDate(100, 0, 0) + authzPB, err := bgrpc.AuthzToPB(core.Authorization{ + Status: core.StatusValid, + RegistrationID: req.RegistrationID, + Expires: &exp, + Identifier: identifier.ACMEIdentifier{ + Type: identifier.DNS, + Value: name, + }, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeDNS01, + Token: "exampleToken", + Validated: &now, + }, + }, + }) + if err != nil { + return nil, err + } + auths.Authz = append(auths.Authz, &sapb.Authorizations_MapElement{ + Domain: name, + Authz: authzPB, + }) + } + return auths, nil +} + +func (sa *StorageAuthorityReadOnly) GetAuthorizations2(ctx context.Context, req *sapb.GetAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + return &sapb.Authorizations{}, nil +} + +func (sa *StorageAuthorityReadOnly) GetPendingAuthorization2(ctx context.Context, req *sapb.GetPendingAuthorizationRequest, _ ...grpc.CallOption) (*corepb.Authorization, error) { + return nil, nil +} + +var ( + authzIdValid = int64(1) + authzIdPending = int64(2) + authzIdExpired = int64(3) + authzIdErrorResult = int64(4) + authzIdDiffAccount = int64(5) +) + +// GetAuthorization2 is a mock +func (sa *StorageAuthorityReadOnly) GetAuthorization2(ctx context.Context, id *sapb.AuthorizationID2, _ ...grpc.CallOption) (*corepb.Authorization, error) { + authz := core.Authorization{ + Status: core.StatusValid, + RegistrationID: 1, + Identifier: identifier.DNSIdentifier("not-an-example.com"), + Challenges: []core.Challenge{ + { + Status: "pending", + Token: "token", + Type: "dns", + }, + }, + } + + switch id.Id { + case authzIdValid: + exp := sa.clk.Now().AddDate(100, 0, 0) + authz.Expires = &exp + authz.ID = fmt.Sprintf("%d", authzIdValid) + return bgrpc.AuthzToPB(authz) + case authzIdPending: + exp := sa.clk.Now().AddDate(100, 0, 0) + authz.Expires = &exp + authz.ID = fmt.Sprintf("%d", authzIdPending) + authz.Status = core.StatusPending + return bgrpc.AuthzToPB(authz) + case authzIdExpired: + exp := sa.clk.Now().AddDate(0, -1, 0) + authz.Expires = &exp + authz.ID = fmt.Sprintf("%d", authzIdExpired) + return bgrpc.AuthzToPB(authz) + case authzIdErrorResult: + return nil, fmt.Errorf("unspecified database error") + case authzIdDiffAccount: + exp := sa.clk.Now().AddDate(100, 0, 0) + authz.RegistrationID = 2 + authz.Expires = &exp + authz.ID = fmt.Sprintf("%d", authzIdDiffAccount) + return bgrpc.AuthzToPB(authz) + } + + return nil, berrors.NotFoundError("no authorization found with id %q", id) +} + +// GetSerialsByKey is a mock +func (sa *StorageAuthorityReadOnly) GetSerialsByKey(ctx context.Context, _ *sapb.SPKIHash, _ ...grpc.CallOption) (sapb.StorageAuthorityReadOnly_GetSerialsByKeyClient, error) { + return &ServerStreamClient[sapb.Serial]{}, nil +} + +// GetSerialsByKey is a mock +func (sa *StorageAuthority) GetSerialsByKey(ctx context.Context, _ *sapb.SPKIHash, _ ...grpc.CallOption) (sapb.StorageAuthority_GetSerialsByKeyClient, error) { + return &ServerStreamClient[sapb.Serial]{}, nil +} + +// GetSerialsByAccount is a mock +func (sa *StorageAuthorityReadOnly) GetSerialsByAccount(ctx context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (sapb.StorageAuthorityReadOnly_GetSerialsByAccountClient, error) { + return &ServerStreamClient[sapb.Serial]{}, nil +} + +// GetSerialsByAccount is a mock +func (sa *StorageAuthority) GetSerialsByAccount(ctx context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (sapb.StorageAuthority_GetSerialsByAccountClient, error) { + return &ServerStreamClient[sapb.Serial]{}, nil +} + +// RevokeCertificate is a mock +func (sa *StorageAuthority) RevokeCertificate(ctx context.Context, req *sapb.RevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +// UpdateRevokedCertificate is a mock +func (sa *StorageAuthority) UpdateRevokedCertificate(ctx context.Context, req *sapb.RevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil +} + +// AddBlockedKey is a mock +func (sa *StorageAuthority) AddBlockedKey(ctx context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// KeyBlocked is a mock +func (sa *StorageAuthorityReadOnly) KeyBlocked(ctx context.Context, req *sapb.SPKIHash, _ ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: false}, nil +} + +// IncidentsForSerial is a mock. +func (sa *StorageAuthorityReadOnly) IncidentsForSerial(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.Incidents, error) { + return &sapb.Incidents{}, nil +} + +// LeaseCRLShard is a mock. +func (sa *StorageAuthority) LeaseCRLShard(ctx context.Context, req *sapb.LeaseCRLShardRequest, _ ...grpc.CallOption) (*sapb.LeaseCRLShardResponse, error) { + return nil, errors.New("unimplemented") +} + +// UpdateCRLShard is a mock. +func (sa *StorageAuthority) UpdateCRLShard(ctx context.Context, req *sapb.UpdateCRLShardRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, errors.New("unimplemented") +} + +// ReplacementOrderExists is a mock. +func (sa *StorageAuthorityReadOnly) ReplacementOrderExists(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.Exists, error) { + return nil, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/must/must.go b/third-party/github.com/letsencrypt/boulder/must/must.go new file mode 100644 index 000000000..a7b133731 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/must/must.go @@ -0,0 +1,15 @@ +package must + +// Do panics if err is not nil, otherwise returns t. +// It is useful in wrapping a two-value function call +// where you know statically that the call will succeed. +// +// Example: +// +// url := must.Do(url.Parse("http://example.com")) +func Do[T any](t T, err error) T { + if err != nil { + panic(err) + } + return t +} diff --git a/third-party/github.com/letsencrypt/boulder/must/must_test.go b/third-party/github.com/letsencrypt/boulder/must/must_test.go new file mode 100644 index 000000000..7078fb35d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/must/must_test.go @@ -0,0 +1,13 @@ +package must + +import ( + "net/url" + "testing" +) + +func TestDo(t *testing.T) { + url := Do(url.Parse("http://example.com")) + if url.Host != "example.com" { + t.Errorf("expected host to be example.com, got %s", url.Host) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/nonce/nonce.go b/third-party/github.com/letsencrypt/boulder/nonce/nonce.go new file mode 100644 index 000000000..388ab62d0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/nonce/nonce.go @@ -0,0 +1,340 @@ +// Package nonce implements a service for generating and redeeming nonces. +// To generate a nonce, it encrypts a monotonically increasing counter (latest) +// using an authenticated cipher. To redeem a nonce, it checks that the nonce +// decrypts to a valid integer between the earliest and latest counter values, +// and that it's not on the cross-off list. To avoid a constantly growing cross-off +// list, the nonce service periodically retires the oldest counter values by +// finding the lowest counter value in the cross-off list, deleting it, and setting +// "earliest" to its value. To make this efficient, the cross-off list is represented +// two ways: Once as a map, for quick lookup of a given value, and once as a heap, +// to quickly find the lowest value. +// The MaxUsed value determines how long a generated nonce can be used before it +// is forgotten. To calculate that period, divide the MaxUsed value by average +// redemption rate (valid POSTs per second). +package nonce + +import ( + "container/heap" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "math/big" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + noncepb "github.com/letsencrypt/boulder/nonce/proto" +) + +const ( + // PrefixLen is the character length of a nonce prefix. + PrefixLen = 8 + + // NonceLen is the character length of a nonce, excluding the prefix. + NonceLen = 32 + defaultMaxUsed = 65536 +) + +var errInvalidNonceLength = errors.New("invalid nonce length") + +// PrefixCtxKey is exported for use as a key in a context.Context. +type PrefixCtxKey struct{} + +// HMACKeyCtxKey is exported for use as a key in a context.Context. +type HMACKeyCtxKey struct{} + +// DerivePrefix derives a nonce prefix from the provided listening address and +// key. The prefix is derived by take the first 8 characters of the base64url +// encoded HMAC-SHA256 hash of the listening address using the provided key. +func DerivePrefix(grpcAddr, key string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(grpcAddr)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil))[:PrefixLen] +} + +// NonceService generates, cancels, and tracks Nonces. +type NonceService struct { + mu sync.Mutex + latest int64 + earliest int64 + used map[int64]bool + usedHeap *int64Heap + gcm cipher.AEAD + maxUsed int + prefix string + nonceCreates prometheus.Counter + nonceEarliest prometheus.Gauge + nonceRedeems *prometheus.CounterVec + nonceHeapLatency prometheus.Histogram +} + +type int64Heap []int64 + +func (h int64Heap) Len() int { return len(h) } +func (h int64Heap) Less(i, j int) bool { return h[i] < h[j] } +func (h int64Heap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *int64Heap) Push(x interface{}) { + *h = append(*h, x.(int64)) +} + +func (h *int64Heap) Pop() interface{} { + old := *h + n := len(old) + x := old[n-1] + *h = old[0 : n-1] + return x +} + +// NewNonceService constructs a NonceService with defaults +func NewNonceService(stats prometheus.Registerer, maxUsed int, prefix string) (*NonceService, error) { + // If a prefix is provided it must be eight characters and valid base64. The + // prefix is required to be base64url as RFC8555 section 6.5.1 requires that + // nonces use that encoding. As base64 operates on three byte binary segments + // we require the prefix to be six bytes (eight characters) so that the bytes + // preceding the prefix wouldn't impact the encoding. + if prefix != "" { + if len(prefix) != PrefixLen { + return nil, fmt.Errorf( + "nonce prefix must be %d characters, not %d", + PrefixLen, + len(prefix), + ) + } + if _, err := base64.RawURLEncoding.DecodeString(prefix); err != nil { + return nil, errors.New("nonce prefix must be valid base64url") + } + } + + key := make([]byte, 16) + if _, err := rand.Read(key); err != nil { + return nil, err + } + + c, err := aes.NewCipher(key) + if err != nil { + panic("Failure in NewCipher: " + err.Error()) + } + gcm, err := cipher.NewGCM(c) + if err != nil { + panic("Failure in NewGCM: " + err.Error()) + } + + if maxUsed <= 0 { + maxUsed = defaultMaxUsed + } + + nonceCreates := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "nonce_creates", + Help: "A counter of nonces generated", + }) + stats.MustRegister(nonceCreates) + nonceEarliest := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "nonce_earliest", + Help: "A gauge with the current earliest valid nonce value", + }) + stats.MustRegister(nonceEarliest) + nonceRedeems := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "nonce_redeems", + Help: "A counter of nonce validations labelled by result", + }, []string{"result", "error"}) + stats.MustRegister(nonceRedeems) + nonceHeapLatency := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "nonce_heap_latency", + Help: "A histogram of latencies of heap pop operations", + }) + stats.MustRegister(nonceHeapLatency) + + return &NonceService{ + earliest: 0, + latest: 0, + used: make(map[int64]bool, maxUsed), + usedHeap: &int64Heap{}, + gcm: gcm, + maxUsed: maxUsed, + prefix: prefix, + nonceCreates: nonceCreates, + nonceEarliest: nonceEarliest, + nonceRedeems: nonceRedeems, + nonceHeapLatency: nonceHeapLatency, + }, nil +} + +func (ns *NonceService) encrypt(counter int64) (string, error) { + // Generate a nonce with upper 4 bytes zero + nonce := make([]byte, 12) + for i := range 4 { + nonce[i] = 0 + } + _, err := rand.Read(nonce[4:]) + if err != nil { + return "", err + } + + // Encode counter to plaintext + pt := make([]byte, 8) + ctr := big.NewInt(counter) + pad := 8 - len(ctr.Bytes()) + copy(pt[pad:], ctr.Bytes()) + + // Encrypt + ret := make([]byte, NonceLen) + ct := ns.gcm.Seal(nil, nonce, pt, nil) + copy(ret, nonce[4:]) + copy(ret[8:], ct) + + return ns.prefix + base64.RawURLEncoding.EncodeToString(ret), nil +} + +func (ns *NonceService) decrypt(nonce string) (int64, error) { + body := nonce + if ns.prefix != "" { + var prefix string + var err error + prefix, body, err = ns.splitNonce(nonce) + if err != nil { + return 0, err + } + if ns.prefix != prefix { + return 0, fmt.Errorf("nonce contains invalid prefix: expected %q, got %q", ns.prefix, prefix) + } + } + decoded, err := base64.RawURLEncoding.DecodeString(body) + if err != nil { + return 0, err + } + if len(decoded) != NonceLen { + return 0, errInvalidNonceLength + } + + n := make([]byte, 12) + for i := range 4 { + n[i] = 0 + } + copy(n[4:], decoded[:8]) + + pt, err := ns.gcm.Open(nil, n, decoded[8:], nil) + if err != nil { + return 0, err + } + + ctr := big.NewInt(0) + ctr.SetBytes(pt) + return ctr.Int64(), nil +} + +// Nonce provides a new Nonce. +func (ns *NonceService) Nonce() (string, error) { + ns.mu.Lock() + ns.latest++ + latest := ns.latest + ns.mu.Unlock() + defer ns.nonceCreates.Inc() + return ns.encrypt(latest) +} + +// Valid determines whether the provided Nonce string is valid, returning +// true if so. +func (ns *NonceService) Valid(nonce string) bool { + c, err := ns.decrypt(nonce) + if err != nil { + ns.nonceRedeems.WithLabelValues("invalid", "decrypt").Inc() + return false + } + + ns.mu.Lock() + defer ns.mu.Unlock() + if c > ns.latest { + ns.nonceRedeems.WithLabelValues("invalid", "too high").Inc() + return false + } + + if c <= ns.earliest { + ns.nonceRedeems.WithLabelValues("invalid", "too low").Inc() + return false + } + + if ns.used[c] { + ns.nonceRedeems.WithLabelValues("invalid", "already used").Inc() + return false + } + + ns.used[c] = true + heap.Push(ns.usedHeap, c) + if len(ns.used) > ns.maxUsed { + s := time.Now() + ns.earliest = heap.Pop(ns.usedHeap).(int64) + ns.nonceEarliest.Set(float64(ns.earliest)) + ns.nonceHeapLatency.Observe(time.Since(s).Seconds()) + delete(ns.used, ns.earliest) + } + + ns.nonceRedeems.WithLabelValues("valid", "").Inc() + return true +} + +// splitNonce splits a nonce into a prefix and a body. +func (ns *NonceService) splitNonce(nonce string) (string, string, error) { + if len(nonce) < PrefixLen { + return "", "", errInvalidNonceLength + } + return nonce[:PrefixLen], nonce[PrefixLen:], nil +} + +// NewServer returns a new Server, wrapping a NonceService. +func NewServer(inner *NonceService) *Server { + return &Server{inner: inner} +} + +// Server implements the gRPC nonce service. +type Server struct { + noncepb.UnsafeNonceServiceServer + inner *NonceService +} + +var _ noncepb.NonceServiceServer = (*Server)(nil) + +// Redeem accepts a nonce from a gRPC client and redeems it using the inner nonce service. +func (ns *Server) Redeem(ctx context.Context, msg *noncepb.NonceMessage) (*noncepb.ValidMessage, error) { + return &noncepb.ValidMessage{Valid: ns.inner.Valid(msg.Nonce)}, nil +} + +// Nonce generates a nonce and sends it to a gRPC client. +func (ns *Server) Nonce(_ context.Context, _ *emptypb.Empty) (*noncepb.NonceMessage, error) { + nonce, err := ns.inner.Nonce() + if err != nil { + return nil, err + } + return &noncepb.NonceMessage{Nonce: nonce}, nil +} + +// Getter is an interface for an RPC client that can get a nonce. +type Getter interface { + Nonce(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*noncepb.NonceMessage, error) +} + +// Redeemer is an interface for an RPC client that can redeem a nonce. +type Redeemer interface { + Redeem(ctx context.Context, in *noncepb.NonceMessage, opts ...grpc.CallOption) (*noncepb.ValidMessage, error) +} + +// NewGetter returns a new noncepb.NonceServiceClient which can only be used to +// get nonces. +func NewGetter(cc grpc.ClientConnInterface) Getter { + return noncepb.NewNonceServiceClient(cc) +} + +// NewRedeemer returns a new noncepb.NonceServiceClient which can only be used +// to redeem nonces. +func NewRedeemer(cc grpc.ClientConnInterface) Redeemer { + return noncepb.NewNonceServiceClient(cc) +} diff --git a/third-party/github.com/letsencrypt/boulder/nonce/nonce_test.go b/third-party/github.com/letsencrypt/boulder/nonce/nonce_test.go new file mode 100644 index 000000000..db515d2a3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/nonce/nonce_test.go @@ -0,0 +1,152 @@ +package nonce + +import ( + "fmt" + "testing" + + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func TestValidNonce(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, ns.Valid(n), fmt.Sprintf("Did not recognize fresh nonce %s", n)) +} + +func TestAlreadyUsed(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, ns.Valid(n), "Did not recognize fresh nonce") + test.Assert(t, !ns.Valid(n), "Recognized the same nonce twice") +} + +func TestRejectMalformed(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, !ns.Valid("asdf"+n), "Accepted an invalid nonce") +} + +func TestRejectShort(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + test.Assert(t, !ns.Valid("aGkK"), "Accepted an invalid nonce") +} + +func TestRejectUnknown(t *testing.T) { + ns1, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + ns2, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + + n, err := ns1.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, !ns2.Valid(n), "Accepted a foreign nonce") +} + +func TestRejectTooLate(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + + ns.latest = 2 + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + ns.latest = 1 + test.Assert(t, !ns.Valid(n), "Accepted a nonce with a too-high counter") +} + +func TestRejectTooEarly(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + test.AssertNotError(t, err, "Could not create nonce service") + + n0, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + + for range ns.maxUsed { + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + if !ns.Valid(n) { + t.Errorf("generated invalid nonce") + } + } + + n1, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + n2, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + n3, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + + test.Assert(t, ns.Valid(n3), "Rejected a valid nonce") + test.Assert(t, ns.Valid(n2), "Rejected a valid nonce") + test.Assert(t, ns.Valid(n1), "Rejected a valid nonce") + test.Assert(t, !ns.Valid(n0), "Accepted a nonce that we should have forgotten") +} + +func BenchmarkNonces(b *testing.B) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "") + if err != nil { + b.Fatal("creating nonce service", err) + } + + for range ns.maxUsed { + n, err := ns.Nonce() + if err != nil { + b.Fatal("noncing", err) + } + if !ns.Valid(n) { + b.Fatal("generated invalid nonce") + } + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + n, err := ns.Nonce() + if err != nil { + b.Fatal("noncing", err) + } + if !ns.Valid(n) { + b.Fatal("generated invalid nonce") + } + } + }) +} + +func TestNoncePrefixing(t *testing.T) { + ns, err := NewNonceService(metrics.NoopRegisterer, 0, "aluminum") + test.AssertNotError(t, err, "Could not create nonce service") + + n, err := ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, ns.Valid(n), "Valid nonce rejected") + + n, err = ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + n = n[1:] + test.Assert(t, !ns.Valid(n), "Valid nonce with incorrect prefix accepted") + + n, err = ns.Nonce() + test.AssertNotError(t, err, "Could not create nonce") + test.Assert(t, !ns.Valid(n[6:]), "Valid nonce without prefix accepted") +} + +func TestNoncePrefixValidation(t *testing.T) { + _, err := NewNonceService(metrics.NoopRegisterer, 0, "whatsup") + test.AssertError(t, err, "NewNonceService didn't fail with short prefix") + _, err = NewNonceService(metrics.NoopRegisterer, 0, "whatsup!") + test.AssertError(t, err, "NewNonceService didn't fail with invalid base64") + _, err = NewNonceService(metrics.NoopRegisterer, 0, "whatsupp") + test.AssertNotError(t, err, "NewNonceService failed with valid nonce prefix") +} + +func TestDerivePrefix(t *testing.T) { + prefix := DerivePrefix("192.168.1.1:8080", "3b8c758dd85e113ea340ce0b3a99f389d40a308548af94d1730a7692c1874f1f") + test.AssertEquals(t, prefix, "P9qQaK4o") +} diff --git a/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.pb.go b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.pb.go new file mode 100644 index 000000000..b500162f7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.pb.go @@ -0,0 +1,222 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: nonce.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type NonceMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Nonce string `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"` +} + +func (x *NonceMessage) Reset() { + *x = NonceMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_nonce_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NonceMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NonceMessage) ProtoMessage() {} + +func (x *NonceMessage) ProtoReflect() protoreflect.Message { + mi := &file_nonce_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NonceMessage.ProtoReflect.Descriptor instead. +func (*NonceMessage) Descriptor() ([]byte, []int) { + return file_nonce_proto_rawDescGZIP(), []int{0} +} + +func (x *NonceMessage) GetNonce() string { + if x != nil { + return x.Nonce + } + return "" +} + +type ValidMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` +} + +func (x *ValidMessage) Reset() { + *x = ValidMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_nonce_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidMessage) ProtoMessage() {} + +func (x *ValidMessage) ProtoReflect() protoreflect.Message { + mi := &file_nonce_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidMessage.ProtoReflect.Descriptor instead. +func (*ValidMessage) Descriptor() ([]byte, []int) { + return file_nonce_proto_rawDescGZIP(), []int{1} +} + +func (x *ValidMessage) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +var File_nonce_proto protoreflect.FileDescriptor + +var file_nonce_proto_rawDesc = []byte{ + 0x0a, 0x0b, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6e, + 0x6f, 0x6e, 0x63, 0x65, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x22, 0x24, 0x0a, 0x0c, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x22, 0x24, 0x0a, 0x0c, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x32, 0x7c, 0x0a, + 0x0c, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a, + 0x05, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x13, + 0x2e, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x2e, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, 0x0a, 0x06, 0x52, 0x65, 0x64, 0x65, 0x65, 0x6d, 0x12, + 0x13, 0x2e, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x2e, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x13, 0x2e, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x6e, 0x6f, + 0x6e, 0x63, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_nonce_proto_rawDescOnce sync.Once + file_nonce_proto_rawDescData = file_nonce_proto_rawDesc +) + +func file_nonce_proto_rawDescGZIP() []byte { + file_nonce_proto_rawDescOnce.Do(func() { + file_nonce_proto_rawDescData = protoimpl.X.CompressGZIP(file_nonce_proto_rawDescData) + }) + return file_nonce_proto_rawDescData +} + +var file_nonce_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_nonce_proto_goTypes = []interface{}{ + (*NonceMessage)(nil), // 0: nonce.NonceMessage + (*ValidMessage)(nil), // 1: nonce.ValidMessage + (*emptypb.Empty)(nil), // 2: google.protobuf.Empty +} +var file_nonce_proto_depIdxs = []int32{ + 2, // 0: nonce.NonceService.Nonce:input_type -> google.protobuf.Empty + 0, // 1: nonce.NonceService.Redeem:input_type -> nonce.NonceMessage + 0, // 2: nonce.NonceService.Nonce:output_type -> nonce.NonceMessage + 1, // 3: nonce.NonceService.Redeem:output_type -> nonce.ValidMessage + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_nonce_proto_init() } +func file_nonce_proto_init() { + if File_nonce_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_nonce_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NonceMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_nonce_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_nonce_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_nonce_proto_goTypes, + DependencyIndexes: file_nonce_proto_depIdxs, + MessageInfos: file_nonce_proto_msgTypes, + }.Build() + File_nonce_proto = out.File + file_nonce_proto_rawDesc = nil + file_nonce_proto_goTypes = nil + file_nonce_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.proto b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.proto new file mode 100644 index 000000000..f86255fcc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package nonce; +option go_package = "github.com/letsencrypt/boulder/nonce/proto"; + +import "google/protobuf/empty.proto"; + +service NonceService { + rpc Nonce(google.protobuf.Empty) returns (NonceMessage) {} + rpc Redeem(NonceMessage) returns (ValidMessage) {} +} + +message NonceMessage { + string nonce = 1; +} + +message ValidMessage { + bool valid = 1; +} diff --git a/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce_grpc.pb.go new file mode 100644 index 000000000..e3cb5412f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/nonce/proto/nonce_grpc.pb.go @@ -0,0 +1,149 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: nonce.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + NonceService_Nonce_FullMethodName = "/nonce.NonceService/Nonce" + NonceService_Redeem_FullMethodName = "/nonce.NonceService/Redeem" +) + +// NonceServiceClient is the client API for NonceService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type NonceServiceClient interface { + Nonce(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*NonceMessage, error) + Redeem(ctx context.Context, in *NonceMessage, opts ...grpc.CallOption) (*ValidMessage, error) +} + +type nonceServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewNonceServiceClient(cc grpc.ClientConnInterface) NonceServiceClient { + return &nonceServiceClient{cc} +} + +func (c *nonceServiceClient) Nonce(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*NonceMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(NonceMessage) + err := c.cc.Invoke(ctx, NonceService_Nonce_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nonceServiceClient) Redeem(ctx context.Context, in *NonceMessage, opts ...grpc.CallOption) (*ValidMessage, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidMessage) + err := c.cc.Invoke(ctx, NonceService_Redeem_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// NonceServiceServer is the server API for NonceService service. +// All implementations must embed UnimplementedNonceServiceServer +// for forward compatibility +type NonceServiceServer interface { + Nonce(context.Context, *emptypb.Empty) (*NonceMessage, error) + Redeem(context.Context, *NonceMessage) (*ValidMessage, error) + mustEmbedUnimplementedNonceServiceServer() +} + +// UnimplementedNonceServiceServer must be embedded to have forward compatible implementations. +type UnimplementedNonceServiceServer struct { +} + +func (UnimplementedNonceServiceServer) Nonce(context.Context, *emptypb.Empty) (*NonceMessage, error) { + return nil, status.Errorf(codes.Unimplemented, "method Nonce not implemented") +} +func (UnimplementedNonceServiceServer) Redeem(context.Context, *NonceMessage) (*ValidMessage, error) { + return nil, status.Errorf(codes.Unimplemented, "method Redeem not implemented") +} +func (UnimplementedNonceServiceServer) mustEmbedUnimplementedNonceServiceServer() {} + +// UnsafeNonceServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to NonceServiceServer will +// result in compilation errors. +type UnsafeNonceServiceServer interface { + mustEmbedUnimplementedNonceServiceServer() +} + +func RegisterNonceServiceServer(s grpc.ServiceRegistrar, srv NonceServiceServer) { + s.RegisterService(&NonceService_ServiceDesc, srv) +} + +func _NonceService_Nonce_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NonceServiceServer).Nonce(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NonceService_Nonce_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NonceServiceServer).Nonce(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _NonceService_Redeem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NonceMessage) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NonceServiceServer).Redeem(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NonceService_Redeem_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NonceServiceServer).Redeem(ctx, req.(*NonceMessage)) + } + return interceptor(ctx, in, info, handler) +} + +// NonceService_ServiceDesc is the grpc.ServiceDesc for NonceService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var NonceService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "nonce.NonceService", + HandlerType: (*NonceServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Nonce", + Handler: _NonceService_Nonce_Handler, + }, + { + MethodName: "Redeem", + Handler: _NonceService_Redeem_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "nonce.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/mon_conf.go b/third-party/github.com/letsencrypt/boulder/observer/mon_conf.go new file mode 100644 index 000000000..44ecb1a57 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/mon_conf.go @@ -0,0 +1,63 @@ +package observer + +import ( + "errors" + "time" + + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/yaml.v3" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/observer/probers" +) + +// MonConf is exported to receive YAML configuration in `ObsConf`. +type MonConf struct { + Period config.Duration `yaml:"period"` + Kind string `yaml:"kind" validate:"required,oneof=DNS HTTP CRL TLS TCP"` + Settings probers.Settings `yaml:"settings" validate:"min=1,dive"` +} + +// validatePeriod ensures the received `Period` field is at least 1µs. +func (c *MonConf) validatePeriod() error { + if c.Period.Duration < 1*time.Microsecond { + return errors.New("period must be at least 1µs") + } + return nil +} + +// unmarshalConfigurer constructs a `Configurer` by marshaling the +// value of the `Settings` field back to bytes, then passing it to the +// `UnmarshalSettings` method of the `Configurer` type specified by the +// `Kind` field. +func (c MonConf) unmarshalConfigurer() (probers.Configurer, error) { + configurer, err := probers.GetConfigurer(c.Kind) + if err != nil { + return nil, err + } + settings, _ := yaml.Marshal(c.Settings) + configurer, err = configurer.UnmarshalSettings(settings) + if err != nil { + return nil, err + } + return configurer, nil +} + +// makeMonitor constructs a `monitor` object from the contents of the +// bound `MonConf`. If the `MonConf` cannot be validated, an error +// appropriate for end-user consumption is returned instead. +func (c MonConf) makeMonitor(collectors map[string]prometheus.Collector) (*monitor, error) { + err := c.validatePeriod() + if err != nil { + return nil, err + } + probeConf, err := c.unmarshalConfigurer() + if err != nil { + return nil, err + } + prober, err := probeConf.MakeProber(collectors) + if err != nil { + return nil, err + } + return &monitor{c.Period.Duration, prober}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/mon_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/mon_conf_test.go new file mode 100644 index 000000000..24c5b7110 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/mon_conf_test.go @@ -0,0 +1,37 @@ +package observer + +import ( + "testing" + "time" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/test" +) + +func TestMonConf_validatePeriod(t *testing.T) { + type fields struct { + Period config.Duration + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + {"valid", fields{config.Duration{Duration: 1 * time.Microsecond}}, false}, + {"1 nanosecond", fields{config.Duration{Duration: 1 * time.Nanosecond}}, true}, + {"none supplied", fields{config.Duration{}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &MonConf{ + Period: tt.fields.Period, + } + err := c.validatePeriod() + if tt.wantErr { + test.AssertError(t, err, "MonConf.validatePeriod() should have errored") + } else { + test.AssertNotError(t, err, "MonConf.validatePeriod() shouldn't have errored") + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/monitor.go b/third-party/github.com/letsencrypt/boulder/observer/monitor.go new file mode 100644 index 000000000..c3073a860 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/monitor.go @@ -0,0 +1,38 @@ +package observer + +import ( + "strconv" + "time" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/observer/probers" +) + +type monitor struct { + period time.Duration + prober probers.Prober +} + +// start spins off a 'Prober' goroutine on an interval of `m.period` +// with a timeout of half `m.period` +func (m monitor) start(logger blog.Logger) { + ticker := time.NewTicker(m.period) + timeout := m.period / 2 + for { + go func() { + // Attempt to probe the configured target. + success, dur := m.prober.Probe(timeout) + + // Produce metrics to be scraped by Prometheus. + histObservations.WithLabelValues( + m.prober.Name(), m.prober.Kind(), strconv.FormatBool(success), + ).Observe(dur.Seconds()) + + // Log the outcome of the probe attempt. + logger.Infof( + "kind=[%s] success=[%v] duration=[%f] name=[%s]", + m.prober.Kind(), success, dur.Seconds(), m.prober.Name()) + }() + <-ticker.C + } +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/obs_conf.go b/third-party/github.com/letsencrypt/boulder/observer/obs_conf.go new file mode 100644 index 000000000..a761437ba --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/obs_conf.go @@ -0,0 +1,166 @@ +package observer + +import ( + "errors" + "fmt" + "net" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/observer/probers" +) + +var ( + countMonitors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obs_monitors", + Help: "details of each configured monitor", + }, + []string{"kind", "valid"}, + ) + histObservations *prometheus.HistogramVec +) + +// ObsConf is exported to receive YAML configuration. +type ObsConf struct { + DebugAddr string `yaml:"debugaddr" validate:"omitempty,hostname_port"` + Buckets []float64 `yaml:"buckets" validate:"min=1,dive"` + Syslog cmd.SyslogConfig `yaml:"syslog"` + OpenTelemetry cmd.OpenTelemetryConfig + MonConfs []*MonConf `yaml:"monitors" validate:"min=1,dive"` +} + +// validateSyslog ensures the `Syslog` field received by `ObsConf` +// contains valid log levels. +func (c *ObsConf) validateSyslog() error { + syslog, stdout := c.Syslog.SyslogLevel, c.Syslog.StdoutLevel + if stdout < 0 || stdout > 7 || syslog < 0 || syslog > 7 { + return fmt.Errorf( + "invalid 'syslog', '%+v', valid log levels are 0-7", c.Syslog) + } + return nil +} + +// validateDebugAddr ensures the `debugAddr` received by `ObsConf` is +// properly formatted and a valid port. +func (c *ObsConf) validateDebugAddr() error { + _, p, err := net.SplitHostPort(c.DebugAddr) + if err != nil { + return fmt.Errorf( + "invalid 'debugaddr', %q, not expected format", c.DebugAddr) + } + port, _ := strconv.Atoi(p) + if port <= 0 || port > 65535 { + return fmt.Errorf( + "invalid 'debugaddr','%d' is not a valid port", port) + } + return nil +} + +func (c *ObsConf) makeMonitors(metrics prometheus.Registerer) ([]*monitor, []error, error) { + var errs []error + var monitors []*monitor + proberSpecificMetrics := make(map[string]map[string]prometheus.Collector) + for e, m := range c.MonConfs { + entry := strconv.Itoa(e + 1) + proberConf, err := probers.GetConfigurer(m.Kind) + if err != nil { + // append error to errs + errs = append(errs, fmt.Errorf("'monitors' entry #%s couldn't be validated: %w", entry, err)) + // increment metrics + countMonitors.WithLabelValues(m.Kind, "false").Inc() + // bail out before constructing the monitor. with no configurer, it will fail + continue + } + kind := proberConf.Kind() + + // set up custom metrics internal to each prober kind + _, exist := proberSpecificMetrics[kind] + if !exist { + // we haven't seen this prober kind before, so we need to request + // any custom metrics it may have and register them with the + // prometheus registry + proberSpecificMetrics[kind] = make(map[string]prometheus.Collector) + for name, collector := range proberConf.Instrument() { + // register the collector with the prometheus registry + metrics.MustRegister(collector) + // store the registered collector so we can pass it to every + // monitor that will construct this kind of prober + proberSpecificMetrics[kind][name] = collector + } + } + + monitor, err := m.makeMonitor(proberSpecificMetrics[kind]) + if err != nil { + // append validation error to errs + errs = append(errs, fmt.Errorf("'monitors' entry #%s couldn't be validated: %w", entry, err)) + + // increment metrics + countMonitors.WithLabelValues(kind, "false").Inc() + } else { + // append monitor to monitors + monitors = append(monitors, monitor) + + // increment metrics + countMonitors.WithLabelValues(kind, "true").Inc() + } + } + if len(c.MonConfs) == len(errs) { + return nil, errs, errors.New("no valid monitors, cannot continue") + } + return monitors, errs, nil +} + +// MakeObserver constructs an `Observer` object from the contents of the +// bound `ObsConf`. If the `ObsConf` cannot be validated, an error +// appropriate for end-user consumption is returned instead. +func (c *ObsConf) MakeObserver() (*Observer, error) { + err := c.validateSyslog() + if err != nil { + return nil, err + } + + err = c.validateDebugAddr() + if err != nil { + return nil, err + } + + if len(c.MonConfs) == 0 { + return nil, errors.New("no monitors provided") + } + + if len(c.Buckets) == 0 { + return nil, errors.New("no histogram buckets provided") + } + + // Start monitoring and logging. + metrics, logger, shutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.DebugAddr) + histObservations = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "obs_observations", + Help: "details of each probe attempt", + Buckets: c.Buckets, + }, []string{"name", "kind", "success"}) + metrics.MustRegister(countMonitors) + metrics.MustRegister(histObservations) + defer cmd.AuditPanic() + logger.Info(cmd.VersionString()) + logger.Infof("Initializing boulder-observer daemon") + logger.Debugf("Using config: %+v", c) + + monitors, errs, err := c.makeMonitors(metrics) + if len(errs) != 0 { + logger.Errf("%d of %d monitors failed validation", len(errs), len(c.MonConfs)) + for _, err := range errs { + logger.Errf("%s", err) + } + } else { + logger.Info("all monitors passed validation") + } + if err != nil { + return nil, err + } + return &Observer{logger, monitors, shutdown}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/obs_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/obs_conf_test.go new file mode 100644 index 000000000..fea4f1628 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/obs_conf_test.go @@ -0,0 +1,142 @@ +package observer + +import ( + "errors" + "testing" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/observer/probers" + _ "github.com/letsencrypt/boulder/observer/probers/mock" + "github.com/letsencrypt/boulder/test" +) + +const ( + debugAddr = ":8040" + errDBZMsg = "over 9000" + mockConf = "Mock" +) + +func TestObsConf_makeMonitors(t *testing.T) { + var errDBZ = errors.New(errDBZMsg) + var cfgSyslog = cmd.SyslogConfig{StdoutLevel: 6, SyslogLevel: 6} + var cfgDur = config.Duration{Duration: time.Second * 5} + var cfgBuckets = []float64{.001} + var validMonConf = &MonConf{ + cfgDur, mockConf, probers.Settings{"valid": true, "pname": "foo", "pkind": "bar"}} + var invalidMonConf = &MonConf{ + cfgDur, mockConf, probers.Settings{"valid": false, "errmsg": errDBZMsg, "pname": "foo", "pkind": "bar"}} + type fields struct { + Syslog cmd.SyslogConfig + Buckets []float64 + DebugAddr string + MonConfs []*MonConf + } + tests := []struct { + name string + fields fields + errs []error + wantErr bool + }{ + // valid + {"1 valid", fields{cfgSyslog, cfgBuckets, debugAddr, []*MonConf{validMonConf}}, nil, false}, + {"2 valid", fields{ + cfgSyslog, cfgBuckets, debugAddr, []*MonConf{validMonConf, validMonConf}}, nil, false}, + {"1 valid, 1 invalid", fields{ + cfgSyslog, cfgBuckets, debugAddr, []*MonConf{validMonConf, invalidMonConf}}, []error{errDBZ}, false}, + {"1 valid, 2 invalid", fields{ + cfgSyslog, cfgBuckets, debugAddr, []*MonConf{invalidMonConf, validMonConf, invalidMonConf}}, []error{errDBZ, errDBZ}, false}, + // invalid + {"1 invalid", fields{cfgSyslog, cfgBuckets, debugAddr, []*MonConf{invalidMonConf}}, []error{errDBZ}, true}, + {"0", fields{cfgSyslog, cfgBuckets, debugAddr, []*MonConf{}}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ObsConf{ + Syslog: tt.fields.Syslog, + Buckets: tt.fields.Buckets, + DebugAddr: tt.fields.DebugAddr, + MonConfs: tt.fields.MonConfs, + } + _, errs, err := c.makeMonitors(metrics.NoopRegisterer) + if len(errs) != len(tt.errs) { + t.Errorf("ObsConf.validateMonConfs() errs = %d, want %d", len(errs), len(tt.errs)) + t.Logf("%v", errs) + } + if (err != nil) != tt.wantErr { + t.Errorf("ObsConf.validateMonConfs() err = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestObsConf_ValidateDebugAddr(t *testing.T) { + type fields struct { + DebugAddr string + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"max len and range", fields{":65535"}, false}, + {"min len and range", fields{":1"}, false}, + {"2 digits", fields{":80"}, false}, + // invalid + {"out of range high", fields{":65536"}, true}, + {"out of range low", fields{":0"}, true}, + {"not even a port", fields{":foo"}, true}, + {"missing :", fields{"foo"}, true}, + {"missing port", fields{"foo:"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ObsConf{ + DebugAddr: tt.fields.DebugAddr, + } + err := c.validateDebugAddr() + if tt.wantErr { + test.AssertError(t, err, "ObsConf.ValidateDebugAddr() should have errored") + } else { + test.AssertNotError(t, err, "ObsConf.ValidateDebugAddr() shouldn't have errored") + } + }) + } +} + +func TestObsConf_validateSyslog(t *testing.T) { + type fields struct { + Syslog cmd.SyslogConfig + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"valid", fields{cmd.SyslogConfig{StdoutLevel: 6, SyslogLevel: 6}}, false}, + // invalid + {"both too high", fields{cmd.SyslogConfig{StdoutLevel: 9, SyslogLevel: 9}}, true}, + {"stdout too high", fields{cmd.SyslogConfig{StdoutLevel: 9, SyslogLevel: 6}}, true}, + {"syslog too high", fields{cmd.SyslogConfig{StdoutLevel: 6, SyslogLevel: 9}}, true}, + {"both too low", fields{cmd.SyslogConfig{StdoutLevel: -1, SyslogLevel: -1}}, true}, + {"stdout too low", fields{cmd.SyslogConfig{StdoutLevel: -1, SyslogLevel: 6}}, true}, + {"syslog too low", fields{cmd.SyslogConfig{StdoutLevel: 6, SyslogLevel: -1}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ObsConf{ + Syslog: tt.fields.Syslog, + } + err := c.validateSyslog() + if tt.wantErr { + test.AssertError(t, err, "ObsConf.validateSyslog() should have errored") + } else { + test.AssertNotError(t, err, "ObsConf.validateSyslog() shouldn't have errored") + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/obsdialer/obsdialer.go b/third-party/github.com/letsencrypt/boulder/observer/obsdialer/obsdialer.go new file mode 100644 index 000000000..222f44308 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/obsdialer/obsdialer.go @@ -0,0 +1,10 @@ +// package obsdialer contains a custom dialer for use in observers. +package obsdialer + +import "net" + +// Dialer is a custom dialer for use in observers. It disables IPv6-to-IPv4 +// fallback so we don't mask failures of IPv6 connectivity. +var Dialer = net.Dialer{ + FallbackDelay: -1, // Disable IPv6-to-IPv4 fallback +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/observer.go b/third-party/github.com/letsencrypt/boulder/observer/observer.go new file mode 100644 index 000000000..d42b28d07 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/observer.go @@ -0,0 +1,30 @@ +package observer + +import ( + "context" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" + _ "github.com/letsencrypt/boulder/observer/probers/crl" + _ "github.com/letsencrypt/boulder/observer/probers/dns" + _ "github.com/letsencrypt/boulder/observer/probers/http" + _ "github.com/letsencrypt/boulder/observer/probers/tcp" + _ "github.com/letsencrypt/boulder/observer/probers/tls" +) + +// Observer is the steward of goroutines started for each `monitor`. +type Observer struct { + logger blog.Logger + monitors []*monitor + shutdown func(ctx context.Context) +} + +// Start spins off a goroutine for each monitor, and waits for a signal to exit +func (o Observer) Start() { + for _, mon := range o.monitors { + go mon.start(o.logger) + } + + defer o.shutdown(context.Background()) + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl.go b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl.go new file mode 100644 index 000000000..66f463038 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl.go @@ -0,0 +1,56 @@ +package probers + +import ( + "crypto/x509" + "io" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// CRLProbe is the exported 'Prober' object for monitors configured to +// monitor CRL availability & characteristics. +type CRLProbe struct { + url string + cNextUpdate *prometheus.GaugeVec + cThisUpdate *prometheus.GaugeVec + cCertCount *prometheus.GaugeVec +} + +// Name returns a string that uniquely identifies the monitor. +func (p CRLProbe) Name() string { + return p.url +} + +// Kind returns a name that uniquely identifies the `Kind` of `Prober`. +func (p CRLProbe) Kind() string { + return "CRL" +} + +// Probe requests the configured CRL and publishes metrics about it if found. +func (p CRLProbe) Probe(timeout time.Duration) (bool, time.Duration) { + start := time.Now() + resp, err := http.Get(p.url) + if err != nil { + return false, time.Since(start) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, time.Since(start) + } + dur := time.Since(start) + + crl, err := x509.ParseRevocationList(body) + if err != nil { + return false, dur + } + + // Report metrics for this CRL + p.cThisUpdate.WithLabelValues(p.url).Set(float64(crl.ThisUpdate.Unix())) + p.cNextUpdate.WithLabelValues(p.url).Set(float64(crl.NextUpdate.Unix())) + p.cCertCount.WithLabelValues(p.url).Set(float64(len(crl.RevokedCertificateEntries))) + + return true, dur +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf.go new file mode 100644 index 000000000..991a4328c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf.go @@ -0,0 +1,127 @@ +package probers + +import ( + "fmt" + "net/url" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + nextUpdateName = "obs_crl_next_update" + thisUpdateName = "obs_crl_this_update" + certCountName = "obs_crl_revoked_cert_count" +) + +// CRLConf is exported to receive YAML configuration +type CRLConf struct { + URL string `yaml:"url"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c CRLConf) Kind() string { + return "CRL" +} + +// UnmarshalSettings constructs a CRLConf object from YAML as bytes. +func (c CRLConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf CRLConf + err := strictyaml.Unmarshal(settings, &conf) + + if err != nil { + return nil, err + } + return conf, nil +} + +func (c CRLConf) validateURL() error { + url, err := url.Parse(c.URL) + if err != nil { + return fmt.Errorf( + "invalid 'url', got: %q, expected a valid url", c.URL) + } + if url.Scheme == "" { + return fmt.Errorf( + "invalid 'url', got: %q, missing scheme", c.URL) + } + return nil +} + +// MakeProber constructs a `CRLProbe` object from the contents of the +// bound `CRLConf` object. If the `CRLConf` cannot be validated, an +// error appropriate for end-user consumption is returned instead. +func (c CRLConf) MakeProber(collectors map[string]prometheus.Collector) (probers.Prober, error) { // validate `url` err := c.validateURL() + // validate `url` + err := c.validateURL() + if err != nil { + return nil, err + } + + // validate the prometheus collectors that were passed in + coll, ok := collectors[nextUpdateName] + if !ok { + return nil, fmt.Errorf("crl prober did not receive collector %q", nextUpdateName) + } + nextUpdateColl, ok := coll.(*prometheus.GaugeVec) + if !ok { + return nil, fmt.Errorf("crl prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", nextUpdateName, coll) + } + + coll, ok = collectors[thisUpdateName] + if !ok { + return nil, fmt.Errorf("crl prober did not receive collector %q", thisUpdateName) + } + thisUpdateColl, ok := coll.(*prometheus.GaugeVec) + if !ok { + return nil, fmt.Errorf("crl prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", thisUpdateName, coll) + } + + coll, ok = collectors[certCountName] + if !ok { + return nil, fmt.Errorf("crl prober did not receive collector %q", certCountName) + } + certCountColl, ok := coll.(*prometheus.GaugeVec) + if !ok { + return nil, fmt.Errorf("crl prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", certCountName, coll) + } + + return CRLProbe{c.URL, nextUpdateColl, thisUpdateColl, certCountColl}, nil +} + +// Instrument constructs any `prometheus.Collector` objects the `CRLProbe` will +// need to report its own metrics. A map is returned containing the constructed +// objects, indexed by the name of the prometheus metric. If no objects were +// constructed, nil is returned. +func (c CRLConf) Instrument() map[string]prometheus.Collector { + nextUpdate := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: nextUpdateName, + Help: "CRL nextUpdate Unix timestamp in seconds", + }, []string{"url"}, + )) + thisUpdate := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: thisUpdateName, + Help: "CRL thisUpdate Unix timestamp in seconds", + }, []string{"url"}, + )) + certCount := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: certCountName, + Help: "number of certificates revoked in CRL", + }, []string{"url"}, + )) + return map[string]prometheus.Collector{ + nextUpdateName: nextUpdate, + thisUpdateName: thisUpdate, + certCountName: certCount, + } +} + +// init is called at runtime and registers `CRLConf`, a `Prober` +// `Configurer` type, as "CRL". +func init() { + probers.Register(CRLConf{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf_test.go new file mode 100644 index 000000000..bb99aecaf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/crl/crl_conf_test.go @@ -0,0 +1,103 @@ +package probers + +import ( + "testing" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/test" + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/yaml.v3" +) + +func TestCRLConf_MakeProber(t *testing.T) { + conf := CRLConf{} + colls := conf.Instrument() + badColl := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "obs_crl_foo", + Help: "Hmmm, this shouldn't be here...", + }, + []string{}, + )) + type fields struct { + URL string + } + tests := []struct { + name string + fields fields + colls map[string]prometheus.Collector + wantErr bool + }{ + // valid + {"valid fqdn", fields{"http://example.com"}, colls, false}, + {"valid fqdn with path", fields{"http://example.com/foo/bar"}, colls, false}, + {"valid hostname", fields{"http://example"}, colls, false}, + // invalid + {"bad fqdn", fields{":::::"}, colls, true}, + {"missing scheme", fields{"example.com"}, colls, true}, + { + "unexpected collector", + fields{"http://example.com"}, + map[string]prometheus.Collector{"obs_crl_foo": badColl}, + true, + }, + { + "missing collectors", + fields{"http://example.com"}, + map[string]prometheus.Collector{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := CRLConf{ + URL: tt.fields.URL, + } + p, err := c.MakeProber(tt.colls) + if tt.wantErr { + test.AssertError(t, err, "CRLConf.MakeProber()") + } else { + test.AssertNotError(t, err, "CRLConf.MakeProber()") + + test.AssertNotNil(t, p, "CRLConf.MakeProber(): nil prober") + prober := p.(CRLProbe) + test.AssertNotNil(t, prober.cThisUpdate, "CRLConf.MakeProber(): nil cThisUpdate") + test.AssertNotNil(t, prober.cNextUpdate, "CRLConf.MakeProber(): nil cNextUpdate") + test.AssertNotNil(t, prober.cCertCount, "CRLConf.MakeProber(): nil cCertCount") + } + }) + } +} + +func TestCRLConf_UnmarshalSettings(t *testing.T) { + type fields struct { + url interface{} + } + tests := []struct { + name string + fields fields + want probers.Configurer + wantErr bool + }{ + {"valid", fields{"google.com"}, CRLConf{"google.com"}, false}, + {"invalid (map)", fields{make(map[string]interface{})}, nil, true}, + {"invalid (list)", fields{make([]string, 0)}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := probers.Settings{ + "url": tt.fields.url, + } + settingsBytes, _ := yaml.Marshal(settings) + t.Log(string(settingsBytes)) + c := CRLConf{} + got, err := c.UnmarshalSettings(settingsBytes) + if tt.wantErr { + test.AssertError(t, err, "CRLConf.UnmarshalSettings()") + } else { + test.AssertNotError(t, err, "CRLConf.UnmarshalSettings()") + } + test.AssertDeepEquals(t, got, tt.want) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns.go b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns.go new file mode 100644 index 000000000..5cb7676df --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns.go @@ -0,0 +1,55 @@ +package probers + +import ( + "fmt" + "time" + + "github.com/miekg/dns" +) + +// DNSProbe is the exported 'Prober' object for monitors configured to +// perform DNS requests. +type DNSProbe struct { + proto string + server string + recurse bool + qname string + qtype uint16 +} + +// Name returns a string that uniquely identifies the monitor. +func (p DNSProbe) Name() string { + recursion := func() string { + if p.recurse { + return "recurse" + } + return "no-recurse" + }() + return fmt.Sprintf( + "%s-%s-%s-%s-%s", p.server, p.proto, recursion, dns.TypeToString[p.qtype], p.qname) +} + +// Kind returns a name that uniquely identifies the `Kind` of `Prober`. +func (p DNSProbe) Kind() string { + return "DNS" +} + +// Probe performs the configured DNS query. +func (p DNSProbe) Probe(timeout time.Duration) (bool, time.Duration) { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(p.qname), p.qtype) + m.RecursionDesired = p.recurse + c := dns.Client{Timeout: timeout, Net: p.proto} + start := time.Now() + r, _, err := c.Exchange(m, p.server) + if err != nil { + return false, time.Since(start) + } + if r == nil { + return false, time.Since(start) + } + if r.Rcode != dns.RcodeSuccess { + return false, time.Since(start) + } + return true, time.Since(start) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf.go new file mode 100644 index 000000000..ecd92fb2d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf.go @@ -0,0 +1,144 @@ +package probers + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + validQTypes = map[string]uint16{"A": 1, "TXT": 16, "AAAA": 28, "CAA": 257} +) + +// DNSConf is exported to receive YAML configuration +type DNSConf struct { + Proto string `yaml:"protocol"` + Server string `yaml:"server"` + Recurse bool `yaml:"recurse"` + QName string `yaml:"query_name"` + QType string `yaml:"query_type"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c DNSConf) Kind() string { + return "DNS" +} + +// UnmarshalSettings constructs a DNSConf object from YAML as bytes. +func (c DNSConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf DNSConf + err := strictyaml.Unmarshal(settings, &conf) + if err != nil { + return nil, err + } + return conf, nil +} + +func (c DNSConf) validateServer() error { + server := strings.Trim(strings.ToLower(c.Server), " ") + // Ensure `server` contains a port. + host, port, err := net.SplitHostPort(server) + if err != nil || port == "" { + return fmt.Errorf( + "invalid `server`, %q, could not be split: %s", c.Server, err) + } + // Ensure `server` port is valid. + portNum, err := strconv.Atoi(port) + if err != nil { + return fmt.Errorf( + "invalid `server`, %q, port must be a number", c.Server) + } + if portNum <= 0 || portNum > 65535 { + return fmt.Errorf( + "invalid `server`, %q, port number must be one in [1-65535]", c.Server) + } + // Ensure `server` is a valid FQDN or IPv4 / IPv6 address. + IPv6 := net.ParseIP(host).To16() + IPv4 := net.ParseIP(host).To4() + FQDN := dns.IsFqdn(dns.Fqdn(host)) + if IPv6 == nil && IPv4 == nil && !FQDN { + return fmt.Errorf( + "invalid `server`, %q, is not an FQDN or IPv4 / IPv6 address", c.Server) + } + return nil +} + +func (c DNSConf) validateProto() error { + validProtos := []string{"udp", "tcp"} + proto := strings.Trim(strings.ToLower(c.Proto), " ") + for _, i := range validProtos { + if proto == i { + return nil + } + } + return fmt.Errorf( + "invalid `protocol`, got: %q, expected one in: %s", c.Proto, validProtos) +} + +func (c DNSConf) validateQType() error { + validQTypes = map[string]uint16{"A": 1, "TXT": 16, "AAAA": 28, "CAA": 257} + qtype := strings.Trim(strings.ToUpper(c.QType), " ") + q := make([]string, 0, len(validQTypes)) + for i := range validQTypes { + q = append(q, i) + if qtype == i { + return nil + } + } + return fmt.Errorf( + "invalid `query_type`, got: %q, expected one in %s", c.QType, q) +} + +// MakeProber constructs a `DNSProbe` object from the contents of the +// bound `DNSConf` object. If the `DNSConf` cannot be validated, an +// error appropriate for end-user consumption is returned instead. +func (c DNSConf) MakeProber(_ map[string]prometheus.Collector) (probers.Prober, error) { + // validate `query_name` + if !dns.IsFqdn(dns.Fqdn(c.QName)) { + return nil, fmt.Errorf( + "invalid `query_name`, %q is not an fqdn", c.QName) + } + + // validate `server` + err := c.validateServer() + if err != nil { + return nil, err + } + + // validate `protocol` + err = c.validateProto() + if err != nil { + return nil, err + } + + // validate `query_type` + err = c.validateQType() + if err != nil { + return nil, err + } + + return DNSProbe{ + proto: strings.Trim(strings.ToLower(c.Proto), " "), + recurse: c.Recurse, + qname: c.QName, + server: c.Server, + qtype: validQTypes[strings.Trim(strings.ToUpper(c.QType), " ")], + }, nil +} + +// Instrument is a no-op to implement the `Configurer` interface. +func (c DNSConf) Instrument() map[string]prometheus.Collector { + return nil +} + +// init is called at runtime and registers `DNSConf`, a `Prober` +// `Configurer` type, as "DNS". +func init() { + probers.Register(DNSConf{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf_test.go new file mode 100644 index 000000000..1f8e19c54 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/dns/dns_conf_test.go @@ -0,0 +1,208 @@ +package probers + +import ( + "reflect" + "testing" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/test" + "gopkg.in/yaml.v3" +) + +func TestDNSConf_validateServer(t *testing.T) { + type fields struct { + Server string + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // ipv4 cases + {"ipv4 with port", fields{"1.1.1.1:53"}, false}, + {"ipv4 without port", fields{"1.1.1.1"}, true}, + {"ipv4 port num missing", fields{"1.1.1.1:"}, true}, + {"ipv4 string for port", fields{"1.1.1.1:foo"}, true}, + {"ipv4 port out of range high", fields{"1.1.1.1:65536"}, true}, + {"ipv4 port out of range low", fields{"1.1.1.1:0"}, true}, + + // ipv6 cases + {"ipv6 with port", fields{"[2606:4700:4700::1111]:53"}, false}, + {"ipv6 without port", fields{"[2606:4700:4700::1111]"}, true}, + {"ipv6 port num missing", fields{"[2606:4700:4700::1111]:"}, true}, + {"ipv6 string for port", fields{"[2606:4700:4700::1111]:foo"}, true}, + {"ipv6 port out of range high", fields{"[2606:4700:4700::1111]:65536"}, true}, + {"ipv6 port out of range low", fields{"[2606:4700:4700::1111]:0"}, true}, + + // hostname cases + {"hostname with port", fields{"foo:53"}, false}, + {"hostname without port", fields{"foo"}, true}, + {"hostname port num missing", fields{"foo:"}, true}, + {"hostname string for port", fields{"foo:bar"}, true}, + {"hostname port out of range high", fields{"foo:65536"}, true}, + {"hostname port out of range low", fields{"foo:0"}, true}, + + // fqdn cases + {"fqdn with port", fields{"bar.foo.baz:53"}, false}, + {"fqdn without port", fields{"bar.foo.baz"}, true}, + {"fqdn port num missing", fields{"bar.foo.baz:"}, true}, + {"fqdn string for port", fields{"bar.foo.baz:bar"}, true}, + {"fqdn port out of range high", fields{"bar.foo.baz:65536"}, true}, + {"fqdn port out of range low", fields{"bar.foo.baz:0"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DNSConf{ + Server: tt.fields.Server, + } + err := c.validateServer() + if tt.wantErr { + test.AssertError(t, err, "DNSConf.validateServer() should have errored") + } else { + test.AssertNotError(t, err, "DNSConf.validateServer() shouldn't have errored") + } + }) + } +} + +func TestDNSConf_validateQType(t *testing.T) { + type fields struct { + QType string + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"A", fields{"A"}, false}, + {"AAAA", fields{"AAAA"}, false}, + {"TXT", fields{"TXT"}, false}, + // invalid + {"AAA", fields{"AAA"}, true}, + {"TXTT", fields{"TXTT"}, true}, + {"D", fields{"D"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DNSConf{ + QType: tt.fields.QType, + } + err := c.validateQType() + if tt.wantErr { + test.AssertError(t, err, "DNSConf.validateQType() should have errored") + } else { + test.AssertNotError(t, err, "DNSConf.validateQType() shouldn't have errored") + } + }) + } +} + +func TestDNSConf_validateProto(t *testing.T) { + type fields struct { + Proto string + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"tcp", fields{"tcp"}, false}, + {"udp", fields{"udp"}, false}, + // invalid + {"foo", fields{"foo"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DNSConf{ + Proto: tt.fields.Proto, + } + err := c.validateProto() + if tt.wantErr { + test.AssertError(t, err, "DNSConf.validateProto() should have errored") + } else { + test.AssertNotError(t, err, "DNSConf.validateProto() shouldn't have errored") + } + }) + } +} + +func TestDNSConf_MakeProber(t *testing.T) { + type fields struct { + Proto string + Server string + Recurse bool + QName string + QType string + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"valid", fields{"udp", "1.1.1.1:53", true, "google.com", "A"}, false}, + // invalid + {"bad proto", fields{"can with string", "1.1.1.1:53", true, "google.com", "A"}, true}, + {"bad server", fields{"udp", "1.1.1.1:9000000", true, "google.com", "A"}, true}, + {"bad qtype", fields{"udp", "1.1.1.1:9000000", true, "google.com", "BAZ"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := DNSConf{ + Proto: tt.fields.Proto, + Server: tt.fields.Server, + Recurse: tt.fields.Recurse, + QName: tt.fields.QName, + QType: tt.fields.QType, + } + _, err := c.MakeProber(nil) + if tt.wantErr { + test.AssertError(t, err, "DNSConf.MakeProber() should have errored") + } else { + test.AssertNotError(t, err, "DNSConf.MakeProber() shouldn't have errored") + } + }) + } +} + +func TestDNSConf_UnmarshalSettings(t *testing.T) { + type fields struct { + protocol interface{} + server interface{} + recurse interface{} + query_name interface{} + query_type interface{} + } + tests := []struct { + name string + fields fields + want probers.Configurer + wantErr bool + }{ + {"valid", fields{"udp", "1.1.1.1:53", true, "google.com", "A"}, DNSConf{"udp", "1.1.1.1:53", true, "google.com", "A"}, false}, + {"invalid", fields{42, 42, 42, 42, 42}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := probers.Settings{ + "protocol": tt.fields.protocol, + "server": tt.fields.server, + "recurse": tt.fields.recurse, + "query_name": tt.fields.query_name, + "query_type": tt.fields.query_type, + } + settingsBytes, _ := yaml.Marshal(settings) + c := DNSConf{} + got, err := c.UnmarshalSettings(settingsBytes) + if (err != nil) != tt.wantErr { + t.Errorf("DNSConf.UnmarshalSettings() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DNSConf.UnmarshalSettings() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/http/http.go b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http.go new file mode 100644 index 000000000..337cbb6d4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http.go @@ -0,0 +1,69 @@ +package probers + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "time" + + "github.com/letsencrypt/boulder/observer/obsdialer" +) + +// HTTPProbe is the exported 'Prober' object for monitors configured to +// perform HTTP requests. +type HTTPProbe struct { + url string + rcodes []int + useragent string + insecure bool +} + +// Name returns a string that uniquely identifies the monitor. + +func (p HTTPProbe) Name() string { + insecure := "" + if p.insecure { + insecure = "-insecure" + } + return fmt.Sprintf("%s-%d-%s%s", p.url, p.rcodes, p.useragent, insecure) +} + +// Kind returns a name that uniquely identifies the `Kind` of `Prober`. +func (p HTTPProbe) Kind() string { + return "HTTP" +} + +// isExpected ensures that the received HTTP response code matches one +// that's expected. +func (p HTTPProbe) isExpected(received int) bool { + for _, c := range p.rcodes { + if received == c { + return true + } + } + return false +} + +// Probe performs the configured HTTP request. +func (p HTTPProbe) Probe(timeout time.Duration) (bool, time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + client := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: p.insecure}, + DialContext: obsdialer.Dialer.DialContext, + }} + req, err := http.NewRequestWithContext(ctx, "GET", p.url, nil) + if err != nil { + return false, 0 + } + req.Header.Set("User-Agent", p.useragent) + start := time.Now() + // TODO(@beautifulentropy): add support for more than HTTP GET + resp, err := client.Do(req) + if err != nil { + return false, time.Since(start) + } + return p.isExpected(resp.StatusCode), time.Since(start) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf.go new file mode 100644 index 000000000..b40065be4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf.go @@ -0,0 +1,96 @@ +package probers + +import ( + "fmt" + "net/url" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/prometheus/client_golang/prometheus" +) + +// HTTPConf is exported to receive YAML configuration. +type HTTPConf struct { + URL string `yaml:"url"` + RCodes []int `yaml:"rcodes"` + UserAgent string `yaml:"useragent"` + Insecure bool `yaml:"insecure"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c HTTPConf) Kind() string { + return "HTTP" +} + +// UnmarshalSettings takes YAML as bytes and unmarshals it to the to an +// HTTPConf object. +func (c HTTPConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf HTTPConf + err := strictyaml.Unmarshal(settings, &conf) + if err != nil { + return nil, err + } + return conf, nil +} + +func (c HTTPConf) validateURL() error { + url, err := url.Parse(c.URL) + if err != nil { + return fmt.Errorf( + "invalid 'url', got: %q, expected a valid url", c.URL) + } + if url.Scheme == "" { + return fmt.Errorf( + "invalid 'url', got: %q, missing scheme", c.URL) + } + return nil +} + +func (c HTTPConf) validateRCodes() error { + if len(c.RCodes) == 0 { + return fmt.Errorf( + "invalid 'rcodes', got: %q, please specify at least one", c.RCodes) + } + for _, c := range c.RCodes { + // ensure rcode entry is in range 100-599 + if c < 100 || c > 599 { + return fmt.Errorf( + "'rcodes' contains an invalid HTTP response code, '%d'", c) + } + } + return nil +} + +// MakeProber constructs a `HTTPProbe` object from the contents of the +// bound `HTTPConf` object. If the `HTTPConf` cannot be validated, an +// error appropriate for end-user consumption is returned instead. +func (c HTTPConf) MakeProber(_ map[string]prometheus.Collector) (probers.Prober, error) { + // validate `url` + err := c.validateURL() + if err != nil { + return nil, err + } + + // validate `rcodes` + err = c.validateRCodes() + if err != nil { + return nil, err + } + + // Set default User-Agent if none set. + if c.UserAgent == "" { + c.UserAgent = "letsencrypt/boulder-observer-http-client" + } + return HTTPProbe{c.URL, c.RCodes, c.UserAgent, c.Insecure}, nil +} + +// Instrument is a no-op to implement the `Configurer` interface. +func (c HTTPConf) Instrument() map[string]prometheus.Collector { + return nil +} + +// init is called at runtime and registers `HTTPConf`, a `Prober` +// `Configurer` type, as "HTTP". +func init() { + probers.Register(HTTPConf{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf_test.go new file mode 100644 index 000000000..338cdf22d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/http/http_conf_test.go @@ -0,0 +1,111 @@ +package probers + +import ( + "reflect" + "testing" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/test" + "gopkg.in/yaml.v3" +) + +func TestHTTPConf_MakeProber(t *testing.T) { + type fields struct { + URL string + RCodes []int + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // valid + {"valid fqdn valid rcode", fields{"http://example.com", []int{200}}, false}, + {"valid hostname valid rcode", fields{"example", []int{200}}, true}, + // invalid + {"valid fqdn no rcode", fields{"http://example.com", nil}, true}, + {"valid fqdn invalid rcode", fields{"http://example.com", []int{1000}}, true}, + {"valid fqdn 1 invalid rcode", fields{"http://example.com", []int{200, 1000}}, true}, + {"bad fqdn good rcode", fields{":::::", []int{200}}, true}, + {"missing scheme", fields{"example.com", []int{200}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := HTTPConf{ + URL: tt.fields.URL, + RCodes: tt.fields.RCodes, + } + if _, err := c.MakeProber(nil); (err != nil) != tt.wantErr { + t.Errorf("HTTPConf.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestHTTPConf_UnmarshalSettings(t *testing.T) { + type fields struct { + url interface{} + rcodes interface{} + useragent interface{} + insecure interface{} + } + tests := []struct { + name string + fields fields + want probers.Configurer + wantErr bool + }{ + {"valid", fields{"google.com", []int{200}, "boulder_observer", false}, HTTPConf{"google.com", []int{200}, "boulder_observer", false}, false}, + {"invalid", fields{42, 42, 42, 42}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := probers.Settings{ + "url": tt.fields.url, + "rcodes": tt.fields.rcodes, + "useragent": tt.fields.useragent, + "insecure": tt.fields.insecure, + } + settingsBytes, _ := yaml.Marshal(settings) + c := HTTPConf{} + got, err := c.UnmarshalSettings(settingsBytes) + if (err != nil) != tt.wantErr { + t.Errorf("DNSConf.UnmarshalSettings() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DNSConf.UnmarshalSettings() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHTTPProberName(t *testing.T) { + // Test with blank `useragent` + proberYAML := ` +url: https://www.google.com +rcodes: [ 200 ] +useragent: "" +insecure: true +` + c := HTTPConf{} + configurer, err := c.UnmarshalSettings([]byte(proberYAML)) + test.AssertNotError(t, err, "Got error for valid prober config") + prober, err := configurer.MakeProber(nil) + test.AssertNotError(t, err, "Got error for valid prober config") + test.AssertEquals(t, prober.Name(), "https://www.google.com-[200]-letsencrypt/boulder-observer-http-client-insecure") + + // Test with custom `useragent` + proberYAML = ` +url: https://www.google.com +rcodes: [ 200 ] +useragent: fancy-custom-http-client +` + c = HTTPConf{} + configurer, err = c.UnmarshalSettings([]byte(proberYAML)) + test.AssertNotError(t, err, "Got error for valid prober config") + prober, err = configurer.MakeProber(nil) + test.AssertNotError(t, err, "Got error for valid prober config") + test.AssertEquals(t, prober.Name(), "https://www.google.com-[200]-fancy-custom-http-client") + +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_conf.go new file mode 100644 index 000000000..3640cb7fc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_conf.go @@ -0,0 +1,49 @@ +package probers + +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" +) + +type MockConfigurer struct { + Valid bool `yaml:"valid"` + ErrMsg string `yaml:"errmsg"` + PName string `yaml:"pname"` + PKind string `yaml:"pkind"` + PTook config.Duration `yaml:"ptook"` + PSuccess bool `yaml:"psuccess"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c MockConfigurer) Kind() string { + return "Mock" +} + +func (c MockConfigurer) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf MockConfigurer + err := strictyaml.Unmarshal(settings, &conf) + if err != nil { + return nil, err + } + return conf, nil +} + +func (c MockConfigurer) MakeProber(_ map[string]prometheus.Collector) (probers.Prober, error) { + if !c.Valid { + return nil, errors.New("could not be validated") + } + return MockProber{c.PName, c.PKind, c.PTook, c.PSuccess}, nil +} + +func (c MockConfigurer) Instrument() map[string]prometheus.Collector { + return nil +} + +func init() { + probers.Register(MockConfigurer{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_prober.go b/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_prober.go new file mode 100644 index 000000000..2446da750 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/mock/mock_prober.go @@ -0,0 +1,26 @@ +package probers + +import ( + "time" + + "github.com/letsencrypt/boulder/config" +) + +type MockProber struct { + name string + kind string + took config.Duration + success bool +} + +func (p MockProber) Name() string { + return p.name +} + +func (p MockProber) Kind() string { + return p.kind +} + +func (p MockProber) Probe(timeout time.Duration) (bool, time.Duration) { + return p.success, p.took.Duration +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/prober.go b/third-party/github.com/letsencrypt/boulder/observer/probers/prober.go new file mode 100644 index 000000000..629f5eed8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/prober.go @@ -0,0 +1,93 @@ +package probers + +import ( + "fmt" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + // Registry is the global mapping of all `Configurer` types. Types + // are added to this mapping on import by including a call to + // `Register` in their `init` function. + Registry = make(map[string]Configurer) +) + +// Prober is the interface for `Prober` types. +type Prober interface { + // Name returns a name that uniquely identifies the monitor that + // configured this `Prober`. + Name() string + + // Kind returns a name that uniquely identifies the `Kind` of + // `Prober`. + Kind() string + + // Probe attempts the configured request or query, Each `Prober` + // must treat the duration passed to it as a timeout. + Probe(time.Duration) (bool, time.Duration) +} + +// Configurer is the interface for `Configurer` types. +type Configurer interface { + // Kind returns a name that uniquely identifies the `Kind` of + // `Configurer`. + Kind() string + + // UnmarshalSettings unmarshals YAML as bytes to a `Configurer` + // object. + UnmarshalSettings([]byte) (Configurer, error) + + // MakeProber constructs a `Prober` object from the contents of the + // bound `Configurer` object. If the `Configurer` cannot be + // validated, an error appropriate for end-user consumption is + // returned instead. The map of `prometheus.Collector` objects passed to + // MakeProber should be the same as the return value from Instrument() + MakeProber(map[string]prometheus.Collector) (Prober, error) + + // Instrument constructs any `prometheus.Collector` objects that a prober of + // the configured type will need to report its own metrics. A map is + // returned containing the constructed objects, indexed by the name of the + // prometheus metric. If no objects were constructed, nil is returned. + Instrument() map[string]prometheus.Collector +} + +// Settings is exported as a temporary receiver for the `settings` field +// of `MonConf`. `Settings` is always marshaled back to bytes and then +// unmarshalled into the `Configurer` specified by the `Kind` field of +// the `MonConf`. +type Settings map[string]interface{} + +// normalizeKind normalizes the input string by stripping spaces and +// transforming it into lowercase +func normalizeKind(kind string) string { + return strings.Trim(strings.ToLower(kind), " ") +} + +// GetConfigurer returns the probe configurer specified by name from +// `Registry`. +func GetConfigurer(kind string) (Configurer, error) { + name := normalizeKind(kind) + // check if exists + if _, ok := Registry[name]; ok { + return Registry[name], nil + } + return nil, fmt.Errorf("%s is not a registered Prober type", kind) +} + +// Register is called by the `init` function of every `Configurer` to +// add the caller to the global `Registry` map. If the caller attempts +// to add a `Configurer` to the registry using the same name as a prior +// `Configurer` Observer will exit after logging an error. +func Register(c Configurer) { + name := normalizeKind(c.Kind()) + // check for name collision + if _, exists := Registry[name]; exists { + cmd.Fail(fmt.Sprintf( + "problem registering configurer %s: name collision", c.Kind())) + } + Registry[name] = c +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp.go b/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp.go new file mode 100644 index 000000000..b978892fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp.go @@ -0,0 +1,36 @@ +package tcp + +import ( + "context" + "time" + + "github.com/letsencrypt/boulder/observer/obsdialer" +) + +type TCPProbe struct { + hostport string +} + +// Name returns a string that uniquely identifies the monitor. + +func (p TCPProbe) Name() string { + return p.hostport +} + +// Kind returns a name that uniquely identifies the `Kind` of `Prober`. +func (p TCPProbe) Kind() string { + return "TCP" +} + +// Probe performs the configured TCP dial. +func (p TCPProbe) Probe(timeout time.Duration) (bool, time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + start := time.Now() + c, err := obsdialer.Dialer.DialContext(ctx, "tcp", p.hostport) + if err != nil { + return false, time.Since(start) + } + c.Close() + return true, time.Since(start) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp_conf.go new file mode 100644 index 000000000..17576ecd7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/tcp/tcp_conf.go @@ -0,0 +1,45 @@ +package tcp + +import ( + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/prometheus/client_golang/prometheus" +) + +// TCPConf is exported to receive YAML configuration. +type TCPConf struct { + Hostport string `yaml:"hostport"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c TCPConf) Kind() string { + return "TCP" +} + +// UnmarshalSettings takes YAML as bytes and unmarshals it to the to an +// TCPConf object. +func (c TCPConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf TCPConf + err := strictyaml.Unmarshal(settings, &conf) + if err != nil { + return nil, err + } + return conf, nil +} + +// MakeProber constructs a `TCPPProbe` object from the contents of the +// bound `TCPPConf` object. +func (c TCPConf) MakeProber(_ map[string]prometheus.Collector) (probers.Prober, error) { + return TCPProbe{c.Hostport}, nil +} + +// Instrument is a no-op to implement the `Configurer` interface. +func (c TCPConf) Instrument() map[string]prometheus.Collector { + return nil +} + +// init is called at runtime and registers `TCPConf`, a `Prober` +// `Configurer` type, as "TCP". +func init() { + probers.Register(TCPConf{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls.go b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls.go new file mode 100644 index 000000000..d7d088aa0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls.go @@ -0,0 +1,213 @@ +package probers + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/letsencrypt/boulder/observer/obsdialer" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" +) + +type reason int + +const ( + none reason = iota + internalError + ocspError + rootDidNotMatch + responseDidNotMatch +) + +var reasonToString = map[reason]string{ + none: "nil", + internalError: "internalError", + ocspError: "ocspError", + rootDidNotMatch: "rootDidNotMatch", + responseDidNotMatch: "responseDidNotMatch", +} + +func getReasons() []string { + var allReasons []string + for _, v := range reasonToString { + allReasons = append(allReasons, v) + } + return allReasons +} + +// TLSProbe is the exported `Prober` object for monitors configured to perform +// TLS protocols. +type TLSProbe struct { + hostname string + rootOrg string + rootCN string + response string + notAfter *prometheus.GaugeVec + notBefore *prometheus.GaugeVec + reason *prometheus.CounterVec +} + +// Name returns a string that uniquely identifies the monitor. +func (p TLSProbe) Name() string { + return p.hostname +} + +// Kind returns a name that uniquely identifies the `Kind` of `Prober`. +func (p TLSProbe) Kind() string { + return "TLS" +} + +// Get OCSP status (good, revoked or unknown) of certificate +func checkOCSP(cert, issuer *x509.Certificate, want int) (bool, error) { + req, err := ocsp.CreateRequest(cert, issuer, nil) + if err != nil { + return false, err + } + + url := fmt.Sprintf("%s/%s", cert.OCSPServer[0], base64.StdEncoding.EncodeToString(req)) + res, err := http.Get(url) + if err != nil { + return false, err + } + + output, err := io.ReadAll(res.Body) + if err != nil { + return false, err + } + + ocspRes, err := ocsp.ParseResponseForCert(output, cert, issuer) + if err != nil { + return false, err + } + + return ocspRes.Status == want, nil +} + +// Return an error if the root settings are nonempty and do not match the +// expected root. +func (p TLSProbe) checkRoot(rootOrg, rootCN string) error { + if (p.rootCN == "" && p.rootOrg == "") || (rootOrg == p.rootOrg && rootCN == p.rootCN) { + return nil + } + return fmt.Errorf("Expected root does not match.") +} + +// Export expiration timestamp and reason to Prometheus. +func (p TLSProbe) exportMetrics(cert *x509.Certificate, reason reason) { + if cert != nil { + p.notAfter.WithLabelValues(p.hostname).Set(float64(cert.NotAfter.Unix())) + p.notBefore.WithLabelValues(p.hostname).Set(float64(cert.NotBefore.Unix())) + } + p.reason.WithLabelValues(p.hostname, reasonToString[reason]).Inc() +} + +func (p TLSProbe) probeExpired(timeout time.Duration) bool { + config := &tls.Config{ + // Set InsecureSkipVerify to skip the default validation we are + // replacing. This will not disable VerifyConnection. + InsecureSkipVerify: true, + VerifyConnection: func(cs tls.ConnectionState) error { + opts := x509.VerifyOptions{ + CurrentTime: cs.PeerCertificates[0].NotAfter, + Intermediates: x509.NewCertPool(), + } + for _, cert := range cs.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := cs.PeerCertificates[0].Verify(opts) + return err + }, + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + tlsDialer := tls.Dialer{ + NetDialer: &obsdialer.Dialer, + Config: config, + } + conn, err := tlsDialer.DialContext(ctx, "tcp", p.hostname+":443") + if err != nil { + p.exportMetrics(nil, internalError) + return false + } + defer conn.Close() + + // tls.Dialer.DialContext is documented to always return *tls.Conn + tlsConn := conn.(*tls.Conn) + peers := tlsConn.ConnectionState().PeerCertificates + if time.Until(peers[0].NotAfter) > 0 { + p.exportMetrics(peers[0], responseDidNotMatch) + return false + } + + root := peers[len(peers)-1].Issuer + err = p.checkRoot(root.Organization[0], root.CommonName) + if err != nil { + p.exportMetrics(peers[0], rootDidNotMatch) + return false + } + + p.exportMetrics(peers[0], none) + return true +} + +func (p TLSProbe) probeUnexpired(timeout time.Duration) bool { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", p.hostname+":443", &tls.Config{}) + if err != nil { + p.exportMetrics(nil, internalError) + return false + } + + defer conn.Close() + peers := conn.ConnectionState().PeerCertificates + root := peers[len(peers)-1].Issuer + err = p.checkRoot(root.Organization[0], root.CommonName) + if err != nil { + p.exportMetrics(peers[0], rootDidNotMatch) + return false + } + + var ocspStatus bool + switch p.response { + case "valid": + ocspStatus, err = checkOCSP(peers[0], peers[1], ocsp.Good) + case "revoked": + ocspStatus, err = checkOCSP(peers[0], peers[1], ocsp.Revoked) + } + if err != nil { + p.exportMetrics(peers[0], ocspError) + return false + } + + if !ocspStatus { + p.exportMetrics(peers[0], responseDidNotMatch) + return false + } + + p.exportMetrics(peers[0], none) + return true +} + +// Probe performs the configured TLS probe. Return true if the root has the +// expected Subject (or if no root is provided for comparison in settings), and +// the end entity certificate has the correct expiration status (either expired +// or unexpired, depending on what is configured). Exports metrics for the +// NotAfter timestamp of the end entity certificate and the reason for the Probe +// returning false ("none" if returns true). +func (p TLSProbe) Probe(timeout time.Duration) (bool, time.Duration) { + start := time.Now() + var success bool + if p.response == "expired" { + success = p.probeExpired(timeout) + } else { + success = p.probeUnexpired(timeout) + } + + return success, time.Since(start) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf.go b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf.go new file mode 100644 index 000000000..461ff9169 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf.go @@ -0,0 +1,155 @@ +package probers + +import ( + "fmt" + "net/url" + "strings" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/letsencrypt/boulder/strictyaml" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + notAfterName = "obs_tls_not_after" + notBeforeName = "obs_tls_not_before" + reasonName = "obs_tls_reason" +) + +// TLSConf is exported to receive YAML configuration. +type TLSConf struct { + Hostname string `yaml:"hostname"` + RootOrg string `yaml:"rootOrg"` + RootCN string `yaml:"rootCN"` + Response string `yaml:"response"` +} + +// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. +func (c TLSConf) Kind() string { + return "TLS" +} + +// UnmarshalSettings takes YAML as bytes and unmarshals it to the to an TLSConf +// object. +func (c TLSConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) { + var conf TLSConf + err := strictyaml.Unmarshal(settings, &conf) + if err != nil { + return nil, err + } + + return conf, nil +} + +func (c TLSConf) validateHostname() error { + url, err := url.Parse(c.Hostname) + if err != nil { + return fmt.Errorf( + "invalid 'hostname', got %q, expected a valid hostname: %s", c.Hostname, err) + } + + if url.Scheme != "" { + return fmt.Errorf( + "invalid 'hostname', got: %q, should not include scheme", c.Hostname) + } + + return nil +} + +func (c TLSConf) validateResponse() error { + acceptable := []string{"valid", "expired", "revoked"} + for _, a := range acceptable { + if strings.ToLower(c.Response) == a { + return nil + } + } + + return fmt.Errorf( + "invalid `response`, got %q. Must be one of %s", c.Response, acceptable) +} + +// MakeProber constructs a `TLSProbe` object from the contents of the bound +// `TLSConf` object. If the `TLSConf` cannot be validated, an error appropriate +// for end-user consumption is returned instead. +func (c TLSConf) MakeProber(collectors map[string]prometheus.Collector) (probers.Prober, error) { + // Validate `hostname` + err := c.validateHostname() + if err != nil { + return nil, err + } + + // Valid `response` + err = c.validateResponse() + if err != nil { + return nil, err + } + + // Validate the Prometheus collectors that were passed in + coll, ok := collectors[notAfterName] + if !ok { + return nil, fmt.Errorf("tls prober did not receive collector %q", notAfterName) + } + + notAfterColl, ok := coll.(*prometheus.GaugeVec) + if !ok { + return nil, fmt.Errorf("tls prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", notAfterName, coll) + } + + coll, ok = collectors[notBeforeName] + if !ok { + return nil, fmt.Errorf("tls prober did not receive collector %q", notBeforeName) + } + + notBeforeColl, ok := coll.(*prometheus.GaugeVec) + if !ok { + return nil, fmt.Errorf("tls prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", notBeforeName, coll) + } + + coll, ok = collectors[reasonName] + if !ok { + return nil, fmt.Errorf("tls prober did not receive collector %q", reasonName) + } + + reasonColl, ok := coll.(*prometheus.CounterVec) + if !ok { + return nil, fmt.Errorf("tls prober received collector %q of wrong type, got: %T, expected *prometheus.CounterVec", reasonName, coll) + } + + return TLSProbe{c.Hostname, c.RootOrg, c.RootCN, strings.ToLower(c.Response), notAfterColl, notBeforeColl, reasonColl}, nil +} + +// Instrument constructs any `prometheus.Collector` objects the `TLSProbe` will +// need to report its own metrics. A map is returned containing the constructed +// objects, indexed by the name of the Promtheus metric. If no objects were +// constructed, nil is returned. +func (c TLSConf) Instrument() map[string]prometheus.Collector { + notBefore := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: notBeforeName, + Help: "Certificate notBefore value as a Unix timestamp in seconds", + }, []string{"hostname"}, + )) + notAfter := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: notAfterName, + Help: "Certificate notAfter value as a Unix timestamp in seconds", + }, []string{"hostname"}, + )) + reason := prometheus.Collector(prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: reasonName, + Help: fmt.Sprintf("Reason for TLS Prober check failure. Can be one of %s", getReasons()), + }, []string{"hostname", "reason"}, + )) + return map[string]prometheus.Collector{ + notAfterName: notAfter, + notBeforeName: notBefore, + reasonName: reason, + } +} + +// init is called at runtime and registers `TLSConf`, a `Prober` `Configurer` +// type, as "TLS". +func init() { + probers.Register(TLSConf{}) +} diff --git a/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf_test.go b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf_test.go new file mode 100644 index 000000000..1bf3355cf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/observer/probers/tls/tls_conf_test.go @@ -0,0 +1,111 @@ +package probers + +import ( + "reflect" + "testing" + + "github.com/letsencrypt/boulder/observer/probers" + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/yaml.v3" +) + +func TestTLSConf_MakeProber(t *testing.T) { + goodHostname, goodRootCN, goodResponse := "example.com", "ISRG Root X1", "valid" + colls := TLSConf{}.Instrument() + badColl := prometheus.Collector(prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "obs_crl_foo", + Help: "Hmmm, this shouldn't be here...", + }, + []string{}, + )) + type fields struct { + Hostname string + RootCN string + Response string + } + tests := []struct { + name string + fields fields + colls map[string]prometheus.Collector + wantErr bool + }{ + // valid + {"valid hostname", fields{"example.com", goodRootCN, "valid"}, colls, false}, + {"valid hostname with path", fields{"example.com/foo/bar", "ISRG Root X2", "Revoked"}, colls, false}, + + // invalid hostname + {"bad hostname", fields{":::::", goodRootCN, goodResponse}, colls, true}, + {"included scheme", fields{"https://example.com", goodRootCN, goodResponse}, colls, true}, + + // invalid response + {"empty response", fields{goodHostname, goodRootCN, ""}, colls, true}, + {"unaccepted response", fields{goodHostname, goodRootCN, "invalid"}, colls, true}, + + // invalid collector + { + "unexpected collector", + fields{"http://example.com", goodRootCN, goodResponse}, + map[string]prometheus.Collector{"obs_crl_foo": badColl}, + true, + }, + { + "missing collectors", + fields{"http://example.com", goodRootCN, goodResponse}, + map[string]prometheus.Collector{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := TLSConf{ + Hostname: tt.fields.Hostname, + RootCN: tt.fields.RootCN, + Response: tt.fields.Response, + } + if _, err := c.MakeProber(tt.colls); (err != nil) != tt.wantErr { + t.Errorf("TLSConf.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTLSConf_UnmarshalSettings(t *testing.T) { + type fields struct { + hostname interface{} + rootOrg interface{} + rootCN interface{} + response interface{} + } + tests := []struct { + name string + fields fields + want probers.Configurer + wantErr bool + }{ + {"valid", fields{"google.com", "", "ISRG Root X1", "valid"}, TLSConf{"google.com", "", "ISRG Root X1", "valid"}, false}, + {"invalid hostname (map)", fields{make(map[string]interface{}), 42, 42, 42}, nil, true}, + {"invalid rootOrg (list)", fields{42, make([]string, 0), 42, 42}, nil, true}, + {"invalid response (list)", fields{42, 42, 42, make([]string, 0)}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := probers.Settings{ + "hostname": tt.fields.hostname, + "rootOrg": tt.fields.rootOrg, + "rootCN": tt.fields.rootCN, + "response": tt.fields.response, + } + settingsBytes, _ := yaml.Marshal(settings) + c := TLSConf{} + got, err := c.UnmarshalSettings(settingsBytes) + if (err != nil) != tt.wantErr { + t.Errorf("DNSConf.UnmarshalSettings() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DNSConf.UnmarshalSettings() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source.go new file mode 100644 index 000000000..d97ba80d4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source.go @@ -0,0 +1,197 @@ +package responder + +import ( + "bytes" + "context" + "crypto" + "crypto/sha1" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" +) + +// responderID contains the SHA1 hashes of an issuer certificate's name and key, +// exactly as the issuerNameHash and issuerKeyHash fields of an OCSP request +// should be computed by OCSP clients that are compliant with RFC 5019, the +// Lightweight OCSP Profile for High-Volume Environments. It also contains the +// Subject Common Name of the issuer certificate, for our own observability. +type responderID struct { + nameHash []byte + keyHash []byte + commonName string +} + +// computeLightweightResponderID builds a responderID from an issuer certificate. +func computeLightweightResponderID(ic *issuance.Certificate) (responderID, error) { + // nameHash is the SHA1 hash over the DER encoding of the issuer certificate's + // Subject Distinguished Name. + nameHash := sha1.Sum(ic.RawSubject) + + // keyHash is the SHA1 hash over the DER encoding of the issuer certificate's + // Subject Public Key Info. We can't use MarshalPKIXPublicKey for this since + // it encodes keys using the SPKI structure itself, and we just want the + // contents of the subjectPublicKey for the hash, so we need to extract it + // ourselves. + var spki struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString + } + _, err := asn1.Unmarshal(ic.RawSubjectPublicKeyInfo, &spki) + if err != nil { + return responderID{}, err + } + keyHash := sha1.Sum(spki.PublicKey.RightAlign()) + + return responderID{nameHash[:], keyHash[:], ic.Subject.CommonName}, nil +} + +type filterSource struct { + wrapped Source + hashAlgorithm crypto.Hash + issuers map[issuance.NameID]responderID + serialPrefixes []string + counter *prometheus.CounterVec + log blog.Logger + clk clock.Clock +} + +// NewFilterSource returns a filterSource which performs various checks on the +// OCSP requests sent to the wrapped Source, and the OCSP responses returned +// by it. +func NewFilterSource(issuerCerts []*issuance.Certificate, serialPrefixes []string, wrapped Source, stats prometheus.Registerer, log blog.Logger, clk clock.Clock) (*filterSource, error) { + if len(issuerCerts) < 1 { + return nil, errors.New("filter must include at least 1 issuer cert") + } + + issuersByNameId := make(map[issuance.NameID]responderID) + for _, issuerCert := range issuerCerts { + rid, err := computeLightweightResponderID(issuerCert) + if err != nil { + return nil, fmt.Errorf("computing lightweight OCSP responder ID: %w", err) + } + issuersByNameId[issuerCert.NameID()] = rid + } + + counter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ocsp_filter_responses", + Help: "Count of OCSP requests/responses by action taken by the filter", + }, []string{"result", "issuer"}) + stats.MustRegister(counter) + + return &filterSource{ + wrapped: wrapped, + hashAlgorithm: crypto.SHA1, + issuers: issuersByNameId, + serialPrefixes: serialPrefixes, + counter: counter, + log: log, + clk: clk, + }, nil +} + +// Response implements the Source interface. It checks the incoming request +// to ensure that we want to handle it, fetches the response from the wrapped +// Source, and checks that the response matches the request. +func (src *filterSource) Response(ctx context.Context, req *ocsp.Request) (*Response, error) { + iss, err := src.checkRequest(req) + if err != nil { + src.log.Debugf("Not responding to filtered OCSP request: %s", err.Error()) + src.counter.WithLabelValues("request_filtered", "none").Inc() + return nil, err + } + + counter := src.counter.MustCurryWith(prometheus.Labels{"issuer": src.issuers[iss].commonName}) + + resp, err := src.wrapped.Response(ctx, req) + if err != nil { + counter.WithLabelValues("wrapped_error").Inc() + return nil, err + } + + err = src.checkResponse(iss, resp) + if err != nil { + src.log.Warningf("OCSP Response not sent for CA=%s, Serial=%s, err: %s", hex.EncodeToString(req.IssuerKeyHash), core.SerialToString(req.SerialNumber), err) + counter.WithLabelValues("response_filtered").Inc() + return nil, err + } + + counter.WithLabelValues("success").Inc() + return resp, nil +} + +// checkNextUpdate evaluates whether the nextUpdate field of the requested OCSP +// response is in the past. If so, `errOCSPResponseExpired` will be returned. +func (src *filterSource) checkNextUpdate(resp *Response) error { + if src.clk.Now().Before(resp.NextUpdate) { + return nil + } + return errOCSPResponseExpired +} + +// checkRequest returns a descriptive error if the request does not satisfy any of +// the requirements of an OCSP request, or nil if the request should be handled. +// If the request passes all checks, then checkRequest returns the unique id of +// the issuer cert specified in the request. +func (src *filterSource) checkRequest(req *ocsp.Request) (issuance.NameID, error) { + if req.HashAlgorithm != src.hashAlgorithm { + return 0, fmt.Errorf("unsupported issuer key/name hash algorithm %s: %w", req.HashAlgorithm, ErrNotFound) + } + + if len(src.serialPrefixes) > 0 { + serialString := core.SerialToString(req.SerialNumber) + match := false + for _, prefix := range src.serialPrefixes { + if strings.HasPrefix(serialString, prefix) { + match = true + break + } + } + if !match { + return 0, fmt.Errorf("unrecognized serial prefix: %w", ErrNotFound) + } + } + + for nameID, rid := range src.issuers { + if bytes.Equal(req.IssuerNameHash, rid.nameHash) && bytes.Equal(req.IssuerKeyHash, rid.keyHash) { + return nameID, nil + } + } + return 0, fmt.Errorf("unrecognized issuer key hash %s: %w", hex.EncodeToString(req.IssuerKeyHash), ErrNotFound) +} + +// checkResponse returns nil if the ocsp response was generated by the same +// issuer as was identified in the request, or an error otherwise. This filters +// out, for example, responses which are for a serial that we issued, but from a +// different issuer than that contained in the request. +func (src *filterSource) checkResponse(reqIssuerID issuance.NameID, resp *Response) error { + respIssuerID := issuance.ResponderNameID(resp.Response) + if reqIssuerID != respIssuerID { + // This would be allowed if we used delegated responders, but we don't. + return fmt.Errorf("responder name does not match requested issuer name") + } + + err := src.checkNextUpdate(resp) + if err != nil { + return err + } + + // In an ideal world, we'd also compare the Issuer Key Hash from the request's + // CertID (equivalent to looking up the key hash in src.issuers) against the + // Issuer Key Hash contained in the response's CertID. However, the Go OCSP + // library does not provide access to the response's CertID, so we can't. + // Specifically, we want to compare `src.issuers[reqIssuerID].keyHash` against + // something like resp.CertID.IssuerKeyHash, but the latter does not exist. + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source_test.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source_test.go new file mode 100644 index 000000000..1dd55e219 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/filter_source_test.go @@ -0,0 +1,138 @@ +package responder + +import ( + "context" + "crypto" + "encoding/hex" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" + "golang.org/x/crypto/ocsp" +) + +func TestNewFilter(t *testing.T) { + _, err := NewFilterSource([]*issuance.Certificate{}, []string{}, nil, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertError(t, err, "didn't error when creating empty filter") + + issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem") + test.AssertNotError(t, err, "failed to load issuer cert") + issuerNameId := issuer.NameID() + + f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, nil, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + test.AssertEquals(t, len(f.issuers), 1) + test.AssertEquals(t, len(f.serialPrefixes), 1) + test.AssertEquals(t, hex.EncodeToString(f.issuers[issuerNameId].keyHash), "fb784f12f96015832c9f177f3419b32e36ea4189") +} + +func TestCheckNextUpdate(t *testing.T) { + issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem") + test.AssertNotError(t, err, "failed to load issuer cert") + + f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, nil, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + + resp := &Response{ + Response: &ocsp.Response{ + NextUpdate: time.Now().Add(time.Hour), + }, + } + test.AssertNotError(t, f.checkNextUpdate(resp), "error during valid check") + + resp.NextUpdate = time.Now().Add(-time.Hour) + test.AssertErrorIs(t, f.checkNextUpdate(resp), errOCSPResponseExpired) +} + +func TestCheckRequest(t *testing.T) { + issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem") + test.AssertNotError(t, err, "failed to load issuer cert") + + f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, nil, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + + reqBytes, err := os.ReadFile("./testdata/ocsp.req") + test.AssertNotError(t, err, "failed to read OCSP request") + + // Select a bad hash algorithm. + ocspReq, err := ocsp.ParseRequest(reqBytes) + test.AssertNotError(t, err, "failed to prepare fake ocsp request") + ocspReq.HashAlgorithm = crypto.MD5 + _, err = f.Response(context.Background(), ocspReq) + test.AssertError(t, err, "accepted ocsp request with bad hash algorithm") + + // Make the hash invalid. + ocspReq, err = ocsp.ParseRequest(reqBytes) + test.AssertNotError(t, err, "failed to prepare fake ocsp request") + ocspReq.IssuerKeyHash[0]++ + _, err = f.Response(context.Background(), ocspReq) + test.AssertError(t, err, "accepted ocsp request with bad issuer key hash") + + // Make the serial prefix wrong by incrementing the first byte by 1. + ocspReq, err = ocsp.ParseRequest(reqBytes) + test.AssertNotError(t, err, "failed to prepare fake ocsp request") + serialStr := []byte(core.SerialToString(ocspReq.SerialNumber)) + serialStr[0] = serialStr[0] + 1 + ocspReq.SerialNumber.SetString(string(serialStr), 16) + _, err = f.Response(context.Background(), ocspReq) + test.AssertError(t, err, "accepted ocsp request with bad serial prefix") +} + +type echoSource struct { + resp *Response +} + +func (src *echoSource) Response(context.Context, *ocsp.Request) (*Response, error) { + return src.resp, nil +} + +func TestCheckResponse(t *testing.T) { + issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem") + test.AssertNotError(t, err, "failed to load issuer cert") + + reqBytes, err := os.ReadFile("./testdata/ocsp.req") + test.AssertNotError(t, err, "failed to read OCSP request") + req, err := ocsp.ParseRequest(reqBytes) + test.AssertNotError(t, err, "failed to prepare fake ocsp request") + + respBytes, err := os.ReadFile("./testdata/ocsp.resp") + test.AssertNotError(t, err, "failed to read OCSP response") + resp, err := ocsp.ParseResponse(respBytes, nil) + test.AssertNotError(t, err, "failed to parse OCSP response") + + source := &echoSource{&Response{resp, respBytes}} + f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, source, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + + actual, err := f.Response(context.Background(), req) + test.AssertNotError(t, err, "unexpected error") + test.AssertEquals(t, actual.Response, resp) + + // test expired source + expiredResp, err := ocsp.ParseResponse(respBytes, nil) + test.AssertNotError(t, err, "failed to parse OCSP response") + expiredResp.NextUpdate = time.Time{} + + sourceExpired := &echoSource{&Response{expiredResp, nil}} + fExpired, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, sourceExpired, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + + _, err = fExpired.Response(context.Background(), req) + test.AssertError(t, err, "missing error") + test.AssertErrorIs(t, err, errOCSPResponseExpired) + + // Overwrite the Responder Name in the stored response to cause a diagreement. + resp.RawResponderName = []byte("C = US, O = Foo, DN = Bar") + source = &echoSource{&Response{resp, respBytes}} + f, err = NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, source, metrics.NoopRegisterer, blog.NewMock(), clock.New()) + test.AssertNotError(t, err, "errored when creating good filter") + + _, err = f.Response(context.Background(), req) + test.AssertError(t, err, "expected error") +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/inmem_source.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/inmem_source.go new file mode 100644 index 000000000..5214aa555 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/inmem_source.go @@ -0,0 +1,78 @@ +package responder + +import ( + "context" + "encoding/base64" + "os" + "regexp" + + blog "github.com/letsencrypt/boulder/log" + "golang.org/x/crypto/ocsp" +) + +// inMemorySource wraps a map from serialNumber to Response and just looks up +// Responses from that map with no safety checks. Useful for testing. +type inMemorySource struct { + responses map[string]*Response + log blog.Logger +} + +// NewMemorySource returns an initialized InMemorySource which simply looks up +// responses from an in-memory map based on the serial number in the request. +func NewMemorySource(responses map[string]*Response, logger blog.Logger) (*inMemorySource, error) { + return &inMemorySource{ + responses: responses, + log: logger, + }, nil +} + +// NewMemorySourceFromFile reads the named file into an InMemorySource. +// The file read by this function must contain whitespace-separated OCSP +// responses. Each OCSP response must be in base64-encoded DER form (i.e., +// PEM without headers or whitespace). Invalid responses are ignored. +// This function pulls the entire file into an InMemorySource. +func NewMemorySourceFromFile(responseFile string, logger blog.Logger) (*inMemorySource, error) { + fileContents, err := os.ReadFile(responseFile) + if err != nil { + return nil, err + } + + responsesB64 := regexp.MustCompile(`\s`).Split(string(fileContents), -1) + responses := make(map[string]*Response, len(responsesB64)) + for _, b64 := range responsesB64 { + // if the line/space is empty just skip + if b64 == "" { + continue + } + der, tmpErr := base64.StdEncoding.DecodeString(b64) + if tmpErr != nil { + logger.Errf("Base64 decode error %s on: %s", tmpErr, b64) + continue + } + + response, tmpErr := ocsp.ParseResponse(der, nil) + if tmpErr != nil { + logger.Errf("OCSP decode error %s on: %s", tmpErr, b64) + continue + } + + responses[response.SerialNumber.String()] = &Response{ + Response: response, + Raw: der, + } + } + + logger.Infof("Read %d OCSP responses", len(responses)) + return NewMemorySource(responses, logger) +} + +// Response looks up an OCSP response to provide for a given request. +// InMemorySource looks up a response purely based on serial number, +// without regard to what issuer the request is asking for. +func (src inMemorySource) Response(_ context.Context, request *ocsp.Request) (*Response, error) { + response, present := src.responses[request.SerialNumber.String()] + if !present { + return nil, ErrNotFound + } + return response, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live.go new file mode 100644 index 000000000..28c2102bb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live.go @@ -0,0 +1,60 @@ +package live + +import ( + "context" + "errors" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/ocsp/responder" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/semaphore" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" +) + +type ocspGenerator interface { + GenerateOCSP(ctx context.Context, in *rapb.GenerateOCSPRequest, opts ...grpc.CallOption) (*capb.OCSPResponse, error) +} + +type Source struct { + ra ocspGenerator + sem *semaphore.Weighted +} + +func New(ra ocspGenerator, maxInflight int64, maxWaiters int) *Source { + return &Source{ + ra: ra, + sem: semaphore.NewWeighted(maxInflight, maxWaiters), + } +} + +func (s *Source) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + err := s.sem.Acquire(ctx, 1) + if err != nil { + return nil, err + } + defer s.sem.Release(1) + if ctx.Err() != nil { + return nil, ctx.Err() + } + + resp, err := s.ra.GenerateOCSP(ctx, &rapb.GenerateOCSPRequest{ + Serial: core.SerialToString(req.SerialNumber), + }) + if err != nil { + if errors.Is(err, berrors.NotFound) { + return nil, responder.ErrNotFound + } + return nil, err + } + parsed, err := ocsp.ParseResponse(resp.Response, nil) + if err != nil { + return nil, err + } + return &responder.Response{ + Raw: resp.Response, + Response: parsed, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live_test.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live_test.go new file mode 100644 index 000000000..f05a5c9eb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/live/live_test.go @@ -0,0 +1,69 @@ +package live + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/ocsp/responder" + ocsp_test "github.com/letsencrypt/boulder/ocsp/test" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/test" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" +) + +// mockOCSPGenerator is an ocspGenerator that always emits the provided bytes +// when serial number 1 is requested, but otherwise returns an error. +type mockOCSPGenerator struct { + resp []byte +} + +func (m mockOCSPGenerator) GenerateOCSP(ctx context.Context, in *rapb.GenerateOCSPRequest, opts ...grpc.CallOption) (*capb.OCSPResponse, error) { + expectedSerial := core.SerialToString(big.NewInt(1)) + if in.Serial != expectedSerial { + return nil, fmt.Errorf("expected serial %s, got %s", expectedSerial, in.Serial) + } + + return &capb.OCSPResponse{Response: m.resp}, nil +} + +// notFoundOCSPGenerator always returns berrors.NotFound +type notFoundOCSPGenerator struct{} + +func (n notFoundOCSPGenerator) GenerateOCSP(ctx context.Context, in *rapb.GenerateOCSPRequest, opts ...grpc.CallOption) (*capb.OCSPResponse, error) { + return nil, berrors.NotFoundError("not found") +} + +func TestLiveResponse(t *testing.T) { + eeSerial := big.NewInt(1) + fakeResp, _, _ := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: eeSerial, + }) + source := New(mockOCSPGenerator{fakeResp.Raw}, 1, 0) + resp, err := source.Response(context.Background(), &ocsp.Request{ + SerialNumber: eeSerial, + }) + test.AssertNotError(t, err, "getting response") + test.AssertByteEquals(t, resp.Raw, fakeResp.Raw) + expectedSerial := "000000000000000000000000000000000001" + if core.SerialToString(resp.SerialNumber) != expectedSerial { + t.Errorf("expected serial %s, got %s", expectedSerial, resp.SerialNumber) + } +} + +func TestNotFound(t *testing.T) { + eeSerial := big.NewInt(1) + source := New(notFoundOCSPGenerator{}, 1, 0) + _, err := source.Response(context.Background(), &ocsp.Request{ + SerialNumber: eeSerial, + }) + if !errors.Is(err, responder.ErrNotFound) { + t.Errorf("expected responder.ErrNotFound, got %#v", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source.go new file mode 100644 index 000000000..47d784933 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source.go @@ -0,0 +1,159 @@ +package redis + +import ( + "context" + "errors" + "reflect" + "sync" + + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/ocsp/responder" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// dbSelector is a limited subset of the db.WrappedMap interface to allow for +// easier mocking of mysql operations in tests. +type dbSelector interface { + SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error +} + +// rocspSourceInterface expands on responder.Source by adding a private signAndSave method. +// This allows checkedRedisSource to trigger a live signing if the DB disagrees with Redis. +type rocspSourceInterface interface { + Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) + signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error) +} + +// checkedRedisSource implements the Source interface. It relies on two +// underlying datastores to provide its OCSP responses: a rocspSourceInterface +// (a Source that can also signAndSave new responses) to provide the responses +// themselves, and the database to double-check that those responses match the +// authoritative revocation status stored in the db. +// TODO(#6285): Inline the rocspSourceInterface into this type. +// TODO(#6295): Remove the dbMap after all deployments use the SA instead. +type checkedRedisSource struct { + base rocspSourceInterface + dbMap dbSelector + sac sapb.StorageAuthorityReadOnlyClient + counter *prometheus.CounterVec + log blog.Logger +} + +// NewCheckedRedisSource builds a source that queries both the DB and Redis, and confirms +// the value in Redis matches the DB. +func NewCheckedRedisSource(base *redisSource, dbMap dbSelector, sac sapb.StorageAuthorityReadOnlyClient, stats prometheus.Registerer, log blog.Logger) (*checkedRedisSource, error) { + if base == nil { + return nil, errors.New("base was nil") + } + + // We have to use reflect here because these arguments are interfaces, and + // thus checking for nil the normal way doesn't work reliably, because they + // may be non-nil interfaces whose inner value is still nil, i.e. "boxed nil". + // But using reflect here is okay, because we only expect this constructor to + // be called once per process. + if (reflect.TypeOf(sac) == nil || reflect.ValueOf(sac).IsNil()) && + (reflect.TypeOf(dbMap) == nil || reflect.ValueOf(dbMap).IsNil()) { + return nil, errors.New("either SA gRPC or direct DB connection must be provided") + } + + return newCheckedRedisSource(base, dbMap, sac, stats, log), nil +} + +// newCheckedRedisSource is an internal-only constructor that takes a private interface as a parameter. +// We call this from tests and from NewCheckedRedisSource. +func newCheckedRedisSource(base rocspSourceInterface, dbMap dbSelector, sac sapb.StorageAuthorityReadOnlyClient, stats prometheus.Registerer, log blog.Logger) *checkedRedisSource { + counter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "checked_rocsp_responses", + Help: "Count of OCSP requests/responses from checkedRedisSource, by result", + }, []string{"result"}) + stats.MustRegister(counter) + + return &checkedRedisSource{ + base: base, + dbMap: dbMap, + sac: sac, + counter: counter, + log: log, + } +} + +// Response implements the responder.Source interface. It looks up the requested OCSP +// response in the redis cluster and looks up the corresponding status in the DB. If +// the status disagrees with what redis says, it signs a fresh response and serves it. +func (src *checkedRedisSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + serialString := core.SerialToString(req.SerialNumber) + + var wg sync.WaitGroup + wg.Add(2) + var dbStatus *sapb.RevocationStatus + var redisResult *responder.Response + var redisErr, dbErr error + go func() { + defer wg.Done() + if src.sac != nil { + dbStatus, dbErr = src.sac.GetRevocationStatus(ctx, &sapb.Serial{Serial: serialString}) + } else { + dbStatus, dbErr = sa.SelectRevocationStatus(ctx, src.dbMap, serialString) + } + }() + go func() { + defer wg.Done() + redisResult, redisErr = src.base.Response(ctx, req) + }() + wg.Wait() + + if dbErr != nil { + // If the DB says "not found", the certificate either doesn't exist or has + // expired and been removed from the DB. We don't need to check the Redis error. + if db.IsNoRows(dbErr) || errors.Is(dbErr, berrors.NotFound) { + src.counter.WithLabelValues("not_found").Inc() + return nil, responder.ErrNotFound + } + + src.counter.WithLabelValues("db_error").Inc() + return nil, dbErr + } + + if redisErr != nil { + src.counter.WithLabelValues("redis_error").Inc() + return nil, redisErr + } + + // If the DB status matches the status returned from the Redis pipeline, all is good. + if agree(dbStatus, redisResult.Response) { + src.counter.WithLabelValues("success").Inc() + return redisResult, nil + } + + // Otherwise, the DB is authoritative. Trigger a fresh signing. + freshResult, err := src.base.signAndSave(ctx, req, causeMismatch) + if err != nil { + src.counter.WithLabelValues("revocation_re_sign_error").Inc() + return nil, err + } + + if agree(dbStatus, freshResult.Response) { + src.counter.WithLabelValues("revocation_re_sign_success").Inc() + return freshResult, nil + } + + // This could happen for instance with replication lag, or if the + // RA was talking to a different DB. + src.counter.WithLabelValues("revocation_re_sign_mismatch").Inc() + return nil, errors.New("freshly signed status did not match DB") + +} + +// agree returns true if the contents of the redisResult ocsp.Response agree with what's in the DB. +func agree(dbStatus *sapb.RevocationStatus, redisResult *ocsp.Response) bool { + return dbStatus.Status == int64(redisResult.Status) && + dbStatus.RevokedReason == int64(redisResult.RevocationReason) && + dbStatus.RevokedDate.AsTime().Equal(redisResult.RevokedAt) +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source_test.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source_test.go new file mode 100644 index 000000000..ea1ce198e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source_test.go @@ -0,0 +1,294 @@ +package redis + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/ocsp/responder" + ocsp_test "github.com/letsencrypt/boulder/ocsp/test" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +// echoSource implements rocspSourceInterface, returning the provided response +// and panicking if signAndSave is called. +type echoSource struct { + resp *ocsp.Response +} + +func (es echoSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + return &responder.Response{Response: es.resp, Raw: es.resp.Raw}, nil +} + +func (es echoSource) signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error) { + panic("should not happen") +} + +// recordingEchoSource acts like echoSource, but instead of panicking on signAndSave, +// it records the serial number it was called with and returns the given secondResp. +type recordingEchoSource struct { + echoSource + secondResp *responder.Response + ch chan string +} + +func (res recordingEchoSource) signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error) { + res.ch <- req.SerialNumber.String() + return res.secondResp, nil +} + +// errorSource implements rocspSourceInterface, and always returns an error. +type errorSource struct{} + +func (es errorSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + return nil, errors.New("sad trombone") +} + +func (es errorSource) signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error) { + panic("should not happen") +} + +// echoSelector always returns the given certificateStatus. +type echoSelector struct { + db.MockSqlExecutor + status sa.RevocationStatusModel +} + +func (s echoSelector) SelectOne(_ context.Context, output interface{}, _ string, _ ...interface{}) error { + outputPtr, ok := output.(*sa.RevocationStatusModel) + if !ok { + return fmt.Errorf("incorrect output type %T", output) + } + *outputPtr = s.status + return nil +} + +// errorSelector always returns an error. +type errorSelector struct { + db.MockSqlExecutor +} + +func (s errorSelector) SelectOne(_ context.Context, _ interface{}, _ string, _ ...interface{}) error { + return errors.New("oops") +} + +// notFoundSelector always returns an NoRows error. +type notFoundSelector struct { + db.MockSqlExecutor +} + +func (s notFoundSelector) SelectOne(_ context.Context, _ interface{}, _ string, _ ...interface{}) error { + return db.ErrDatabaseOp{Err: sql.ErrNoRows} +} + +// echoSA always returns the given revocation status. +type echoSA struct { + sapb.StorageAuthorityReadOnlyClient + status *sapb.RevocationStatus +} + +func (s *echoSA) GetRevocationStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.RevocationStatus, error) { + return s.status, nil +} + +// errorSA always returns an error. +type errorSA struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (s *errorSA) GetRevocationStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.RevocationStatus, error) { + return nil, errors.New("oops") +} + +// notFoundSA always returns a NotFound error. +type notFoundSA struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (s *notFoundSA) GetRevocationStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.RevocationStatus, error) { + return nil, berrors.NotFoundError("purged") +} + +func TestCheckedRedisSourceSuccess(t *testing.T) { + serial := big.NewInt(17777) + thisUpdate := time.Now().Truncate(time.Second).UTC() + + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + + status := sa.RevocationStatusModel{ + Status: core.OCSPStatusGood, + } + src := newCheckedRedisSource(echoSource{resp: resp}, echoSelector{status: status}, nil, metrics.NoopRegisterer, blog.NewMock()) + responderResponse, err := src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "getting response") + test.AssertEquals(t, responderResponse.SerialNumber.String(), resp.SerialNumber.String()) +} + +func TestCheckedRedisSourceDBError(t *testing.T) { + serial := big.NewInt(404040) + thisUpdate := time.Now().Truncate(time.Second).UTC() + + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + + src := newCheckedRedisSource(echoSource{resp: resp}, errorSelector{}, nil, metrics.NoopRegisterer, blog.NewMock()) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "getting response") + test.AssertContains(t, err.Error(), "oops") + + src = newCheckedRedisSource(echoSource{resp: resp}, notFoundSelector{}, nil, metrics.NoopRegisterer, blog.NewMock()) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "getting response") + test.AssertErrorIs(t, err, responder.ErrNotFound) +} + +func TestCheckedRedisSourceSAError(t *testing.T) { + serial := big.NewInt(404040) + thisUpdate := time.Now().Truncate(time.Second).UTC() + + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + + src := newCheckedRedisSource(echoSource{resp: resp}, nil, &errorSA{}, metrics.NoopRegisterer, blog.NewMock()) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "getting response") + test.AssertContains(t, err.Error(), "oops") + + src = newCheckedRedisSource(echoSource{resp: resp}, nil, ¬FoundSA{}, metrics.NoopRegisterer, blog.NewMock()) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "getting response") + test.AssertErrorIs(t, err, responder.ErrNotFound) +} + +func TestCheckedRedisSourceRedisError(t *testing.T) { + serial := big.NewInt(314159262) + + status := sa.RevocationStatusModel{ + Status: core.OCSPStatusGood, + } + src := newCheckedRedisSource(errorSource{}, echoSelector{status: status}, nil, metrics.NoopRegisterer, blog.NewMock()) + _, err := src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "getting response") +} + +func TestCheckedRedisStatusDisagreement(t *testing.T) { + serial := big.NewInt(2718) + thisUpdate := time.Now().Truncate(time.Second).UTC() + + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate.Add(-time.Minute), + }) + test.AssertNotError(t, err, "making fake response") + + secondResp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Revoked, + RevokedAt: thisUpdate, + RevocationReason: ocsp.KeyCompromise, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + status := sa.RevocationStatusModel{ + Status: core.OCSPStatusRevoked, + RevokedDate: thisUpdate, + RevokedReason: ocsp.KeyCompromise, + } + source := recordingEchoSource{ + echoSource: echoSource{resp: resp}, + secondResp: &responder.Response{Response: secondResp, Raw: secondResp.Raw}, + ch: make(chan string, 1), + } + src := newCheckedRedisSource(source, echoSelector{status: status}, nil, metrics.NoopRegisterer, blog.NewMock()) + fetchedResponse, err := src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "getting re-signed response") + test.Assert(t, fetchedResponse.ThisUpdate.Equal(thisUpdate), "thisUpdate not updated") + test.AssertEquals(t, fetchedResponse.SerialNumber.String(), serial.String()) + test.AssertEquals(t, fetchedResponse.RevokedAt, thisUpdate) + test.AssertEquals(t, fetchedResponse.RevocationReason, ocsp.KeyCompromise) + test.AssertEquals(t, fetchedResponse.ThisUpdate, thisUpdate) +} + +func TestCheckedRedisStatusSADisagreement(t *testing.T) { + serial := big.NewInt(2718) + thisUpdate := time.Now().Truncate(time.Second).UTC() + + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate.Add(-time.Minute), + }) + test.AssertNotError(t, err, "making fake response") + + secondResp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Revoked, + RevokedAt: thisUpdate, + RevocationReason: ocsp.KeyCompromise, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + statusPB := sapb.RevocationStatus{ + Status: 1, + RevokedDate: timestamppb.New(thisUpdate), + RevokedReason: ocsp.KeyCompromise, + } + source := recordingEchoSource{ + echoSource: echoSource{resp: resp}, + secondResp: &responder.Response{Response: secondResp, Raw: secondResp.Raw}, + ch: make(chan string, 1), + } + src := newCheckedRedisSource(source, nil, &echoSA{status: &statusPB}, metrics.NoopRegisterer, blog.NewMock()) + fetchedResponse, err := src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "getting re-signed response") + test.Assert(t, fetchedResponse.ThisUpdate.Equal(thisUpdate), "thisUpdate not updated") + test.AssertEquals(t, fetchedResponse.SerialNumber.String(), serial.String()) + test.AssertEquals(t, fetchedResponse.RevokedAt, thisUpdate) + test.AssertEquals(t, fetchedResponse.RevocationReason, ocsp.KeyCompromise) + test.AssertEquals(t, fetchedResponse.ThisUpdate, thisUpdate) +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source.go new file mode 100644 index 000000000..0629928ed --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source.go @@ -0,0 +1,188 @@ +// Package redis provides a Redis-based OCSP responder. +// +// This responder will first look for a response cached in Redis. If there is +// no response, or the response is too old, it will make a request to the RA +// for a freshly-signed response. If that succeeds, this responder will return +// the response to the user right away, while storing a copy to Redis in a +// separate goroutine. +// +// If the response was too old, but the request to the RA failed, this +// responder will serve the response anyhow. This allows for graceful +// degradation: it is better to serve a response that is 5 days old (outside +// the Baseline Requirements limits) than to serve no response at all. +// It's assumed that this will be wrapped in a responder.filterSource, which +// means that if a response is past its NextUpdate, we'll generate a 500. +package redis + +import ( + "context" + "errors" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/ocsp/responder" + "github.com/letsencrypt/boulder/rocsp" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + berrors "github.com/letsencrypt/boulder/errors" +) + +type rocspClient interface { + GetResponse(ctx context.Context, serial string) ([]byte, error) + StoreResponse(ctx context.Context, resp *ocsp.Response) error +} + +type redisSource struct { + client rocspClient + signer responder.Source + counter *prometheus.CounterVec + signAndSaveCounter *prometheus.CounterVec + cachedResponseAges prometheus.Histogram + clk clock.Clock + liveSigningPeriod time.Duration + // Error logs will be emitted at a rate of 1 in logSampleRate. + // If logSampleRate is 0, no logs will be emitted. + logSampleRate int + // Note: this logger is not currently used, as all audit log events are from + // the dbSource right now, but it should and will be used in the future. + log blog.Logger +} + +// NewRedisSource returns a responder.Source which will look up OCSP responses in a +// Redis table. +func NewRedisSource( + client *rocsp.RWClient, + signer responder.Source, + liveSigningPeriod time.Duration, + clk clock.Clock, + stats prometheus.Registerer, + log blog.Logger, + logSampleRate int, +) (*redisSource, error) { + counter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ocsp_redis_responses", + Help: "Count of OCSP requests/responses by action taken by the redisSource", + }, []string{"result"}) + stats.MustRegister(counter) + + signAndSaveCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "ocsp_redis_sign_and_save", + Help: "Count of OCSP sign and save requests", + }, []string{"cause", "result"}) + stats.MustRegister(signAndSaveCounter) + + // Set up 12-hour-wide buckets, measured in seconds. + buckets := make([]float64, 14) + for i := range buckets { + buckets[i] = 43200 * float64(i) + } + + cachedResponseAges := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "ocsp_redis_cached_response_ages", + Help: "How old are the cached OCSP responses when we successfully retrieve them.", + Buckets: buckets, + }) + stats.MustRegister(cachedResponseAges) + + var rocspReader rocspClient + if client != nil { + rocspReader = client + } + return &redisSource{ + client: rocspReader, + signer: signer, + counter: counter, + signAndSaveCounter: signAndSaveCounter, + cachedResponseAges: cachedResponseAges, + liveSigningPeriod: liveSigningPeriod, + clk: clk, + log: log, + }, nil +} + +// Response implements the responder.Source interface. It looks up the requested OCSP +// response in the redis cluster. +func (src *redisSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + serialString := core.SerialToString(req.SerialNumber) + + respBytes, err := src.client.GetResponse(ctx, serialString) + if err != nil { + if errors.Is(err, rocsp.ErrRedisNotFound) { + src.counter.WithLabelValues("not_found").Inc() + } else { + src.counter.WithLabelValues("lookup_error").Inc() + responder.SampledError(src.log, src.logSampleRate, "looking for cached response: %s", err) + // Proceed despite the error; when Redis is down we'd like to limp along with live signing + // rather than returning an error to the client. + } + return src.signAndSave(ctx, req, causeNotFound) + } + + resp, err := ocsp.ParseResponse(respBytes, nil) + if err != nil { + src.counter.WithLabelValues("parse_error").Inc() + return nil, err + } + + if src.isStale(resp) { + src.counter.WithLabelValues("stale").Inc() + freshResp, err := src.signAndSave(ctx, req, causeStale) + // Note: we could choose to return the stale response (up to its actual + // NextUpdate date), but if we pass the BR/root program limits, that + // becomes a compliance problem; returning an error is an availability + // problem and only becomes a compliance problem if we serve too many + // of them for too long (the exact conditions are not clearly defined + // by the BRs or root programs). + if err != nil { + return nil, err + } + return freshResp, nil + } + + src.counter.WithLabelValues("success").Inc() + return &responder.Response{Response: resp, Raw: respBytes}, nil +} + +func (src *redisSource) isStale(resp *ocsp.Response) bool { + age := src.clk.Since(resp.ThisUpdate) + src.cachedResponseAges.Observe(age.Seconds()) + return age > src.liveSigningPeriod +} + +type signAndSaveCause string + +const ( + causeStale signAndSaveCause = "stale" + causeNotFound signAndSaveCause = "not_found" + causeMismatch signAndSaveCause = "mismatch" +) + +func (src *redisSource) signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error) { + resp, err := src.signer.Response(ctx, req) + if errors.Is(err, responder.ErrNotFound) { + src.signAndSaveCounter.WithLabelValues(string(cause), "certificate_not_found").Inc() + return nil, responder.ErrNotFound + } else if errors.Is(err, berrors.UnknownSerial) { + // UnknownSerial is more interesting than NotFound, because it means we don't + // have a record in the `serials` table, which is kept longer-term than the + // `certificateStatus` table. That could mean someone is making up silly serial + // numbers in their requests to us, or it could mean there's site on the internet + // using a certificate that we don't have a record of in the `serials` table. + src.signAndSaveCounter.WithLabelValues(string(cause), "unknown_serial").Inc() + responder.SampledError(src.log, src.logSampleRate, "unknown serial: %s", core.SerialToString(req.SerialNumber)) + return nil, responder.ErrNotFound + } else if err != nil { + src.signAndSaveCounter.WithLabelValues(string(cause), "signing_error").Inc() + return nil, err + } + src.signAndSaveCounter.WithLabelValues(string(cause), "signing_success").Inc() + go func() { + // We don't care about the error here, because if storing the response + // fails, we'll just generate a new one on the next request. + _ = src.client.StoreResponse(context.Background(), resp.Response) + }() + return resp, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source_test.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source_test.go new file mode 100644 index 000000000..7b73b2185 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/redis/redis_source_test.go @@ -0,0 +1,255 @@ +package redis + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/ocsp/responder" + ocsp_test "github.com/letsencrypt/boulder/ocsp/test" + "github.com/letsencrypt/boulder/rocsp" + "github.com/letsencrypt/boulder/test" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" +) + +// notFoundRedis is a mock *rocsp.WritingClient that (a) returns "not found" +// for all GetResponse, and (b) sends all StoreResponse serial numbers to +// a channel. The latter is necessary because the code under test calls +// StoreResponse from a goroutine, so we need something to synchronize back to +// the testing goroutine. +// For tests where you do not expect StoreResponse to be called, set the chan +// to nil so sends will panic. +type notFoundRedis struct { + serialStored chan *big.Int +} + +func (nfr *notFoundRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) { + return nil, rocsp.ErrRedisNotFound +} + +func (nfr *notFoundRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + nfr.serialStored <- resp.SerialNumber + return nil +} + +type recordingSigner struct { + serialRequested *big.Int +} + +func (rs *recordingSigner) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + if rs.serialRequested != nil { + panic("signed twice") + } + rs.serialRequested = req.SerialNumber + // Return a fake response with only serial number filled, because that's + // all the test cares about. + return &responder.Response{Response: &ocsp.Response{ + SerialNumber: req.SerialNumber, + }}, nil +} + +func TestNotFound(t *testing.T) { + recordingSigner := recordingSigner{} + src, err := NewRedisSource(nil, &recordingSigner, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + notFoundRedis := ¬FoundRedis{make(chan *big.Int)} + src.client = notFoundRedis + + serial := big.NewInt(987654321) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "signing response when not found") + if recordingSigner.serialRequested.Cmp(serial) != 0 { + t.Errorf("issued signing request for serial %x; expected %x", recordingSigner.serialRequested, serial) + } + stored := <-notFoundRedis.serialStored + if stored == nil { + t.Fatalf("response was never stored") + } + if stored.Cmp(serial) != 0 { + t.Errorf("stored response for serial %x; expected %x", notFoundRedis.serialStored, serial) + } +} + +type panicSource struct{} + +func (ps panicSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + panic("shouldn't happen") +} + +type errorRedis struct{} + +func (er errorRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) { + return nil, errors.New("the enzabulators florbled") +} + +func (er errorRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + return nil +} + +// When the initial Redis lookup returns an error, we should +// proceed with live signing. +func TestQueryError(t *testing.T) { + serial := big.NewInt(314159) + thisUpdate := time.Now().Truncate(time.Second).UTC() + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serial, + Status: ocsp.Good, + ThisUpdate: thisUpdate, + }) + test.AssertNotError(t, err, "making fake response") + source := echoSource{resp: resp} + + src, err := NewRedisSource(nil, source, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + src.client = errorRedis{} + + receivedResp, err := src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "expected no error when Redis errored") + test.AssertDeepEquals(t, resp.Raw, receivedResp.Raw) + test.AssertMetricWithLabelsEquals(t, src.counter, prometheus.Labels{"result": "lookup_error"}, 1) +} + +type garbleRedis struct{} + +func (er garbleRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) { + return []byte("not a valid OCSP response, I can tell by the pixels"), nil +} + +func (er garbleRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + panic("shouldn't happen") +} + +func TestParseError(t *testing.T) { + src, err := NewRedisSource(nil, panicSource{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + src.client = garbleRedis{} + + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: big.NewInt(314159), + }) + test.AssertError(t, err, "expected error when Redis returned junk") + if errors.Is(err, rocsp.ErrRedisNotFound) { + t.Errorf("incorrect error value ErrRedisNotFound; expected general error") + } +} + +func TestSignError(t *testing.T) { + src, err := NewRedisSource(nil, errorSource{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + src.client = ¬FoundRedis{nil} + + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: big.NewInt(2718), + }) + test.AssertError(t, err, "Expected error when signer errored") +} + +// staleRedis is a mock *rocsp.WritingClient that (a) returns response with a +// fixed ThisUpdate for all GetResponse, and (b) sends all StoreResponse serial +// numbers to a channel. The latter is necessary because the code under test +// calls StoreResponse from a goroutine, so we need something to synchronize +// back to the testing goroutine. +type staleRedis struct { + serialStored chan *big.Int + thisUpdate time.Time +} + +func (sr *staleRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) { + serInt, err := core.StringToSerial(serial) + if err != nil { + return nil, err + } + resp, _, err := ocsp_test.FakeResponse(ocsp.Response{ + SerialNumber: serInt, + ThisUpdate: sr.thisUpdate, + }) + if err != nil { + return nil, err + } + return resp.Raw, nil +} + +func (sr *staleRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + sr.serialStored <- resp.SerialNumber + return nil +} + +func TestStale(t *testing.T) { + recordingSigner := recordingSigner{} + clk := clock.NewFake() + src, err := NewRedisSource(nil, &recordingSigner, time.Second, clk, metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + staleRedis := &staleRedis{ + serialStored: make(chan *big.Int), + thisUpdate: clk.Now().Add(-time.Hour), + } + src.client = staleRedis + + serial := big.NewInt(8675309) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertNotError(t, err, "signing response when not found") + if recordingSigner.serialRequested == nil { + t.Fatalf("signing source was never called") + } + if recordingSigner.serialRequested.Cmp(serial) != 0 { + t.Errorf("issued signing request for serial %x; expected %x", recordingSigner.serialRequested, serial) + } + stored := <-staleRedis.serialStored + if stored == nil { + t.Fatalf("response was never stored") + } + if stored.Cmp(serial) != 0 { + t.Errorf("stored response for serial %x; expected %x", staleRedis.serialStored, serial) + } +} + +// notFoundSigner is a Source that always returns NotFound. +type notFoundSigner struct{} + +func (nfs notFoundSigner) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) { + return nil, responder.ErrNotFound +} + +func TestCertificateNotFound(t *testing.T) { + src, err := NewRedisSource(nil, notFoundSigner{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + notFoundRedis := ¬FoundRedis{nil} + src.client = notFoundRedis + + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: big.NewInt(777777777), + }) + if !errors.Is(err, responder.ErrNotFound) { + t.Errorf("expected NotFound error, got %s", err) + } +} + +func TestNoServeStale(t *testing.T) { + clk := clock.NewFake() + src, err := NewRedisSource(nil, errorSource{}, time.Second, clk, metrics.NoopRegisterer, log.NewMock(), 1) + test.AssertNotError(t, err, "making source") + staleRedis := &staleRedis{ + serialStored: nil, + thisUpdate: clk.Now().Add(-time.Hour), + } + src.client = staleRedis + + serial := big.NewInt(111111) + _, err = src.Response(context.Background(), &ocsp.Request{ + SerialNumber: serial, + }) + test.AssertError(t, err, "expected to error when signer was down") +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder.go new file mode 100644 index 000000000..5fc273644 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder.go @@ -0,0 +1,365 @@ +/* +This code was originally forked from https://github.com/cloudflare/cfssl/blob/1a911ca1b1d6e899bf97dcfa4a14b38db0d31134/ocsp/responder.go + +Copyright (c) 2014 CloudFlare Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +// Package responder implements an OCSP HTTP responder based on a generic +// storage backend. +package responder + +import ( + "context" + "crypto" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" +) + +// ErrNotFound indicates the request OCSP response was not found. It is used to +// indicate that the responder should reply with unauthorizedErrorResponse. +var ErrNotFound = errors.New("request OCSP Response not found") + +// errOCSPResponseExpired indicates that the nextUpdate field of the requested +// OCSP response occurred in the past and an HTTP status code of 533 should be +// returned to the caller. +var errOCSPResponseExpired = errors.New("OCSP response is expired") + +var responseTypeToString = map[ocsp.ResponseStatus]string{ + ocsp.Success: "Success", + ocsp.Malformed: "Malformed", + ocsp.InternalError: "InternalError", + ocsp.TryLater: "TryLater", + ocsp.SignatureRequired: "SignatureRequired", + ocsp.Unauthorized: "Unauthorized", +} + +// A Responder object provides an HTTP wrapper around a Source. +type Responder struct { + Source Source + timeout time.Duration + responseTypes *prometheus.CounterVec + responseAges prometheus.Histogram + requestSizes prometheus.Histogram + sampleRate int + clk clock.Clock + log blog.Logger +} + +// NewResponder instantiates a Responder with the give Source. +func NewResponder(source Source, timeout time.Duration, stats prometheus.Registerer, logger blog.Logger, sampleRate int) *Responder { + requestSizes := prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "ocsp_request_sizes", + Help: "Size of OCSP requests", + Buckets: []float64{1, 100, 200, 400, 800, 1200, 2000, 5000, 10000}, + }, + ) + stats.MustRegister(requestSizes) + + // Set up 12-hour-wide buckets, measured in seconds. + buckets := make([]float64, 14) + for i := range buckets { + buckets[i] = 43200 * float64(i) + } + responseAges := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "ocsp_response_ages", + Help: "How old are the OCSP responses when we serve them. Must stay well below 84 hours.", + Buckets: buckets, + }) + stats.MustRegister(responseAges) + + responseTypes := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocsp_responses", + Help: "Number of OCSP responses returned by type", + }, + []string{"type"}, + ) + stats.MustRegister(responseTypes) + + return &Responder{ + Source: source, + timeout: timeout, + responseTypes: responseTypes, + responseAges: responseAges, + requestSizes: requestSizes, + clk: clock.New(), + log: logger, + sampleRate: sampleRate, + } +} + +type logEvent struct { + IP string `json:"ip,omitempty"` + UA string `json:"ua,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Body string `json:"body,omitempty"` + Received time.Time `json:"received,omitempty"` + Took time.Duration `json:"took,omitempty"` + Headers http.Header `json:"headers,omitempty"` + + Serial string `json:"serial,omitempty"` + IssuerKeyHash string `json:"issuerKeyHash,omitempty"` + IssuerNameHash string `json:"issuerNameHash,omitempty"` + HashAlg string `json:"hashAlg,omitempty"` +} + +// hashToString contains mappings for the only hash functions +// x/crypto/ocsp supports +var hashToString = map[crypto.Hash]string{ + crypto.SHA1: "SHA1", + crypto.SHA256: "SHA256", + crypto.SHA384: "SHA384", + crypto.SHA512: "SHA512", +} + +func SampledError(log blog.Logger, sampleRate int, format string, a ...interface{}) { + if sampleRate > 0 && rand.Intn(sampleRate) == 0 { + log.Errf(format, a...) + } +} + +func (rs Responder) sampledError(format string, a ...interface{}) { + SampledError(rs.log, rs.sampleRate, format, a...) +} + +// ServeHTTP is a Responder that can process both GET and POST requests. The +// mapping from an OCSP request to an OCSP response is done by the Source; the +// Responder simply decodes the request, and passes back whatever response is +// provided by the source. +// The Responder will set these headers: +// +// Cache-Control: "max-age=(response.NextUpdate-now), public, no-transform, must-revalidate", +// Last-Modified: response.ThisUpdate, +// Expires: response.NextUpdate, +// ETag: the SHA256 hash of the response, and +// Content-Type: application/ocsp-response. +// +// Note: The caller must use http.StripPrefix to strip any path components +// (including '/') on GET requests. +// Do not use this responder in conjunction with http.NewServeMux, because the +// default handler will try to canonicalize path components by changing any +// strings of repeated '/' into a single '/', which will break the base64 +// encoding. +func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Request) { + // We specifically ignore request.Context() because we would prefer for clients + // to not be able to cancel our operations in arbitrary places. Instead we + // start a new context, and apply timeouts in our various RPCs. + ctx := context.WithoutCancel(request.Context()) + request = request.WithContext(ctx) + + if rs.timeout != 0 { + var cancel func() + ctx, cancel = context.WithTimeout(ctx, rs.timeout) + defer cancel() + } + + le := logEvent{ + IP: request.RemoteAddr, + UA: request.UserAgent(), + Method: request.Method, + Path: request.URL.Path, + Received: time.Now(), + } + + defer func() { + le.Headers = response.Header() + le.Took = time.Since(le.Received) + jb, err := json.Marshal(le) + if err != nil { + // we log this error at the debug level as if we aren't at that level anyway + // we shouldn't really care about marshalling the log event object + rs.log.Debugf("failed to marshal log event object: %s", err) + return + } + rs.log.Debugf("Received request: %s", string(jb)) + }() + // By default we set a 'max-age=0, no-cache' Cache-Control header, this + // is only returned to the client if a valid authorized OCSP response + // is not found or an error is returned. If a response if found the header + // will be altered to contain the proper max-age and modifiers. + response.Header().Add("Cache-Control", "max-age=0, no-cache") + // Read response from request + var requestBody []byte + var err error + switch request.Method { + case "GET": + base64Request, err := url.QueryUnescape(request.URL.Path) + if err != nil { + rs.log.Debugf("Error decoding URL: %s", request.URL.Path) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc() + response.WriteHeader(http.StatusBadRequest) + return + } + // url.QueryUnescape not only unescapes %2B escaping, but it additionally + // turns the resulting '+' into a space, which makes base64 decoding fail. + // So we go back afterwards and turn ' ' back into '+'. This means we + // accept some malformed input that includes ' ' or %20, but that's fine. + base64RequestBytes := []byte(base64Request) + for i := range base64RequestBytes { + if base64RequestBytes[i] == ' ' { + base64RequestBytes[i] = '+' + } + } + // In certain situations a UA may construct a request that has a double + // slash between the host name and the base64 request body due to naively + // constructing the request URL. In that case strip the leading slash + // so that we can still decode the request. + if len(base64RequestBytes) > 0 && base64RequestBytes[0] == '/' { + base64RequestBytes = base64RequestBytes[1:] + } + requestBody, err = base64.StdEncoding.DecodeString(string(base64RequestBytes)) + if err != nil { + rs.log.Debugf("Error decoding base64 from URL: %s", string(base64RequestBytes)) + response.WriteHeader(http.StatusBadRequest) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc() + return + } + case "POST": + requestBody, err = io.ReadAll(http.MaxBytesReader(nil, request.Body, 10000)) + if err != nil { + rs.log.Errf("Problem reading body of POST: %s", err) + response.WriteHeader(http.StatusBadRequest) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc() + return + } + rs.requestSizes.Observe(float64(len(requestBody))) + default: + response.WriteHeader(http.StatusMethodNotAllowed) + return + } + b64Body := base64.StdEncoding.EncodeToString(requestBody) + rs.log.Debugf("Received OCSP request: %s", b64Body) + if request.Method == http.MethodPost { + le.Body = b64Body + } + + // All responses after this point will be OCSP. + // We could check for the content type of the request, but that + // seems unnecessariliy restrictive. + response.Header().Add("Content-Type", "application/ocsp-response") + + // Parse response as an OCSP request + // XXX: This fails if the request contains the nonce extension. + // We don't intend to support nonces anyway, but maybe we + // should return unauthorizedRequest instead of malformed. + ocspRequest, err := ocsp.ParseRequest(requestBody) + if err != nil { + rs.log.Debugf("Error decoding request body: %s", b64Body) + response.WriteHeader(http.StatusBadRequest) + response.Write(ocsp.MalformedRequestErrorResponse) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc() + return + } + le.Serial = fmt.Sprintf("%x", ocspRequest.SerialNumber.Bytes()) + le.IssuerKeyHash = fmt.Sprintf("%x", ocspRequest.IssuerKeyHash) + le.IssuerNameHash = fmt.Sprintf("%x", ocspRequest.IssuerNameHash) + le.HashAlg = hashToString[ocspRequest.HashAlgorithm] + + // Look up OCSP response from source + ocspResponse, err := rs.Source.Response(ctx, ocspRequest) + if err != nil { + if errors.Is(err, ErrNotFound) { + response.Write(ocsp.UnauthorizedErrorResponse) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc() + return + } else if errors.Is(err, errOCSPResponseExpired) { + rs.sampledError("Requested ocsp response is expired: serial %x, request body %s", + ocspRequest.SerialNumber, b64Body) + // HTTP StatusCode - unassigned + response.WriteHeader(533) + response.Write(ocsp.InternalErrorErrorResponse) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc() + return + } + rs.sampledError("Error retrieving response for request: serial %x, request body %s, error: %s", + ocspRequest.SerialNumber, b64Body, err) + response.WriteHeader(http.StatusInternalServerError) + response.Write(ocsp.InternalErrorErrorResponse) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.InternalError]}).Inc() + return + } + + // Write OCSP response + response.Header().Add("Last-Modified", ocspResponse.ThisUpdate.Format(time.RFC1123)) + response.Header().Add("Expires", ocspResponse.NextUpdate.Format(time.RFC1123)) + now := rs.clk.Now() + var maxAge int + if now.Before(ocspResponse.NextUpdate) { + maxAge = int(ocspResponse.NextUpdate.Sub(now) / time.Second) + } else { + // TODO(#530): we want max-age=0 but this is technically an authorized OCSP response + // (despite being stale) and 5019 forbids attaching no-cache + maxAge = 0 + } + response.Header().Set( + "Cache-Control", + fmt.Sprintf( + "max-age=%d, public, no-transform, must-revalidate", + maxAge, + ), + ) + responseHash := sha256.Sum256(ocspResponse.Raw) + response.Header().Add("ETag", fmt.Sprintf("\"%X\"", responseHash)) + + serialString := core.SerialToString(ocspResponse.SerialNumber) + if len(serialString) > 2 { + // Set a cache tag that is equal to the last two bytes of the serial. + // We expect that to be randomly distributed, so each tag should map to + // about 1/256 of our responses. + response.Header().Add("Edge-Cache-Tag", serialString[len(serialString)-2:]) + } + + // RFC 7232 says that a 304 response must contain the above + // headers if they would also be sent for a 200 for the same + // request, so we have to wait until here to do this + if etag := request.Header.Get("If-None-Match"); etag != "" { + if etag == fmt.Sprintf("\"%X\"", responseHash) { + response.WriteHeader(http.StatusNotModified) + return + } + } + response.WriteHeader(http.StatusOK) + response.Write(ocspResponse.Raw) + rs.responseAges.Observe(rs.clk.Now().Sub(ocspResponse.ThisUpdate).Seconds()) + rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Success]}).Inc() +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder_test.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder_test.go new file mode 100644 index 000000000..efd7630ac --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/responder_test.go @@ -0,0 +1,318 @@ +/* +This code was originally forked from https://github.com/cloudflare/cfssl/blob/1a911ca1b1d6e899bf97dcfa4a14b38db0d31134/ocsp/responder_test.go + +Copyright (c) 2014 CloudFlare Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package responder + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" +) + +const ( + responseFile = "testdata/resp64.pem" + binResponseFile = "testdata/response.der" + brokenResponseFile = "testdata/response_broken.pem" + mixResponseFile = "testdata/response_mix.pem" +) + +type testSource struct{} + +func (ts testSource) Response(_ context.Context, r *ocsp.Request) (*Response, error) { + respBytes, err := hex.DecodeString("3082031D0A0100A08203163082031206092B060105050730010104820303308202FF3081E8A1453043310B300906035504061302555331123010060355040A1309676F6F6420677579733120301E06035504031317434120696E7465726D6564696174652028525341292041180F32303230303631393030333730305A30818D30818A304C300906052B0E03021A0500041417779CF67D84CD4449A2FC7EAC431F9823D8575A04149F2970E80CF9C75ECC1F2871D8C390CD19F40108021300FF8B2AEC5293C6B31D0BC0BA329CF594E7BAA116180F32303230303631393030333733305AA0030A0101180F32303230303631393030303030305AA011180F32303230303632333030303030305A300D06092A864886F70D01010B0500038202010011688303203098FC522D2C599A234B136930E3C4680F2F3192188B98D6EE90E8479449968C51335FADD1636584ACEA9D01A30790BD90190FA35A47E793718128B19E9ED156382C1B68245A6887F547B0B86C44C2354B8DBA94D8BFCAA768EB55FA84AEB4026DBEFC687DB280D21C0B3497A11909804A20F402BDD95E4843C02E30435C2570FFC4EB152FE2785B8D268AC996619644AEC9CF50959D46DEB21DFE96B4D2881D61ABBCA9B6BFEC2DB9132801CAE737C862F0AEAB4948B63F35740CE93FCDBC148F5070790D7BBA1A87E15078CD8335F83686142CE8AC3AD21FAE45B87A7B12562D9F245352A83E3901E97E5EC77E9817990712D8BE60860ABA58804DDE4ECDCA6AEFD3D8764FDBABF0AB1902FA9A7C4C3F5814C25C5E78E0754469E087CAED81E50A5873CADFCAC42963AB38CFD11096BE4201DE4589B57EC48B3DA05A65800D654160E022F6748CD93B431A17270C1B27E313734FCF85F22547D060F23F594BD68C6330C2705190A04905FBD2389E2DD21C0188809E03D713F56BF95953C9897DA6D4D074D70F164270C41BFB386B69E86EB3B9192FEA8F43CE5368CC9AF8687DEE567672A8580BA6A9F76E6E6705DD2F76F48C2C180C763CF4C48AF78C25D40EA7278CB2FBC78958B3179301825B420A7CAE7ACE4C41B5BA7D567AABC9C2701EE75A28F9181E044EDAAA55A31538AA9C526D4C324B9AE58D2922") + if err != nil { + return nil, err + } + resp, err := ocsp.ParseResponse(respBytes, nil) + if err != nil { + return nil, err + } + return &Response{resp, respBytes}, nil +} + +type expiredSource struct{} + +func (es expiredSource) Response(_ context.Context, r *ocsp.Request) (*Response, error) { + return nil, errOCSPResponseExpired +} + +type testCase struct { + method, path string + expected int +} + +func TestResponseExpired(t *testing.T) { + cases := []testCase{ + {"GET", "/MFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", 533}, + } + + responder := Responder{ + Source: expiredSource{}, + responseTypes: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocspResponses-test", + }, + []string{"type"}, + ), + clk: clock.NewFake(), + log: blog.NewMock(), + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("%s %s", tc.method, tc.path), func(t *testing.T) { + rw := httptest.NewRecorder() + responder.responseTypes.Reset() + + responder.ServeHTTP(rw, &http.Request{ + Method: tc.method, + URL: &url.URL{ + Path: tc.path, + }, + }) + if rw.Code != tc.expected { + t.Errorf("Incorrect response code: got %d, wanted %d", rw.Code, tc.expected) + } + test.AssertByteEquals(t, ocsp.InternalErrorErrorResponse, rw.Body.Bytes()) + }) + } +} + +func TestOCSP(t *testing.T) { + cases := []testCase{ + {"OPTIONS", "/", http.StatusMethodNotAllowed}, + {"GET", "/", http.StatusBadRequest}, + // Bad URL encoding + {"GET", "%ZZFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusBadRequest}, + // Bad URL encoding + {"GET", "%%FQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusBadRequest}, + // Bad base64 encoding + {"GET", "==MFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusBadRequest}, + // Bad OCSP DER encoding + {"GET", "AAAMFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusBadRequest}, + // Good encoding all around, including a double slash + {"GET", "MFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusOK}, + // Good request, leading slash + {"GET", "/MFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D", http.StatusOK}, + } + + responder := Responder{ + Source: testSource{}, + responseTypes: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocspResponses-test", + }, + []string{"type"}, + ), + responseAges: prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "ocspAges-test", + Buckets: []float64{43200}, + }, + ), + clk: clock.NewFake(), + log: blog.NewMock(), + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("%s %s", tc.method, tc.path), func(t *testing.T) { + rw := httptest.NewRecorder() + responder.responseTypes.Reset() + + responder.ServeHTTP(rw, &http.Request{ + Method: tc.method, + URL: &url.URL{ + Path: tc.path, + }, + }) + if rw.Code != tc.expected { + t.Errorf("Incorrect response code: got %d, wanted %d", rw.Code, tc.expected) + } + if rw.Code == http.StatusOK { + test.AssertMetricWithLabelsEquals( + t, responder.responseTypes, prometheus.Labels{"type": "Success"}, 1) + } else if rw.Code == http.StatusBadRequest { + test.AssertMetricWithLabelsEquals( + t, responder.responseTypes, prometheus.Labels{"type": "Malformed"}, 1) + } + }) + } + // Exactly two of the cases above result in an OCSP response being sent. + test.AssertMetricWithLabelsEquals(t, responder.responseAges, prometheus.Labels{}, 2) +} + +func TestRequestTooBig(t *testing.T) { + responder := Responder{ + Source: testSource{}, + responseTypes: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocspResponses-test", + }, + []string{"type"}, + ), + responseAges: prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "ocspAges-test", + Buckets: []float64{43200}, + }, + ), + clk: clock.NewFake(), + log: blog.NewMock(), + } + + rw := httptest.NewRecorder() + + responder.ServeHTTP(rw, httptest.NewRequest("POST", "/", + bytes.NewBuffer([]byte(strings.Repeat("a", 10001))))) + expected := 400 + if rw.Code != expected { + t.Errorf("Incorrect response code: got %d, wanted %d", rw.Code, expected) + } +} + +func TestCacheHeaders(t *testing.T) { + source, err := NewMemorySourceFromFile(responseFile, blog.NewMock()) + if err != nil { + t.Fatalf("Error constructing source: %s", err) + } + + fc := clock.NewFake() + fc.Set(time.Date(2015, 11, 12, 0, 0, 0, 0, time.UTC)) + responder := Responder{ + Source: source, + responseTypes: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocspResponses-test", + }, + []string{"type"}, + ), + responseAges: prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "ocspAges-test", + Buckets: []float64{43200}, + }, + ), + clk: fc, + log: blog.NewMock(), + } + + rw := httptest.NewRecorder() + responder.ServeHTTP(rw, &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "MEMwQTA/MD0wOzAJBgUrDgMCGgUABBSwLsMRhyg1dJUwnXWk++D57lvgagQU6aQ/7p6l5vLV13lgPJOmLiSOl6oCAhJN", + }, + }) + if rw.Code != http.StatusOK { + t.Errorf("Unexpected HTTP status code %d", rw.Code) + } + testCases := []struct { + header string + value string + }{ + {"Last-Modified", "Tue, 20 Oct 2015 00:00:00 UTC"}, + {"Expires", "Sun, 20 Oct 2030 00:00:00 UTC"}, + {"Cache-Control", "max-age=471398400, public, no-transform, must-revalidate"}, + {"Etag", "\"8169FB0843B081A76E9F6F13FD70C8411597BEACF8B182136FFDD19FBD26140A\""}, + } + for _, tc := range testCases { + headers, ok := rw.Result().Header[tc.header] + if !ok { + t.Errorf("Header %s missing from HTTP response", tc.header) + continue + } + if len(headers) != 1 { + t.Errorf("Wrong number of headers in HTTP response. Wanted 1, got %d", len(headers)) + continue + } + actual := headers[0] + if actual != tc.value { + t.Errorf("Got header %s: %s. Expected %s", tc.header, actual, tc.value) + } + } + + rw = httptest.NewRecorder() + headers := http.Header{} + headers.Add("If-None-Match", "\"8169FB0843B081A76E9F6F13FD70C8411597BEACF8B182136FFDD19FBD26140A\"") + responder.ServeHTTP(rw, &http.Request{ + Method: "GET", + URL: &url.URL{ + Path: "MEMwQTA/MD0wOzAJBgUrDgMCGgUABBSwLsMRhyg1dJUwnXWk++D57lvgagQU6aQ/7p6l5vLV13lgPJOmLiSOl6oCAhJN", + }, + Header: headers, + }) + if rw.Code != http.StatusNotModified { + t.Fatalf("Got wrong status code: expected %d, got %d", http.StatusNotModified, rw.Code) + } +} + +func TestNewSourceFromFile(t *testing.T) { + logger := blog.NewMock() + _, err := NewMemorySourceFromFile("", logger) + if err == nil { + t.Fatal("Didn't fail on non-file input") + } + + // expected case + _, err = NewMemorySourceFromFile(responseFile, logger) + if err != nil { + t.Fatal(err) + } + + // binary-formatted file + _, err = NewMemorySourceFromFile(binResponseFile, logger) + if err != nil { + t.Fatal(err) + } + + // the response file from before, with stuff deleted + _, err = NewMemorySourceFromFile(brokenResponseFile, logger) + if err != nil { + t.Fatal(err) + } + + // mix of a correct and malformed responses + _, err = NewMemorySourceFromFile(mixResponseFile, logger) + if err != nil { + t.Fatal(err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/source.go b/third-party/github.com/letsencrypt/boulder/ocsp/responder/source.go new file mode 100644 index 000000000..d0c39ae8f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/source.go @@ -0,0 +1,20 @@ +package responder + +import ( + "context" + + "golang.org/x/crypto/ocsp" +) + +// Response is a wrapper around the standard library's *ocsp.Response, but it +// also carries with it the raw bytes of the encoded response. +type Response struct { + *ocsp.Response + Raw []byte +} + +// Source represents the logical source of OCSP responses, i.e., +// the logic that actually chooses a response based on a request. +type Source interface { + Response(context.Context, *ocsp.Request) (*Response, error) +} diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/LICENSE b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/LICENSE new file mode 100644 index 000000000..ed9302875 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/LICENSE @@ -0,0 +1,26 @@ +These files were originally taken from https://github.com/cloudflare/cfssl/tree/1a911ca1b1d6e899bf97dcfa4a14b38db0d31134/ocsp/testdata + +Copyright (c) 2014 CloudFlare Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.req b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.req new file mode 100644 index 000000000..587871502 Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.req differ diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.resp b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.resp new file mode 100644 index 000000000..a35f0bb9f Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/ocsp.resp differ diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/resp64.pem b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/resp64.pem new file mode 100644 index 000000000..dea2591d5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/resp64.pem @@ -0,0 +1,2 @@ +MIIFCAoBAKCCBQEwggT9BgkrBgEFBQcwAQEEggTuMIIE6jCBrKADAgEAoS0wKzEpMCcGA1UEAwwgY2Fja2xpbmcgY3J5cHRvZ3JhcGhlciBmYWtlIFJPT1QYDzIwMTUxMDIxMjEyNjAwWjBlMGMwOzAJBgUrDgMCGgUABBSwLsMRhyg1dJUwnXWk++D57lvgagQU6aQ/7p6l5vLV13lgPJOmLiSOl6oCAhJNgAAYDzIwMTUwOTAxMDAwMDAwWqARGA8yMDE0MDEwMTAwMDAwMFowDQYJKoZIhvcNAQELBQADggEBAHlFcNKa7mZDJeWzJt1S45kx4gDqOLzyeZzflFbSjsrHRrLA7Y3RKoy0i4Y9Vi6Jfhe7xj6dgDMJy1Z1qayI/Q8QvnaU6V2kFcnaD7pah9uALu2xNYMJPllq8KsQYvDLa1E2PMvQTqDhY2/QrIuxw3jkqtzeI5aG0idFm3aF1z/v3dt6XPWjE8IlAJfXY4CeUorLvA+mK2YHJ3V7MSgymVXZdyth1rg0/0cP9v77Rlb8hmWA/EUMcIPKQqErVQK+gZiVC0SfElaMO25CD9cjY+fd904oC5+ahvhHXxOSEbXVZBT1FY2teFCKEpx86gAVcZWpGmVwJO+dpsrkgwpN786gggMjMIIDHzCCAxswggIDoAMCAQICCQDNMc/iNkPNdTANBgkqhkiG9w0BAQsFADArMSkwJwYDVQQDDCBjYWNrbGluZyBjcnlwdG9ncmFwaGVyIGZha2UgUk9PVDAeFw0xNTEwMjEyMDExNTJaFw0yMDEwMTkyMDExNTJaMCsxKTAnBgNVBAMMIGNhY2tsaW5nIGNyeXB0b2dyYXBoZXIgZmFrZSBST09UMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+TbvalHXQYO6GhJUJZI5mF2k4+nZDIvqWyrjw+2k9+UAcekuLKPpSclu9aBRvUggw3XFHAW95qW6Dv2+5gvinUmTq9Ry7kVTUYAxyZu1ydHt+wDETmFJfeY6/fpBHHIsuGLItqpUGmr8D6LROGEqfFY2B9+08O7Zs+FufDRgLHWEvLTdpPkrzeDJs9Oo6g38jfT9b4+9Ahs+FvvwqneAkbeZgBC2NWKB+drMuNBTPbF/W1a8czAzHeOs6qy0dBlTHNjL62/o9cRKNiKe3IqwHJdd01V1aLSUgIbe2HrP9EC1djnUXWR3jx3ursaKt7PTKsC52UJkRqnai80MzQj0WwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU6aQ/7p6l5vLV13lgPJOmLiSOl6owDQYJKoZIhvcNAQELBQADggEBACuwILDTvaBrdorv2zMsYnZuKvXtknWAf/DTcvF4N5PMOPBNkeHuGfv0VDe6VXpBHiU5G9E2RdU435W7o0kRSn27YcqrxaXGt9m2kArW6e49136+MnFx47jjk0p4T48s6MeaL5JVLJzxYouu1ZOZqlVokwNPO+8bxn6ALumIVUOD1jSBN7Y9pgLUS2rzO5pe5pxS2Ak/eO7Q7M21r1sEuG/uPuWqBFogk+4Z9omKVZdRDbzm9vYUATgEZdlTe2tct3BVBQ2zWbe0R2svIuCs8XzERykvfv1JawxI68I9vN0Dh9vj/xDM6udorfALlhjgQdftmbHovRLpJ1ZSOMIUNGY= +MIIFCAoBAKCCBQEwggT9BgkrBgEFBQcwAQEEggTuMIIE6jCBrKADAgEAoS0wKzEpMCcGA1UEAwwgY2Fja2xpbmcgY3J5cHRvZ3JhcGhlciBmYWtlIFJPT1QYDzIwMTUxMDIxMjA1NTAwWjBlMGMwOzAJBgUrDgMCGgUABBSwLsMRhyg1dJUwnXWk++D57lvgagQU6aQ/7p6l5vLV13lgPJOmLiSOl6oCAhJNgAAYDzIwMTUxMDIwMDAwMDAwWqARGA8yMDMwMTAyMDAwMDAwMFowDQYJKoZIhvcNAQELBQADggEBAFgnZ/Ft1LTDYPwPlecOtLykgwS4HZTelUaSi841nq/tgfLM11G3D1AUXAT2V2jxiG+0YTxzkWd5v44KJGB9Mm+qjafPMKR3ULjQkJHJ8goFHpWkUtLrIYurj8N+4HpwZ+RJccieuZIX8SMeSWRq5w83okWZPGoUrl6GRdQDteE7imrNkBa35zrzUWozPqY8k90ttKfhZHRXNCJe8YbVfJRDh0vVZABzlfHeW8V+ie15HPVDx/M341KC3tBMM88e5/bt3sLyUU8SwxGH5nOe/ohVpjhkjk2Pz4TPdwD2ZK5Auc09VBfivdLYRE84BMhd8/yOEt53VWGPIMxWUVtrUyegggMjMIIDHzCCAxswggIDoAMCAQICCQDNMc/iNkPNdTANBgkqhkiG9w0BAQsFADArMSkwJwYDVQQDDCBjYWNrbGluZyBjcnlwdG9ncmFwaGVyIGZha2UgUk9PVDAeFw0xNTEwMjEyMDExNTJaFw0yMDEwMTkyMDExNTJaMCsxKTAnBgNVBAMMIGNhY2tsaW5nIGNyeXB0b2dyYXBoZXIgZmFrZSBST09UMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+TbvalHXQYO6GhJUJZI5mF2k4+nZDIvqWyrjw+2k9+UAcekuLKPpSclu9aBRvUggw3XFHAW95qW6Dv2+5gvinUmTq9Ry7kVTUYAxyZu1ydHt+wDETmFJfeY6/fpBHHIsuGLItqpUGmr8D6LROGEqfFY2B9+08O7Zs+FufDRgLHWEvLTdpPkrzeDJs9Oo6g38jfT9b4+9Ahs+FvvwqneAkbeZgBC2NWKB+drMuNBTPbF/W1a8czAzHeOs6qy0dBlTHNjL62/o9cRKNiKe3IqwHJdd01V1aLSUgIbe2HrP9EC1djnUXWR3jx3ursaKt7PTKsC52UJkRqnai80MzQj0WwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU6aQ/7p6l5vLV13lgPJOmLiSOl6owDQYJKoZIhvcNAQELBQADggEBACuwILDTvaBrdorv2zMsYnZuKvXtknWAf/DTcvF4N5PMOPBNkeHuGfv0VDe6VXpBHiU5G9E2RdU435W7o0kRSn27YcqrxaXGt9m2kArW6e49136+MnFx47jjk0p4T48s6MeaL5JVLJzxYouu1ZOZqlVokwNPO+8bxn6ALumIVUOD1jSBN7Y9pgLUS2rzO5pe5pxS2Ak/eO7Q7M21r1sEuG/uPuWqBFogk+4Z9omKVZdRDbzm9vYUATgEZdlTe2tct3BVBQ2zWbe0R2svIuCs8XzERykvfv1JawxI68I9vN0Dh9vj/xDM6udorfALlhjgQdftmbHovRLpJ1ZSOMIUNGY= diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response.der b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response.der new file mode 100644 index 000000000..bd43e37bf Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response.der differ diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_broken.pem b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_broken.pem new file mode 100644 index 000000000..29a64c666 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_broken.pem @@ -0,0 +1 @@ +MIICGAoBAKCCAhEwggINBgkrBgEFBQcwAQEEggH+OZ4ZSKS2J85Kr9UaI2LAEFKvOM8/hjk8uyp7KnqJ12h8GOhGZAgIBdaADAQH/GA8wMDAxMDEwMTAwMDAwMFqgERgPMDAwMTAxMDEwMDAwMDBaMA0GCSqGSIb3DQEBCwUAA4IBAQCBGs+8UNwUdkEBladnajZIV+sHtmao/mMTIvpyPqnmV2Ab9KfNWlSDSDuMtZYKS4VsEwtbZ+4kKWI8DugE6egjP3o64R7VP2aqrh41IORwccLGVsexILBpxg4h602JbhXM0sxgXoh5WAt9f1oy6PsHAt/XAuJGSo7yMNv3nHKNFwjExmZt21sNLYlWlljjtX92rlo/mBTWKO0js4YRNyeNQhchARbn9oL18jW0yAVqB9a8rees+EippbTfoktFf0cIhnmkiknPZSZ+dN2qHkxiXIujWlymZzUZcqRTNtrmmhlOdt35QSg7Vw8eyw2rl8ZU94zaI5DPWn1QYn0dk7l9 \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_mix.pem b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_mix.pem new file mode 100644 index 000000000..43249fb0a Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/response_mix.pem differ diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/test-ca.der.pem b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/test-ca.der.pem new file mode 100644 index 000000000..760417fe9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/responder/testdata/test-ca.der.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDETCCAfmgAwIBAgIJAJzxkS6o1QkIMA0GCSqGSIb3DQEBCwUAMB8xHTAbBgNV +BAMMFGhhcHB5IGhhY2tlciBmYWtlIENBMB4XDTE1MDQwNzIzNTAzOFoXDTI1MDQw +NDIzNTAzOFowHzEdMBsGA1UEAwwUaGFwcHkgaGFja2VyIGZha2UgQ0EwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCCkd5mgXFErJ3F2M0E9dw+Ta/md5i +8TDId01HberAApqmydG7UZYF3zLTSzNjlNSOmtybvrSGUnZ9r9tSQcL8VM6WUOM8 +tnIpiIjEA2QkBycMwvRmZ/B2ltPdYs/R9BqNwO1g18GDZrHSzUYtNKNeFI6Glamj +7GK2Vr0SmiEamlNIR5ktAFsEErzf/d4jCF7sosMsJpMCm1p58QkP4LHLShVLXDa8 +BMfVoI+ipYcA08iNUFkgW8VWDclIDxcysa0psDDtMjX3+4aPkE/cefmP+1xOfUuD +HOGV8XFynsP4EpTfVOZr0/g9gYQ7ZArqXX7GTQkFqduwPm/w5qxSPTarAgMBAAGj +UDBOMB0GA1UdDgQWBBT7eE8S+WAVgyyfF380GbMuNupBiTAfBgNVHSMEGDAWgBT7 +eE8S+WAVgyyfF380GbMuNupBiTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA +A4IBAQAd9Da+Zv+TjMv7NTAmliqnWHY6d3UxEZN3hFEJ58IQVHbBZVZdW7zhRktB +vR05Kweac0HJeK91TKmzvXl21IXLvh0gcNLU/uweD3no/snfdB4OoFompljThmgl +zBqiqWoKBJQrLCA8w5UB+ReomRYd/EYXF/6TAfzm6hr//Xt5mPiUHPdvYt75lMAo +vRxLSbF8TSQ6b7BYxISWjPgFASNNqJNHEItWsmQMtAjjwzb9cs01XH9pChVAWn9L +oeMKa+SlHSYrWG93+EcrIH/dGU76uNOiaDzBSKvaehG53h25MHuO1anNICJvZovW +rFo4Uv1EnkKJm3vJFe50eJGhEKlx +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/ocsp/test/response.go b/third-party/github.com/letsencrypt/boulder/ocsp/test/response.go new file mode 100644 index 000000000..2d9e5316a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ocsp/test/response.go @@ -0,0 +1,48 @@ +package ocsp_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + + "golang.org/x/crypto/ocsp" +) + +// FakeResponse signs and then parses an OCSP response, using fields from the input +// template. To do so, it generates a new signing key and makes an issuer certificate. +func FakeResponse(template ocsp.Response) (*ocsp.Response, *x509.Certificate, error) { + // Make a fake CA to sign OCSP with + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + BasicConstraintsValid: true, + IsCA: true, + Subject: pkix.Name{CommonName: "test CA"}, + } + issuerBytes, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key) + if err != nil { + return nil, nil, err + } + + issuer, err := x509.ParseCertificate(issuerBytes) + if err != nil { + return nil, nil, err + } + + respBytes, err := ocsp.CreateResponse(issuer, issuer, template, key) + if err != nil { + return nil, nil, err + } + + response, err := ocsp.ParseResponse(respBytes, issuer) + if err != nil { + return nil, nil, err + } + return response, issuer, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers.go b/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers.go new file mode 100644 index 000000000..173123e17 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers.go @@ -0,0 +1,421 @@ +package pkcs11helpers + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/asn1" + "errors" + "fmt" + "io" + "math/big" + + "github.com/miekg/pkcs11" +) + +type PKCtx interface { + GenerateKeyPair(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) + GetAttributeValue(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) + SignInit(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error + Sign(pkcs11.SessionHandle, []byte) ([]byte, error) + GenerateRandom(pkcs11.SessionHandle, int) ([]byte, error) + FindObjectsInit(sh pkcs11.SessionHandle, temp []*pkcs11.Attribute) error + FindObjects(sh pkcs11.SessionHandle, max int) ([]pkcs11.ObjectHandle, bool, error) + FindObjectsFinal(sh pkcs11.SessionHandle) error +} + +// Session represents a session with a given PKCS#11 module. It is not safe for +// concurrent access. +type Session struct { + Module PKCtx + Session pkcs11.SessionHandle +} + +func Initialize(module string, slot uint, pin string) (*Session, error) { + ctx := pkcs11.New(module) + if ctx == nil { + return nil, errors.New("failed to load module") + } + err := ctx.Initialize() + if err != nil { + return nil, fmt.Errorf("couldn't initialize context: %s", err) + } + + session, err := ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION|pkcs11.CKF_RW_SESSION) + if err != nil { + return nil, fmt.Errorf("couldn't open session: %s", err) + } + + err = ctx.Login(session, pkcs11.CKU_USER, pin) + if err != nil { + return nil, fmt.Errorf("couldn't login: %s", err) + } + + return &Session{ctx, session}, nil +} + +// https://tools.ietf.org/html/rfc5759#section-3.2 +var curveOIDs = map[string]asn1.ObjectIdentifier{ + "P-256": {1, 2, 840, 10045, 3, 1, 7}, + "P-384": {1, 3, 132, 0, 34}, +} + +// getPublicKeyID looks up the given public key in the PKCS#11 token, and +// returns its ID as a []byte, for use in looking up the corresponding private +// key. +func (s *Session) getPublicKeyID(label string, publicKey crypto.PublicKey) ([]byte, error) { + var template []*pkcs11.Attribute + switch key := publicKey.(type) { + case *rsa.PublicKey: + template = []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PUBLIC_KEY), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, []byte(label)), + pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, pkcs11.CKK_RSA), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, key.N.Bytes()), + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, big.NewInt(int64(key.E)).Bytes()), + } + case *ecdsa.PublicKey: + // http://docs.oasis-open.org/pkcs11/pkcs11-curr/v2.40/os/pkcs11-curr-v2.40-os.html#_ftn1 + // PKCS#11 v2.20 specified that the CKA_EC_POINT was to be store in a DER-encoded + // OCTET STRING. + rawValue := asn1.RawValue{ + Tag: asn1.TagOctetString, + Bytes: elliptic.Marshal(key.Curve, key.X, key.Y), + } + marshalledPoint, err := asn1.Marshal(rawValue) + if err != nil { + return nil, err + } + curveOID, err := asn1.Marshal(curveOIDs[key.Curve.Params().Name]) + if err != nil { + return nil, err + } + template = []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PUBLIC_KEY), + pkcs11.NewAttribute(pkcs11.CKA_LABEL, []byte(label)), + pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, pkcs11.CKK_EC), + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, curveOID), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, marshalledPoint), + } + default: + return nil, fmt.Errorf("unsupported public key of type %T", publicKey) + } + + publicKeyHandle, err := s.FindObject(template) + if err != nil { + return nil, err + } + + attrs, err := s.Module.GetAttributeValue(s.Session, publicKeyHandle, []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_ID, nil), + }) + if err != nil { + return nil, err + } + if len(attrs) == 1 && attrs[0].Type == pkcs11.CKA_ID { + return attrs[0].Value, nil + } + return nil, fmt.Errorf("invalid result from GetAttributeValue") +} + +// getPrivateKey gets a handle to the private key whose CKA_ID matches the +// provided publicKeyID. +func (s *Session) getPrivateKey(publicKeyID []byte) (pkcs11.ObjectHandle, error) { + return s.FindObject([]*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY), + pkcs11.NewAttribute(pkcs11.CKA_ID, publicKeyID), + }) +} + +func (s *Session) GetAttributeValue(object pkcs11.ObjectHandle, attributes []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return s.Module.GetAttributeValue(s.Session, object, attributes) +} + +func (s *Session) GenerateKeyPair(m []*pkcs11.Mechanism, pubAttrs []*pkcs11.Attribute, privAttrs []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return s.Module.GenerateKeyPair(s.Session, m, pubAttrs, privAttrs) +} + +func (s *Session) GetRSAPublicKey(object pkcs11.ObjectHandle) (*rsa.PublicKey, error) { + // Retrieve the public exponent and modulus for the public key + attrs, err := s.Module.GetAttributeValue(s.Session, object, []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, nil), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, nil), + }) + if err != nil { + return nil, fmt.Errorf("Failed to retrieve key attributes: %s", err) + } + + // Attempt to build the public key from the retrieved attributes + pubKey := &rsa.PublicKey{} + gotMod, gotExp := false, false + for _, a := range attrs { + switch a.Type { + case pkcs11.CKA_PUBLIC_EXPONENT: + pubKey.E = int(big.NewInt(0).SetBytes(a.Value).Int64()) + gotExp = true + case pkcs11.CKA_MODULUS: + pubKey.N = big.NewInt(0).SetBytes(a.Value) + gotMod = true + } + } + // Fail if we are missing either the public exponent or modulus + if !gotExp || !gotMod { + return nil, errors.New("Couldn't retrieve modulus and exponent") + } + return pubKey, nil +} + +// oidDERToCurve maps the hex of the DER encoding of the various curve OIDs to +// the relevant curve parameters +var oidDERToCurve = map[string]elliptic.Curve{ + "06052B81040021": elliptic.P224(), + "06082A8648CE3D030107": elliptic.P256(), + "06052B81040022": elliptic.P384(), + "06052B81040023": elliptic.P521(), +} + +func (s *Session) GetECDSAPublicKey(object pkcs11.ObjectHandle) (*ecdsa.PublicKey, error) { + // Retrieve the curve and public point for the generated public key + attrs, err := s.Module.GetAttributeValue(s.Session, object, []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, nil), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, nil), + }) + if err != nil { + return nil, fmt.Errorf("Failed to retrieve key attributes: %s", err) + } + + pubKey := &ecdsa.PublicKey{} + var pointBytes []byte + for _, a := range attrs { + switch a.Type { + case pkcs11.CKA_EC_PARAMS: + rCurve, present := oidDERToCurve[fmt.Sprintf("%X", a.Value)] + if !present { + return nil, errors.New("Unknown curve OID value returned") + } + pubKey.Curve = rCurve + case pkcs11.CKA_EC_POINT: + pointBytes = a.Value + } + } + if pointBytes == nil || pubKey.Curve == nil { + return nil, errors.New("Couldn't retrieve EC point and EC parameters") + } + + x, y := elliptic.Unmarshal(pubKey.Curve, pointBytes) + if x == nil { + // http://docs.oasis-open.org/pkcs11/pkcs11-curr/v2.40/os/pkcs11-curr-v2.40-os.html#_ftn1 + // PKCS#11 v2.20 specified that the CKA_EC_POINT was to be stored in a DER-encoded + // OCTET STRING. + var point asn1.RawValue + _, err = asn1.Unmarshal(pointBytes, &point) + if err != nil { + return nil, fmt.Errorf("Failed to unmarshal returned CKA_EC_POINT: %s", err) + } + if len(point.Bytes) == 0 { + return nil, errors.New("Invalid CKA_EC_POINT value returned, OCTET string is empty") + } + x, y = elliptic.Unmarshal(pubKey.Curve, point.Bytes) + if x == nil { + return nil, errors.New("Invalid CKA_EC_POINT value returned, point is malformed") + } + } + pubKey.X, pubKey.Y = x, y + + return pubKey, nil +} + +type keyType int + +const ( + RSAKey keyType = iota + ECDSAKey +) + +// Hash identifiers required for PKCS#11 RSA signing. Only support SHA-256, SHA-384, +// and SHA-512 +var hashIdentifiers = map[crypto.Hash][]byte{ + crypto.SHA256: {0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20}, + crypto.SHA384: {0x30, 0x41, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02, 0x05, 0x00, 0x04, 0x30}, + crypto.SHA512: {0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, 0x05, 0x00, 0x04, 0x40}, +} + +func (s *Session) Sign(object pkcs11.ObjectHandle, keyType keyType, digest []byte, hash crypto.Hash) ([]byte, error) { + if len(digest) != hash.Size() { + return nil, errors.New("digest length doesn't match hash length") + } + + mech := make([]*pkcs11.Mechanism, 1) + switch keyType { + case RSAKey: + mech[0] = pkcs11.NewMechanism(pkcs11.CKM_RSA_PKCS, nil) + prefix, ok := hashIdentifiers[hash] + if !ok { + return nil, errors.New("unsupported hash function") + } + digest = append(prefix, digest...) + case ECDSAKey: + mech[0] = pkcs11.NewMechanism(pkcs11.CKM_ECDSA, nil) + } + + err := s.Module.SignInit(s.Session, mech, object) + if err != nil { + return nil, fmt.Errorf("failed to initialize signing operation: %s", err) + } + signature, err := s.Module.Sign(s.Session, digest) + if err != nil { + return nil, fmt.Errorf("failed to sign data: %s", err) + } + + return signature, nil +} + +var ErrNoObject = errors.New("no objects found matching provided template") + +// FindObject looks up a PKCS#11 object handle based on the provided template. +// In the case where zero or more than one objects are found to match the +// template an error is returned. +func (s *Session) FindObject(tmpl []*pkcs11.Attribute) (pkcs11.ObjectHandle, error) { + err := s.Module.FindObjectsInit(s.Session, tmpl) + if err != nil { + return 0, err + } + handles, _, err := s.Module.FindObjects(s.Session, 2) + if err != nil { + return 0, err + } + err = s.Module.FindObjectsFinal(s.Session) + if err != nil { + return 0, err + } + if len(handles) == 0 { + return 0, ErrNoObject + } + if len(handles) > 1 { + return 0, fmt.Errorf("too many objects (%d) that match the provided template", len(handles)) + } + return handles[0], nil +} + +// x509Signer is a convenience wrapper used for converting between the +// PKCS#11 ECDSA signature format and the RFC 5480 one which is required +// for X.509 certificates +type x509Signer struct { + session *Session + objectHandle pkcs11.ObjectHandle + keyType keyType + + pub crypto.PublicKey +} + +// Sign signs a digest. If the signing key is ECDSA then the signature +// is converted from the PKCS#11 format to the RFC 5480 format. For RSA keys a +// conversion step is not needed. +func (p *x509Signer) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + signature, err := p.session.Sign(p.objectHandle, p.keyType, digest, opts.HashFunc()) + if err != nil { + return nil, err + } + + if p.keyType == ECDSAKey { + // Convert from the PKCS#11 format to the RFC 5480 format so that + // it can be used in a X.509 certificate + r := big.NewInt(0).SetBytes(signature[:len(signature)/2]) + s := big.NewInt(0).SetBytes(signature[len(signature)/2:]) + signature, err = asn1.Marshal(struct { + R, S *big.Int + }{R: r, S: s}) + if err != nil { + return nil, fmt.Errorf("failed to convert signature to RFC 5480 format: %s", err) + } + } + return signature, nil +} + +func (p *x509Signer) Public() crypto.PublicKey { + return p.pub +} + +// NewSigner constructs an x509Signer for the private key object associated with the +// given label and public key. +func (s *Session) NewSigner(label string, publicKey crypto.PublicKey) (crypto.Signer, error) { + var kt keyType + switch publicKey.(type) { + case *rsa.PublicKey: + kt = RSAKey + case *ecdsa.PublicKey: + kt = ECDSAKey + default: + return nil, fmt.Errorf("unsupported public key of type %T", publicKey) + } + + publicKeyID, err := s.getPublicKeyID(label, publicKey) + if err != nil { + return nil, fmt.Errorf("looking up public key: %s", err) + } + + // Fetch the private key by matching its id to the public key handle. + privateKeyHandle, err := s.getPrivateKey(publicKeyID) + if err != nil { + return nil, fmt.Errorf("getting private key: %s", err) + } + return &x509Signer{ + session: s, + objectHandle: privateKeyHandle, + keyType: kt, + pub: publicKey, + }, nil +} + +func NewMock() *MockCtx { + return &MockCtx{} +} + +func NewSessionWithMock() (*Session, *MockCtx) { + ctx := NewMock() + return &Session{ctx, 0}, ctx +} + +type MockCtx struct { + GenerateKeyPairFunc func(pkcs11.SessionHandle, []*pkcs11.Mechanism, []*pkcs11.Attribute, []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) + GetAttributeValueFunc func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) + SignInitFunc func(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error + SignFunc func(pkcs11.SessionHandle, []byte) ([]byte, error) + GenerateRandomFunc func(pkcs11.SessionHandle, int) ([]byte, error) + FindObjectsInitFunc func(sh pkcs11.SessionHandle, temp []*pkcs11.Attribute) error + FindObjectsFunc func(sh pkcs11.SessionHandle, max int) ([]pkcs11.ObjectHandle, bool, error) + FindObjectsFinalFunc func(sh pkcs11.SessionHandle) error +} + +func (mc MockCtx) GenerateKeyPair(s pkcs11.SessionHandle, m []*pkcs11.Mechanism, a1 []*pkcs11.Attribute, a2 []*pkcs11.Attribute) (pkcs11.ObjectHandle, pkcs11.ObjectHandle, error) { + return mc.GenerateKeyPairFunc(s, m, a1, a2) +} + +func (mc MockCtx) GetAttributeValue(s pkcs11.SessionHandle, o pkcs11.ObjectHandle, a []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return mc.GetAttributeValueFunc(s, o, a) +} + +func (mc MockCtx) SignInit(s pkcs11.SessionHandle, m []*pkcs11.Mechanism, o pkcs11.ObjectHandle) error { + return mc.SignInitFunc(s, m, o) +} + +func (mc MockCtx) Sign(s pkcs11.SessionHandle, m []byte) ([]byte, error) { + return mc.SignFunc(s, m) +} + +func (mc MockCtx) GenerateRandom(s pkcs11.SessionHandle, c int) ([]byte, error) { + return mc.GenerateRandomFunc(s, c) +} + +func (mc MockCtx) FindObjectsInit(sh pkcs11.SessionHandle, temp []*pkcs11.Attribute) error { + return mc.FindObjectsInitFunc(sh, temp) +} + +func (mc MockCtx) FindObjects(sh pkcs11.SessionHandle, max int) ([]pkcs11.ObjectHandle, bool, error) { + return mc.FindObjectsFunc(sh, max) +} + +func (mc MockCtx) FindObjectsFinal(sh pkcs11.SessionHandle) error { + return mc.FindObjectsFinalFunc(sh) +} diff --git a/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers_test.go b/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers_test.go new file mode 100644 index 000000000..f70899645 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/pkcs11helpers/helpers_test.go @@ -0,0 +1,420 @@ +package pkcs11helpers + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/asn1" + "errors" + "math/big" + "strings" + "testing" + + "github.com/letsencrypt/boulder/test" + "github.com/miekg/pkcs11" +) + +func TestGetECDSAPublicKey(t *testing.T) { + ctx := &MockCtx{} + s := &Session{ctx, 0} + + // test attribute retrieval failing + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("yup") + } + _, err := s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail on GetAttributeValue error") + + // test we fail to construct key with missing params and point + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{}, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail with empty attribute list") + + // test we fail to construct key with unknown curve + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{1, 2, 3}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail with unknown curve") + + // test we fail to construct key with invalid EC point (invalid encoding) + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 8, 42, 134, 72, 206, 61, 3, 1, 7}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{255}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail with invalid EC point (invalid encoding)") + + // test we fail to construct key with invalid EC point (empty octet string) + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 8, 42, 134, 72, 206, 61, 3, 1, 7}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{4, 0}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail with invalid EC point (empty octet string)") + + // test we fail to construct key with invalid EC point (octet string, invalid contents) + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 8, 42, 134, 72, 206, 61, 3, 1, 7}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{4, 4, 4, 1, 2, 3}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertError(t, err, "ecPub didn't fail with invalid EC point (octet string, invalid contents)") + + // test we don't fail with the correct attributes (traditional encoding) + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 5, 43, 129, 4, 0, 33}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{4, 217, 225, 246, 210, 153, 134, 246, 104, 95, 79, 122, 206, 135, 241, 37, 114, 199, 87, 56, 167, 83, 56, 136, 174, 6, 145, 97, 239, 221, 49, 67, 148, 13, 126, 65, 90, 208, 195, 193, 171, 105, 40, 98, 132, 124, 30, 189, 215, 197, 178, 226, 166, 238, 240, 57, 215}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertNotError(t, err, "ecPub failed with valid attributes (traditional encoding)") + + // test we don't fail with the correct attributes (non-traditional encoding) + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_EC_PARAMS, []byte{6, 5, 43, 129, 4, 0, 33}), + pkcs11.NewAttribute(pkcs11.CKA_EC_POINT, []byte{4, 57, 4, 217, 225, 246, 210, 153, 134, 246, 104, 95, 79, 122, 206, 135, 241, 37, 114, 199, 87, 56, 167, 83, 56, 136, 174, 6, 145, 97, 239, 221, 49, 67, 148, 13, 126, 65, 90, 208, 195, 193, 171, 105, 40, 98, 132, 124, 30, 189, 215, 197, 178, 226, 166, 238, 240, 57, 215}), + }, nil + } + _, err = s.GetECDSAPublicKey(0) + test.AssertNotError(t, err, "ecPub failed with valid attributes (non-traditional encoding)") +} + +func TestRSAPublicKey(t *testing.T) { + ctx := &MockCtx{} + s := &Session{ctx, 0} + + // test attribute retrieval failing + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("yup") + } + _, err := s.GetRSAPublicKey(0) + test.AssertError(t, err, "rsaPub didn't fail on GetAttributeValue error") + + // test we fail to construct key with missing modulus and exp + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{}, nil + } + _, err = s.GetRSAPublicKey(0) + test.AssertError(t, err, "rsaPub didn't fail with empty attribute list") + + // test we don't fail with the correct attributes + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{ + pkcs11.NewAttribute(pkcs11.CKA_PUBLIC_EXPONENT, []byte{1, 0, 1}), + pkcs11.NewAttribute(pkcs11.CKA_MODULUS, []byte{255}), + }, nil + } + _, err = s.GetRSAPublicKey(0) + test.AssertNotError(t, err, "rsaPub failed with valid attributes") +} + +func findObjectsInitOK(pkcs11.SessionHandle, []*pkcs11.Attribute) error { + return nil +} + +func findObjectsOK(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{1}, false, nil +} + +func findObjectsFinalOK(pkcs11.SessionHandle) error { + return nil +} + +func newMock() *MockCtx { + return &MockCtx{ + FindObjectsInitFunc: findObjectsInitOK, + FindObjectsFunc: findObjectsOK, + FindObjectsFinalFunc: findObjectsFinalOK, + } +} + +func newSessionWithMock() (*Session, *MockCtx) { + ctx := newMock() + return &Session{ctx, 0}, ctx +} + +func TestFindObjectFailsOnFailedInit(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsFinalFunc = findObjectsFinalOK + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{1}, false, nil + } + + // test FindObject fails when FindObjectsInit fails + ctx.FindObjectsInitFunc = func(pkcs11.SessionHandle, []*pkcs11.Attribute) error { + return errors.New("broken") + } + s := &Session{ctx, 0} + _, err := s.FindObject(nil) + test.AssertError(t, err, "FindObject didn't fail when FindObjectsInit failed") +} + +func TestFindObjectFailsOnFailedFindObjects(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsInitFunc = findObjectsInitOK + ctx.FindObjectsFinalFunc = findObjectsFinalOK + + // test FindObject fails when FindObjects fails + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return nil, false, errors.New("broken") + } + s := &Session{ctx, 0} + _, err := s.FindObject(nil) + test.AssertError(t, err, "FindObject didn't fail when FindObjects failed") +} + +func TestFindObjectFailsOnNoHandles(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsInitFunc = findObjectsInitOK + ctx.FindObjectsFinalFunc = findObjectsFinalOK + + // test FindObject fails when no handles are returned + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{}, false, nil + } + s := &Session{ctx, 0} + _, err := s.FindObject(nil) + test.AssertEquals(t, err, ErrNoObject) +} + +func TestFindObjectFailsOnMultipleHandles(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsInitFunc = findObjectsInitOK + ctx.FindObjectsFinalFunc = findObjectsFinalOK + + // test FindObject fails when multiple handles are returned + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{1, 2, 3}, false, nil + } + s := &Session{ctx, 0} + _, err := s.FindObject(nil) + test.AssertError(t, err, "FindObject didn't fail when FindObjects returns multiple handles") + test.Assert(t, strings.HasPrefix(err.Error(), "too many objects"), "FindObject failed with wrong error") +} + +func TestFindObjectFailsOnFinalizeFailure(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsInitFunc = findObjectsInitOK + + // test FindObject fails when FindObjectsFinal fails + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{1}, false, nil + } + ctx.FindObjectsFinalFunc = func(pkcs11.SessionHandle) error { + return errors.New("broken") + } + s := &Session{ctx, 0} + _, err := s.FindObject(nil) + test.AssertError(t, err, "FindObject didn't fail when FindObjectsFinal fails") +} + +func TestFindObjectSucceeds(t *testing.T) { + ctx := MockCtx{} + ctx.FindObjectsInitFunc = findObjectsInitOK + ctx.FindObjectsFinalFunc = findObjectsFinalOK + ctx.FindObjectsFunc = func(pkcs11.SessionHandle, int) ([]pkcs11.ObjectHandle, bool, error) { + return []pkcs11.ObjectHandle{1}, false, nil + } + s := &Session{ctx, 0} + + // test FindObject works + handle, err := s.FindObject(nil) + test.AssertNotError(t, err, "FindObject failed when everything worked as expected") + test.AssertEquals(t, handle, pkcs11.ObjectHandle(1)) +} + +func TestX509Signer(t *testing.T) { + ctx := MockCtx{} + + // test that x509Signer.Sign properly converts the PKCS#11 format signature to + // the RFC 5480 format signature + ctx.SignInitFunc = func(pkcs11.SessionHandle, []*pkcs11.Mechanism, pkcs11.ObjectHandle) error { + return nil + } + tk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Failed to generate test key") + ctx.SignFunc = func(_ pkcs11.SessionHandle, digest []byte) ([]byte, error) { + r, s, err := ecdsa.Sign(rand.Reader, tk, digest[:]) + if err != nil { + return nil, err + } + rBytes := r.Bytes() + sBytes := s.Bytes() + // http://docs.oasis-open.org/pkcs11/pkcs11-curr/v2.40/os/pkcs11-curr-v2.40-os.html + // Section 2.3.1: EC Signatures + // "If r and s have different octet length, the shorter of both must be padded with + // leading zero octets such that both have the same octet length." + switch { + case len(rBytes) < len(sBytes): + padding := make([]byte, len(sBytes)-len(rBytes)) + rBytes = append(padding, rBytes...) + case len(rBytes) > len(sBytes): + padding := make([]byte, len(rBytes)-len(sBytes)) + sBytes = append(padding, sBytes...) + } + return append(rBytes, sBytes...), nil + } + digest := sha256.Sum256([]byte("hello")) + s := &Session{ctx, 0} + signer := &x509Signer{session: s, keyType: ECDSAKey, pub: tk.Public()} + signature, err := signer.Sign(nil, digest[:], crypto.SHA256) + test.AssertNotError(t, err, "x509Signer.Sign failed") + + var rfcFormat struct { + R, S *big.Int + } + rest, err := asn1.Unmarshal(signature, &rfcFormat) + test.AssertNotError(t, err, "asn1.Unmarshal failed trying to parse signature") + test.Assert(t, len(rest) == 0, "Signature had trailing garbage") + verified := ecdsa.Verify(&tk.PublicKey, digest[:], rfcFormat.R, rfcFormat.S) + test.Assert(t, verified, "Failed to verify RFC format signature") + // For the sake of coverage + test.AssertEquals(t, signer.Public(), tk.Public()) +} + +func TestGetKeyWhenLabelIsWrong(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + rightLabel := "label" + var objectsToReturn []pkcs11.ObjectHandle + + ctx.FindObjectsInitFunc = func(_ pkcs11.SessionHandle, attr []*pkcs11.Attribute) error { + objectsToReturn = []pkcs11.ObjectHandle{1} + for _, a := range attr { + if a.Type == pkcs11.CKA_LABEL && !bytes.Equal(a.Value, []byte(rightLabel)) { + objectsToReturn = nil + } + } + return nil + } + ctx.FindObjectsFunc = func(_ pkcs11.SessionHandle, _ int) ([]pkcs11.ObjectHandle, bool, error) { + return objectsToReturn, false, nil + } + ctx.FindObjectsFinalFunc = func(_ pkcs11.SessionHandle) error { + return nil + } + + _, err := s.NewSigner("wrong-label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when label was a mismatch for public key") + expected := "no objects found matching provided template" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error to contain %q but it was %q", expected, err) + } +} + +func TestGetKeyWhenGetAttributeValueFails(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + // test newSigner fails when GetAttributeValue fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("broken") + } + _, err := s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when GetAttributeValue for private key type failed") +} + +func TestGetKeyWhenGetAttributeValueReturnsNone(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, errors.New("broken") + } + // test newSigner fails when GetAttributeValue returns no attributes + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return nil, nil + } + _, err := s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when GetAttributeValue for private key type returned no attributes") +} + +func TestGetKeyWhenFindObjectForPublicKeyFails(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + // test newSigner fails when FindObject for public key + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, pkcs11.CKK_EC)}, nil + } + ctx.FindObjectsInitFunc = func(_ pkcs11.SessionHandle, tmpl []*pkcs11.Attribute) error { + if bytes.Equal(tmpl[0].Value, []byte{2, 0, 0, 0, 0, 0, 0, 0}) { + return errors.New("broken") + } + return nil + } + _, err := s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when FindObject for public key handle failed") +} + +func TestGetKeyWhenFindObjectForPrivateKeyReturnsUnknownType(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + // test newSigner fails when FindObject for private key returns unknown CKA_KEY_TYPE + ctx.FindObjectsInitFunc = func(_ pkcs11.SessionHandle, tmpl []*pkcs11.Attribute) error { + return nil + } + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, []byte{2, 0, 0, 0, 0, 0, 0, 0})}, nil + } + _, err := s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when GetAttributeValue for private key returned unknown key type") +} + +func TestGetKeyWhenFindObjectForPrivateKeyFails(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + // test newSigner fails when FindObject for private key fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, []byte{0, 0, 0, 0, 0, 0, 0, 0})}, nil + } + _, err := s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when GetRSAPublicKey fails") + + // test newSigner fails when GetECDSAPublicKey fails + ctx.GetAttributeValueFunc = func(pkcs11.SessionHandle, pkcs11.ObjectHandle, []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + return []*pkcs11.Attribute{pkcs11.NewAttribute(pkcs11.CKA_KEY_TYPE, []byte{3, 0, 0, 0, 0, 0, 0, 0})}, nil + } + _, err = s.NewSigner("label", pubKey) + test.AssertError(t, err, "newSigner didn't fail when GetECDSAPublicKey fails") +} + +func TestGetKeySucceeds(t *testing.T) { + s, ctx := newSessionWithMock() + pubKey := &rsa.PublicKey{N: big.NewInt(1), E: 1} + + // test newSigner works when everything... works + ctx.GetAttributeValueFunc = func(_ pkcs11.SessionHandle, _ pkcs11.ObjectHandle, attrs []*pkcs11.Attribute) ([]*pkcs11.Attribute, error) { + var returns []*pkcs11.Attribute + for _, attr := range attrs { + switch attr.Type { + case pkcs11.CKA_ID: + returns = append(returns, pkcs11.NewAttribute(pkcs11.CKA_ID, []byte{99})) + default: + return nil, errors.New("GetAttributeValue got unexpected attribute type") + } + } + return returns, nil + } + _, err := s.NewSigner("label", pubKey) + test.AssertNotError(t, err, "newSigner failed when everything worked properly") +} diff --git a/third-party/github.com/letsencrypt/boulder/policy/pa.go b/third-party/github.com/letsencrypt/boulder/policy/pa.go new file mode 100644 index 000000000..ce7857a7d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/policy/pa.go @@ -0,0 +1,623 @@ +package policy + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math/rand" + "net" + "net/mail" + "os" + "regexp" + "slices" + "strings" + "sync" + + "golang.org/x/net/idna" + "golang.org/x/text/unicode/norm" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/iana" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/strictyaml" +) + +// AuthorityImpl enforces CA policy decisions. +type AuthorityImpl struct { + log blog.Logger + + blocklist map[string]bool + exactBlocklist map[string]bool + wildcardExactBlocklist map[string]bool + blocklistMu sync.RWMutex + + enabledChallenges map[core.AcmeChallenge]bool + pseudoRNG *rand.Rand + rngMu sync.Mutex +} + +// New constructs a Policy Authority. +func New(challengeTypes map[core.AcmeChallenge]bool, log blog.Logger) (*AuthorityImpl, error) { + + pa := AuthorityImpl{ + log: log, + enabledChallenges: challengeTypes, + // We don't need real randomness for this. + pseudoRNG: rand.New(rand.NewSource(99)), + } + + return &pa, nil +} + +// blockedNamesPolicy is a struct holding lists of blocked domain names. One for +// exact blocks and one for blocks including all subdomains. +type blockedNamesPolicy struct { + // ExactBlockedNames is a list of domain names. Issuance for names exactly + // matching an entry in the list will be forbidden. (e.g. `ExactBlockedNames` + // containing `www.example.com` will not block `example.com` or + // `mail.example.com`). + ExactBlockedNames []string `yaml:"ExactBlockedNames"` + // HighRiskBlockedNames is like ExactBlockedNames except that issuance is + // blocked for subdomains as well. (e.g. BlockedNames containing `example.com` + // will block `www.example.com`). + // + // This list typically doesn't change with much regularity. + HighRiskBlockedNames []string `yaml:"HighRiskBlockedNames"` + + // AdminBlockedNames operates the same as BlockedNames but is changed with more + // frequency based on administrative blocks/revocations that are added over + // time above and beyond the high-risk domains. Managing these entries separately + // from HighRiskBlockedNames makes it easier to vet changes accurately. + AdminBlockedNames []string `yaml:"AdminBlockedNames"` +} + +// LoadHostnamePolicyFile will load the given policy file, returning an error if +// it fails. +func (pa *AuthorityImpl) LoadHostnamePolicyFile(f string) error { + configBytes, err := os.ReadFile(f) + if err != nil { + return err + } + hash := sha256.Sum256(configBytes) + pa.log.Infof("loading hostname policy, sha256: %s", hex.EncodeToString(hash[:])) + var policy blockedNamesPolicy + err = strictyaml.Unmarshal(configBytes, &policy) + if err != nil { + return err + } + if len(policy.HighRiskBlockedNames) == 0 { + return fmt.Errorf("No entries in HighRiskBlockedNames.") + } + if len(policy.ExactBlockedNames) == 0 { + return fmt.Errorf("No entries in ExactBlockedNames.") + } + return pa.processHostnamePolicy(policy) +} + +// processHostnamePolicy handles loading a new blockedNamesPolicy into the PA. +// All of the policy.ExactBlockedNames will be added to the +// wildcardExactBlocklist by processHostnamePolicy to ensure that wildcards for +// exact blocked names entries are forbidden. +func (pa *AuthorityImpl) processHostnamePolicy(policy blockedNamesPolicy) error { + nameMap := make(map[string]bool) + for _, v := range policy.HighRiskBlockedNames { + nameMap[v] = true + } + for _, v := range policy.AdminBlockedNames { + nameMap[v] = true + } + exactNameMap := make(map[string]bool) + wildcardNameMap := make(map[string]bool) + for _, v := range policy.ExactBlockedNames { + exactNameMap[v] = true + // Remove the leftmost label of the exact blocked names entry to make an exact + // wildcard block list entry that will prevent issuing a wildcard that would + // include the exact blocklist entry. e.g. if "highvalue.example.com" is on + // the exact blocklist we want "example.com" to be in the + // wildcardExactBlocklist so that "*.example.com" cannot be issued. + // + // First, split the domain into two parts: the first label and the rest of the domain. + parts := strings.SplitN(v, ".", 2) + // if there are less than 2 parts then this entry is malformed! There should + // at least be a "something." and a TLD like "com" + if len(parts) < 2 { + return fmt.Errorf( + "Malformed ExactBlockedNames entry, only one label: %q", v) + } + // Add the second part, the domain minus the first label, to the + // wildcardNameMap to block issuance for `*.`+parts[1] + wildcardNameMap[parts[1]] = true + } + pa.blocklistMu.Lock() + pa.blocklist = nameMap + pa.exactBlocklist = exactNameMap + pa.wildcardExactBlocklist = wildcardNameMap + pa.blocklistMu.Unlock() + return nil +} + +// The values of maxDNSIdentifierLength, maxLabelLength and maxLabels are hard coded +// into the error messages errNameTooLong, errLabelTooLong and errTooManyLabels. +// If their values change, the related error messages should be updated. + +const ( + maxLabels = 10 + + // RFC 1034 says DNS labels have a max of 63 octets, and names have a max of 255 + // octets: https://tools.ietf.org/html/rfc1035#page-10. Since two of those octets + // are taken up by the leading length byte and the trailing root period the actual + // max length becomes 253. + maxLabelLength = 63 + maxDNSIdentifierLength = 253 +) + +var dnsLabelCharacterRegexp = regexp.MustCompile("^[a-z0-9-]+$") + +func isDNSCharacter(ch byte) bool { + return ('a' <= ch && ch <= 'z') || + ('A' <= ch && ch <= 'Z') || + ('0' <= ch && ch <= '9') || + ch == '.' || ch == '-' +} + +// In these error messages: +// 253 is the value of maxDNSIdentifierLength +// 63 is the value of maxLabelLength +// 10 is the value of maxLabels +// If these values change, the related error messages should be updated. + +var ( + errNonPublic = berrors.MalformedError("Domain name does not end with a valid public suffix (TLD)") + errICANNTLD = berrors.MalformedError("Domain name is an ICANN TLD") + errPolicyForbidden = berrors.RejectedIdentifierError("The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy") + errInvalidDNSCharacter = berrors.MalformedError("Domain name contains an invalid character") + errNameTooLong = berrors.MalformedError("Domain name is longer than 253 bytes") + errIPAddress = berrors.MalformedError("The ACME server can not issue a certificate for an IP address") + errTooManyLabels = berrors.MalformedError("Domain name has more than 10 labels (parts)") + errEmptyName = berrors.MalformedError("Domain name is empty") + errNameEndsInDot = berrors.MalformedError("Domain name ends in a dot") + errTooFewLabels = berrors.MalformedError("Domain name needs at least one dot") + errLabelTooShort = berrors.MalformedError("Domain name can not have two dots in a row") + errLabelTooLong = berrors.MalformedError("Domain has a label (component between dots) longer than 63 bytes") + errMalformedIDN = berrors.MalformedError("Domain name contains malformed punycode") + errInvalidRLDH = berrors.RejectedIdentifierError("Domain name contains an invalid label in a reserved format (R-LDH: '??--')") + errTooManyWildcards = berrors.MalformedError("Domain name has more than one wildcard") + errMalformedWildcard = berrors.MalformedError("Domain name contains an invalid wildcard. A wildcard is only permitted before the first dot in a domain name") + errICANNTLDWildcard = berrors.MalformedError("Domain name is a wildcard for an ICANN TLD") + errWildcardNotSupported = berrors.MalformedError("Wildcard domain names are not supported") +) + +// validNonWildcardDomain checks that a domain isn't: +// - empty +// - prefixed with the wildcard label `*.` +// - made of invalid DNS characters +// - longer than the maxDNSIdentifierLength +// - an IPv4 or IPv6 address +// - suffixed with just "." +// - made of too many DNS labels +// - made of any invalid DNS labels +// - suffixed with something other than an IANA registered TLD +// - exactly equal to an IANA registered TLD +// +// It does NOT ensure that the domain is absent from any PA blocked lists. +func validNonWildcardDomain(domain string) error { + if domain == "" { + return errEmptyName + } + + if strings.HasPrefix(domain, "*.") { + return errWildcardNotSupported + } + + for _, ch := range []byte(domain) { + if !isDNSCharacter(ch) { + return errInvalidDNSCharacter + } + } + + if len(domain) > maxDNSIdentifierLength { + return errNameTooLong + } + + if ip := net.ParseIP(domain); ip != nil { + return errIPAddress + } + + if strings.HasSuffix(domain, ".") { + return errNameEndsInDot + } + + labels := strings.Split(domain, ".") + if len(labels) > maxLabels { + return errTooManyLabels + } + if len(labels) < 2 { + return errTooFewLabels + } + for _, label := range labels { + // Check that this is a valid LDH Label: "A string consisting of ASCII + // letters, digits, and the hyphen with the further restriction that the + // hyphen cannot appear at the beginning or end of the string. Like all DNS + // labels, its total length must not exceed 63 octets." (RFC 5890, 2.3.1) + if len(label) < 1 { + return errLabelTooShort + } + if len(label) > maxLabelLength { + return errLabelTooLong + } + if !dnsLabelCharacterRegexp.MatchString(label) { + return errInvalidDNSCharacter + } + if label[0] == '-' || label[len(label)-1] == '-' { + return errInvalidDNSCharacter + } + + // Check if this is a Reserved LDH Label: "[has] the property that they + // contain "--" in the third and fourth characters but which otherwise + // conform to LDH label rules." (RFC 5890, 2.3.1) + if len(label) >= 4 && label[2:4] == "--" { + // Check if this is an XN-Label: "labels that begin with the prefix "xn--" + // (case independent), but otherwise conform to the rules for LDH labels." + // (RFC 5890, 2.3.1) + if label[0:2] != "xn" { + return errInvalidRLDH + } + + // Check if this is a P-Label: "A XN-Label that contains valid output of + // the Punycode algorithm (as defined in RFC 3492, Section 6.3) from the + // fifth and subsequent positions." (Baseline Requirements, 1.6.1) + ulabel, err := idna.ToUnicode(label) + if err != nil { + return errMalformedIDN + } + if !norm.NFC.IsNormalString(ulabel) { + return errMalformedIDN + } + } + } + + // Names must end in an ICANN TLD, but they must not be equal to an ICANN TLD. + icannTLD, err := iana.ExtractSuffix(domain) + if err != nil { + return errNonPublic + } + if icannTLD == domain { + return errICANNTLD + } + + return nil +} + +// ValidDomain checks that a domain is valid and that it doesn't contain any +// invalid wildcard characters. It does NOT ensure that the domain is absent +// from any PA blocked lists. +func ValidDomain(domain string) error { + if strings.Count(domain, "*") <= 0 { + return validNonWildcardDomain(domain) + } + + // Names containing more than one wildcard are invalid. + if strings.Count(domain, "*") > 1 { + return errTooManyWildcards + } + + // If the domain has a wildcard character, but it isn't the first most + // label of the domain name then the wildcard domain is malformed + if !strings.HasPrefix(domain, "*.") { + return errMalformedWildcard + } + + // The base domain is the wildcard request with the `*.` prefix removed + baseDomain := strings.TrimPrefix(domain, "*.") + + // Names must end in an ICANN TLD, but they must not be equal to an ICANN TLD. + icannTLD, err := iana.ExtractSuffix(baseDomain) + if err != nil { + return errNonPublic + } + // Names must have a non-wildcard label immediately adjacent to the ICANN + // TLD. No `*.com`! + if baseDomain == icannTLD { + return errICANNTLDWildcard + } + return validNonWildcardDomain(baseDomain) +} + +// forbiddenMailDomains is a map of domain names we do not allow after the +// @ symbol in contact mailto addresses. These are frequently used when +// copy-pasting example configurations and would not result in expiration +// messages and subscriber communications reaching the user that created the +// registration if allowed. +var forbiddenMailDomains = map[string]bool{ + // https://tools.ietf.org/html/rfc2606#section-3 + "example.com": true, + "example.net": true, + "example.org": true, +} + +// ValidEmail returns an error if the input doesn't parse as an email address, +// the domain isn't a valid hostname in Preferred Name Syntax, or its on the +// list of domains forbidden for mail (because they are often used in examples). +func ValidEmail(address string) error { + email, err := mail.ParseAddress(address) + if err != nil { + if len(address) > 254 { + address = address[:254] + "..." + } + return berrors.InvalidEmailError("%q is not a valid e-mail address", address) + } + splitEmail := strings.SplitN(email.Address, "@", -1) + domain := strings.ToLower(splitEmail[len(splitEmail)-1]) + err = validNonWildcardDomain(domain) + if err != nil { + return berrors.InvalidEmailError( + "contact email %q has invalid domain : %s", + email.Address, err) + } + if forbiddenMailDomains[domain] { + return berrors.InvalidEmailError( + "invalid contact domain. Contact emails @%s are forbidden", + domain) + } + return nil +} + +// subError returns an appropriately typed error based on the input error +func subError(name string, err error) berrors.SubBoulderError { + var bErr *berrors.BoulderError + if errors.As(err, &bErr) { + return berrors.SubBoulderError{ + Identifier: identifier.DNSIdentifier(name), + BoulderError: bErr, + } + } else { + return berrors.SubBoulderError{ + Identifier: identifier.DNSIdentifier(name), + BoulderError: &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: err.Error(), + }, + } + } +} + +// WillingToIssue determines whether the CA is willing to issue for the provided +// domain names. +// +// It checks the criteria checked by `WellFormedDomainNames`, and additionally checks +// whether any domain is on a blocklist. +// +// If multiple domains are invalid, the error will contain suberrors specific to +// each domain. +// +// Precondition: all input domain names must be in lowercase. +func (pa *AuthorityImpl) WillingToIssue(domains []string) error { + err := WellFormedDomainNames(domains) + if err != nil { + return err + } + + var subErrors []berrors.SubBoulderError + for _, domain := range domains { + if strings.Count(domain, "*") > 0 { + // The base domain is the wildcard request with the `*.` prefix removed + baseDomain := strings.TrimPrefix(domain, "*.") + + // The base domain can't be in the wildcard exact blocklist + err = pa.checkWildcardHostList(baseDomain) + if err != nil { + subErrors = append(subErrors, subError(domain, err)) + continue + } + } + + // For both wildcard and non-wildcard domains, check whether any parent domain + // name is on the regular blocklist. + err := pa.checkHostLists(domain) + if err != nil { + subErrors = append(subErrors, subError(domain, err)) + continue + } + } + return combineSubErrors(subErrors) +} + +// WellFormedDomainNames returns an error if any of the provided domains do not meet these criteria: +// +// - MUST contains only lowercase characters, numbers, hyphens, and dots +// - MUST NOT have more than maxLabels labels +// - MUST follow the DNS hostname syntax rules in RFC 1035 and RFC 2181 +// +// In particular, it: +// - MUST NOT contain underscores +// - MUST NOT match the syntax of an IP address +// - MUST end in a public suffix +// - MUST have at least one label in addition to the public suffix +// - MUST NOT be a label-wise suffix match for a name on the block list, +// where comparison is case-independent (normalized to lower case) +// +// If a domain contains a *, we additionally require: +// - There is at most one `*` wildcard character +// - That the wildcard character is the leftmost label +// - That the wildcard label is not immediately adjacent to a top level ICANN +// TLD +// +// If multiple domains are invalid, the error will contain suberrors specific to +// each domain. +func WellFormedDomainNames(domains []string) error { + var subErrors []berrors.SubBoulderError + for _, domain := range domains { + err := ValidDomain(domain) + if err != nil { + subErrors = append(subErrors, subError(domain, err)) + } + } + return combineSubErrors(subErrors) +} + +func combineSubErrors(subErrors []berrors.SubBoulderError) error { + if len(subErrors) > 0 { + // If there was only one error, then use it as the top level error that is + // returned. + if len(subErrors) == 1 { + return berrors.RejectedIdentifierError( + "Cannot issue for %q: %s", + subErrors[0].Identifier.Value, + subErrors[0].BoulderError.Detail, + ) + } + + detail := fmt.Sprintf( + "Cannot issue for %q: %s (and %d more problems. Refer to sub-problems for more information.)", + subErrors[0].Identifier.Value, + subErrors[0].BoulderError.Detail, + len(subErrors)-1, + ) + return (&berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: detail, + }).WithSubErrors(subErrors) + } + return nil +} + +// checkWildcardHostList checks the wildcardExactBlocklist for a given domain. +// If the domain is not present on the list nil is returned, otherwise +// errPolicyForbidden is returned. +func (pa *AuthorityImpl) checkWildcardHostList(domain string) error { + pa.blocklistMu.RLock() + defer pa.blocklistMu.RUnlock() + + if pa.wildcardExactBlocklist == nil { + return fmt.Errorf("Hostname policy not yet loaded.") + } + + if pa.wildcardExactBlocklist[domain] { + return errPolicyForbidden + } + + return nil +} + +func (pa *AuthorityImpl) checkHostLists(domain string) error { + pa.blocklistMu.RLock() + defer pa.blocklistMu.RUnlock() + + if pa.blocklist == nil { + return fmt.Errorf("Hostname policy not yet loaded.") + } + + labels := strings.Split(domain, ".") + for i := range labels { + joined := strings.Join(labels[i:], ".") + if pa.blocklist[joined] { + return errPolicyForbidden + } + } + + if pa.exactBlocklist[domain] { + return errPolicyForbidden + } + return nil +} + +// challengeTypesFor determines which challenge types are acceptable for the +// given identifier. +func (pa *AuthorityImpl) challengeTypesFor(identifier identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) { + var challenges []core.AcmeChallenge + + // If the identifier is for a DNS wildcard name we only + // provide a DNS-01 challenge as a matter of CA policy. + if strings.HasPrefix(identifier.Value, "*.") { + // We must have the DNS-01 challenge type enabled to create challenges for + // a wildcard identifier per LE policy. + if !pa.ChallengeTypeEnabled(core.ChallengeTypeDNS01) { + return nil, fmt.Errorf( + "Challenges requested for wildcard identifier but DNS-01 " + + "challenge type is not enabled") + } + // Only provide a DNS-01-Wildcard challenge + challenges = []core.AcmeChallenge{core.ChallengeTypeDNS01} + } else { + // Otherwise we collect up challenges based on what is enabled. + if pa.ChallengeTypeEnabled(core.ChallengeTypeHTTP01) { + challenges = append(challenges, core.ChallengeTypeHTTP01) + } + + if pa.ChallengeTypeEnabled(core.ChallengeTypeTLSALPN01) { + challenges = append(challenges, core.ChallengeTypeTLSALPN01) + } + + if pa.ChallengeTypeEnabled(core.ChallengeTypeDNS01) { + challenges = append(challenges, core.ChallengeTypeDNS01) + } + } + + return challenges, nil +} + +// ChallengesFor determines which challenge types are acceptable for the given +// identifier, and constructs new challenge objects for those challenge types. +// The resulting challenge objects all share a single challenge token and are +// returned in a random order. +func (pa *AuthorityImpl) ChallengesFor(identifier identifier.ACMEIdentifier) ([]core.Challenge, error) { + challTypes, err := pa.challengeTypesFor(identifier) + if err != nil { + return nil, err + } + + challenges := make([]core.Challenge, len(challTypes)) + + token := core.NewToken() + + for i, t := range challTypes { + c, err := core.NewChallenge(t, token) + if err != nil { + return nil, err + } + + challenges[i] = c + } + + // We shuffle the challenges to prevent ACME clients from relying on the + // specific order that boulder returns them in. + shuffled := make([]core.Challenge, len(challenges)) + + pa.rngMu.Lock() + defer pa.rngMu.Unlock() + for i, challIdx := range pa.pseudoRNG.Perm(len(challenges)) { + shuffled[i] = challenges[challIdx] + } + + return shuffled, nil +} + +// ChallengeTypeEnabled returns whether the specified challenge type is enabled +func (pa *AuthorityImpl) ChallengeTypeEnabled(t core.AcmeChallenge) bool { + pa.blocklistMu.RLock() + defer pa.blocklistMu.RUnlock() + return pa.enabledChallenges[t] +} + +// CheckAuthz determines that an authorization was fulfilled by a challenge +// that was appropriate for the kind of identifier in the authorization. +func (pa *AuthorityImpl) CheckAuthz(authz *core.Authorization) error { + chall, err := authz.SolvedBy() + if err != nil { + return err + } + + challTypes, err := pa.challengeTypesFor(authz.Identifier) + if err != nil { + return err + } + + if !slices.Contains(challTypes, chall) { + return errors.New("authorization fulfilled by invalid challenge") + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/policy/pa_test.go b/third-party/github.com/letsencrypt/boulder/policy/pa_test.go new file mode 100644 index 000000000..e2f4fdc9d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/policy/pa_test.go @@ -0,0 +1,485 @@ +package policy + +import ( + "fmt" + "os" + "testing" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/must" + "github.com/letsencrypt/boulder/test" + "gopkg.in/yaml.v3" +) + +var enabledChallenges = map[core.AcmeChallenge]bool{ + core.ChallengeTypeHTTP01: true, + core.ChallengeTypeDNS01: true, +} + +func paImpl(t *testing.T) *AuthorityImpl { + pa, err := New(enabledChallenges, blog.NewMock()) + if err != nil { + t.Fatalf("Couldn't create policy implementation: %s", err) + } + return pa +} + +func TestWellFormedDomainNames(t *testing.T) { + testCases := []struct { + domain string + err error + }{ + {``, errEmptyName}, // Empty name + {`zomb!.com`, errInvalidDNSCharacter}, // ASCII character out of range + {`emailaddress@myseriously.present.com`, errInvalidDNSCharacter}, + {`user:pass@myseriously.present.com`, errInvalidDNSCharacter}, + {`zömbo.com`, errInvalidDNSCharacter}, // non-ASCII character + {`127.0.0.1`, errIPAddress}, // IPv4 address + {`fe80::1:1`, errInvalidDNSCharacter}, // IPv6 addresses + {`[2001:db8:85a3:8d3:1319:8a2e:370:7348]`, errInvalidDNSCharacter}, // unexpected IPv6 variants + {`[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443`, errInvalidDNSCharacter}, + {`2001:db8::/32`, errInvalidDNSCharacter}, + {`a.b.c.d.e.f.g.h.i.j.k`, errTooManyLabels}, // Too many labels (>10) + + {`www.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345.com`, errNameTooLong}, // Too long (254 characters) + + {`www.ef0123456789abcdef013456789abcdef012345.789abcdef012345679abcdef0123456789abcdef01234.6789abcdef0123456789abcdef0.23456789abcdef0123456789a.cdef0123456789abcdef0123456789ab.def0123456789abcdef0123456789.bcdef0123456789abcdef012345.com`, nil}, // OK, not too long (240 characters) + + {`www.abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz.com`, errLabelTooLong}, // Label too long (>63 characters) + + {`www.-ombo.com`, errInvalidDNSCharacter}, // Label starts with '-' + {`www.zomb-.com`, errInvalidDNSCharacter}, // Label ends with '-' + {`xn--.net`, errInvalidDNSCharacter}, // Label ends with '-' + {`-0b.net`, errInvalidDNSCharacter}, // First label begins with '-' + {`-0.net`, errInvalidDNSCharacter}, // First label begins with '-' + {`-.net`, errInvalidDNSCharacter}, // First label is only '-' + {`---.net`, errInvalidDNSCharacter}, // First label is only hyphens + {`0`, errTooFewLabels}, + {`1`, errTooFewLabels}, + {`*`, errMalformedWildcard}, + {`**`, errTooManyWildcards}, + {`*.*`, errTooManyWildcards}, + {`zombo*com`, errMalformedWildcard}, + {`*.com`, errICANNTLDWildcard}, + {`..a`, errLabelTooShort}, + {`a..a`, errLabelTooShort}, + {`.a..a`, errLabelTooShort}, + {`..foo.com`, errLabelTooShort}, + {`.`, errNameEndsInDot}, + {`..`, errNameEndsInDot}, + {`a..`, errNameEndsInDot}, + {`.....`, errNameEndsInDot}, + {`.a.`, errNameEndsInDot}, + {`www.zombo.com.`, errNameEndsInDot}, + {`www.zombo_com.com`, errInvalidDNSCharacter}, + {`\uFEFF`, errInvalidDNSCharacter}, // Byte order mark + {`\uFEFFwww.zombo.com`, errInvalidDNSCharacter}, + {`www.zom\u202Ebo.com`, errInvalidDNSCharacter}, // Right-to-Left Override + {`\u202Ewww.zombo.com`, errInvalidDNSCharacter}, + {`www.zom\u200Fbo.com`, errInvalidDNSCharacter}, // Right-to-Left Mark + {`\u200Fwww.zombo.com`, errInvalidDNSCharacter}, + // Underscores are technically disallowed in DNS. Some DNS + // implementations accept them but we will be conservative. + {`www.zom_bo.com`, errInvalidDNSCharacter}, + {`zombocom`, errTooFewLabels}, + {`localhost`, errTooFewLabels}, + {`mail`, errTooFewLabels}, + + // disallow capitalized letters for #927 + {`CapitalizedLetters.com`, errInvalidDNSCharacter}, + + {`example.acting`, errNonPublic}, + {`example.internal`, errNonPublic}, + // All-numeric final label not okay. + {`www.zombo.163`, errNonPublic}, + {`xn--109-3veba6djs1bfxlfmx6c9g.xn--f1awi.xn--p1ai`, errMalformedIDN}, // Not in Unicode NFC + {`bq--abwhky3f6fxq.jakacomo.com`, errInvalidRLDH}, + // Three hyphens starting at third second char of first label. + {`bq---abwhky3f6fxq.jakacomo.com`, errInvalidRLDH}, + // Three hyphens starting at second char of first label. + {`h---test.hk2yz.org`, errInvalidRLDH}, + {`co.uk`, errICANNTLD}, + {`foo.bd`, errICANNTLD}, + } + + // Test syntax errors + for _, tc := range testCases { + err := WellFormedDomainNames([]string{tc.domain}) + if tc.err == nil { + test.AssertNil(t, err, fmt.Sprintf("Unexpected error for domain %q, got %s", tc.domain, err)) + } else { + test.AssertError(t, err, fmt.Sprintf("Expected error for domain %q, but got none", tc.domain)) + var berr *berrors.BoulderError + test.AssertErrorWraps(t, err, &berr) + test.AssertContains(t, berr.Error(), tc.err.Error()) + } + } +} + +func TestWillingToIssue(t *testing.T) { + shouldBeBlocked := []string{ + `highvalue.website1.org`, + `website2.co.uk`, + `www.website3.com`, + `lots.of.labels.website4.com`, + `banned.in.dc.com`, + `bad.brains.banned.in.dc.com`, + } + blocklistContents := []string{ + `website2.com`, + `website2.org`, + `website2.co.uk`, + `website3.com`, + `website4.com`, + } + exactBlocklistContents := []string{ + `www.website1.org`, + `highvalue.website1.org`, + `dl.website1.org`, + } + adminBlockedContents := []string{ + `banned.in.dc.com`, + } + + shouldBeAccepted := []string{ + `lowvalue.website1.org`, + `website4.sucks`, + "www.unrelated.com", + "unrelated.com", + "www.8675309.com", + "8675309.com", + "web5ite2.com", + "www.web-site2.com", + } + + policy := blockedNamesPolicy{ + HighRiskBlockedNames: blocklistContents, + ExactBlockedNames: exactBlocklistContents, + AdminBlockedNames: adminBlockedContents, + } + + yamlPolicyBytes, err := yaml.Marshal(policy) + test.AssertNotError(t, err, "Couldn't YAML serialize blocklist") + yamlPolicyFile, _ := os.CreateTemp("", "test-blocklist.*.yaml") + defer os.Remove(yamlPolicyFile.Name()) + err = os.WriteFile(yamlPolicyFile.Name(), yamlPolicyBytes, 0640) + test.AssertNotError(t, err, "Couldn't write YAML blocklist") + + pa := paImpl(t) + + err = pa.LoadHostnamePolicyFile(yamlPolicyFile.Name()) + test.AssertNotError(t, err, "Couldn't load rules") + + // Invalid encoding + err = pa.WillingToIssue([]string{"www.xn--m.com"}) + test.AssertError(t, err, "WillingToIssue didn't fail on a malformed IDN") + // Valid encoding + err = pa.WillingToIssue([]string{"www.xn--mnich-kva.com"}) + test.AssertNotError(t, err, "WillingToIssue failed on a properly formed IDN") + // IDN TLD + err = pa.WillingToIssue([]string{"xn--example--3bhk5a.xn--p1ai"}) + test.AssertNotError(t, err, "WillingToIssue failed on a properly formed domain with IDN TLD") + features.Reset() + + // Test expected blocked domains + for _, domain := range shouldBeBlocked { + err := pa.WillingToIssue([]string{domain}) + test.AssertError(t, err, "domain was not correctly forbidden") + var berr *berrors.BoulderError + test.AssertErrorWraps(t, err, &berr) + test.AssertContains(t, berr.Detail, errPolicyForbidden.Error()) + } + + // Test acceptance of good names + for _, domain := range shouldBeAccepted { + err := pa.WillingToIssue([]string{domain}) + test.AssertNotError(t, err, "domain was incorrectly forbidden") + } +} + +func TestWillingToIssue_Wildcards(t *testing.T) { + bannedDomains := []string{ + "zombo.gov.us", + } + exactBannedDomains := []string{ + "highvalue.letsdecrypt.org", + } + pa := paImpl(t) + + bannedBytes, err := yaml.Marshal(blockedNamesPolicy{ + HighRiskBlockedNames: bannedDomains, + ExactBlockedNames: exactBannedDomains, + }) + test.AssertNotError(t, err, "Couldn't serialize banned list") + f, _ := os.CreateTemp("", "test-wildcard-banlist.*.yaml") + defer os.Remove(f.Name()) + err = os.WriteFile(f.Name(), bannedBytes, 0640) + test.AssertNotError(t, err, "Couldn't write serialized banned list to file") + err = pa.LoadHostnamePolicyFile(f.Name()) + test.AssertNotError(t, err, "Couldn't load policy contents from file") + + testCases := []struct { + Name string + Domain string + ExpectedErr error + }{ + { + Name: "Too many wildcards", + Domain: "ok.*.whatever.*.example.com", + ExpectedErr: errTooManyWildcards, + }, + { + Name: "Misplaced wildcard", + Domain: "ok.*.whatever.example.com", + ExpectedErr: errMalformedWildcard, + }, + { + Name: "Missing ICANN TLD", + Domain: "*.ok.madeup", + ExpectedErr: errNonPublic, + }, + { + Name: "Wildcard for ICANN TLD", + Domain: "*.com", + ExpectedErr: errICANNTLDWildcard, + }, + { + Name: "Forbidden base domain", + Domain: "*.zombo.gov.us", + ExpectedErr: errPolicyForbidden, + }, + // We should not allow getting a wildcard for that would cover an exact + // blocklist domain + { + Name: "Wildcard for ExactBlocklist base domain", + Domain: "*.letsdecrypt.org", + ExpectedErr: errPolicyForbidden, + }, + // We should allow a wildcard for a domain that doesn't match the exact + // blocklist domain + { + Name: "Wildcard for non-matching subdomain of ExactBlocklist domain", + Domain: "*.lowvalue.letsdecrypt.org", + ExpectedErr: nil, + }, + // We should allow getting a wildcard for an exact blocklist domain since it + // only covers subdomains, not the exact name. + { + Name: "Wildcard for ExactBlocklist domain", + Domain: "*.highvalue.letsdecrypt.org", + ExpectedErr: nil, + }, + { + Name: "Valid wildcard domain", + Domain: "*.everything.is.possible.at.zombo.com", + ExpectedErr: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + err := pa.WillingToIssue([]string{tc.Domain}) + if tc.ExpectedErr == nil { + test.AssertNil(t, err, fmt.Sprintf("Unexpected error for domain %q, got %s", tc.Domain, err)) + } else { + test.AssertError(t, err, fmt.Sprintf("Expected error for domain %q, but got none", tc.Domain)) + var berr *berrors.BoulderError + test.AssertErrorWraps(t, err, &berr) + test.AssertContains(t, berr.Error(), tc.ExpectedErr.Error()) + } + }) + } +} + +// TestWillingToIssue_SubErrors tests that more than one rejected identifier +// results in an error with suberrors. +func TestWillingToIssue_SubErrors(t *testing.T) { + banned := []string{ + "letsdecrypt.org", + "example.com", + } + pa := paImpl(t) + + bannedBytes, err := yaml.Marshal(blockedNamesPolicy{ + HighRiskBlockedNames: banned, + ExactBlockedNames: banned, + }) + test.AssertNotError(t, err, "Couldn't serialize banned list") + f, _ := os.CreateTemp("", "test-wildcard-banlist.*.yaml") + defer os.Remove(f.Name()) + err = os.WriteFile(f.Name(), bannedBytes, 0640) + test.AssertNotError(t, err, "Couldn't write serialized banned list to file") + err = pa.LoadHostnamePolicyFile(f.Name()) + test.AssertNotError(t, err, "Couldn't load policy contents from file") + + // Test multiple malformed domains and one banned domain; only the malformed ones will generate errors + err = pa.WillingToIssue([]string{ + "perfectly-fine.com", // fine + "letsdecrypt_org", // malformed + "example.comm", // malformed + "letsdecrypt.org", // banned + "also-perfectly-fine.com", // fine + }) + test.AssertDeepEquals(t, err, + &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "Cannot issue for \"letsdecrypt_org\": Domain name contains an invalid character (and 1 more problems. Refer to sub-problems for more information.)", + SubErrors: []berrors.SubBoulderError{ + { + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "Domain name contains an invalid character", + }, + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: "letsdecrypt_org"}, + }, + { + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "Domain name does not end with a valid public suffix (TLD)", + }, + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: "example.comm"}, + }, + }, + }) + + // Test multiple banned domains. + err = pa.WillingToIssue([]string{ + "perfectly-fine.com", // fine + "letsdecrypt.org", // banned + "example.com", // banned + "also-perfectly-fine.com", // fine + }) + test.AssertError(t, err, "Expected err from WillingToIssueWildcards") + + test.AssertDeepEquals(t, err, + &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "Cannot issue for \"letsdecrypt.org\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy (and 1 more problems. Refer to sub-problems for more information.)", + SubErrors: []berrors.SubBoulderError{ + { + BoulderError: &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy", + }, + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: "letsdecrypt.org"}, + }, + { + BoulderError: &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy", + }, + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: "example.com"}, + }, + }, + }) + + // Test willing to issue with only *one* bad identifier. + err = pa.WillingToIssue([]string{"letsdecrypt.org"}) + test.AssertDeepEquals(t, err, + &berrors.BoulderError{ + Type: berrors.RejectedIdentifier, + Detail: "Cannot issue for \"letsdecrypt.org\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy", + }) +} + +func TestChallengesFor(t *testing.T) { + pa := paImpl(t) + + challenges, err := pa.ChallengesFor(identifier.ACMEIdentifier{}) + test.AssertNotError(t, err, "ChallengesFor failed") + + test.Assert(t, len(challenges) == len(enabledChallenges), "Wrong number of challenges returned") + + seenChalls := make(map[core.AcmeChallenge]bool) + for _, challenge := range challenges { + test.Assert(t, !seenChalls[challenge.Type], "should not already have seen this type") + seenChalls[challenge.Type] = true + + test.Assert(t, enabledChallenges[challenge.Type], "Unsupported challenge returned") + } + test.AssertEquals(t, len(seenChalls), len(enabledChallenges)) + +} + +func TestChallengesForWildcard(t *testing.T) { + // wildcardIdent is an identifier for a wildcard domain name + wildcardIdent := identifier.ACMEIdentifier{ + Type: identifier.DNS, + Value: "*.zombo.com", + } + + // First try to get a challenge for the wildcard ident without the + // DNS-01 challenge type enabled. This should produce an error + var enabledChallenges = map[core.AcmeChallenge]bool{ + core.ChallengeTypeHTTP01: true, + core.ChallengeTypeDNS01: false, + } + pa := must.Do(New(enabledChallenges, blog.NewMock())) + _, err := pa.ChallengesFor(wildcardIdent) + test.AssertError(t, err, "ChallengesFor did not error for a wildcard ident "+ + "when DNS-01 was disabled") + test.AssertEquals(t, err.Error(), "Challenges requested for wildcard "+ + "identifier but DNS-01 challenge type is not enabled") + + // Try again with DNS-01 enabled. It should not error and + // should return only one DNS-01 type challenge + enabledChallenges[core.ChallengeTypeDNS01] = true + pa = must.Do(New(enabledChallenges, blog.NewMock())) + challenges, err := pa.ChallengesFor(wildcardIdent) + test.AssertNotError(t, err, "ChallengesFor errored for a wildcard ident "+ + "unexpectedly") + test.AssertEquals(t, len(challenges), 1) + test.AssertEquals(t, challenges[0].Type, core.ChallengeTypeDNS01) +} + +// TestMalformedExactBlocklist tests that loading a YAML policy file with an +// invalid exact blocklist entry will fail as expected. +func TestMalformedExactBlocklist(t *testing.T) { + pa := paImpl(t) + + exactBannedDomains := []string{ + // Only one label - not valid + "com", + } + bannedDomains := []string{ + "placeholder.domain.not.important.for.this.test.com", + } + + // Create YAML for the exactBannedDomains + bannedBytes, err := yaml.Marshal(blockedNamesPolicy{ + HighRiskBlockedNames: bannedDomains, + ExactBlockedNames: exactBannedDomains, + }) + test.AssertNotError(t, err, "Couldn't serialize banned list") + + // Create a temp file for the YAML contents + f, _ := os.CreateTemp("", "test-invalid-exactblocklist.*.yaml") + defer os.Remove(f.Name()) + // Write the YAML to the temp file + err = os.WriteFile(f.Name(), bannedBytes, 0640) + test.AssertNotError(t, err, "Couldn't write serialized banned list to file") + + // Try to use the YAML tempfile as the hostname policy. It should produce an + // error since the exact blocklist contents are malformed. + err = pa.LoadHostnamePolicyFile(f.Name()) + test.AssertError(t, err, "Loaded invalid exact blocklist content without error") + test.AssertEquals(t, err.Error(), "Malformed ExactBlockedNames entry, only one label: \"com\"") +} + +func TestValidEmailError(t *testing.T) { + err := ValidEmail("(๑•́ ω •̀๑)") + test.AssertEquals(t, err.Error(), "\"(๑•́ ω •̀๑)\" is not a valid e-mail address") + + err = ValidEmail("john.smith@gmail.com #replace with real email") + test.AssertEquals(t, err.Error(), "\"john.smith@gmail.com #replace with real email\" is not a valid e-mail address") + + err = ValidEmail("example@example.com") + test.AssertEquals(t, err.Error(), "invalid contact domain. Contact emails @example.com are forbidden") + + err = ValidEmail("example@-foobar.com") + test.AssertEquals(t, err.Error(), "contact email \"example@-foobar.com\" has invalid domain : Domain name contains an invalid character") +} diff --git a/third-party/github.com/letsencrypt/boulder/precert/corr.go b/third-party/github.com/letsencrypt/boulder/precert/corr.go new file mode 100644 index 000000000..f70c5cf43 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/corr.go @@ -0,0 +1,222 @@ +package precert + +import ( + "bytes" + encoding_asn1 "encoding/asn1" + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" +) + +// Correspond returns nil if the two certificates are a valid precertificate/final certificate pair. +// Order of the arguments matters: the precertificate is first and the final certificate is second. +// Note that RFC 6962 allows the precertificate and final certificate to have different Issuers, but +// this function rejects such pairs. +func Correspond(precertDER, finalDER []byte) error { + preTBS, err := tbsDERFromCertDER(precertDER) + if err != nil { + return fmt.Errorf("parsing precert: %w", err) + } + + finalTBS, err := tbsDERFromCertDER(finalDER) + if err != nil { + return fmt.Errorf("parsing final cert: %w", err) + } + + // The first 7 fields of TBSCertificate must be byte-for-byte identical. + // The next 2 fields (issuerUniqueID and subjectUniqueID) are forbidden + // by the Baseline Requirements so we assume they are not present (if they + // are, they will fail the next check, for extensions). + // https://datatracker.ietf.org/doc/html/rfc5280#page-117 + // TBSCertificate ::= SEQUENCE { + // version [0] Version DEFAULT v1, + // serialNumber CertificateSerialNumber, + // signature AlgorithmIdentifier, + // issuer Name, + // validity Validity, + // subject Name, + // subjectPublicKeyInfo SubjectPublicKeyInfo, + // issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // extensions [3] Extensions OPTIONAL + // -- If present, version MUST be v3 -- } + for i := range 7 { + if err := readIdenticalElement(&preTBS, &finalTBS); err != nil { + return fmt.Errorf("checking for identical field %d: %w", i, err) + } + } + + // The extensions should be mostly the same, with these exceptions: + // - The precertificate should have exactly one precertificate poison extension + // not present in the final certificate. + // - The final certificate should have exactly one SCTList extension not present + // in the precertificate. + // - As a consequence, the byte lengths of the extensions fields will not be the + // same, so we ignore the lengths (so long as they parse) + precertExtensionBytes, err := unwrapExtensions(preTBS) + if err != nil { + return fmt.Errorf("parsing precert extensions: %w", err) + } + + finalCertExtensionBytes, err := unwrapExtensions(finalTBS) + if err != nil { + return fmt.Errorf("parsing final cert extensions: %w", err) + } + + precertParser := extensionParser{bytes: precertExtensionBytes, skippableOID: poisonOID} + finalCertParser := extensionParser{bytes: finalCertExtensionBytes, skippableOID: sctListOID} + + for i := 0; ; i++ { + precertExtn, err := precertParser.Next() + if err != nil { + return err + } + + finalCertExtn, err := finalCertParser.Next() + if err != nil { + return err + } + + if !bytes.Equal(precertExtn, finalCertExtn) { + return fmt.Errorf("precert extension %d (%x) not equal to final cert extension %d (%x)", + i+precertParser.skipped, precertExtn, i+finalCertParser.skipped, finalCertExtn) + } + + if precertExtn == nil && finalCertExtn == nil { + break + } + } + + if precertParser.skipped == 0 { + return fmt.Errorf("no poison extension found in precert") + } + if precertParser.skipped > 1 { + return fmt.Errorf("multiple poison extensions found in precert") + } + if finalCertParser.skipped == 0 { + return fmt.Errorf("no SCTList extension found in final cert") + } + if finalCertParser.skipped > 1 { + return fmt.Errorf("multiple SCTList extensions found in final cert") + } + return nil +} + +var poisonOID = []int{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} +var sctListOID = []int{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + +// extensionParser takes a sequence of bytes representing the inner bytes of the +// `extensions` field. Repeated calls to Next() will return all the extensions +// except those that match the skippableOID. The skipped extensions will be +// counted in `skipped`. +type extensionParser struct { + skippableOID encoding_asn1.ObjectIdentifier + bytes cryptobyte.String + skipped int +} + +// Next returns the next extension in the sequence, skipping (and counting) +// any extension that matches the skippableOID. +// Returns nil, nil when there are no more extensions. +func (e *extensionParser) Next() (cryptobyte.String, error) { + if e.bytes.Empty() { + return nil, nil + } + + var next cryptobyte.String + if !e.bytes.ReadASN1(&next, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to parse extension") + } + + var oid encoding_asn1.ObjectIdentifier + nextCopy := next + if !nextCopy.ReadASN1ObjectIdentifier(&oid) { + return nil, fmt.Errorf("failed to parse extension OID") + } + + if oid.Equal(e.skippableOID) { + e.skipped++ + return e.Next() + } + + return next, nil +} + +// unwrapExtensions takes a given a sequence of bytes representing the `extensions` field +// of a TBSCertificate and parses away the outermost two layers, returning the inner bytes +// of the Extensions SEQUENCE. +// +// https://datatracker.ietf.org/doc/html/rfc5280#page-117 +// +// TBSCertificate ::= SEQUENCE { +// ... +// extensions [3] Extensions OPTIONAL +// } +// +// Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension +func unwrapExtensions(field cryptobyte.String) (cryptobyte.String, error) { + var extensions cryptobyte.String + if !field.ReadASN1(&extensions, asn1.Tag(3).Constructed().ContextSpecific()) { + return nil, errors.New("error reading extensions") + } + + var extensionsInner cryptobyte.String + if !extensions.ReadASN1(&extensionsInner, asn1.SEQUENCE) { + return nil, errors.New("error reading extensions inner") + } + + return extensionsInner, nil +} + +// readIdenticalElement parses a single ASN1 element and returns an error if +// their tags are different or their contents are different. +func readIdenticalElement(a, b *cryptobyte.String) error { + var aInner, bInner cryptobyte.String + var aTag, bTag asn1.Tag + if !a.ReadAnyASN1Element(&aInner, &aTag) { + return fmt.Errorf("failed to read element from first input") + } + if !b.ReadAnyASN1Element(&bInner, &bTag) { + return fmt.Errorf("failed to read element from first input") + } + if aTag != bTag { + return fmt.Errorf("tags differ: %d != %d", aTag, bTag) + } + if !bytes.Equal([]byte(aInner), []byte(bInner)) { + return fmt.Errorf("elements differ: %x != %x", aInner, bInner) + } + return nil +} + +// tbsDERFromCertDER takes a Certificate object encoded as DER, and parses +// away the outermost two SEQUENCEs to get the inner bytes of the TBSCertificate. +// +// https://datatracker.ietf.org/doc/html/rfc5280#page-116 +// +// Certificate ::= SEQUENCE { +// tbsCertificate TBSCertificate, +// ... +// +// TBSCertificate ::= SEQUENCE { +// version [0] Version DEFAULT v1, +// serialNumber CertificateSerialNumber, +// ... +func tbsDERFromCertDER(certDER []byte) (cryptobyte.String, error) { + var inner cryptobyte.String + input := cryptobyte.String(certDER) + + if !input.ReadASN1(&inner, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read outer sequence") + } + + var tbsCertificate cryptobyte.String + if !inner.ReadASN1(&tbsCertificate, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read tbsCertificate") + } + + return tbsCertificate, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/precert/corr_test.go b/third-party/github.com/letsencrypt/boulder/precert/corr_test.go new file mode 100644 index 000000000..8d29ee077 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/corr_test.go @@ -0,0 +1,341 @@ +package precert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "strings" + "testing" + "time" +) + +func TestCorrespondIncorrectArgumentOrder(t *testing.T) { + pre, final, err := readPair("testdata/good/precert.pem", "testdata/good/final.pem") + if err != nil { + t.Fatal(err) + } + + // The final cert is in the precert position and vice versa. + err = Correspond(final, pre) + if err == nil { + t.Errorf("expected failure when final and precertificates were in wrong order, got success") + } +} + +func TestCorrespondGood(t *testing.T) { + pre, final, err := readPair("testdata/good/precert.pem", "testdata/good/final.pem") + if err != nil { + t.Fatal(err) + } + + err = Correspond(pre, final) + if err != nil { + t.Errorf("expected testdata/good/ certs to correspond, got %s", err) + } +} + +func TestCorrespondBad(t *testing.T) { + pre, final, err := readPair("testdata/bad/precert.pem", "testdata/bad/final.pem") + if err != nil { + t.Fatal(err) + } + + err = Correspond(pre, final) + if err == nil { + t.Errorf("expected testdata/bad/ certs to not correspond, got nil error") + } + expected := "precert extension 7 (0603551d20040c300a3008060667810c010201) not equal to final cert extension 7 (0603551d20044530433008060667810c0102013037060b2b0601040182df130101013028302606082b06010505070201161a687474703a2f2f6370732e6c657473656e63727970742e6f7267)" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error to contain %q, got %q", expected, err.Error()) + } +} + +func TestCorrespondCompleteMismatch(t *testing.T) { + pre, final, err := readPair("testdata/good/precert.pem", "testdata/bad/final.pem") + if err != nil { + t.Fatal(err) + } + + err = Correspond(pre, final) + if err == nil { + t.Errorf("expected testdata/good and testdata/bad/ certs to not correspond, got nil error") + } + expected := "checking for identical field 1: elements differ: 021203d91c3d22b404f20df3c1631c22e1754b8d != 021203e2267b786b7e338317ddd62e764fcb3c71" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error to contain %q, got %q", expected, err.Error()) + } +} + +func readPair(a, b string) ([]byte, []byte, error) { + aDER, err := derFromPEMFile(a) + if err != nil { + return nil, nil, err + } + bDER, err := derFromPEMFile(b) + if err != nil { + return nil, nil, err + } + return aDER, bDER, nil +} + +// derFromPEMFile reads a PEM file and returns the DER-encoded bytes. +func derFromPEMFile(filename string) ([]byte, error) { + precertPEM, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", filename, err) + } + + precertPEMBlock, _ := pem.Decode(precertPEM) + if precertPEMBlock == nil { + return nil, fmt.Errorf("error PEM decoding %s", filename) + } + + return precertPEMBlock.Bytes, nil +} + +func TestMismatches(t *testing.T) { + now := time.Now() + + issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + // A separate issuer key, used for signing the final certificate, but + // using the same simulated issuer certificate. + untrustedIssuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + subscriberKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + // By reading the crypto/x509 code, we know that Subject is the only field + // of the issuer certificate that we need to care about for the purposes + // of signing below. + issuer := x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Some Issuer", + }, + } + + precertTemplate := x509.Certificate{ + SerialNumber: big.NewInt(3141592653589793238), + NotBefore: now, + NotAfter: now.Add(24 * time.Hour), + DNSNames: []string{"example.com"}, + ExtraExtensions: []pkix.Extension{ + { + Id: poisonOID, + Value: []byte{0x5, 0x0}, + }, + }, + } + + precertDER, err := x509.CreateCertificate(rand.Reader, &precertTemplate, &issuer, &subscriberKey.PublicKey, issuerKey) + if err != nil { + t.Fatal(err) + } + + // Sign a final certificate with the untrustedIssuerKey, first applying the + // given modify function to the default template. Return the DER encoded bytes. + makeFinalCert := func(modify func(c *x509.Certificate)) []byte { + t.Helper() + finalCertTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3141592653589793238), + NotBefore: now, + NotAfter: now.Add(24 * time.Hour), + DNSNames: []string{"example.com"}, + ExtraExtensions: []pkix.Extension{ + { + Id: sctListOID, + Value: nil, + }, + }, + } + + modify(finalCertTemplate) + + finalCertDER, err := x509.CreateCertificate(rand.Reader, finalCertTemplate, + &issuer, &subscriberKey.PublicKey, untrustedIssuerKey) + if err != nil { + t.Fatal(err) + } + + return finalCertDER + } + + // Expect success with a matching precert and final cert + finalCertDER := makeFinalCert(func(c *x509.Certificate) {}) + err = Correspond(precertDER, finalCertDER) + if err != nil { + t.Errorf("expected precert and final cert to correspond, got: %s", err) + } + + // Set up a precert / final cert pair where the SCTList and poison extensions are + // not in the same position + precertTemplate2 := x509.Certificate{ + SerialNumber: big.NewInt(3141592653589793238), + NotBefore: now, + NotAfter: now.Add(24 * time.Hour), + DNSNames: []string{"example.com"}, + ExtraExtensions: []pkix.Extension{ + { + Id: poisonOID, + Value: []byte{0x5, 0x0}, + }, + // Arbitrary extension to make poisonOID not be the last extension + { + Id: []int{1, 2, 3, 4}, + Value: []byte{0x5, 0x0}, + }, + }, + } + + precertDER2, err := x509.CreateCertificate(rand.Reader, &precertTemplate2, &issuer, &subscriberKey.PublicKey, issuerKey) + if err != nil { + t.Fatal(err) + } + + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.ExtraExtensions = []pkix.Extension{ + { + Id: []int{1, 2, 3, 4}, + Value: []byte{0x5, 0x0}, + }, + { + Id: sctListOID, + Value: nil, + }, + } + }) + err = Correspond(precertDER2, finalCertDER) + if err != nil { + t.Errorf("expected precert and final cert to correspond with differently positioned extensions, got: %s", err) + } + + // Expect failure with a mismatched Issuer + issuer = x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Some Other Issuer", + }, + } + + finalCertDER = makeFinalCert(func(c *x509.Certificate) {}) + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched issuer, got nil error") + } + + // Restore original issuer + issuer = x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Some Issuer", + }, + } + + // Expect failure with a mismatched Serial + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.SerialNumber = big.NewInt(2718281828459045) + }) + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched serial, got nil error") + } + + // Expect failure with mismatched names + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.DNSNames = []string{"example.com", "www.example.com"} + }) + + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched names, got nil error") + } + + // Expect failure with mismatched NotBefore + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.NotBefore = now.Add(24 * time.Hour) + }) + + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched NotBefore, got nil error") + } + + // Expect failure with mismatched NotAfter + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.NotAfter = now.Add(48 * time.Hour) + }) + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched NotAfter, got nil error") + } + + // Expect failure for mismatched extensions + finalCertDER = makeFinalCert(func(c *x509.Certificate) { + c.ExtraExtensions = append(c.ExtraExtensions, pkix.Extension{ + Critical: true, + Id: []int{1, 2, 3}, + Value: []byte("hello"), + }) + }) + + err = Correspond(precertDER, finalCertDER) + if err == nil { + t.Errorf("expected error for mismatched extensions, got nil error") + } + expectedError := "precert extension 2 () not equal to final cert extension 2 (06022a030101ff040568656c6c6f)" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err) + } +} + +func TestUnwrapExtensions(t *testing.T) { + validExtensionsOuter := []byte{0xA3, 0x3, 0x30, 0x1, 0x0} + _, err := unwrapExtensions(validExtensionsOuter) + if err != nil { + t.Errorf("expected success for validExtensionsOuter, got %s", err) + } + + invalidExtensionsOuter := []byte{0xA3, 0x99, 0x30, 0x1, 0x0} + _, err = unwrapExtensions(invalidExtensionsOuter) + if err == nil { + t.Error("expected error for invalidExtensionsOuter, got none") + } + + invalidExtensionsInner := []byte{0xA3, 0x3, 0x30, 0x99, 0x0} + _, err = unwrapExtensions(invalidExtensionsInner) + if err == nil { + t.Error("expected error for invalidExtensionsInner, got none") + } +} + +func TestTBSFromCertDER(t *testing.T) { + validCertOuter := []byte{0x30, 0x3, 0x30, 0x1, 0x0} + _, err := tbsDERFromCertDER(validCertOuter) + if err != nil { + t.Errorf("expected success for validCertOuter, got %s", err) + } + + invalidCertOuter := []byte{0x30, 0x99, 0x30, 0x1, 0x0} + _, err = tbsDERFromCertDER(invalidCertOuter) + if err == nil { + t.Error("expected error for invalidCertOuter, got none") + } + + invalidCertInner := []byte{0x30, 0x3, 0x30, 0x99, 0x0} + _, err = tbsDERFromCertDER(invalidCertInner) + if err == nil { + t.Error("expected error for invalidExtensionsInner, got none") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/precert/testdata/README.md b/third-party/github.com/letsencrypt/boulder/precert/testdata/README.md new file mode 100644 index 000000000..e6852915b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/testdata/README.md @@ -0,0 +1,8 @@ +The data in this directory consists of real certificates issued by Let's +Encrypt in 2023. The ones under the `bad` directory were issued during +the Duplicate Serial Numbers incident (https://bugzilla.mozilla.org/show_bug.cgi?id=1838667) +and differ in the presence / absence of a second policyIdentifier in the +Certificate Policies extension. + +The ones under the `good` directory were issued shortly after recovery +from the incident and represent a correct correspondence relationship. diff --git a/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/final.pem b/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/final.pem new file mode 100644 index 000000000..bfc9847c9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/final.pem @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGRjCCBS6gAwIBAgISA+Ime3hrfjODF93WLnZPyzxxMA0GCSqGSIb3DQEBCwUA +MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD +EwJSMzAeFw0yMzA2MTUxNDM2MTZaFw0yMzA5MTMxNDM2MTVaMB4xHDAaBgNVBAMM +EyouN2FjbnIubW9uZ29kYi5uZXQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCjLiLXI/mTBSEkSKVucC3NcnXGu/M2qwLIk1uenifnoNMmdJmEyp+oWFUS +n9rIXtHw27YTlJLRRYLSIzqqujDV5PmXzFrSJ/9JrgIbNUowaVF3j9bf1+NPENEH +81RnNGevtKUN5NoEo3fAmZaMWrGjWioNnpIsegSjvvuHeqMqC7SNrGSvtKLBiPkO +bL5oScPYj/cHzt3RYJ17ru6xWgUDV6aqvEblrxcXvPmd/1SxB3Vkdkc+bCuSLSNM +/NmcET0YUhWizanjodJarpYJRuW1SjGmPda0jBAQZQDPmZHCEgwTBcCEIg5J3XzA +fFUZPPlTVgE+7Mbjd/DK7iz46D0uHOigVTZto3lPYRdRiyVFNUMAN0GLAlkaJ7Td +0FnAxvhE74lSjI7lFqDNtiyA8ovp/JbKfPmnvfH+fQa7vEFbR5H9v4UZt0XLeI6W +dV4pYoCwuK5mfr0NQLCy/015OAU8WF4MLM+Fyt+GG+sOk2Maz6ysAShMOvdNH7B3 +GSn65xBVgBxlPWyYpodW9SS1NSVgrgbKMg0yHzx/PdosQehyh9p6OpuTaeEi2iQg +yTODKGHX+cmjzUx0iCG2ByC9bvMo32eZXiC+itZCaHb0FGXh+K7UcOCsvsi7NLGR +ngVKK7u7gZmPu4UkVUBpF3jz/OK3OsudHcflZIGd6nf8w4lp0wIDAQABo4ICaDCC +AmQwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBREcOX3VXl7+uM7aqTQ/coniJsAAjAf +BgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTCxjBVBggrBgEFBQcBAQRJMEcw +IQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYW +aHR0cDovL3IzLmkubGVuY3Iub3JnLzA4BgNVHREEMTAvghgqLjdhY25yLm1lc2gu +bW9uZ29kYi5uZXSCEyouN2FjbnIubW9uZ29kYi5uZXQwTAYDVR0gBEUwQzAIBgZn +gQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0cDovL2Nwcy5s +ZXRzZW5jcnlwdC5vcmcwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdgC3Pvsk35xN +unXyOcW6WPRsXfxCz3qfNcSeHQmBJe20mQAAAYi/s0QZAAAEAwBHMEUCID4vc7PN +WNauTkmkS7CqSwdiyOV+LYIT9g8KygWW4atTAiEA6Re4Cz7BsEMi+/U8G+r9Lmqb +qwGXGS4mXG7RiEfeQEcAdgB6MoxU2LcttiDqOOBSHumEFnAyE4VNO9IrwTpXo1Lr +UgAAAYi/s0RQAAAEAwBHMEUCIQD95SqDycwXGZ+JKBUVBR+hBxn4BRIQ7EPIaMTI +/+854gIgDpJm5BFX9vKUf5tKWn9f/Fagktt5J6hPnrmURSV/egAwDQYJKoZIhvcN +AQELBQADggEBAKWyDSRmiM9N+2AhYgRuzh3JnxtvhmEXUBEgwuFnlQyCm5ZvScvW +Kmw2sqcj+gI2UNUxmWjq3PbIVBrTLDEgXtVN+JU6HwC4TdYPIB4LzfrWsGY7cc2a +aY76YbWlwEyhN9niQLijZORKhZ6HLM7MI76FM7oJ9eZmvnfypjJ7E0J9ek/y7S1w +qg5EM+QiAf03YcjSxUCyL3/+EzlYRz65diLh7Eb6gBd58rWLOa1nbgTOFsToAkBE +7qR3HymfWysxApDN8x95jDzubbkqiyuk3dvzjn3oouN1H8NsG/xYrYmMMwnJ8xul +1AJ31ZMxJ9hr29G122DSEaX9smAyyzWhAwM= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/precert.pem b/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/precert.pem new file mode 100644 index 000000000..ab323b7fc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/testdata/bad/precert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFGjCCBAKgAwIBAgISA+Ime3hrfjODF93WLnZPyzxxMA0GCSqGSIb3DQEBCwUA +MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD +EwJSMzAeFw0yMzA2MTUxNDM2MTZaFw0yMzA5MTMxNDM2MTVaMB4xHDAaBgNVBAMM +EyouN2FjbnIubW9uZ29kYi5uZXQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCjLiLXI/mTBSEkSKVucC3NcnXGu/M2qwLIk1uenifnoNMmdJmEyp+oWFUS +n9rIXtHw27YTlJLRRYLSIzqqujDV5PmXzFrSJ/9JrgIbNUowaVF3j9bf1+NPENEH +81RnNGevtKUN5NoEo3fAmZaMWrGjWioNnpIsegSjvvuHeqMqC7SNrGSvtKLBiPkO +bL5oScPYj/cHzt3RYJ17ru6xWgUDV6aqvEblrxcXvPmd/1SxB3Vkdkc+bCuSLSNM +/NmcET0YUhWizanjodJarpYJRuW1SjGmPda0jBAQZQDPmZHCEgwTBcCEIg5J3XzA +fFUZPPlTVgE+7Mbjd/DK7iz46D0uHOigVTZto3lPYRdRiyVFNUMAN0GLAlkaJ7Td +0FnAxvhE74lSjI7lFqDNtiyA8ovp/JbKfPmnvfH+fQa7vEFbR5H9v4UZt0XLeI6W +dV4pYoCwuK5mfr0NQLCy/015OAU8WF4MLM+Fyt+GG+sOk2Maz6ysAShMOvdNH7B3 +GSn65xBVgBxlPWyYpodW9SS1NSVgrgbKMg0yHzx/PdosQehyh9p6OpuTaeEi2iQg +yTODKGHX+cmjzUx0iCG2ByC9bvMo32eZXiC+itZCaHb0FGXh+K7UcOCsvsi7NLGR +ngVKK7u7gZmPu4UkVUBpF3jz/OK3OsudHcflZIGd6nf8w4lp0wIDAQABo4IBPDCC +ATgwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBREcOX3VXl7+uM7aqTQ/coniJsAAjAf +BgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTCxjBVBggrBgEFBQcBAQRJMEcw +IQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYW +aHR0cDovL3IzLmkubGVuY3Iub3JnLzA4BgNVHREEMTAvghgqLjdhY25yLm1lc2gu +bW9uZ29kYi5uZXSCEyouN2FjbnIubW9uZ29kYi5uZXQwEwYDVR0gBAwwCjAIBgZn +gQwBAgEwEwYKKwYBBAHWeQIEAwEB/wQCBQAwDQYJKoZIhvcNAQELBQADggEBALIU +rHns6TWfT/kfJ60D9R1Ek4YGB/jVsrh2d3uiIU2hiRBBjgDkCLyKd7oXM761uXX3 +LL4H4JPegqTrZAPO88tUtzBSb3IF4yA0o1NWhE6ceLnBk9fl5TRCC8QASliApsOi +gDgRi1VFmyFOHpHnVZdbpPucy6T+CdKXKfj4iNw+aOZcoQxJ70XECXxQbdqJ7VdY +f0B+wtk5HZU8cuVVCj1i/iDv1zqITCzaavbz870QugiHO/8rj2ctrA07SX3Ovs4J +GbCGuMzlpxeIFtQDWVufVbu1ZZltzPlSHFqv6mPKW9stYtt8JCjmPwNW6UdrlBtN +gvFgkgDpz+Q6/Vu+u7g= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/precert/testdata/good/final.pem b/third-party/github.com/letsencrypt/boulder/precert/testdata/good/final.pem new file mode 100644 index 000000000..0b27cc646 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/testdata/good/final.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIE/TCCA+WgAwIBAgISA9kcPSK0BPIN88FjHCLhdUuNMA0GCSqGSIb3DQEBCwUAMDIxCzAJBgNVBAYT +AlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJSMzAeFw0yMzA2MTUxNTAxNDRaFw0y +MzA5MTMxNTAxNDNaMCIxIDAeBgNVBAMTF2hvdXNldHJhaW5pbmdwdXBweS5pbmZvMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr/XUbBzyFKRMJ0vYSpqw4Wy2Y2eV+vSCix5TcGNxTR9tB9EX+hNd +C7/zlKJAGUj9ZTSfbJO27HvleVN3D5idhIFxfP2tdfAp4OxQkf4a4nqKXZzPJpTlDs2LQNjKcwszaxKY +CMzGThieeBm7jUiWL6fuAX+sCsBIO0frJ9klq77f7NplfwJ3FcKWFyvMo71rtFZCoLt7dfgKim+SBGYn +agfNe8mmxy4ipqvWtGzMO3cdcKdiRijMzZG1upRjhoggHI/vS2JkWP4bNoZdGCAvaxriEoBdS5K9LqHQ +P6GurVXM5B3kuJkMBN+OmnrXxvcnWbYY6JwAO3KZ1+Vbi2ryPQIDAQABo4ICGzCCAhcwDgYDVR0PAQH/ +BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW +BBQmE8zNXgf+dOmQ3kFb3p4xfznLjTAfBgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTCxjBVBggr +BgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYW +aHR0cDovL3IzLmkubGVuY3Iub3JnLzAiBgNVHREEGzAZghdob3VzZXRyYWluaW5ncHVwcHkuaW5mbzAT +BgNVHSAEDDAKMAgGBmeBDAECATCCAQYGCisGAQQB1nkCBAIEgfcEgfQA8gB3AHoyjFTYty22IOo44FIe +6YQWcDIThU070ivBOlejUutSAAABiL/Kk3wAAAQDAEgwRgIhAN//jI1iByfobY0b+JXWFhc5zQpKC+mI +qXIWrWlXPgrqAiEAiArpAl0FCxvy5vv/C/t+ZOFh0OTxMc2w9rj0GlAhPrAAdwDoPtDaPvUGNTLnVyi8 +iWvJA9PL0RFr7Otp4Xd9bQa9bgAAAYi/ypP1AAAEAwBIMEYCIQC7XKe+yYzkIeu/294qGrQB/G4I8+hz +//3HJVWFam+6KQIhAMy2iY3IITazdGhmQXGQAUPSzXt2wtm1PGHPmyNmIQnXMA0GCSqGSIb3DQEBCwUA +A4IBAQBtrtoi4zea7CnswZc/1Ql3aV0j7nblq4gXxiMoHdoq1srZbypnqvDIFaEp5BjSccEc0D0jK4u2 +nwnFzIljjRi/HXoTBJBHKIxX/s9G/tWFgfnrRSonyN1mguyi7avfWLELrl+Or2+h1K4LZIasrlN8oJpu +a4msgl8HXRdla9Kej7x6fYgyBOJEAcb82i7Ur4bM5OGKZObePHGK6NDsTcpdmqBAjAuKLYMtpHXpFo4/ +14X2A027hOdDBFkeNcRF2KZsbSvp78qIZsSYtjEyYBlTPWLh/aoXx2sc2vl43VaLYOlEIfuzrEKCTiqr +D3TU5CmThOuzm/H0HeCmtlNuQlzK +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/precert/testdata/good/precert.pem b/third-party/github.com/letsencrypt/boulder/precert/testdata/good/precert.pem new file mode 100644 index 000000000..9791bc5bb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/precert/testdata/good/precert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgISA9kcPSK0BPIN88FjHCLhdUuNMA0GCSqGSIb3DQEBCwUAMDIxCzAJBgNVBAYT +AlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJSMzAeFw0yMzA2MTUxNTAxNDRaFw0y +MzA5MTMxNTAxNDNaMCIxIDAeBgNVBAMTF2hvdXNldHJhaW5pbmdwdXBweS5pbmZvMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr/XUbBzyFKRMJ0vYSpqw4Wy2Y2eV+vSCix5TcGNxTR9tB9EX+hNd +C7/zlKJAGUj9ZTSfbJO27HvleVN3D5idhIFxfP2tdfAp4OxQkf4a4nqKXZzPJpTlDs2LQNjKcwszaxKY +CMzGThieeBm7jUiWL6fuAX+sCsBIO0frJ9klq77f7NplfwJ3FcKWFyvMo71rtFZCoLt7dfgKim+SBGYn +agfNe8mmxy4ipqvWtGzMO3cdcKdiRijMzZG1upRjhoggHI/vS2JkWP4bNoZdGCAvaxriEoBdS5K9LqHQ +P6GurVXM5B3kuJkMBN+OmnrXxvcnWbYY6JwAO3KZ1+Vbi2ryPQIDAQABo4IBJjCCASIwDgYDVR0PAQH/ +BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW +BBQmE8zNXgf+dOmQ3kFb3p4xfznLjTAfBgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTCxjBVBggr +BgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYW +aHR0cDovL3IzLmkubGVuY3Iub3JnLzAiBgNVHREEGzAZghdob3VzZXRyYWluaW5ncHVwcHkuaW5mbzAT +BgNVHSAEDDAKMAgGBmeBDAECATATBgorBgEEAdZ5AgQDAQH/BAIFADANBgkqhkiG9w0BAQsFAAOCAQEA +n8r5gDWJjoEEE9+hmk/61EleSVQA9SslR7deQnCrItdSOZQo877FJfWtfoRZNItcOfml9E7uYjXhzEOc +bVRe9+VbBt1jjUUu3xLLM7RA5+2pvb+cN1LJ2ijIsnkJwSgYhudGPx+1EgKEJ2huKQTVXqu8AT6rp9Tr +vs/3gXzqlVncXcfEb+5PjvcibCugdt9pE5BfRYBP5V2GcwOQs3zr2DShPuSPmXiLSoUxVczltfndPfM+ +WYaj5VOkvW5UNsm+IVPRlEcbHGmHwEHkBeBGHn4kvgv/14fKpEClkZ+VxgnRky6x951NDMVEJLdV9Vbs +G04Vh0wRjRyiuTPyT5Zj3g== +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/privatekey/privatekey.go b/third-party/github.com/letsencrypt/boulder/privatekey/privatekey.go new file mode 100644 index 000000000..912ce8f6a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/privatekey/privatekey.go @@ -0,0 +1,130 @@ +package privatekey + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "hash" + "os" +) + +func makeVerifyHash() (hash.Hash, error) { + randBytes := make([]byte, 32) + _, err := rand.Read(randBytes) + if err != nil { + return nil, err + } + + hash := sha256.New() + _, err = hash.Write(randBytes) + if err != nil { + return nil, err + } + return hash, nil +} + +// verifyRSA is broken out of Verify for testing purposes. +func verifyRSA(privKey *rsa.PrivateKey, pubKey *rsa.PublicKey, msgHash hash.Hash) (crypto.Signer, crypto.PublicKey, error) { + signatureRSA, err := rsa.SignPSS(rand.Reader, privKey, crypto.SHA256, msgHash.Sum(nil), nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign using the provided RSA private key: %s", err) + } + + err = rsa.VerifyPSS(pubKey, crypto.SHA256, msgHash.Sum(nil), signatureRSA, nil) + if err != nil { + return nil, nil, fmt.Errorf("the provided RSA private key failed signature verification: %s", err) + } + return privKey, privKey.Public(), nil +} + +// verifyECDSA is broken out of Verify for testing purposes. +func verifyECDSA(privKey *ecdsa.PrivateKey, pubKey *ecdsa.PublicKey, msgHash hash.Hash) (crypto.Signer, crypto.PublicKey, error) { + r, s, err := ecdsa.Sign(rand.Reader, privKey, msgHash.Sum(nil)) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign using the provided ECDSA private key: %s", err) + } + + verify := ecdsa.Verify(pubKey, msgHash.Sum(nil), r, s) + if !verify { + return nil, nil, errors.New("the provided ECDSA private key failed signature verification") + } + return privKey, privKey.Public(), nil +} + +// verify ensures that the embedded PublicKey of the provided privateKey is +// actually a match for the private key. For an example of private keys +// embedding a mismatched public key, see: +// https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html. +func verify(privateKey crypto.Signer) (crypto.Signer, crypto.PublicKey, error) { + verifyHash, err := makeVerifyHash() + if err != nil { + return nil, nil, err + } + + switch k := privateKey.(type) { + case *rsa.PrivateKey: + return verifyRSA(k, &k.PublicKey, verifyHash) + + case *ecdsa.PrivateKey: + return verifyECDSA(k, &k.PublicKey, verifyHash) + + default: + // This should never happen. + return nil, nil, errors.New("the provided private key could not be asserted to ECDSA or RSA") + } +} + +// Load decodes and parses a private key from the provided file path and returns +// the private key as crypto.Signer. keyPath is expected to be a PEM formatted +// RSA or ECDSA private key in a PKCS #1, PKCS# 8, or SEC 1 container. The +// embedded PublicKey of the provided private key will be verified as an actual +// match for the private key and returned as a crypto.PublicKey. This function +// is only intended for use in administrative tooling and tests. +func Load(keyPath string) (crypto.Signer, crypto.PublicKey, error) { + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("could not read key file %q", keyPath) + } + + var keyDER *pem.Block + for { + keyDER, keyBytes = pem.Decode(keyBytes) + if keyDER == nil || keyDER.Type != "EC PARAMETERS" { + break + } + } + if keyDER == nil { + return nil, nil, fmt.Errorf("no PEM formatted block found in %q", keyPath) + } + + // Attempt to parse the PEM block as a private key in a PKCS #8 container. + signer, err := x509.ParsePKCS8PrivateKey(keyDER.Bytes) + if err == nil { + cryptoSigner, ok := signer.(crypto.Signer) + if ok { + return verify(cryptoSigner) + } + } + + // Attempt to parse the PEM block as a private key in a PKCS #1 container. + rsaSigner, err := x509.ParsePKCS1PrivateKey(keyDER.Bytes) + if err != nil && keyDER.Type == "RSA PRIVATE KEY" { + return nil, nil, fmt.Errorf("unable to parse %q as a PKCS#1 RSA private key: %w", keyPath, err) + } + if err == nil { + return verify(rsaSigner) + } + + // Attempt to parse the PEM block as a private key in a SEC 1 container. + ecdsaSigner, err := x509.ParseECPrivateKey(keyDER.Bytes) + if err == nil { + return verify(ecdsaSigner) + } + return nil, nil, fmt.Errorf("unable to parse %q as a private key", keyPath) +} diff --git a/third-party/github.com/letsencrypt/boulder/privatekey/privatekey_test.go b/third-party/github.com/letsencrypt/boulder/privatekey/privatekey_test.go new file mode 100644 index 000000000..bcc2ecf38 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/privatekey/privatekey_test.go @@ -0,0 +1,62 @@ +package privatekey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestVerifyRSAKeyPair(t *testing.T) { + privKey1, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Failed while generating test key 1") + + _, _, err = verify(privKey1) + test.AssertNotError(t, err, "Failed to verify valid key") + + privKey2, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Failed while generating test key 2") + + verifyHash, err := makeVerifyHash() + test.AssertNotError(t, err, "Failed to make verify hash: %s") + + _, _, err = verifyRSA(privKey1, &privKey2.PublicKey, verifyHash) + test.AssertError(t, err, "Failed to detect invalid key pair") +} + +func TestVerifyECDSAKeyPair(t *testing.T) { + privKey1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Failed while generating test key 1") + + _, _, err = verify(privKey1) + test.AssertNotError(t, err, "Failed to verify valid key") + + privKey2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Failed while generating test key 2") + + verifyHash, err := makeVerifyHash() + test.AssertNotError(t, err, "Failed to make verify hash: %s") + + _, _, err = verifyECDSA(privKey1, &privKey2.PublicKey, verifyHash) + test.AssertError(t, err, "Failed to detect invalid key pair") +} + +func TestLoad(t *testing.T) { + signer, public, err := Load("../test/hierarchy/ee-e1.key.pem") + test.AssertNotError(t, err, "Failed to load a valid ECDSA key file") + test.AssertNotNil(t, signer, "Signer should not be Nil") + test.AssertNotNil(t, public, "Public should not be Nil") + + signer, public, err = Load("../test/hierarchy/ee-r3.key.pem") + test.AssertNotError(t, err, "Failed to load a valid RSA key file") + test.AssertNotNil(t, signer, "Signer should not be Nil") + test.AssertNotNil(t, public, "Public should not be Nil") + + signer, public, err = Load("../test/hierarchy/ee-e1.cert.pem") + test.AssertError(t, err, "Should have failed, file is a certificate") + test.AssertNil(t, signer, "Signer should be nil") + test.AssertNil(t, public, "Public should be nil") +} diff --git a/third-party/github.com/letsencrypt/boulder/probs/probs.go b/third-party/github.com/letsencrypt/boulder/probs/probs.go new file mode 100644 index 000000000..ec6c272ae --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/probs/probs.go @@ -0,0 +1,343 @@ +package probs + +import ( + "fmt" + "net/http" + + "github.com/letsencrypt/boulder/identifier" +) + +const ( + // Error types that can be used in ACME payloads. These are sorted in the + // same order as they are defined in RFC8555 Section 6.7. We do not implement + // the `compound`, `externalAccountRequired`, or `userActionRequired` errors, + // because we have no path that would return them. + AccountDoesNotExistProblem = ProblemType("accountDoesNotExist") + AlreadyRevokedProblem = ProblemType("alreadyRevoked") + BadCSRProblem = ProblemType("badCSR") + BadNonceProblem = ProblemType("badNonce") + BadPublicKeyProblem = ProblemType("badPublicKey") + BadRevocationReasonProblem = ProblemType("badRevocationReason") + BadSignatureAlgorithmProblem = ProblemType("badSignatureAlgorithm") + CAAProblem = ProblemType("caa") + // ConflictProblem is a problem type that is not defined in RFC8555. + ConflictProblem = ProblemType("conflict") + ConnectionProblem = ProblemType("connection") + DNSProblem = ProblemType("dns") + InvalidContactProblem = ProblemType("invalidContact") + MalformedProblem = ProblemType("malformed") + OrderNotReadyProblem = ProblemType("orderNotReady") + RateLimitedProblem = ProblemType("rateLimited") + RejectedIdentifierProblem = ProblemType("rejectedIdentifier") + ServerInternalProblem = ProblemType("serverInternal") + TLSProblem = ProblemType("tls") + UnauthorizedProblem = ProblemType("unauthorized") + UnsupportedContactProblem = ProblemType("unsupportedContact") + UnsupportedIdentifierProblem = ProblemType("unsupportedIdentifier") + + ErrorNS = "urn:ietf:params:acme:error:" +) + +// ProblemType defines the error types in the ACME protocol +type ProblemType string + +// ProblemDetails objects represent problem documents +// https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 +type ProblemDetails struct { + Type ProblemType `json:"type,omitempty"` + Detail string `json:"detail,omitempty"` + // HTTPStatus is the HTTP status code the ProblemDetails should probably be sent + // as. + HTTPStatus int `json:"status,omitempty"` + // SubProblems are optional additional per-identifier problems. See + // RFC 8555 Section 6.7.1: https://tools.ietf.org/html/rfc8555#section-6.7.1 + SubProblems []SubProblemDetails `json:"subproblems,omitempty"` +} + +// SubProblemDetails represents sub-problems specific to an identifier that are +// related to a top-level ProblemDetails. +// See RFC 8555 Section 6.7.1: https://tools.ietf.org/html/rfc8555#section-6.7.1 +type SubProblemDetails struct { + ProblemDetails + Identifier identifier.ACMEIdentifier `json:"identifier"` +} + +func (pd *ProblemDetails) Error() string { + return fmt.Sprintf("%s :: %s", pd.Type, pd.Detail) +} + +// WithSubProblems returns a new ProblemsDetails instance created by adding the +// provided subProbs to the existing ProblemsDetail. +func (pd *ProblemDetails) WithSubProblems(subProbs []SubProblemDetails) *ProblemDetails { + return &ProblemDetails{ + Type: pd.Type, + Detail: pd.Detail, + HTTPStatus: pd.HTTPStatus, + SubProblems: append(pd.SubProblems, subProbs...), + } +} + +// Helper functions which construct the basic RFC8555 Problem Documents, with +// the Type already set and the Details supplied by the caller. + +// AccountDoesNotExist returns a ProblemDetails representing an +// AccountDoesNotExistProblem error +func AccountDoesNotExist(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: AccountDoesNotExistProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// AlreadyRevoked returns a ProblemDetails with a AlreadyRevokedProblem and a 400 Bad +// Request status code. +func AlreadyRevoked(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: AlreadyRevokedProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// BadCSR returns a ProblemDetails representing a BadCSRProblem. +func BadCSR(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: BadCSRProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// BadNonce returns a ProblemDetails with a BadNonceProblem and a 400 Bad +// Request status code. +func BadNonce(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: BadNonceProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// BadPublicKey returns a ProblemDetails with a BadPublicKeyProblem and a 400 Bad +// Request status code. +func BadPublicKey(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: BadPublicKeyProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// BadRevocationReason returns a ProblemDetails representing +// a BadRevocationReasonProblem +func BadRevocationReason(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: BadRevocationReasonProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// BadSignatureAlgorithm returns a ProblemDetails with a BadSignatureAlgorithmProblem +// and a 400 Bad Request status code. +func BadSignatureAlgorithm(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: BadSignatureAlgorithmProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// CAA returns a ProblemDetails representing a CAAProblem +func CAA(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: CAAProblem, + Detail: detail, + HTTPStatus: http.StatusForbidden, + } +} + +// Connection returns a ProblemDetails representing a ConnectionProblem +// error +func Connection(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: ConnectionProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// DNS returns a ProblemDetails representing a DNSProblem +func DNS(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: DNSProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// InvalidContact returns a ProblemDetails representing an InvalidContactProblem. +func InvalidContact(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: InvalidContactProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// Malformed returns a ProblemDetails with a MalformedProblem and a 400 Bad +// Request status code. +func Malformed(detail string, a ...any) *ProblemDetails { + if len(a) > 0 { + detail = fmt.Sprintf(detail, a...) + } + return &ProblemDetails{ + Type: MalformedProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// OrderNotReady returns a ProblemDetails representing a OrderNotReadyProblem +func OrderNotReady(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: OrderNotReadyProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusForbidden, + } +} + +// RateLimited returns a ProblemDetails representing a RateLimitedProblem error +func RateLimited(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: RateLimitedProblem, + Detail: detail, + HTTPStatus: http.StatusTooManyRequests, + } +} + +// RejectedIdentifier returns a ProblemDetails with a RejectedIdentifierProblem and a 400 Bad +// Request status code. +func RejectedIdentifier(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: RejectedIdentifierProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// ServerInternal returns a ProblemDetails with a ServerInternalProblem and a +// 500 Internal Server Failure status code. +func ServerInternal(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: ServerInternalProblem, + Detail: detail, + HTTPStatus: http.StatusInternalServerError, + } +} + +// TLS returns a ProblemDetails representing a TLSProblem error +func TLS(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: TLSProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// Unauthorized returns a ProblemDetails with an UnauthorizedProblem and a 403 +// Forbidden status code. +func Unauthorized(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: UnauthorizedProblem, + Detail: detail, + HTTPStatus: http.StatusForbidden, + } +} + +// UnsupportedContact returns a ProblemDetails representing an +// UnsupportedContactProblem +func UnsupportedContact(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: UnsupportedContactProblem, + Detail: detail, + HTTPStatus: http.StatusBadRequest, + } +} + +// UnsupportedIdentifier returns a ProblemDetails representing an +// UnsupportedIdentifierProblem +func UnsupportedIdentifier(detail string, a ...any) *ProblemDetails { + return &ProblemDetails{ + Type: UnsupportedIdentifierProblem, + Detail: fmt.Sprintf(detail, a...), + HTTPStatus: http.StatusBadRequest, + } +} + +// Additional helper functions that return variations on MalformedProblem with +// different HTTP status codes set. + +// Canceled returns a ProblemDetails with a MalformedProblem and a 408 Request +// Timeout status code. +func Canceled(detail string, a ...any) *ProblemDetails { + if len(a) > 0 { + detail = fmt.Sprintf(detail, a...) + } + return &ProblemDetails{ + Type: MalformedProblem, + Detail: detail, + HTTPStatus: http.StatusRequestTimeout, + } +} + +// Conflict returns a ProblemDetails with a ConflictProblem and a 409 Conflict +// status code. +func Conflict(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: ConflictProblem, + Detail: detail, + HTTPStatus: http.StatusConflict, + } +} + +// ContentLengthRequired returns a ProblemDetails representing a missing +// Content-Length header error +func ContentLengthRequired() *ProblemDetails { + return &ProblemDetails{ + Type: MalformedProblem, + Detail: "missing Content-Length header", + HTTPStatus: http.StatusLengthRequired, + } +} + +// InvalidContentType returns a ProblemDetails suitable for a missing +// ContentType header, or an incorrect ContentType header +func InvalidContentType(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: MalformedProblem, + Detail: detail, + HTTPStatus: http.StatusUnsupportedMediaType, + } +} + +// MethodNotAllowed returns a ProblemDetails representing a disallowed HTTP +// method error. +func MethodNotAllowed() *ProblemDetails { + return &ProblemDetails{ + Type: MalformedProblem, + Detail: "Method not allowed", + HTTPStatus: http.StatusMethodNotAllowed, + } +} + +// NotFound returns a ProblemDetails with a MalformedProblem and a 404 Not Found +// status code. +func NotFound(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: MalformedProblem, + Detail: detail, + HTTPStatus: http.StatusNotFound, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/probs/probs_test.go b/third-party/github.com/letsencrypt/boulder/probs/probs_test.go new file mode 100644 index 000000000..af00e899f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/probs/probs_test.go @@ -0,0 +1,104 @@ +package probs + +import ( + "testing" + + "net/http" + + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/test" +) + +func TestProblemDetails(t *testing.T) { + pd := &ProblemDetails{ + Type: MalformedProblem, + Detail: "Wat? o.O", + HTTPStatus: 403, + } + test.AssertEquals(t, pd.Error(), "malformed :: Wat? o.O") +} + +func TestProblemDetailsConvenience(t *testing.T) { + testCases := []struct { + pb *ProblemDetails + expectedType ProblemType + statusCode int + detail string + }{ + {InvalidContact("invalid email detail"), InvalidContactProblem, http.StatusBadRequest, "invalid email detail"}, + {Connection("connection failure detail"), ConnectionProblem, http.StatusBadRequest, "connection failure detail"}, + {Malformed("malformed detail"), MalformedProblem, http.StatusBadRequest, "malformed detail"}, + {ServerInternal("internal error detail"), ServerInternalProblem, http.StatusInternalServerError, "internal error detail"}, + {Unauthorized("unauthorized detail"), UnauthorizedProblem, http.StatusForbidden, "unauthorized detail"}, + {RateLimited("rate limited detail"), RateLimitedProblem, http.StatusTooManyRequests, "rate limited detail"}, + {BadNonce("bad nonce detail"), BadNonceProblem, http.StatusBadRequest, "bad nonce detail"}, + {TLS("TLS error detail"), TLSProblem, http.StatusBadRequest, "TLS error detail"}, + {RejectedIdentifier("rejected identifier detail"), RejectedIdentifierProblem, http.StatusBadRequest, "rejected identifier detail"}, + {AccountDoesNotExist("no account detail"), AccountDoesNotExistProblem, http.StatusBadRequest, "no account detail"}, + {BadRevocationReason("only reason xxx is supported"), BadRevocationReasonProblem, http.StatusBadRequest, "only reason xxx is supported"}, + } + + for _, c := range testCases { + if c.pb.Type != c.expectedType { + t.Errorf("Incorrect problem type. Expected %s got %s", c.expectedType, c.pb.Type) + } + + if c.pb.HTTPStatus != c.statusCode { + t.Errorf("Incorrect HTTP Status. Expected %d got %d", c.statusCode, c.pb.HTTPStatus) + } + + if c.pb.Detail != c.detail { + t.Errorf("Incorrect detail message. Expected %s got %s", c.detail, c.pb.Detail) + } + + if subProbLen := len(c.pb.SubProblems); subProbLen != 0 { + t.Errorf("Incorrect SubProblems. Expected 0, found %d", subProbLen) + } + } +} + +// TestWithSubProblems tests that a new problem can be constructed by adding +// subproblems. +func TestWithSubProblems(t *testing.T) { + topProb := &ProblemDetails{ + Type: RateLimitedProblem, + Detail: "don't you think you have enough certificates already?", + HTTPStatus: http.StatusTooManyRequests, + } + subProbs := []SubProblemDetails{ + { + Identifier: identifier.DNSIdentifier("example.com"), + ProblemDetails: ProblemDetails{ + Type: RateLimitedProblem, + Detail: "don't you think you have enough certificates already?", + HTTPStatus: http.StatusTooManyRequests, + }, + }, + { + Identifier: identifier.DNSIdentifier("what about example.com"), + ProblemDetails: ProblemDetails{ + Type: MalformedProblem, + Detail: "try a real identifier value next time", + HTTPStatus: http.StatusConflict, + }, + }, + } + + outResult := topProb.WithSubProblems(subProbs) + + // The outResult should be a new, distinct problem details instance + test.AssertNotEquals(t, topProb, outResult) + // The outResult problem details should have the correct sub problems + test.AssertDeepEquals(t, outResult.SubProblems, subProbs) + // Adding another sub problem shouldn't squash the original sub problems + anotherSubProb := SubProblemDetails{ + Identifier: identifier.DNSIdentifier("another ident"), + ProblemDetails: ProblemDetails{ + Type: RateLimitedProblem, + Detail: "yet another rate limit err", + HTTPStatus: http.StatusTooManyRequests, + }, + } + outResult = outResult.WithSubProblems([]SubProblemDetails{anotherSubProb}) + test.AssertDeepEquals(t, outResult.SubProblems, append(subProbs, anotherSubProb)) +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.pb.go b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.pb.go new file mode 100644 index 000000000..9705dea9a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.pb.go @@ -0,0 +1,301 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: publisher.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SubmissionType int32 + +const ( + SubmissionType_unknown SubmissionType = 0 + SubmissionType_sct SubmissionType = 1 // Submitting a precert with the intent of getting SCTs + SubmissionType_info SubmissionType = 2 // Submitting a precert on a best-effort basis + SubmissionType_final SubmissionType = 3 // Submitting a final cert on a best-effort basis +) + +// Enum value maps for SubmissionType. +var ( + SubmissionType_name = map[int32]string{ + 0: "unknown", + 1: "sct", + 2: "info", + 3: "final", + } + SubmissionType_value = map[string]int32{ + "unknown": 0, + "sct": 1, + "info": 2, + "final": 3, + } +) + +func (x SubmissionType) Enum() *SubmissionType { + p := new(SubmissionType) + *p = x + return p +} + +func (x SubmissionType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SubmissionType) Descriptor() protoreflect.EnumDescriptor { + return file_publisher_proto_enumTypes[0].Descriptor() +} + +func (SubmissionType) Type() protoreflect.EnumType { + return &file_publisher_proto_enumTypes[0] +} + +func (x SubmissionType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SubmissionType.Descriptor instead. +func (SubmissionType) EnumDescriptor() ([]byte, []int) { + return file_publisher_proto_rawDescGZIP(), []int{0} +} + +type Request struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Der []byte `protobuf:"bytes,1,opt,name=der,proto3" json:"der,omitempty"` + LogURL string `protobuf:"bytes,2,opt,name=LogURL,proto3" json:"LogURL,omitempty"` + LogPublicKey string `protobuf:"bytes,3,opt,name=LogPublicKey,proto3" json:"LogPublicKey,omitempty"` + Kind SubmissionType `protobuf:"varint,5,opt,name=kind,proto3,enum=SubmissionType" json:"kind,omitempty"` +} + +func (x *Request) Reset() { + *x = Request{} + if protoimpl.UnsafeEnabled { + mi := &file_publisher_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_publisher_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_publisher_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetDer() []byte { + if x != nil { + return x.Der + } + return nil +} + +func (x *Request) GetLogURL() string { + if x != nil { + return x.LogURL + } + return "" +} + +func (x *Request) GetLogPublicKey() string { + if x != nil { + return x.LogPublicKey + } + return "" +} + +func (x *Request) GetKind() SubmissionType { + if x != nil { + return x.Kind + } + return SubmissionType_unknown +} + +type Result struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sct []byte `protobuf:"bytes,1,opt,name=sct,proto3" json:"sct,omitempty"` +} + +func (x *Result) Reset() { + *x = Result{} + if protoimpl.UnsafeEnabled { + mi := &file_publisher_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Result) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Result) ProtoMessage() {} + +func (x *Result) ProtoReflect() protoreflect.Message { + mi := &file_publisher_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Result.ProtoReflect.Descriptor instead. +func (*Result) Descriptor() ([]byte, []int) { + return file_publisher_proto_rawDescGZIP(), []int{1} +} + +func (x *Result) GetSct() []byte { + if x != nil { + return x.Sct + } + return nil +} + +var File_publisher_proto protoreflect.FileDescriptor + +var file_publisher_proto_rawDesc = []byte{ + 0x0a, 0x0f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x22, 0x82, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x64, 0x65, 0x72, 0x12, + 0x16, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x55, 0x52, 0x4c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x4c, 0x6f, 0x67, 0x55, 0x52, 0x4c, 0x12, 0x22, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x50, 0x75, + 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4c, + 0x6f, 0x67, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x23, 0x0a, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x53, 0x75, 0x62, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, + 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x1a, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x12, 0x10, 0x0a, 0x03, 0x73, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x73, + 0x63, 0x74, 0x2a, 0x3b, 0x0a, 0x0e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, + 0x00, 0x12, 0x07, 0x0a, 0x03, 0x73, 0x63, 0x74, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x69, 0x6e, + 0x66, 0x6f, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x10, 0x03, 0x32, + 0x3e, 0x0a, 0x09, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x12, 0x31, 0x0a, 0x1a, + 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x54, 0x6f, 0x53, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x43, 0x54, + 0x57, 0x69, 0x74, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x08, 0x2e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x07, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x00, 0x42, + 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, + 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, + 0x72, 0x2f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_publisher_proto_rawDescOnce sync.Once + file_publisher_proto_rawDescData = file_publisher_proto_rawDesc +) + +func file_publisher_proto_rawDescGZIP() []byte { + file_publisher_proto_rawDescOnce.Do(func() { + file_publisher_proto_rawDescData = protoimpl.X.CompressGZIP(file_publisher_proto_rawDescData) + }) + return file_publisher_proto_rawDescData +} + +var file_publisher_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_publisher_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_publisher_proto_goTypes = []interface{}{ + (SubmissionType)(0), // 0: SubmissionType + (*Request)(nil), // 1: Request + (*Result)(nil), // 2: Result +} +var file_publisher_proto_depIdxs = []int32{ + 0, // 0: Request.kind:type_name -> SubmissionType + 1, // 1: Publisher.SubmitToSingleCTWithResult:input_type -> Request + 2, // 2: Publisher.SubmitToSingleCTWithResult:output_type -> Result + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_publisher_proto_init() } +func file_publisher_proto_init() { + if File_publisher_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_publisher_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Request); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_publisher_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Result); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_publisher_proto_rawDesc, + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_publisher_proto_goTypes, + DependencyIndexes: file_publisher_proto_depIdxs, + EnumInfos: file_publisher_proto_enumTypes, + MessageInfos: file_publisher_proto_msgTypes, + }.Build() + File_publisher_proto = out.File + file_publisher_proto_rawDesc = nil + file_publisher_proto_goTypes = nil + file_publisher_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.proto b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.proto new file mode 100644 index 000000000..b155afdc4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +option go_package = "github.com/letsencrypt/boulder/publisher/proto"; + +service Publisher { + rpc SubmitToSingleCTWithResult(Request) returns (Result) {} +} + +enum SubmissionType { + unknown = 0; + sct = 1; // Submitting a precert with the intent of getting SCTs + info = 2; // Submitting a precert on a best-effort basis + final = 3; // Submitting a final cert on a best-effort basis +} + +message Request { + bytes der = 1; + string LogURL = 2; + string LogPublicKey = 3; + reserved 4; // Previously precert + SubmissionType kind = 5; +} + +message Result { + bytes sct = 1; +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher_grpc.pb.go new file mode 100644 index 000000000..0c91e6fb5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/proto/publisher_grpc.pb.go @@ -0,0 +1,110 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: publisher.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Publisher_SubmitToSingleCTWithResult_FullMethodName = "/Publisher/SubmitToSingleCTWithResult" +) + +// PublisherClient is the client API for Publisher service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type PublisherClient interface { + SubmitToSingleCTWithResult(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Result, error) +} + +type publisherClient struct { + cc grpc.ClientConnInterface +} + +func NewPublisherClient(cc grpc.ClientConnInterface) PublisherClient { + return &publisherClient{cc} +} + +func (c *publisherClient) SubmitToSingleCTWithResult(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Result, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Result) + err := c.cc.Invoke(ctx, Publisher_SubmitToSingleCTWithResult_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PublisherServer is the server API for Publisher service. +// All implementations must embed UnimplementedPublisherServer +// for forward compatibility +type PublisherServer interface { + SubmitToSingleCTWithResult(context.Context, *Request) (*Result, error) + mustEmbedUnimplementedPublisherServer() +} + +// UnimplementedPublisherServer must be embedded to have forward compatible implementations. +type UnimplementedPublisherServer struct { +} + +func (UnimplementedPublisherServer) SubmitToSingleCTWithResult(context.Context, *Request) (*Result, error) { + return nil, status.Errorf(codes.Unimplemented, "method SubmitToSingleCTWithResult not implemented") +} +func (UnimplementedPublisherServer) mustEmbedUnimplementedPublisherServer() {} + +// UnsafePublisherServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PublisherServer will +// result in compilation errors. +type UnsafePublisherServer interface { + mustEmbedUnimplementedPublisherServer() +} + +func RegisterPublisherServer(s grpc.ServiceRegistrar, srv PublisherServer) { + s.RegisterService(&Publisher_ServiceDesc, srv) +} + +func _Publisher_SubmitToSingleCTWithResult_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PublisherServer).SubmitToSingleCTWithResult(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Publisher_SubmitToSingleCTWithResult_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PublisherServer).SubmitToSingleCTWithResult(ctx, req.(*Request)) + } + return interceptor(ctx, in, info, handler) +} + +// Publisher_ServiceDesc is the grpc.ServiceDesc for Publisher service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Publisher_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "Publisher", + HandlerType: (*PublisherServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SubmitToSingleCTWithResult", + Handler: _Publisher_SubmitToSingleCTWithResult_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "publisher.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/publisher.go b/third-party/github.com/letsencrypt/boulder/publisher/publisher.go new file mode 100644 index 000000000..7e43a56f6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/publisher.go @@ -0,0 +1,414 @@ +package publisher + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + ct "github.com/google/certificate-transparency-go" + ctClient "github.com/google/certificate-transparency-go/client" + "github.com/google/certificate-transparency-go/jsonclient" + cttls "github.com/google/certificate-transparency-go/tls" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/canceled" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + pubpb "github.com/letsencrypt/boulder/publisher/proto" +) + +// Log contains the CT client for a particular CT log +type Log struct { + logID string + uri string + client *ctClient.LogClient +} + +// logCache contains a cache of *Log's that are constructed as required by +// `SubmitToSingleCT` +type logCache struct { + sync.RWMutex + logs map[string]*Log +} + +// AddLog adds a *Log to the cache by constructing the statName, client and +// verifier for the given uri & base64 public key. +func (c *logCache) AddLog(uri, b64PK, userAgent string, logger blog.Logger) (*Log, error) { + // Lock the mutex for reading to check the cache + c.RLock() + log, present := c.logs[b64PK] + c.RUnlock() + + // If we have already added this log, give it back + if present { + return log, nil + } + + // Lock the mutex for writing to add to the cache + c.Lock() + defer c.Unlock() + + // Construct a Log, add it to the cache, and return it to the caller + log, err := NewLog(uri, b64PK, userAgent, logger) + if err != nil { + return nil, err + } + c.logs[b64PK] = log + return log, nil +} + +// Len returns the number of logs in the logCache +func (c *logCache) Len() int { + c.RLock() + defer c.RUnlock() + return len(c.logs) +} + +type logAdaptor struct { + blog.Logger +} + +func (la logAdaptor) Printf(s string, args ...interface{}) { + la.Logger.Infof(s, args...) +} + +// NewLog returns an initialized Log struct +func NewLog(uri, b64PK, userAgent string, logger blog.Logger) (*Log, error) { + url, err := url.Parse(uri) + if err != nil { + return nil, err + } + url.Path = strings.TrimSuffix(url.Path, "/") + + derPK, err := base64.StdEncoding.DecodeString(b64PK) + if err != nil { + return nil, err + } + + opts := jsonclient.Options{ + Logger: logAdaptor{logger}, + PublicKeyDER: derPK, + UserAgent: userAgent, + } + httpClient := &http.Client{ + // We set the HTTP client timeout to about half of what we expect + // the gRPC timeout to be set to. This allows us to retry the + // request at least twice in the case where the server we are + // talking to is simply hanging indefinitely. + Timeout: time.Minute*2 + time.Second*30, + // We provide a new Transport for each Client so that different logs don't + // share a connection pool. This shouldn't matter, but we occasionally see a + // strange bug where submission to all logs hangs for about fifteen minutes. + // One possibility is that there is a strange bug in the locking on + // connection pools (possibly triggered by timed-out TCP connections). If + // that's the case, separate connection pools should prevent cross-log impact. + // We set some fields like TLSHandshakeTimeout to the values from + // DefaultTransport because the zero value for these fields means + // "unlimited," which would be bad. + Transport: &http.Transport{ + MaxIdleConns: http.DefaultTransport.(*http.Transport).MaxIdleConns, + MaxIdleConnsPerHost: http.DefaultTransport.(*http.Transport).MaxIdleConns, + IdleConnTimeout: http.DefaultTransport.(*http.Transport).IdleConnTimeout, + TLSHandshakeTimeout: http.DefaultTransport.(*http.Transport).TLSHandshakeTimeout, + // In Boulder Issue 3821[0] we found that HTTP/2 support was causing hard + // to diagnose intermittent freezes in CT submission. Disabling HTTP/2 with + // an environment variable resolved the freezes but is not a stable fix. + // + // Per the Go `http` package docs we can make this change persistent by + // changing the `http.Transport` config: + // "Programs that must disable HTTP/2 can do so by setting + // Transport.TLSNextProto (for clients) or Server.TLSNextProto (for + // servers) to a non-nil, empty map" + // + // [0]: https://github.com/letsencrypt/boulder/issues/3821 + TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, + }, + } + client, err := ctClient.New(url.String(), httpClient, opts) + if err != nil { + return nil, fmt.Errorf("making CT client: %s", err) + } + + return &Log{ + logID: b64PK, + uri: url.String(), + client: client, + }, nil +} + +type ctSubmissionRequest struct { + Chain []string `json:"chain"` +} + +type pubMetrics struct { + submissionLatency *prometheus.HistogramVec + probeLatency *prometheus.HistogramVec + errorCount *prometheus.CounterVec +} + +func initMetrics(stats prometheus.Registerer) *pubMetrics { + submissionLatency := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ct_submission_time_seconds", + Help: "Time taken to submit a certificate to a CT log", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"log", "type", "status", "http_status"}, + ) + stats.MustRegister(submissionLatency) + + probeLatency := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ct_probe_time_seconds", + Help: "Time taken to probe a CT log", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"log", "status"}, + ) + stats.MustRegister(probeLatency) + + errorCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ct_errors_count", + Help: "Count of errors by type", + }, + []string{"log", "type"}, + ) + stats.MustRegister(errorCount) + + return &pubMetrics{submissionLatency, probeLatency, errorCount} +} + +// Impl defines a Publisher +type Impl struct { + pubpb.UnsafePublisherServer + log blog.Logger + userAgent string + issuerBundles map[issuance.NameID][]ct.ASN1Cert + ctLogsCache logCache + metrics *pubMetrics +} + +var _ pubpb.PublisherServer = (*Impl)(nil) + +// New creates a Publisher that will submit certificates +// to requested CT logs +func New( + bundles map[issuance.NameID][]ct.ASN1Cert, + userAgent string, + logger blog.Logger, + stats prometheus.Registerer, +) *Impl { + return &Impl{ + issuerBundles: bundles, + userAgent: userAgent, + ctLogsCache: logCache{ + logs: make(map[string]*Log), + }, + log: logger, + metrics: initMetrics(stats), + } +} + +// SubmitToSingleCTWithResult will submit the certificate represented by certDER +// to the CT log specified by log URL and public key (base64) and return the SCT +// to the caller. +func (pub *Impl) SubmitToSingleCTWithResult(ctx context.Context, req *pubpb.Request) (*pubpb.Result, error) { + if core.IsAnyNilOrZero(req.Der, req.LogURL, req.LogPublicKey, req.Kind) { + return nil, errors.New("incomplete gRPC request message") + } + + cert, err := x509.ParseCertificate(req.Der) + if err != nil { + pub.log.AuditErrf("Failed to parse certificate: %s", err) + return nil, err + } + + chain := []ct.ASN1Cert{{Data: req.Der}} + id := issuance.IssuerNameID(cert) + issuerBundle, ok := pub.issuerBundles[id] + if !ok { + err := fmt.Errorf("No issuerBundle matching issuerNameID: %d", int64(id)) + pub.log.AuditErrf("Failed to submit certificate to CT log: %s", err) + return nil, err + } + chain = append(chain, issuerBundle...) + + // Add a log URL/pubkey to the cache, if already present the + // existing *Log will be returned, otherwise one will be constructed, added + // and returned. + ctLog, err := pub.ctLogsCache.AddLog(req.LogURL, req.LogPublicKey, pub.userAgent, pub.log) + if err != nil { + pub.log.AuditErrf("Making Log: %s", err) + return nil, err + } + + sct, err := pub.singleLogSubmit(ctx, chain, req.Kind, ctLog) + if err != nil { + if canceled.Is(err) { + return nil, err + } + var body string + var rspErr jsonclient.RspError + if errors.As(err, &rspErr) && rspErr.StatusCode < 500 { + body = string(rspErr.Body) + } + pub.log.AuditErrf("Failed to submit certificate to CT log at %s: %s Body=%q", + ctLog.uri, err, body) + return nil, err + } + + sctBytes, err := cttls.Marshal(*sct) + if err != nil { + return nil, err + } + return &pubpb.Result{Sct: sctBytes}, nil +} + +func (pub *Impl) singleLogSubmit( + ctx context.Context, + chain []ct.ASN1Cert, + kind pubpb.SubmissionType, + ctLog *Log, +) (*ct.SignedCertificateTimestamp, error) { + submissionMethod := ctLog.client.AddChain + if kind == pubpb.SubmissionType_sct || kind == pubpb.SubmissionType_info { + submissionMethod = ctLog.client.AddPreChain + } + + start := time.Now() + sct, err := submissionMethod(ctx, chain) + took := time.Since(start).Seconds() + if err != nil { + status := "error" + if canceled.Is(err) { + status = "canceled" + } + httpStatus := "" + var rspError ctClient.RspError + if errors.As(err, &rspError) && rspError.StatusCode != 0 { + httpStatus = fmt.Sprintf("%d", rspError.StatusCode) + } + pub.metrics.submissionLatency.With(prometheus.Labels{ + "log": ctLog.uri, + "type": kind.String(), + "status": status, + "http_status": httpStatus, + }).Observe(took) + pub.metrics.errorCount.With(prometheus.Labels{ + "log": ctLog.uri, + "type": kind.String(), + }).Inc() + return nil, err + } + pub.metrics.submissionLatency.With(prometheus.Labels{ + "log": ctLog.uri, + "type": kind.String(), + "status": "success", + "http_status": "", + }).Observe(took) + + timestamp := time.Unix(int64(sct.Timestamp)/1000, 0) + if time.Until(timestamp) > time.Minute { + return nil, fmt.Errorf("SCT Timestamp was too far in the future (%s)", timestamp) + } + + // For regular certificates, we could get an old SCT, but that shouldn't + // happen for precertificates. + if kind != pubpb.SubmissionType_final && time.Until(timestamp) < -10*time.Minute { + return nil, fmt.Errorf("SCT Timestamp was too far in the past (%s)", timestamp) + } + + return sct, nil +} + +// CreateTestingSignedSCT is used by both the publisher tests and ct-test-serv, which is +// why it is exported. It creates a signed SCT based on the provided chain. +func CreateTestingSignedSCT(req []string, k *ecdsa.PrivateKey, precert bool, timestamp time.Time) []byte { + chain := make([]ct.ASN1Cert, len(req)) + for i, str := range req { + b, err := base64.StdEncoding.DecodeString(str) + if err != nil { + panic("cannot decode chain") + } + chain[i] = ct.ASN1Cert{Data: b} + } + + // Generate the internal leaf entry for the SCT + etype := ct.X509LogEntryType + if precert { + etype = ct.PrecertLogEntryType + } + leaf, err := ct.MerkleTreeLeafFromRawChain(chain, etype, 0) + if err != nil { + panic(fmt.Sprintf("failed to create leaf: %s", err)) + } + + // Sign the SCT + rawKey, _ := x509.MarshalPKIXPublicKey(&k.PublicKey) + logID := sha256.Sum256(rawKey) + timestampMillis := uint64(timestamp.UnixNano()) / 1e6 + serialized, _ := ct.SerializeSCTSignatureInput(ct.SignedCertificateTimestamp{ + SCTVersion: ct.V1, + LogID: ct.LogID{KeyID: logID}, + Timestamp: timestampMillis, + }, ct.LogEntry{Leaf: *leaf}) + hashed := sha256.Sum256(serialized) + var ecdsaSig struct { + R, S *big.Int + } + ecdsaSig.R, ecdsaSig.S, _ = ecdsa.Sign(rand.Reader, k, hashed[:]) + sig, _ := asn1.Marshal(ecdsaSig) + + // The ct.SignedCertificateTimestamp object doesn't have the needed + // `json` tags to properly marshal so we need to transform in into + // a struct that does before we can send it off + var jsonSCTObj struct { + SCTVersion ct.Version `json:"sct_version"` + ID string `json:"id"` + Timestamp uint64 `json:"timestamp"` + Extensions string `json:"extensions"` + Signature string `json:"signature"` + } + jsonSCTObj.SCTVersion = ct.V1 + jsonSCTObj.ID = base64.StdEncoding.EncodeToString(logID[:]) + jsonSCTObj.Timestamp = timestampMillis + ds := ct.DigitallySigned{ + Algorithm: cttls.SignatureAndHashAlgorithm{ + Hash: cttls.SHA256, + Signature: cttls.ECDSA, + }, + Signature: sig, + } + jsonSCTObj.Signature, _ = ds.Base64String() + + jsonSCT, _ := json.Marshal(jsonSCTObj) + return jsonSCT +} + +// GetCTBundleForChain takes a slice of *issuance.Certificate(s) +// representing a certificate chain and returns a slice of +// ct.ASN1Cert(s) in the same order +func GetCTBundleForChain(chain []*issuance.Certificate) []ct.ASN1Cert { + var ctBundle []ct.ASN1Cert + for _, cert := range chain { + ctBundle = append(ctBundle, ct.ASN1Cert{Data: cert.Raw}) + } + return ctBundle +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/publisher_test.go b/third-party/github.com/letsencrypt/boulder/publisher/publisher_test.go new file mode 100644 index 000000000..3ed5007fc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/publisher_test.go @@ -0,0 +1,474 @@ +package publisher + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + ct "github.com/google/certificate-transparency-go" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + pubpb "github.com/letsencrypt/boulder/publisher/proto" + "github.com/letsencrypt/boulder/test" +) + +var log = blog.UseMock() +var ctx = context.Background() + +func getPort(srvURL string) (int, error) { + url, err := url.Parse(srvURL) + if err != nil { + return 0, err + } + _, portString, err := net.SplitHostPort(url.Host) + if err != nil { + return 0, err + } + port, err := strconv.ParseInt(portString, 10, 64) + if err != nil { + return 0, err + } + return int(port), nil +} + +type testLogSrv struct { + *httptest.Server + submissions int64 +} + +func logSrv(k *ecdsa.PrivateKey) *testLogSrv { + testLog := &testLogSrv{} + m := http.NewServeMux() + m.HandleFunc("/ct/", func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var jsonReq ctSubmissionRequest + err := decoder.Decode(&jsonReq) + if err != nil { + return + } + precert := false + if r.URL.Path == "/ct/v1/add-pre-chain" { + precert = true + } + sct := CreateTestingSignedSCT(jsonReq.Chain, k, precert, time.Now()) + fmt.Fprint(w, string(sct)) + atomic.AddInt64(&testLog.submissions, 1) + }) + + testLog.Server = httptest.NewUnstartedServer(m) + testLog.Server.Start() + return testLog +} + +// lyingLogSrv always signs SCTs with the timestamp it was given. +func lyingLogSrv(k *ecdsa.PrivateKey, timestamp time.Time) *testLogSrv { + testLog := &testLogSrv{} + m := http.NewServeMux() + m.HandleFunc("/ct/", func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var jsonReq ctSubmissionRequest + err := decoder.Decode(&jsonReq) + if err != nil { + return + } + precert := false + if r.URL.Path == "/ct/v1/add-pre-chain" { + precert = true + } + sct := CreateTestingSignedSCT(jsonReq.Chain, k, precert, timestamp) + fmt.Fprint(w, string(sct)) + atomic.AddInt64(&testLog.submissions, 1) + }) + + testLog.Server = httptest.NewUnstartedServer(m) + testLog.Server.Start() + return testLog +} + +func errorBodyLogSrv() *httptest.Server { + m := http.NewServeMux() + m.HandleFunc("/ct/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("well this isn't good now is it.")) + }) + + server := httptest.NewUnstartedServer(m) + server.Start() + return server +} + +func setup(t *testing.T) (*Impl, *x509.Certificate, *ecdsa.PrivateKey) { + // Load chain: R3 <- Root DST + chain1, err := issuance.LoadChain([]string{ + "../test/hierarchy/int-r3-cross.cert.pem", + "../test/hierarchy/root-dst.cert.pem", + }) + test.AssertNotError(t, err, "failed to load chain1.") + + // Load chain: R3 <- Root X1 + chain2, err := issuance.LoadChain([]string{ + "../test/hierarchy/int-r3.cert.pem", + "../test/hierarchy/root-x1.cert.pem", + }) + test.AssertNotError(t, err, "failed to load chain2.") + + // Load chain: E1 <- Root X2 + chain3, err := issuance.LoadChain([]string{ + "../test/hierarchy/int-e1.cert.pem", + "../test/hierarchy/root-x2.cert.pem", + }) + test.AssertNotError(t, err, "failed to load chain3.") + + // Create an example issuerNameID to CT bundle mapping + issuerBundles := map[issuance.NameID][]ct.ASN1Cert{ + chain1[0].NameID(): GetCTBundleForChain(chain1), + chain2[0].NameID(): GetCTBundleForChain(chain2), + chain3[0].NameID(): GetCTBundleForChain(chain3), + } + pub := New( + issuerBundles, + "test-user-agent/1.0", + log, + metrics.NoopRegisterer) + + // Load leaf certificate + leaf, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "unable to load leaf certificate.") + + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Couldn't generate test key") + + return pub, leaf, k +} + +func addLog(t *testing.T, port int, pubKey *ecdsa.PublicKey) *Log { + uri := fmt.Sprintf("http://localhost:%d", port) + der, err := x509.MarshalPKIXPublicKey(pubKey) + test.AssertNotError(t, err, "Failed to marshal key") + newLog, err := NewLog(uri, base64.StdEncoding.EncodeToString(der), "test-user-agent/1.0", log) + test.AssertNotError(t, err, "Couldn't create log") + test.AssertEquals(t, newLog.uri, fmt.Sprintf("http://localhost:%d", port)) + return newLog +} + +func makePrecert(k *ecdsa.PrivateKey) (map[issuance.NameID][]ct.ASN1Cert, []byte, error) { + rootTmpl := x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: "root"}, + BasicConstraintsValid: true, + IsCA: true, + } + rootBytes, err := x509.CreateCertificate(rand.Reader, &rootTmpl, &rootTmpl, k.Public(), k) + if err != nil { + return nil, nil, err + } + root, err := x509.ParseCertificate(rootBytes) + if err != nil { + return nil, nil, err + } + precertTmpl := x509.Certificate{ + SerialNumber: big.NewInt(0), + ExtraExtensions: []pkix.Extension{ + {Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3}, Critical: true, Value: []byte{0x05, 0x00}}, + }, + } + precert, err := x509.CreateCertificate(rand.Reader, &precertTmpl, root, k.Public(), k) + if err != nil { + return nil, nil, err + } + precertX509, err := x509.ParseCertificate(precert) + if err != nil { + return nil, nil, err + } + precertIssuerNameID := issuance.IssuerNameID(precertX509) + bundles := map[issuance.NameID][]ct.ASN1Cert{ + precertIssuerNameID: { + ct.ASN1Cert{Data: rootBytes}, + }, + } + return bundles, precert, err +} + +func TestTimestampVerificationFuture(t *testing.T) { + pub, _, k := setup(t) + + server := lyingLogSrv(k, time.Now().Add(time.Hour)) + defer server.Close() + port, err := getPort(server.URL) + test.AssertNotError(t, err, "Failed to get test server port") + testLog := addLog(t, port, &k.PublicKey) + + // Precert + issuerBundles, precert, err := makePrecert(k) + test.AssertNotError(t, err, "Failed to create test leaf") + pub.issuerBundles = issuerBundles + + _, err = pub.SubmitToSingleCTWithResult(ctx, &pubpb.Request{ + LogURL: testLog.uri, + LogPublicKey: testLog.logID, + Der: precert, + Kind: pubpb.SubmissionType_sct, + }) + if err == nil { + t.Fatal("Expected error for lying log server, got none") + } + if !strings.HasPrefix(err.Error(), "SCT Timestamp was too far in the future") { + t.Fatalf("Got wrong error: %s", err) + } +} + +func TestTimestampVerificationPast(t *testing.T) { + pub, _, k := setup(t) + + server := lyingLogSrv(k, time.Now().Add(-time.Hour)) + defer server.Close() + port, err := getPort(server.URL) + test.AssertNotError(t, err, "Failed to get test server port") + testLog := addLog(t, port, &k.PublicKey) + + // Precert + issuerBundles, precert, err := makePrecert(k) + test.AssertNotError(t, err, "Failed to create test leaf") + + pub.issuerBundles = issuerBundles + + _, err = pub.SubmitToSingleCTWithResult(ctx, &pubpb.Request{ + LogURL: testLog.uri, + LogPublicKey: testLog.logID, + Der: precert, + Kind: pubpb.SubmissionType_sct, + }) + if err == nil { + t.Fatal("Expected error for lying log server, got none") + } + if !strings.HasPrefix(err.Error(), "SCT Timestamp was too far in the past") { + t.Fatalf("Got wrong error: %s", err) + } +} + +func TestLogCache(t *testing.T) { + cache := logCache{ + logs: make(map[string]*Log), + } + + // Adding a log with an invalid base64 public key should error + _, err := cache.AddLog("www.test.com", "1234", "test-user-agent/1.0", log) + test.AssertError(t, err, "AddLog() with invalid base64 pk didn't error") + + // Adding a log with an invalid URI should error + _, err = cache.AddLog(":", "", "test-user-agent/1.0", log) + test.AssertError(t, err, "AddLog() with an invalid log URI didn't error") + + // Create one keypair & base 64 public key + k1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey() failed for k1") + der1, err := x509.MarshalPKIXPublicKey(&k1.PublicKey) + test.AssertNotError(t, err, "x509.MarshalPKIXPublicKey(der1) failed") + k1b64 := base64.StdEncoding.EncodeToString(der1) + + // Create a second keypair & base64 public key + k2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "ecdsa.GenerateKey() failed for k2") + der2, err := x509.MarshalPKIXPublicKey(&k2.PublicKey) + test.AssertNotError(t, err, "x509.MarshalPKIXPublicKey(der2) failed") + k2b64 := base64.StdEncoding.EncodeToString(der2) + + // Adding the first log should not produce an error + l1, err := cache.AddLog("http://log.one.example.com", k1b64, "test-user-agent/1.0", log) + test.AssertNotError(t, err, "cache.AddLog() failed for log 1") + test.AssertEquals(t, cache.Len(), 1) + test.AssertEquals(t, l1.uri, "http://log.one.example.com") + test.AssertEquals(t, l1.logID, k1b64) + + // Adding it again should not produce any errors, or increase the Len() + l1, err = cache.AddLog("http://log.one.example.com", k1b64, "test-user-agent/1.0", log) + test.AssertNotError(t, err, "cache.AddLog() failed for second add of log 1") + test.AssertEquals(t, cache.Len(), 1) + test.AssertEquals(t, l1.uri, "http://log.one.example.com") + test.AssertEquals(t, l1.logID, k1b64) + + // Adding a second log should not error and should increase the Len() + l2, err := cache.AddLog("http://log.two.example.com", k2b64, "test-user-agent/1.0", log) + test.AssertNotError(t, err, "cache.AddLog() failed for log 2") + test.AssertEquals(t, cache.Len(), 2) + test.AssertEquals(t, l2.uri, "http://log.two.example.com") + test.AssertEquals(t, l2.logID, k2b64) +} + +func TestLogErrorBody(t *testing.T) { + pub, leaf, k := setup(t) + + srv := errorBodyLogSrv() + defer srv.Close() + port, err := getPort(srv.URL) + test.AssertNotError(t, err, "Failed to get test server port") + + log.Clear() + logURI := fmt.Sprintf("http://localhost:%d", port) + pkDER, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + test.AssertNotError(t, err, "Failed to marshal key") + pkB64 := base64.StdEncoding.EncodeToString(pkDER) + _, err = pub.SubmitToSingleCTWithResult(context.Background(), &pubpb.Request{ + LogURL: logURI, + LogPublicKey: pkB64, + Der: leaf.Raw, + Kind: pubpb.SubmissionType_final, + }) + test.AssertError(t, err, "SubmitToSingleCTWithResult didn't fail") + test.AssertEquals(t, len(log.GetAllMatching("well this isn't good now is it")), 1) +} + +// TestErrorMetrics checks that the ct_errors_count and +// ct_submission_time_seconds metrics are updated with the correct labels when +// the publisher encounters errors. +func TestErrorMetrics(t *testing.T) { + pub, leaf, k := setup(t) + + pkDER, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + test.AssertNotError(t, err, "Failed to marshal key") + pkB64 := base64.StdEncoding.EncodeToString(pkDER) + + // Set up a bad server that will always produce errors. + badSrv := errorBodyLogSrv() + defer badSrv.Close() + port, err := getPort(badSrv.URL) + test.AssertNotError(t, err, "Failed to get test server port") + logURI := fmt.Sprintf("http://localhost:%d", port) + + _, err = pub.SubmitToSingleCTWithResult(context.Background(), &pubpb.Request{ + LogURL: logURI, + LogPublicKey: pkB64, + Der: leaf.Raw, + Kind: pubpb.SubmissionType_sct, + }) + test.AssertError(t, err, "SubmitToSingleCTWithResult didn't fail") + test.AssertMetricWithLabelsEquals(t, pub.metrics.submissionLatency, prometheus.Labels{ + "log": logURI, + "type": "sct", + "status": "error", + "http_status": "400", + }, 1) + test.AssertMetricWithLabelsEquals(t, pub.metrics.errorCount, prometheus.Labels{ + "log": logURI, + "type": "sct", + }, 1) + + _, err = pub.SubmitToSingleCTWithResult(context.Background(), &pubpb.Request{ + LogURL: logURI, + LogPublicKey: pkB64, + Der: leaf.Raw, + Kind: pubpb.SubmissionType_final, + }) + test.AssertError(t, err, "SubmitToSingleCTWithResult didn't fail") + test.AssertMetricWithLabelsEquals(t, pub.metrics.submissionLatency, prometheus.Labels{ + "log": logURI, + "type": "final", + "status": "error", + "http_status": "400", + }, 1) + test.AssertMetricWithLabelsEquals(t, pub.metrics.errorCount, prometheus.Labels{ + "log": logURI, + "type": "final", + }, 1) + + _, err = pub.SubmitToSingleCTWithResult(context.Background(), &pubpb.Request{ + LogURL: logURI, + LogPublicKey: pkB64, + Der: leaf.Raw, + Kind: pubpb.SubmissionType_info, + }) + test.AssertError(t, err, "SubmitToSingleCTWithResult didn't fail") + test.AssertMetricWithLabelsEquals(t, pub.metrics.submissionLatency, prometheus.Labels{ + "log": logURI, + "type": "info", + "status": "error", + "http_status": "400", + }, 1) + test.AssertMetricWithLabelsEquals(t, pub.metrics.errorCount, prometheus.Labels{ + "log": logURI, + "type": "info", + }, 1) +} + +// TestSuccessMetrics checks that the ct_errors_count and +// ct_submission_time_seconds metrics are updated with the correct labels when +// the publisher succeeds. +func TestSuccessMetrics(t *testing.T) { + pub, leaf, k := setup(t) + + pkDER, err := x509.MarshalPKIXPublicKey(&k.PublicKey) + test.AssertNotError(t, err, "Failed to marshal key") + pkB64 := base64.StdEncoding.EncodeToString(pkDER) + + // Set up a working server that will succeed. + workingSrv := logSrv(k) + defer workingSrv.Close() + port, err := getPort(workingSrv.URL) + test.AssertNotError(t, err, "Failed to get test server port") + logURI := fmt.Sprintf("http://localhost:%d", port) + + // Only the latency metric should be updated on a success. + _, err = pub.SubmitToSingleCTWithResult(context.Background(), &pubpb.Request{ + LogURL: logURI, + LogPublicKey: pkB64, + Der: leaf.Raw, + Kind: pubpb.SubmissionType_final, + }) + test.AssertNotError(t, err, "SubmitToSingleCTWithResult failed") + test.AssertMetricWithLabelsEquals(t, pub.metrics.submissionLatency, prometheus.Labels{ + "log": logURI, + "type": "final", + "status": "success", + "http_status": "", + }, 1) + test.AssertMetricWithLabelsEquals(t, pub.metrics.errorCount, prometheus.Labels{ + "log": logURI, + "type": "final", + }, 0) +} + +func Test_GetCTBundleForChain(t *testing.T) { + chain, err := issuance.LoadChain([]string{ + "../test/hierarchy/int-r3.cert.pem", + "../test/hierarchy/root-x1.cert.pem", + }) + test.AssertNotError(t, err, "Failed to load chain.") + expect := []ct.ASN1Cert{{Data: chain[0].Raw}} + type args struct { + chain []*issuance.Certificate + } + tests := []struct { + name string + args args + want []ct.ASN1Cert + }{ + {"Create a ct bundle with a single intermediate", args{chain}, expect}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundle := GetCTBundleForChain(tt.args.chain) + test.AssertDeepEquals(t, bundle, tt.want) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/publisher/test/testIntermediate.pem b/third-party/github.com/letsencrypt/boulder/publisher/test/testIntermediate.pem new file mode 100644 index 000000000..680580e17 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/publisher/test/testIntermediate.pem @@ -0,0 +1,39 @@ +-----BEGIN CERTIFICATE----- +MIIG3zCCBMegAwIBAgIQAJv84kD9Vb7ZJp4MASwbdzANBgkqhkiG9w0BAQsFADBK +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu +VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMzIwMTgwNTM4WhcNMjIw +MzIwMTgwNTM4WjBaMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MRcw +FQYDVQQLEw5UcnVzdElEIFNlcnZlcjEeMBwGA1UEAxMVVHJ1c3RJRCBTZXJ2ZXIg +Q0EgQTUyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl2nXmZiFAj/p +JkJ26PRzP6kyRCaQeC54V5EZoF12K0n5k1pdWs6C88LY5Uw2eisdDdump/6REnzt +cgG3jKHF2syd/gn7V+IURw/onpGPlC2AMpOTA/UoeGi6fg9CtDF6BRQiUzPko61s +j6++Y2uyMp/ZF7nJ4GB8mdYx4eSgtz+vsjKsfoyc3ALr4bwfFJy8kfey+0Lz4SAr +y7+P87NwY/r3dSgCq8XUsO3qJX+HzTcUloM8QAIboJ4ZR3/zsMzFJWC4NRLxUesX +3Pxbpdmb70BM13dx6ftFi37y42mwQmYXRpA6zUY98bAJb9z/7jNhyvzHLjztXgrR +vyISaYBLIwIDAQABo4ICrzCCAqswgYkGCCsGAQUFBwEBBH0wezAwBggrBgEFBQcw +AYYkaHR0cDovL2NvbW1lcmNpYWwub2NzcC5pZGVudHJ1c3QuY29tMEcGCCsGAQUF +BzAChjtodHRwOi8vdmFsaWRhdGlvbi5pZGVudHJ1c3QuY29tL3Jvb3RzL2NvbW1l +cmNpYWxyb290Y2ExLnA3YzAfBgNVHSMEGDAWgBTtRBnA0/AGi+6ke75C5yZUyI42 +djAPBgNVHRMBAf8EBTADAQH/MIIBMQYDVR0gBIIBKDCCASQwggEgBgRVHSAAMIIB +FjBQBggrBgEFBQcCAjBEMEIWPmh0dHBzOi8vc2VjdXJlLmlkZW50cnVzdC5jb20v +Y2VydGlmaWNhdGVzL3BvbGljeS90cy9pbmRleC5odG1sMAAwgcEGCCsGAQUFBwIC +MIG0GoGxVGhpcyBUcnVzdElEIFNlcnZlciBDZXJ0aWZpY2F0ZSBoYXMgYmVlbiBp +c3N1ZWQgaW4gYWNjb3JkYW5jZSB3aXRoIElkZW5UcnVzdCdzIFRydXN0SUQgQ2Vy +dGlmaWNhdGUgUG9saWN5IGZvdW5kIGF0IGh0dHBzOi8vc2VjdXJlLmlkZW50cnVz +dC5jb20vY2VydGlmaWNhdGVzL3BvbGljeS90cy9pbmRleC5odG1sMEoGA1UdHwRD +MEEwP6A9oDuGOWh0dHA6Ly92YWxpZGF0aW9uLmlkZW50cnVzdC5jb20vY3JsL2Nv +bW1lcmNpYWxyb290Y2ExLmNybDA7BgNVHSUENDAyBggrBgEFBQcDAQYIKwYBBQUH +AwIGCCsGAQUFBwMFBggrBgEFBQcDBgYIKwYBBQUHAwcwDgYDVR0PAQH/BAQDAgGG +MB0GA1UdDgQWBBSiViQ80NQVuei/eKMTEFhILhZU4TANBgkqhkiG9w0BAQsFAAOC +AgEAm4oWcizMGDsjzYFKfWUKferHD1Vusclu4/dra0PCx3HctXJMnuXc4Ngvn6Ab +BcanG0Uht+bkuC4TaaS3QMCl0LwcsIzlfRzDJdxIpREWHH8yoNoPafVN3u2iGiyT +5qda4Ej4WQgOmmNiluZPk8a4d4MkAxyQdVF/AVVx6Or+9d+bkQenjPSxWVmi/bfW +RBXq2AcD8Ej7AIU15dRnLEkESmJm4xtV2aqmCd0SSBGhJHYLcInUPzWVg1zcB5EQ +78GOTue8UrZvbcYhOufHG0k5JX5HVoVZ6GSXKqn5kqbcHXT6adVoWT/BxZruZiKQ +qkryoZoSywt7dDdDhpC2+oAOC+XwX2HJp2mrPaAea1+E4LM9C9iEDtjsn5FfsBz0 +VRbMRdaoayXzOlTRhF3pGU2LLCmrXy/pqpqAGYPxyHr3auRn9fjv77UMEqVFdfOc +CspkK71IGqM9UwwMtCZBp0fK/Xv9o1d85paXcJ/aH8zg6EK4UkuXDFnLsg1LrIru ++YHeHOeSaXJlcjzwWVY/Exe5HymtqGH8klMhy65bjtapNt76+j2CJgxOdPEiTy/l +9LH5ujlo5qgemXE3ePwYZ9D3iiJThTf3tWkvdbz2wCPJAy2EHS0FxHMfx5sXsFsa +OY8B7wwvZTLzU6WWs781TJXx2CE04PneeeArLpVLkiGIWjk= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/ra/proto/ra.pb.go b/third-party/github.com/letsencrypt/boulder/ra/proto/ra.pb.go new file mode 100644 index 000000000..34c6b7305 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ra/proto/ra.pb.go @@ -0,0 +1,985 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: ra.proto + +package proto + +import ( + proto1 "github.com/letsencrypt/boulder/ca/proto" + proto "github.com/letsencrypt/boulder/core/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GenerateOCSPRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` +} + +func (x *GenerateOCSPRequest) Reset() { + *x = GenerateOCSPRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GenerateOCSPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateOCSPRequest) ProtoMessage() {} + +func (x *GenerateOCSPRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateOCSPRequest.ProtoReflect.Descriptor instead. +func (*GenerateOCSPRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{0} +} + +func (x *GenerateOCSPRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type UpdateRegistrationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Base *proto.Registration `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Update *proto.Registration `protobuf:"bytes,2,opt,name=update,proto3" json:"update,omitempty"` +} + +func (x *UpdateRegistrationRequest) Reset() { + *x = UpdateRegistrationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateRegistrationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRegistrationRequest) ProtoMessage() {} + +func (x *UpdateRegistrationRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRegistrationRequest.ProtoReflect.Descriptor instead. +func (*UpdateRegistrationRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{1} +} + +func (x *UpdateRegistrationRequest) GetBase() *proto.Registration { + if x != nil { + return x.Base + } + return nil +} + +func (x *UpdateRegistrationRequest) GetUpdate() *proto.Registration { + if x != nil { + return x.Update + } + return nil +} + +type UpdateAuthorizationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Authz *proto.Authorization `protobuf:"bytes,1,opt,name=authz,proto3" json:"authz,omitempty"` + ChallengeIndex int64 `protobuf:"varint,2,opt,name=challengeIndex,proto3" json:"challengeIndex,omitempty"` + Response *proto.Challenge `protobuf:"bytes,3,opt,name=response,proto3" json:"response,omitempty"` +} + +func (x *UpdateAuthorizationRequest) Reset() { + *x = UpdateAuthorizationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateAuthorizationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateAuthorizationRequest) ProtoMessage() {} + +func (x *UpdateAuthorizationRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateAuthorizationRequest.ProtoReflect.Descriptor instead. +func (*UpdateAuthorizationRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{2} +} + +func (x *UpdateAuthorizationRequest) GetAuthz() *proto.Authorization { + if x != nil { + return x.Authz + } + return nil +} + +func (x *UpdateAuthorizationRequest) GetChallengeIndex() int64 { + if x != nil { + return x.ChallengeIndex + } + return 0 +} + +func (x *UpdateAuthorizationRequest) GetResponse() *proto.Challenge { + if x != nil { + return x.Response + } + return nil +} + +type PerformValidationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Authz *proto.Authorization `protobuf:"bytes,1,opt,name=authz,proto3" json:"authz,omitempty"` + ChallengeIndex int64 `protobuf:"varint,2,opt,name=challengeIndex,proto3" json:"challengeIndex,omitempty"` +} + +func (x *PerformValidationRequest) Reset() { + *x = PerformValidationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PerformValidationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PerformValidationRequest) ProtoMessage() {} + +func (x *PerformValidationRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PerformValidationRequest.ProtoReflect.Descriptor instead. +func (*PerformValidationRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{3} +} + +func (x *PerformValidationRequest) GetAuthz() *proto.Authorization { + if x != nil { + return x.Authz + } + return nil +} + +func (x *PerformValidationRequest) GetChallengeIndex() int64 { + if x != nil { + return x.ChallengeIndex + } + return 0 +} + +type RevokeCertByApplicantRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + Code int64 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + RegID int64 `protobuf:"varint,3,opt,name=regID,proto3" json:"regID,omitempty"` +} + +func (x *RevokeCertByApplicantRequest) Reset() { + *x = RevokeCertByApplicantRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RevokeCertByApplicantRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeCertByApplicantRequest) ProtoMessage() {} + +func (x *RevokeCertByApplicantRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeCertByApplicantRequest.ProtoReflect.Descriptor instead. +func (*RevokeCertByApplicantRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{4} +} + +func (x *RevokeCertByApplicantRequest) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *RevokeCertByApplicantRequest) GetCode() int64 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *RevokeCertByApplicantRequest) GetRegID() int64 { + if x != nil { + return x.RegID + } + return 0 +} + +type RevokeCertByKeyRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` +} + +func (x *RevokeCertByKeyRequest) Reset() { + *x = RevokeCertByKeyRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RevokeCertByKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeCertByKeyRequest) ProtoMessage() {} + +func (x *RevokeCertByKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeCertByKeyRequest.ProtoReflect.Descriptor instead. +func (*RevokeCertByKeyRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{5} +} + +func (x *RevokeCertByKeyRequest) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +type AdministrativelyRevokeCertificateRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Deprecated: this field is ignored. + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + // The `serial` field is required. + Serial string `protobuf:"bytes,4,opt,name=serial,proto3" json:"serial,omitempty"` + Code int64 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + AdminName string `protobuf:"bytes,3,opt,name=adminName,proto3" json:"adminName,omitempty"` + SkipBlockKey bool `protobuf:"varint,5,opt,name=skipBlockKey,proto3" json:"skipBlockKey,omitempty"` + // If the malformed flag is set, the RA will not attempt to parse the + // certificate in question. In this case, the keyCompromise reason cannot be + // specified, because the key cannot be blocked. + Malformed bool `protobuf:"varint,6,opt,name=malformed,proto3" json:"malformed,omitempty"` +} + +func (x *AdministrativelyRevokeCertificateRequest) Reset() { + *x = AdministrativelyRevokeCertificateRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AdministrativelyRevokeCertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdministrativelyRevokeCertificateRequest) ProtoMessage() {} + +func (x *AdministrativelyRevokeCertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdministrativelyRevokeCertificateRequest.ProtoReflect.Descriptor instead. +func (*AdministrativelyRevokeCertificateRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{6} +} + +func (x *AdministrativelyRevokeCertificateRequest) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *AdministrativelyRevokeCertificateRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *AdministrativelyRevokeCertificateRequest) GetCode() int64 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *AdministrativelyRevokeCertificateRequest) GetAdminName() string { + if x != nil { + return x.AdminName + } + return "" +} + +func (x *AdministrativelyRevokeCertificateRequest) GetSkipBlockKey() bool { + if x != nil { + return x.SkipBlockKey + } + return false +} + +func (x *AdministrativelyRevokeCertificateRequest) GetMalformed() bool { + if x != nil { + return x.Malformed + } + return false +} + +type NewOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 6 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Names []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"` + ReplacesSerial string `protobuf:"bytes,3,opt,name=replacesSerial,proto3" json:"replacesSerial,omitempty"` + LimitsExempt bool `protobuf:"varint,4,opt,name=limitsExempt,proto3" json:"limitsExempt,omitempty"` + CertificateProfileName string `protobuf:"bytes,5,opt,name=certificateProfileName,proto3" json:"certificateProfileName,omitempty"` +} + +func (x *NewOrderRequest) Reset() { + *x = NewOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NewOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NewOrderRequest) ProtoMessage() {} + +func (x *NewOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NewOrderRequest.ProtoReflect.Descriptor instead. +func (*NewOrderRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{7} +} + +func (x *NewOrderRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *NewOrderRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +func (x *NewOrderRequest) GetReplacesSerial() string { + if x != nil { + return x.ReplacesSerial + } + return "" +} + +func (x *NewOrderRequest) GetLimitsExempt() bool { + if x != nil { + return x.LimitsExempt + } + return false +} + +func (x *NewOrderRequest) GetCertificateProfileName() string { + if x != nil { + return x.CertificateProfileName + } + return "" +} + +type FinalizeOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Order *proto.Order `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"` + Csr []byte `protobuf:"bytes,2,opt,name=csr,proto3" json:"csr,omitempty"` +} + +func (x *FinalizeOrderRequest) Reset() { + *x = FinalizeOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeOrderRequest) ProtoMessage() {} + +func (x *FinalizeOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeOrderRequest.ProtoReflect.Descriptor instead. +func (*FinalizeOrderRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{8} +} + +func (x *FinalizeOrderRequest) GetOrder() *proto.Order { + if x != nil { + return x.Order + } + return nil +} + +func (x *FinalizeOrderRequest) GetCsr() []byte { + if x != nil { + return x.Csr + } + return nil +} + +type UnpauseAccountRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The registrationID to be unpaused so issuance can be resumed. + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` +} + +func (x *UnpauseAccountRequest) Reset() { + *x = UnpauseAccountRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_ra_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UnpauseAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnpauseAccountRequest) ProtoMessage() {} + +func (x *UnpauseAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_ra_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnpauseAccountRequest.ProtoReflect.Descriptor instead. +func (*UnpauseAccountRequest) Descriptor() ([]byte, []int) { + return file_ra_proto_rawDescGZIP(), []int{9} +} + +func (x *UnpauseAccountRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +var File_ra_proto protoreflect.FileDescriptor + +var file_ra_proto_rawDesc = []byte{ + 0x0a, 0x08, 0x72, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x72, 0x61, 0x1a, 0x15, + 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x11, 0x63, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, + 0x63, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x2d, 0x0a, 0x13, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, + 0x65, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x22, 0x6f, 0x0a, 0x19, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x26, 0x0a, 0x04, 0x62, 0x61, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x62, 0x61, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x75, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x12, + 0x26, 0x0a, 0x0e, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, + 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x2b, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x6d, 0x0a, 0x18, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x29, 0x0a, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x12, 0x26, 0x0a, 0x0e, 0x63, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x49, 0x6e, + 0x64, 0x65, 0x78, 0x22, 0x5c, 0x0a, 0x1c, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, + 0x74, 0x42, 0x79, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x65, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x63, 0x65, 0x72, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, + 0x65, 0x67, 0x49, 0x44, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x65, 0x67, 0x49, + 0x44, 0x22, 0x32, 0x0a, 0x16, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, + 0x79, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, + 0x65, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x63, 0x65, 0x72, 0x74, 0x4a, + 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0xca, 0x01, 0x0a, 0x28, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x69, + 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x65, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x04, 0x63, 0x65, 0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x12, + 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, + 0x64, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6b, 0x69, 0x70, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x4b, 0x65, 0x79, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x6b, 0x69, 0x70, 0x42, 0x6c, 0x6f, 0x63, + 0x6b, 0x4b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x6d, 0x61, 0x6c, 0x66, 0x6f, 0x72, 0x6d, 0x65, + 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6d, 0x61, 0x6c, 0x66, 0x6f, 0x72, 0x6d, + 0x65, 0x64, 0x22, 0xd3, 0x01, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, + 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x14, + 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, + 0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x22, 0x0a, 0x0c, + 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x45, 0x78, 0x65, 0x6d, 0x70, 0x74, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0c, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x45, 0x78, 0x65, 0x6d, 0x70, 0x74, + 0x12, 0x36, 0x0a, 0x16, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x50, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x16, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x4b, 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x61, + 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x21, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, 0x72, + 0x64, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x73, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x03, 0x63, 0x73, 0x72, 0x22, 0x3f, 0x0a, 0x15, 0x55, 0x6e, 0x70, 0x61, 0x75, 0x73, 0x65, + 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, + 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x32, 0xf4, 0x06, 0x0a, 0x15, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, + 0x12, 0x3b, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x49, 0x0a, + 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x72, 0x61, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x11, 0x50, 0x65, 0x72, 0x66, + 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, + 0x72, 0x61, 0x2e, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x63, 0x6f, + 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x22, 0x00, 0x12, 0x46, 0x0a, 0x16, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, + 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x17, 0x44, 0x65, + 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x00, 0x12, 0x53, 0x0a, 0x15, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, + 0x72, 0x74, 0x42, 0x79, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x6e, 0x74, 0x12, 0x20, 0x2e, + 0x72, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x41, + 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x0f, 0x52, 0x65, 0x76, + 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x2e, 0x72, + 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x4b, 0x65, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x22, 0x00, 0x12, 0x6b, 0x0a, 0x21, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x69, 0x73, 0x74, 0x72, 0x61, + 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x2c, 0x2e, 0x72, 0x61, 0x2e, 0x41, 0x64, 0x6d, + 0x69, 0x6e, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76, + 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, + 0x2e, 0x0a, 0x08, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x13, 0x2e, 0x72, 0x61, + 0x2e, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, + 0x38, 0x0a, 0x0d, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x12, 0x18, 0x2e, 0x72, 0x61, 0x2e, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, + 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x0c, 0x47, 0x65, 0x6e, + 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, 0x43, 0x53, 0x50, 0x12, 0x17, 0x2e, 0x72, 0x61, 0x2e, 0x47, + 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x63, 0x61, 0x2e, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0e, 0x55, 0x6e, 0x70, 0x61, 0x75, 0x73, + 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x19, 0x2e, 0x72, 0x61, 0x2e, 0x55, 0x6e, + 0x70, 0x61, 0x75, 0x73, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x29, 0x5a, + 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, + 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, + 0x72, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_ra_proto_rawDescOnce sync.Once + file_ra_proto_rawDescData = file_ra_proto_rawDesc +) + +func file_ra_proto_rawDescGZIP() []byte { + file_ra_proto_rawDescOnce.Do(func() { + file_ra_proto_rawDescData = protoimpl.X.CompressGZIP(file_ra_proto_rawDescData) + }) + return file_ra_proto_rawDescData +} + +var file_ra_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_ra_proto_goTypes = []interface{}{ + (*GenerateOCSPRequest)(nil), // 0: ra.GenerateOCSPRequest + (*UpdateRegistrationRequest)(nil), // 1: ra.UpdateRegistrationRequest + (*UpdateAuthorizationRequest)(nil), // 2: ra.UpdateAuthorizationRequest + (*PerformValidationRequest)(nil), // 3: ra.PerformValidationRequest + (*RevokeCertByApplicantRequest)(nil), // 4: ra.RevokeCertByApplicantRequest + (*RevokeCertByKeyRequest)(nil), // 5: ra.RevokeCertByKeyRequest + (*AdministrativelyRevokeCertificateRequest)(nil), // 6: ra.AdministrativelyRevokeCertificateRequest + (*NewOrderRequest)(nil), // 7: ra.NewOrderRequest + (*FinalizeOrderRequest)(nil), // 8: ra.FinalizeOrderRequest + (*UnpauseAccountRequest)(nil), // 9: ra.UnpauseAccountRequest + (*proto.Registration)(nil), // 10: core.Registration + (*proto.Authorization)(nil), // 11: core.Authorization + (*proto.Challenge)(nil), // 12: core.Challenge + (*proto.Order)(nil), // 13: core.Order + (*emptypb.Empty)(nil), // 14: google.protobuf.Empty + (*proto1.OCSPResponse)(nil), // 15: ca.OCSPResponse +} +var file_ra_proto_depIdxs = []int32{ + 10, // 0: ra.UpdateRegistrationRequest.base:type_name -> core.Registration + 10, // 1: ra.UpdateRegistrationRequest.update:type_name -> core.Registration + 11, // 2: ra.UpdateAuthorizationRequest.authz:type_name -> core.Authorization + 12, // 3: ra.UpdateAuthorizationRequest.response:type_name -> core.Challenge + 11, // 4: ra.PerformValidationRequest.authz:type_name -> core.Authorization + 13, // 5: ra.FinalizeOrderRequest.order:type_name -> core.Order + 10, // 6: ra.RegistrationAuthority.NewRegistration:input_type -> core.Registration + 1, // 7: ra.RegistrationAuthority.UpdateRegistration:input_type -> ra.UpdateRegistrationRequest + 3, // 8: ra.RegistrationAuthority.PerformValidation:input_type -> ra.PerformValidationRequest + 10, // 9: ra.RegistrationAuthority.DeactivateRegistration:input_type -> core.Registration + 11, // 10: ra.RegistrationAuthority.DeactivateAuthorization:input_type -> core.Authorization + 4, // 11: ra.RegistrationAuthority.RevokeCertByApplicant:input_type -> ra.RevokeCertByApplicantRequest + 5, // 12: ra.RegistrationAuthority.RevokeCertByKey:input_type -> ra.RevokeCertByKeyRequest + 6, // 13: ra.RegistrationAuthority.AdministrativelyRevokeCertificate:input_type -> ra.AdministrativelyRevokeCertificateRequest + 7, // 14: ra.RegistrationAuthority.NewOrder:input_type -> ra.NewOrderRequest + 8, // 15: ra.RegistrationAuthority.FinalizeOrder:input_type -> ra.FinalizeOrderRequest + 0, // 16: ra.RegistrationAuthority.GenerateOCSP:input_type -> ra.GenerateOCSPRequest + 9, // 17: ra.RegistrationAuthority.UnpauseAccount:input_type -> ra.UnpauseAccountRequest + 10, // 18: ra.RegistrationAuthority.NewRegistration:output_type -> core.Registration + 10, // 19: ra.RegistrationAuthority.UpdateRegistration:output_type -> core.Registration + 11, // 20: ra.RegistrationAuthority.PerformValidation:output_type -> core.Authorization + 14, // 21: ra.RegistrationAuthority.DeactivateRegistration:output_type -> google.protobuf.Empty + 14, // 22: ra.RegistrationAuthority.DeactivateAuthorization:output_type -> google.protobuf.Empty + 14, // 23: ra.RegistrationAuthority.RevokeCertByApplicant:output_type -> google.protobuf.Empty + 14, // 24: ra.RegistrationAuthority.RevokeCertByKey:output_type -> google.protobuf.Empty + 14, // 25: ra.RegistrationAuthority.AdministrativelyRevokeCertificate:output_type -> google.protobuf.Empty + 13, // 26: ra.RegistrationAuthority.NewOrder:output_type -> core.Order + 13, // 27: ra.RegistrationAuthority.FinalizeOrder:output_type -> core.Order + 15, // 28: ra.RegistrationAuthority.GenerateOCSP:output_type -> ca.OCSPResponse + 14, // 29: ra.RegistrationAuthority.UnpauseAccount:output_type -> google.protobuf.Empty + 18, // [18:30] is the sub-list for method output_type + 6, // [6:18] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_ra_proto_init() } +func file_ra_proto_init() { + if File_ra_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_ra_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GenerateOCSPRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateRegistrationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateAuthorizationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PerformValidationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RevokeCertByApplicantRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RevokeCertByKeyRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AdministrativelyRevokeCertificateRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NewOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_ra_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UnpauseAccountRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_ra_proto_rawDesc, + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ra_proto_goTypes, + DependencyIndexes: file_ra_proto_depIdxs, + MessageInfos: file_ra_proto_msgTypes, + }.Build() + File_ra_proto = out.File + file_ra_proto_rawDesc = nil + file_ra_proto_goTypes = nil + file_ra_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ra/proto/ra.proto b/third-party/github.com/letsencrypt/boulder/ra/proto/ra.proto new file mode 100644 index 000000000..bc8d0bfcc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ra/proto/ra.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package ra; +option go_package = "github.com/letsencrypt/boulder/ra/proto"; + +import "core/proto/core.proto"; +import "ca/proto/ca.proto"; +import "google/protobuf/empty.proto"; + +service RegistrationAuthority { + rpc NewRegistration(core.Registration) returns (core.Registration) {} + rpc UpdateRegistration(UpdateRegistrationRequest) returns (core.Registration) {} + rpc PerformValidation(PerformValidationRequest) returns (core.Authorization) {} + rpc DeactivateRegistration(core.Registration) returns (google.protobuf.Empty) {} + rpc DeactivateAuthorization(core.Authorization) returns (google.protobuf.Empty) {} + rpc RevokeCertByApplicant(RevokeCertByApplicantRequest) returns (google.protobuf.Empty) {} + rpc RevokeCertByKey(RevokeCertByKeyRequest) returns (google.protobuf.Empty) {} + rpc AdministrativelyRevokeCertificate(AdministrativelyRevokeCertificateRequest) returns (google.protobuf.Empty) {} + rpc NewOrder(NewOrderRequest) returns (core.Order) {} + rpc FinalizeOrder(FinalizeOrderRequest) returns (core.Order) {} + // Generate an OCSP response based on the DB's current status and reason code. + rpc GenerateOCSP(GenerateOCSPRequest) returns (ca.OCSPResponse) {} + rpc UnpauseAccount(UnpauseAccountRequest) returns (google.protobuf.Empty) {} +} + +message GenerateOCSPRequest { + string serial = 1; +} + +message UpdateRegistrationRequest { + core.Registration base = 1; + core.Registration update = 2; +} + +message UpdateAuthorizationRequest { + core.Authorization authz = 1; + int64 challengeIndex = 2; + core.Challenge response = 3; +} + +message PerformValidationRequest { + core.Authorization authz = 1; + int64 challengeIndex = 2; +} + +message RevokeCertByApplicantRequest { + bytes cert = 1; + int64 code = 2; + int64 regID = 3; +} + +message RevokeCertByKeyRequest { + bytes cert = 1; + reserved 2; // previously code +} + +message AdministrativelyRevokeCertificateRequest { + // Deprecated: this field is ignored. + bytes cert = 1; + // The `serial` field is required. + string serial = 4; + int64 code = 2; + string adminName = 3; + bool skipBlockKey = 5; + // If the malformed flag is set, the RA will not attempt to parse the + // certificate in question. In this case, the keyCompromise reason cannot be + // specified, because the key cannot be blocked. + bool malformed = 6; +} + +message NewOrderRequest { + // Next unused field number: 6 + int64 registrationID = 1; + repeated string names = 2; + string replacesSerial = 3; + bool limitsExempt = 4; + string certificateProfileName = 5; +} + +message FinalizeOrderRequest { + core.Order order = 1; + bytes csr = 2; +} + +message UnpauseAccountRequest { + // Next unused field number: 2 + + // The registrationID to be unpaused so issuance can be resumed. + int64 registrationID = 1; +} diff --git a/third-party/github.com/letsencrypt/boulder/ra/proto/ra_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/ra/proto/ra_grpc.pb.go new file mode 100644 index 000000000..d4fcdbab8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ra/proto/ra_grpc.pb.go @@ -0,0 +1,533 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: ra.proto + +package proto + +import ( + context "context" + proto1 "github.com/letsencrypt/boulder/ca/proto" + proto "github.com/letsencrypt/boulder/core/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + RegistrationAuthority_NewRegistration_FullMethodName = "/ra.RegistrationAuthority/NewRegistration" + RegistrationAuthority_UpdateRegistration_FullMethodName = "/ra.RegistrationAuthority/UpdateRegistration" + RegistrationAuthority_PerformValidation_FullMethodName = "/ra.RegistrationAuthority/PerformValidation" + RegistrationAuthority_DeactivateRegistration_FullMethodName = "/ra.RegistrationAuthority/DeactivateRegistration" + RegistrationAuthority_DeactivateAuthorization_FullMethodName = "/ra.RegistrationAuthority/DeactivateAuthorization" + RegistrationAuthority_RevokeCertByApplicant_FullMethodName = "/ra.RegistrationAuthority/RevokeCertByApplicant" + RegistrationAuthority_RevokeCertByKey_FullMethodName = "/ra.RegistrationAuthority/RevokeCertByKey" + RegistrationAuthority_AdministrativelyRevokeCertificate_FullMethodName = "/ra.RegistrationAuthority/AdministrativelyRevokeCertificate" + RegistrationAuthority_NewOrder_FullMethodName = "/ra.RegistrationAuthority/NewOrder" + RegistrationAuthority_FinalizeOrder_FullMethodName = "/ra.RegistrationAuthority/FinalizeOrder" + RegistrationAuthority_GenerateOCSP_FullMethodName = "/ra.RegistrationAuthority/GenerateOCSP" + RegistrationAuthority_UnpauseAccount_FullMethodName = "/ra.RegistrationAuthority/UnpauseAccount" +) + +// RegistrationAuthorityClient is the client API for RegistrationAuthority service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type RegistrationAuthorityClient interface { + NewRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*proto.Registration, error) + UpdateRegistration(ctx context.Context, in *UpdateRegistrationRequest, opts ...grpc.CallOption) (*proto.Registration, error) + PerformValidation(ctx context.Context, in *PerformValidationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) + DeactivateRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeactivateAuthorization(ctx context.Context, in *proto.Authorization, opts ...grpc.CallOption) (*emptypb.Empty, error) + RevokeCertByApplicant(ctx context.Context, in *RevokeCertByApplicantRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RevokeCertByKey(ctx context.Context, in *RevokeCertByKeyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AdministrativelyRevokeCertificate(ctx context.Context, in *AdministrativelyRevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + NewOrder(ctx context.Context, in *NewOrderRequest, opts ...grpc.CallOption) (*proto.Order, error) + FinalizeOrder(ctx context.Context, in *FinalizeOrderRequest, opts ...grpc.CallOption) (*proto.Order, error) + // Generate an OCSP response based on the DB's current status and reason code. + GenerateOCSP(ctx context.Context, in *GenerateOCSPRequest, opts ...grpc.CallOption) (*proto1.OCSPResponse, error) + UnpauseAccount(ctx context.Context, in *UnpauseAccountRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type registrationAuthorityClient struct { + cc grpc.ClientConnInterface +} + +func NewRegistrationAuthorityClient(cc grpc.ClientConnInterface) RegistrationAuthorityClient { + return ®istrationAuthorityClient{cc} +} + +func (c *registrationAuthorityClient) NewRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, RegistrationAuthority_NewRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) UpdateRegistration(ctx context.Context, in *UpdateRegistrationRequest, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, RegistrationAuthority_UpdateRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) PerformValidation(ctx context.Context, in *PerformValidationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Authorization) + err := c.cc.Invoke(ctx, RegistrationAuthority_PerformValidation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) DeactivateRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_DeactivateRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) DeactivateAuthorization(ctx context.Context, in *proto.Authorization, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_DeactivateAuthorization_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) RevokeCertByApplicant(ctx context.Context, in *RevokeCertByApplicantRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_RevokeCertByApplicant_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) RevokeCertByKey(ctx context.Context, in *RevokeCertByKeyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_RevokeCertByKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) AdministrativelyRevokeCertificate(ctx context.Context, in *AdministrativelyRevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_AdministrativelyRevokeCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) NewOrder(ctx context.Context, in *NewOrderRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, RegistrationAuthority_NewOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) FinalizeOrder(ctx context.Context, in *FinalizeOrderRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, RegistrationAuthority_FinalizeOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) GenerateOCSP(ctx context.Context, in *GenerateOCSPRequest, opts ...grpc.CallOption) (*proto1.OCSPResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto1.OCSPResponse) + err := c.cc.Invoke(ctx, RegistrationAuthority_GenerateOCSP_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *registrationAuthorityClient) UnpauseAccount(ctx context.Context, in *UnpauseAccountRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, RegistrationAuthority_UnpauseAccount_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RegistrationAuthorityServer is the server API for RegistrationAuthority service. +// All implementations must embed UnimplementedRegistrationAuthorityServer +// for forward compatibility +type RegistrationAuthorityServer interface { + NewRegistration(context.Context, *proto.Registration) (*proto.Registration, error) + UpdateRegistration(context.Context, *UpdateRegistrationRequest) (*proto.Registration, error) + PerformValidation(context.Context, *PerformValidationRequest) (*proto.Authorization, error) + DeactivateRegistration(context.Context, *proto.Registration) (*emptypb.Empty, error) + DeactivateAuthorization(context.Context, *proto.Authorization) (*emptypb.Empty, error) + RevokeCertByApplicant(context.Context, *RevokeCertByApplicantRequest) (*emptypb.Empty, error) + RevokeCertByKey(context.Context, *RevokeCertByKeyRequest) (*emptypb.Empty, error) + AdministrativelyRevokeCertificate(context.Context, *AdministrativelyRevokeCertificateRequest) (*emptypb.Empty, error) + NewOrder(context.Context, *NewOrderRequest) (*proto.Order, error) + FinalizeOrder(context.Context, *FinalizeOrderRequest) (*proto.Order, error) + // Generate an OCSP response based on the DB's current status and reason code. + GenerateOCSP(context.Context, *GenerateOCSPRequest) (*proto1.OCSPResponse, error) + UnpauseAccount(context.Context, *UnpauseAccountRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedRegistrationAuthorityServer() +} + +// UnimplementedRegistrationAuthorityServer must be embedded to have forward compatible implementations. +type UnimplementedRegistrationAuthorityServer struct { +} + +func (UnimplementedRegistrationAuthorityServer) NewRegistration(context.Context, *proto.Registration) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method NewRegistration not implemented") +} +func (UnimplementedRegistrationAuthorityServer) UpdateRegistration(context.Context, *UpdateRegistrationRequest) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateRegistration not implemented") +} +func (UnimplementedRegistrationAuthorityServer) PerformValidation(context.Context, *PerformValidationRequest) (*proto.Authorization, error) { + return nil, status.Errorf(codes.Unimplemented, "method PerformValidation not implemented") +} +func (UnimplementedRegistrationAuthorityServer) DeactivateRegistration(context.Context, *proto.Registration) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeactivateRegistration not implemented") +} +func (UnimplementedRegistrationAuthorityServer) DeactivateAuthorization(context.Context, *proto.Authorization) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeactivateAuthorization not implemented") +} +func (UnimplementedRegistrationAuthorityServer) RevokeCertByApplicant(context.Context, *RevokeCertByApplicantRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeCertByApplicant not implemented") +} +func (UnimplementedRegistrationAuthorityServer) RevokeCertByKey(context.Context, *RevokeCertByKeyRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeCertByKey not implemented") +} +func (UnimplementedRegistrationAuthorityServer) AdministrativelyRevokeCertificate(context.Context, *AdministrativelyRevokeCertificateRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdministrativelyRevokeCertificate not implemented") +} +func (UnimplementedRegistrationAuthorityServer) NewOrder(context.Context, *NewOrderRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method NewOrder not implemented") +} +func (UnimplementedRegistrationAuthorityServer) FinalizeOrder(context.Context, *FinalizeOrderRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method FinalizeOrder not implemented") +} +func (UnimplementedRegistrationAuthorityServer) GenerateOCSP(context.Context, *GenerateOCSPRequest) (*proto1.OCSPResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GenerateOCSP not implemented") +} +func (UnimplementedRegistrationAuthorityServer) UnpauseAccount(context.Context, *UnpauseAccountRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnpauseAccount not implemented") +} +func (UnimplementedRegistrationAuthorityServer) mustEmbedUnimplementedRegistrationAuthorityServer() {} + +// UnsafeRegistrationAuthorityServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RegistrationAuthorityServer will +// result in compilation errors. +type UnsafeRegistrationAuthorityServer interface { + mustEmbedUnimplementedRegistrationAuthorityServer() +} + +func RegisterRegistrationAuthorityServer(s grpc.ServiceRegistrar, srv RegistrationAuthorityServer) { + s.RegisterService(&RegistrationAuthority_ServiceDesc, srv) +} + +func _RegistrationAuthority_NewRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(proto.Registration) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).NewRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_NewRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).NewRegistration(ctx, req.(*proto.Registration)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_UpdateRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateRegistrationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).UpdateRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_UpdateRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).UpdateRegistration(ctx, req.(*UpdateRegistrationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_PerformValidation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PerformValidationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).PerformValidation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_PerformValidation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).PerformValidation(ctx, req.(*PerformValidationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_DeactivateRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(proto.Registration) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).DeactivateRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_DeactivateRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).DeactivateRegistration(ctx, req.(*proto.Registration)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_DeactivateAuthorization_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(proto.Authorization) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).DeactivateAuthorization(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_DeactivateAuthorization_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).DeactivateAuthorization(ctx, req.(*proto.Authorization)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_RevokeCertByApplicant_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeCertByApplicantRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).RevokeCertByApplicant(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_RevokeCertByApplicant_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).RevokeCertByApplicant(ctx, req.(*RevokeCertByApplicantRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_RevokeCertByKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeCertByKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).RevokeCertByKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_RevokeCertByKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).RevokeCertByKey(ctx, req.(*RevokeCertByKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_AdministrativelyRevokeCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdministrativelyRevokeCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).AdministrativelyRevokeCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_AdministrativelyRevokeCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).AdministrativelyRevokeCertificate(ctx, req.(*AdministrativelyRevokeCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_NewOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NewOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).NewOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_NewOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).NewOrder(ctx, req.(*NewOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_FinalizeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FinalizeOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).FinalizeOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_FinalizeOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).FinalizeOrder(ctx, req.(*FinalizeOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_GenerateOCSP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GenerateOCSPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).GenerateOCSP(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_GenerateOCSP_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).GenerateOCSP(ctx, req.(*GenerateOCSPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RegistrationAuthority_UnpauseAccount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnpauseAccountRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RegistrationAuthorityServer).UnpauseAccount(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RegistrationAuthority_UnpauseAccount_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RegistrationAuthorityServer).UnpauseAccount(ctx, req.(*UnpauseAccountRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RegistrationAuthority_ServiceDesc is the grpc.ServiceDesc for RegistrationAuthority service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RegistrationAuthority_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ra.RegistrationAuthority", + HandlerType: (*RegistrationAuthorityServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "NewRegistration", + Handler: _RegistrationAuthority_NewRegistration_Handler, + }, + { + MethodName: "UpdateRegistration", + Handler: _RegistrationAuthority_UpdateRegistration_Handler, + }, + { + MethodName: "PerformValidation", + Handler: _RegistrationAuthority_PerformValidation_Handler, + }, + { + MethodName: "DeactivateRegistration", + Handler: _RegistrationAuthority_DeactivateRegistration_Handler, + }, + { + MethodName: "DeactivateAuthorization", + Handler: _RegistrationAuthority_DeactivateAuthorization_Handler, + }, + { + MethodName: "RevokeCertByApplicant", + Handler: _RegistrationAuthority_RevokeCertByApplicant_Handler, + }, + { + MethodName: "RevokeCertByKey", + Handler: _RegistrationAuthority_RevokeCertByKey_Handler, + }, + { + MethodName: "AdministrativelyRevokeCertificate", + Handler: _RegistrationAuthority_AdministrativelyRevokeCertificate_Handler, + }, + { + MethodName: "NewOrder", + Handler: _RegistrationAuthority_NewOrder_Handler, + }, + { + MethodName: "FinalizeOrder", + Handler: _RegistrationAuthority_FinalizeOrder_Handler, + }, + { + MethodName: "GenerateOCSP", + Handler: _RegistrationAuthority_GenerateOCSP_Handler, + }, + { + MethodName: "UnpauseAccount", + Handler: _RegistrationAuthority_UnpauseAccount_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ra.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/ra/ra.go b/third-party/github.com/letsencrypt/boulder/ra/ra.go new file mode 100644 index 000000000..a873276f5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ra/ra.go @@ -0,0 +1,2770 @@ +package ra + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "net/url" + "os" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/akamai" + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + csrlib "github.com/letsencrypt/boulder/csr" + "github.com/letsencrypt/boulder/ctpolicy" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/probs" + pubpb "github.com/letsencrypt/boulder/publisher/proto" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/ratelimit" + "github.com/letsencrypt/boulder/ratelimits" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + vapb "github.com/letsencrypt/boulder/va/proto" + + "github.com/letsencrypt/boulder/web" +) + +var ( + errIncompleteGRPCRequest = errors.New("incomplete gRPC request message") + errIncompleteGRPCResponse = errors.New("incomplete gRPC response message") + + // caaRecheckDuration is the amount of time after a CAA check that we will + // recheck the CAA records for a domain. Per Baseline Requirements, we must + // recheck CAA records within 8 hours of issuance. We set this to 7 hours to + // stay on the safe side. + caaRecheckDuration = -7 * time.Hour +) + +type caaChecker interface { + IsCAAValid( + ctx context.Context, + in *vapb.IsCAAValidRequest, + opts ...grpc.CallOption, + ) (*vapb.IsCAAValidResponse, error) +} + +// RegistrationAuthorityImpl defines an RA. +// +// NOTE: All of the fields in RegistrationAuthorityImpl need to be +// populated, or there is a risk of panic. +type RegistrationAuthorityImpl struct { + rapb.UnsafeRegistrationAuthorityServer + CA capb.CertificateAuthorityClient + OCSP capb.OCSPGeneratorClient + VA vapb.VAClient + SA sapb.StorageAuthorityClient + PA core.PolicyAuthority + publisher pubpb.PublisherClient + caa caaChecker + + clk clock.Clock + log blog.Logger + keyPolicy goodkey.KeyPolicy + // How long before a newly created authorization expires. + authorizationLifetime time.Duration + pendingAuthorizationLifetime time.Duration + rlPolicies ratelimit.Limits + maxContactsPerReg int + limiter *ratelimits.Limiter + txnBuilder *ratelimits.TransactionBuilder + maxNames int + orderLifetime time.Duration + finalizeTimeout time.Duration + finalizeWG sync.WaitGroup + + issuersByNameID map[issuance.NameID]*issuance.Certificate + purger akamaipb.AkamaiPurgerClient + + ctpolicy *ctpolicy.CTPolicy + + ctpolicyResults *prometheus.HistogramVec + revocationReasonCounter *prometheus.CounterVec + namesPerCert *prometheus.HistogramVec + rlCheckLatency *prometheus.HistogramVec + rlOverrideUsageGauge *prometheus.GaugeVec + newRegCounter prometheus.Counter + recheckCAACounter prometheus.Counter + newCertCounter *prometheus.CounterVec + recheckCAAUsedAuthzLifetime prometheus.Counter + authzAges *prometheus.HistogramVec + orderAges *prometheus.HistogramVec + inflightFinalizes prometheus.Gauge + certCSRMismatch prometheus.Counter +} + +var _ rapb.RegistrationAuthorityServer = (*RegistrationAuthorityImpl)(nil) + +// NewRegistrationAuthorityImpl constructs a new RA object. +func NewRegistrationAuthorityImpl( + clk clock.Clock, + logger blog.Logger, + stats prometheus.Registerer, + maxContactsPerReg int, + keyPolicy goodkey.KeyPolicy, + limiter *ratelimits.Limiter, + txnBuilder *ratelimits.TransactionBuilder, + maxNames int, + authorizationLifetime time.Duration, + pendingAuthorizationLifetime time.Duration, + pubc pubpb.PublisherClient, + caaClient caaChecker, + orderLifetime time.Duration, + finalizeTimeout time.Duration, + ctp *ctpolicy.CTPolicy, + purger akamaipb.AkamaiPurgerClient, + issuers []*issuance.Certificate, +) *RegistrationAuthorityImpl { + ctpolicyResults := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ctpolicy_results", + Help: "Histogram of latencies of ctpolicy.GetSCTs calls with success/failure/deadlineExceeded labels", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"result"}, + ) + stats.MustRegister(ctpolicyResults) + + namesPerCert := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "names_per_cert", + Help: "Histogram of the number of SANs in requested and issued certificates", + // The namesPerCert buckets are chosen based on the current Let's Encrypt + // limit of 100 SANs per certificate. + Buckets: []float64{1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100}, + }, + // Type label value is either "requested" or "issued". + []string{"type"}, + ) + stats.MustRegister(namesPerCert) + + rlCheckLatency := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "ratelimitsv1_check_latency_seconds", + Help: fmt.Sprintf("Latency of ratelimit checks labeled by limit=[name] and decision=[%s|%s], in seconds", ratelimits.Allowed, ratelimits.Denied), + }, []string{"limit", "decision"}) + stats.MustRegister(rlCheckLatency) + + overrideUsageGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ratelimitsv1_override_usage", + Help: "Proportion of override limit used, by limit name and client identifier.", + }, []string{"limit", "override_key"}) + stats.MustRegister(overrideUsageGauge) + + newRegCounter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "new_registrations", + Help: "A counter of new registrations", + }) + stats.MustRegister(newRegCounter) + + recheckCAACounter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "recheck_caa", + Help: "A counter of CAA rechecks", + }) + stats.MustRegister(recheckCAACounter) + + recheckCAAUsedAuthzLifetime := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "recheck_caa_used_authz_lifetime", + Help: "A counter times the old codepath was used for CAA recheck time", + }) + stats.MustRegister(recheckCAAUsedAuthzLifetime) + + newCertCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "new_certificates", + Help: "A counter of new certificates including the certificate profile name and hexadecimal certificate profile hash", + }, []string{"profileName", "profileHash"}) + stats.MustRegister(newCertCounter) + + revocationReasonCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "revocation_reason", + Help: "A counter of certificate revocation reasons", + }, []string{"reason"}) + stats.MustRegister(revocationReasonCounter) + + authzAges := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "authz_ages", + Help: "Histogram of ages, in seconds, of Authorization objects, labelled by method and type", + // authzAges keeps track of how old, in seconds, authorizations are when + // we attach them to a new order and again when we finalize that order. + // We give it a non-standard bucket distribution so that the leftmost + // (closest to zero) bucket can be used exclusively for brand-new (i.e. + // not reused) authzs. Our buckets are: one nanosecond, one second, one + // minute, one hour, 7 hours (our CAA reuse time), 1 day, 2 days, 7 + // days, 30 days, +inf (should be empty). + Buckets: []float64{0.000000001, 1, 60, 3600, 25200, 86400, 172800, 604800, 2592000, 7776000}, + }, []string{"method", "type"}) + stats.MustRegister(authzAges) + + orderAges := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "order_ages", + Help: "Histogram of ages, in seconds, of Order objects when they're reused and finalized, labelled by method", + // Orders currently have a max age of 7 days (168hrs), so our buckets + // are: one nanosecond (new), 1 second, 10 seconds, 1 minute, 10 + // minutes, 1 hour, 7 hours (our CAA reuse time), 1 day, 2 days, 7 days, +inf. + Buckets: []float64{0.000000001, 1, 10, 60, 600, 3600, 25200, 86400, 172800, 604800}, + }, []string{"method"}) + stats.MustRegister(orderAges) + + inflightFinalizes := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "inflight_finalizes", + Help: "Gauge of the number of current asynchronous finalize goroutines", + }) + stats.MustRegister(inflightFinalizes) + + certCSRMismatch := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "cert_csr_mismatch", + Help: "Number of issued certificates that have failed ra.matchesCSR for any reason. This is _real bad_ and should be alerted upon.", + }) + stats.MustRegister(certCSRMismatch) + + issuersByNameID := make(map[issuance.NameID]*issuance.Certificate) + for _, issuer := range issuers { + issuersByNameID[issuer.NameID()] = issuer + } + + ra := &RegistrationAuthorityImpl{ + clk: clk, + log: logger, + authorizationLifetime: authorizationLifetime, + pendingAuthorizationLifetime: pendingAuthorizationLifetime, + rlPolicies: ratelimit.New(), + maxContactsPerReg: maxContactsPerReg, + keyPolicy: keyPolicy, + limiter: limiter, + txnBuilder: txnBuilder, + maxNames: maxNames, + publisher: pubc, + caa: caaClient, + orderLifetime: orderLifetime, + finalizeTimeout: finalizeTimeout, + ctpolicy: ctp, + ctpolicyResults: ctpolicyResults, + purger: purger, + issuersByNameID: issuersByNameID, + namesPerCert: namesPerCert, + rlCheckLatency: rlCheckLatency, + rlOverrideUsageGauge: overrideUsageGauge, + newRegCounter: newRegCounter, + recheckCAACounter: recheckCAACounter, + newCertCounter: newCertCounter, + revocationReasonCounter: revocationReasonCounter, + recheckCAAUsedAuthzLifetime: recheckCAAUsedAuthzLifetime, + authzAges: authzAges, + orderAges: orderAges, + inflightFinalizes: inflightFinalizes, + certCSRMismatch: certCSRMismatch, + } + return ra +} + +func (ra *RegistrationAuthorityImpl) LoadRateLimitPoliciesFile(filename string) error { + configBytes, err := os.ReadFile(filename) + if err != nil { + return err + } + err = ra.rlPolicies.LoadPolicies(configBytes) + if err != nil { + return err + } + + return nil +} + +// certificateRequestAuthz is a struct for holding information about a valid +// authz referenced during a certificateRequestEvent. It holds both the +// authorization ID and the challenge type that made the authorization valid. We +// specifically include the challenge type that solved the authorization to make +// some common analysis easier. +type certificateRequestAuthz struct { + ID string + ChallengeType core.AcmeChallenge +} + +// certificateRequestEvent is a struct for holding information that is logged as +// JSON to the audit log as the result of an issuance event. +type certificateRequestEvent struct { + ID string `json:",omitempty"` + // Requester is the associated account ID + Requester int64 `json:",omitempty"` + // OrderID is the associated order ID (may be empty for an ACME v1 issuance) + OrderID int64 `json:",omitempty"` + // SerialNumber is the string representation of the issued certificate's + // serial number + SerialNumber string `json:",omitempty"` + // VerifiedFields are required by the baseline requirements and are always + // a static value for Boulder. + VerifiedFields []string `json:",omitempty"` + // CommonName is the subject common name from the issued cert + CommonName string `json:",omitempty"` + // Names are the DNS SAN entries from the issued cert + Names []string `json:",omitempty"` + // NotBefore is the starting timestamp of the issued cert's validity period + NotBefore time.Time `json:",omitempty"` + // NotAfter is the ending timestamp of the issued cert's validity period + NotAfter time.Time `json:",omitempty"` + // RequestTime and ResponseTime are for tracking elapsed time during issuance + RequestTime time.Time `json:",omitempty"` + ResponseTime time.Time `json:",omitempty"` + // Error contains any encountered errors + Error string `json:",omitempty"` + // Authorizations is a map of identifier names to certificateRequestAuthz + // objects. It can be used to understand how the names in a certificate + // request were authorized. + Authorizations map[string]certificateRequestAuthz + // CertProfileName is a human readable name used to refer to the certificate + // profile. + CertProfileName string `json:",omitempty"` + // CertProfileHash is SHA256 sum over every exported field of an + // issuance.ProfileConfig, represented here as a hexadecimal string. + CertProfileHash string `json:",omitempty"` +} + +// certificateRevocationEvent is a struct for holding information that is logged +// as JSON to the audit log as the result of a revocation event. +type certificateRevocationEvent struct { + ID string `json:",omitempty"` + // SerialNumber is the string representation of the revoked certificate's + // serial number. + SerialNumber string `json:",omitempty"` + // Reason is the integer representing the revocation reason used. + Reason int64 `json:",omitempty"` + // Method is the way in which revocation was requested. + // It will be one of the strings: "applicant", "subscriber", "control", "key", or "admin". + Method string `json:",omitempty"` + // RequesterID is the account ID of the requester. + // Will be zero for admin revocations. + RequesterID int64 `json:",omitempty"` + // AdminName is the name of the admin requester. + // Will be zero for subscriber revocations. + AdminName string `json:",omitempty"` + // Error contains any error encountered during revocation. + Error string `json:",omitempty"` +} + +// finalizationCAACheckEvent is a struct for holding information logged as JSON +// to the info log as the result of an issuance event. It is logged when the RA +// performs the final CAA check of a certificate finalization request. +type finalizationCAACheckEvent struct { + // Requester is the associated account ID. + Requester int64 `json:",omitempty"` + // Reused is a count of Authz where the original CAA check was performed in + // the last 7 hours. + Reused int `json:",omitempty"` + // Rechecked is a count of Authz where a new CAA check was performed because + // the original check was older than 7 hours. + Rechecked int `json:",omitempty"` +} + +// noRegistrationID is used for the regID parameter to GetThreshold when no +// registration-based overrides are necessary. +const noRegistrationID = -1 + +// registrationCounter is a type to abstract the use of `CountRegistrationsByIP` +// or `CountRegistrationsByIPRange` SA methods. +type registrationCounter func(context.Context, *sapb.CountRegistrationsByIPRequest, ...grpc.CallOption) (*sapb.Count, error) + +// checkRegistrationIPLimit checks a specific registraton limit by using the +// provided registrationCounter function to determine if the limit has been +// exceeded for a given IP or IP range +func (ra *RegistrationAuthorityImpl) checkRegistrationIPLimit(ctx context.Context, limit ratelimit.RateLimitPolicy, ip net.IP, counter registrationCounter) error { + now := ra.clk.Now() + count, err := counter(ctx, &sapb.CountRegistrationsByIPRequest{ + Ip: ip, + Range: &sapb.Range{ + Earliest: timestamppb.New(limit.WindowBegin(now)), + Latest: timestamppb.New(now), + }, + }) + if err != nil { + return err + } + + threshold, overrideKey := limit.GetThreshold(ip.String(), noRegistrationID) + if count.Count >= threshold { + return berrors.RegistrationsPerIPError(0, "too many registrations for this IP") + } + if overrideKey != "" { + // We do not support overrides for the NewRegistrationsPerIPRange limit. + utilization := float64(count.Count+1) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.RegistrationsPerIP, overrideKey).Set(utilization) + } + + return nil +} + +// checkRegistrationLimits enforces the RegistrationsPerIP and +// RegistrationsPerIPRange limits +func (ra *RegistrationAuthorityImpl) checkRegistrationLimits(ctx context.Context, ip net.IP) error { + // Check the registrations per IP limit using the CountRegistrationsByIP SA + // function that matches IP addresses exactly + exactRegLimit := ra.rlPolicies.RegistrationsPerIP() + if exactRegLimit.Enabled() { + started := ra.clk.Now() + err := ra.checkRegistrationIPLimit(ctx, exactRegLimit, ip, ra.SA.CountRegistrationsByIP) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.RegistrationsPerIP, ratelimits.Denied).Observe(elapsed.Seconds()) + ra.log.Infof("Rate limit exceeded, RegistrationsPerIP, by IP: %q", ip) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.RegistrationsPerIP, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + // We only apply the fuzzy reg limit to IPv6 addresses. + // Per https://golang.org/pkg/net/#IP.To4 "If ip is not an IPv4 address, To4 + // returns nil" + if ip.To4() != nil { + return nil + } + + // Check the registrations per IP range limit using the + // CountRegistrationsByIPRange SA function that fuzzy-matches IPv6 addresses + // within a larger address range + fuzzyRegLimit := ra.rlPolicies.RegistrationsPerIPRange() + if fuzzyRegLimit.Enabled() { + started := ra.clk.Now() + err := ra.checkRegistrationIPLimit(ctx, fuzzyRegLimit, ip, ra.SA.CountRegistrationsByIPRange) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.RegistrationsPerIPRange, ratelimits.Denied).Observe(elapsed.Seconds()) + ra.log.Infof("Rate limit exceeded, RegistrationsByIPRange, IP: %q", ip) + + // For the fuzzyRegLimit we use a new error message that specifically + // mentions that the limit being exceeded is applied to a *range* of IPs + return berrors.RateLimitError(0, "too many registrations for this IP range") + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.RegistrationsPerIPRange, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + return nil +} + +// NewRegistration constructs a new Registration from a request. +func (ra *RegistrationAuthorityImpl) NewRegistration(ctx context.Context, request *corepb.Registration) (*corepb.Registration, error) { + // Error if the request is nil, there is no account key or IP address + if request == nil || len(request.Key) == 0 || len(request.InitialIP) == 0 { + return nil, errIncompleteGRPCRequest + } + + // Check if account key is acceptable for use. + var key jose.JSONWebKey + err := key.UnmarshalJSON(request.Key) + if err != nil { + return nil, berrors.InternalServerError("failed to unmarshal account key: %s", err.Error()) + } + err = ra.keyPolicy.GoodKey(ctx, key.Key) + if err != nil { + return nil, berrors.MalformedError("invalid public key: %s", err.Error()) + } + + // Check IP address rate limits. + var ipAddr net.IP + err = ipAddr.UnmarshalText(request.InitialIP) + if err != nil { + return nil, berrors.InternalServerError("failed to unmarshal ip address: %s", err.Error()) + } + err = ra.checkRegistrationLimits(ctx, ipAddr) + if err != nil { + return nil, err + } + + // Check that contacts conform to our expectations. + err = validateContactsPresent(request.Contact, request.ContactsPresent) + if err != nil { + return nil, err + } + err = ra.validateContacts(request.Contact) + if err != nil { + return nil, err + } + + // Don't populate ID or CreatedAt because those will be set by the SA. + req := &corepb.Registration{ + Key: request.Key, + Contact: request.Contact, + ContactsPresent: request.ContactsPresent, + Agreement: request.Agreement, + InitialIP: request.InitialIP, + Status: string(core.StatusValid), + } + + // Store the registration object, then return the version that got stored. + res, err := ra.SA.NewRegistration(ctx, req) + if err != nil { + return nil, err + } + + ra.newRegCounter.Inc() + return res, nil +} + +// validateContacts checks the provided list of contacts, returning an error if +// any are not acceptable. Unacceptable contacts lists include: +// * An empty list +// * A list has more than maxContactsPerReg contacts +// * A list containing an empty contact +// * A list containing a contact that does not parse as a URL +// * A list containing a contact that has a URL scheme other than mailto +// * A list containing a mailto contact that contains hfields +// * A list containing a contact that has non-ascii characters +// * A list containing a contact that doesn't pass `policy.ValidEmail` +func (ra *RegistrationAuthorityImpl) validateContacts(contacts []string) error { + if len(contacts) == 0 { + return nil // Nothing to validate + } + if ra.maxContactsPerReg > 0 && len(contacts) > ra.maxContactsPerReg { + return berrors.MalformedError( + "too many contacts provided: %d > %d", + len(contacts), + ra.maxContactsPerReg, + ) + } + + for _, contact := range contacts { + if contact == "" { + return berrors.InvalidEmailError("empty contact") + } + parsed, err := url.Parse(contact) + if err != nil { + return berrors.InvalidEmailError("invalid contact") + } + if parsed.Scheme != "mailto" { + return berrors.UnsupportedContactError("contact method %q is not supported", parsed.Scheme) + } + if parsed.RawQuery != "" || contact[len(contact)-1] == '?' { + return berrors.InvalidEmailError("contact email %q contains a question mark", contact) + } + if parsed.Fragment != "" || contact[len(contact)-1] == '#' { + return berrors.InvalidEmailError("contact email %q contains a '#'", contact) + } + if !core.IsASCII(contact) { + return berrors.InvalidEmailError( + "contact email [%q] contains non-ASCII characters", + contact, + ) + } + err = policy.ValidEmail(parsed.Opaque) + if err != nil { + return err + } + } + + // NOTE(@cpu): For historical reasons (= maxContactBytes { + return berrors.InvalidEmailError( + "too many/too long contact(s). Please use shorter or fewer email addresses") + } + + return nil +} + +func (ra *RegistrationAuthorityImpl) checkPendingAuthorizationLimit(ctx context.Context, regID int64, limit ratelimit.RateLimitPolicy) error { + // This rate limit's threshold can only be overridden on a per-regID basis, + // not based on any other key. + threshold, overrideKey := limit.GetThreshold("", regID) + if threshold == -1 { + return nil + } + countPB, err := ra.SA.CountPendingAuthorizations2(ctx, &sapb.RegistrationID{ + Id: regID, + }) + if err != nil { + return err + } + if countPB.Count >= threshold { + ra.log.Infof("Rate limit exceeded, PendingAuthorizationsByRegID, regID: %d", regID) + return berrors.RateLimitError(0, "too many currently pending authorizations: %d", countPB.Count) + } + if overrideKey != "" { + utilization := float64(countPB.Count) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.PendingAuthorizationsPerAccount, overrideKey).Set(utilization) + } + return nil +} + +// checkInvalidAuthorizationLimits checks the failed validation limit for each +// of the provided hostnames. It returns the first error. +func (ra *RegistrationAuthorityImpl) checkInvalidAuthorizationLimits(ctx context.Context, regID int64, hostnames []string, limits ratelimit.RateLimitPolicy) error { + results := make(chan error, len(hostnames)) + for _, hostname := range hostnames { + go func(hostname string) { + results <- ra.checkInvalidAuthorizationLimit(ctx, regID, hostname, limits) + }(hostname) + } + // We don't have to wait for all of the goroutines to finish because there's + // enough capacity in the chan for them all to write their result even if + // nothing is reading off the chan anymore. + for range len(hostnames) { + err := <-results + if err != nil { + return err + } + } + return nil +} + +func (ra *RegistrationAuthorityImpl) checkInvalidAuthorizationLimit(ctx context.Context, regID int64, hostname string, limit ratelimit.RateLimitPolicy) error { + latest := ra.clk.Now().Add(ra.pendingAuthorizationLifetime) + earliest := latest.Add(-limit.Window.Duration) + req := &sapb.CountInvalidAuthorizationsRequest{ + RegistrationID: regID, + Hostname: hostname, + Range: &sapb.Range{ + Earliest: timestamppb.New(earliest), + Latest: timestamppb.New(latest), + }, + } + count, err := ra.SA.CountInvalidAuthorizations2(ctx, req) + if err != nil { + return err + } + // Most rate limits have a key for overrides, but there is no meaningful key + // here. + noKey := "" + threshold, overrideKey := limit.GetThreshold(noKey, regID) + if count.Count >= threshold { + ra.log.Infof("Rate limit exceeded, InvalidAuthorizationsByRegID, regID: %d", regID) + return berrors.FailedValidationError(0, "too many failed authorizations recently") + } + if overrideKey != "" { + utilization := float64(count.Count) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.InvalidAuthorizationsPerAccount, overrideKey).Set(utilization) + } + return nil +} + +// checkNewOrdersPerAccountLimit enforces the rlPolicies `NewOrdersPerAccount` +// rate limit. This rate limit ensures a client can not create more than the +// specified threshold of new orders within the specified time window. +func (ra *RegistrationAuthorityImpl) checkNewOrdersPerAccountLimit(ctx context.Context, acctID int64, names []string, limit ratelimit.RateLimitPolicy) error { + // Check if there is already an existing certificate for the exact name set we + // are issuing for. If so bypass the newOrders limit. + exists, err := ra.SA.FQDNSetExists(ctx, &sapb.FQDNSetExistsRequest{Domains: names}) + if err != nil { + return fmt.Errorf("checking renewal exemption for %q: %s", names, err) + } + if exists.Exists { + return nil + } + + now := ra.clk.Now() + count, err := ra.SA.CountOrders(ctx, &sapb.CountOrdersRequest{ + AccountID: acctID, + Range: &sapb.Range{ + Earliest: timestamppb.New(now.Add(-limit.Window.Duration)), + Latest: timestamppb.New(now), + }, + }) + if err != nil { + return err + } + // There is no meaningful override key to use for this rate limit + noKey := "" + threshold, overrideKey := limit.GetThreshold(noKey, acctID) + if count.Count >= threshold { + return berrors.RateLimitError(0, "too many new orders recently") + } + if overrideKey != "" { + utilization := float64(count.Count+1) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.NewOrdersPerAccount, overrideKey).Set(utilization) + } + return nil +} + +// matchesCSR tests the contents of a generated certificate to make sure +// that the PublicKey, CommonName, and DNSNames match those provided in +// the CSR that was used to generate the certificate. It also checks the +// following fields for: +// - notBefore is not more than 24 hours ago +// - BasicConstraintsValid is true +// - IsCA is false +// - ExtKeyUsage only contains ExtKeyUsageServerAuth & ExtKeyUsageClientAuth +// - Subject only contains CommonName & Names +func (ra *RegistrationAuthorityImpl) matchesCSR(parsedCertificate *x509.Certificate, csr *x509.CertificateRequest) error { + if !core.KeyDigestEquals(parsedCertificate.PublicKey, csr.PublicKey) { + return berrors.InternalServerError("generated certificate public key doesn't match CSR public key") + } + + csrNames := csrlib.NamesFromCSR(csr) + if parsedCertificate.Subject.CommonName != "" { + // Only check that the issued common name matches one of the SANs if there + // is an issued CN at all: this allows flexibility on whether we include + // the CN. + if !slices.Contains(csrNames.SANs, parsedCertificate.Subject.CommonName) { + return berrors.InternalServerError("generated certificate CommonName doesn't match any CSR name") + } + } + + parsedNames := parsedCertificate.DNSNames + sort.Strings(parsedNames) + if !slices.Equal(parsedNames, csrNames.SANs) { + return berrors.InternalServerError("generated certificate DNSNames don't match CSR DNSNames") + } + + if !slices.EqualFunc(parsedCertificate.IPAddresses, csr.IPAddresses, func(l, r net.IP) bool { return l.Equal(r) }) { + return berrors.InternalServerError("generated certificate IPAddresses don't match CSR IPAddresses") + } + if !slices.Equal(parsedCertificate.EmailAddresses, csr.EmailAddresses) { + return berrors.InternalServerError("generated certificate EmailAddresses don't match CSR EmailAddresses") + } + + if len(parsedCertificate.Subject.Country) > 0 || len(parsedCertificate.Subject.Organization) > 0 || + len(parsedCertificate.Subject.OrganizationalUnit) > 0 || len(parsedCertificate.Subject.Locality) > 0 || + len(parsedCertificate.Subject.Province) > 0 || len(parsedCertificate.Subject.StreetAddress) > 0 || + len(parsedCertificate.Subject.PostalCode) > 0 { + return berrors.InternalServerError("generated certificate Subject contains fields other than CommonName, or SerialNumber") + } + now := ra.clk.Now() + if now.Sub(parsedCertificate.NotBefore) > time.Hour*24 { + return berrors.InternalServerError("generated certificate is back dated %s", now.Sub(parsedCertificate.NotBefore)) + } + if !parsedCertificate.BasicConstraintsValid { + return berrors.InternalServerError("generated certificate doesn't have basic constraints set") + } + if parsedCertificate.IsCA { + return berrors.InternalServerError("generated certificate can sign other certificates") + } + if !slices.Equal(parsedCertificate.ExtKeyUsage, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) { + return berrors.InternalServerError("generated certificate doesn't have correct key usage extensions") + } + + return nil +} + +// checkOrderAuthorizations verifies that a provided set of names associated +// with a specific order and account has all of the required valid, unexpired +// authorizations to proceed with issuance. It returns the authorizations that +// satisfied the set of names or it returns an error. If it returns an error, it +// will be of type BoulderError. +func (ra *RegistrationAuthorityImpl) checkOrderAuthorizations( + ctx context.Context, + names []string, + acctID accountID, + orderID orderID) (map[string]*core.Authorization, error) { + // Get all of the valid authorizations for this account/order + req := &sapb.GetValidOrderAuthorizationsRequest{ + Id: int64(orderID), + AcctID: int64(acctID), + } + authzMapPB, err := ra.SA.GetValidOrderAuthorizations2(ctx, req) + if err != nil { + return nil, berrors.InternalServerError("error in GetValidOrderAuthorizations: %s", err) + } + authzs, err := bgrpc.PBToAuthzMap(authzMapPB) + if err != nil { + return nil, err + } + + // Ensure the names from the CSR are free of duplicates & lowercased. + names = core.UniqueLowerNames(names) + + // Check the authorizations to ensure validity for the names required. + err = ra.checkAuthorizationsCAA(ctx, int64(acctID), names, authzs, ra.clk.Now()) + if err != nil { + return nil, err + } + + // Check the challenges themselves too. + for _, authz := range authzs { + err = ra.PA.CheckAuthz(authz) + if err != nil { + return nil, err + } + } + + return authzs, nil +} + +// validatedBefore checks if a given authorization's challenge was +// validated before a given time. Returns a bool. +func validatedBefore(authz *core.Authorization, caaRecheckTime time.Time) (bool, error) { + numChallenges := len(authz.Challenges) + if numChallenges != 1 { + return false, fmt.Errorf("authorization has incorrect number of challenges. 1 expected, %d found for: id %s", numChallenges, authz.ID) + } + if authz.Challenges[0].Validated == nil { + return false, fmt.Errorf("authorization's challenge has no validated timestamp for: id %s", authz.ID) + } + return authz.Challenges[0].Validated.Before(caaRecheckTime), nil +} + +// checkAuthorizationsCAA implements the common logic of validating a set of +// authorizations against a set of names that is used by both +// `checkAuthorizations` and `checkOrderAuthorizations`. If required CAA will be +// rechecked for authorizations that are too old. +// If it returns an error, it will be of type BoulderError. +func (ra *RegistrationAuthorityImpl) checkAuthorizationsCAA( + ctx context.Context, + acctID int64, + names []string, + authzs map[string]*core.Authorization, + now time.Time) error { + // badNames contains the names that were unauthorized + var badNames []string + // recheckAuthzs is a list of authorizations that must have their CAA records rechecked + var recheckAuthzs []*core.Authorization + + // Per Baseline Requirements, CAA must be checked within 8 hours of + // issuance. CAA is checked when an authorization is validated, so as + // long as that was less than 8 hours ago, we're fine. We recheck if + // that was more than 7 hours ago, to be on the safe side. We can + // check to see if the authorized challenge `AttemptedAt` + // (`Validated`) value from the database is before our caaRecheckTime. + // Set the recheck time to 7 hours ago. + caaRecheckAfter := now.Add(caaRecheckDuration) + + // Set a CAA recheck time based on the assumption of a 30 day authz + // lifetime. This has been deprecated in favor of a new check based + // off the Validated time stored in the database, but we want to check + // both for a time and increment a stat if this code path is hit for + // compliance safety. + caaRecheckTime := now.Add(ra.authorizationLifetime).Add(caaRecheckDuration) + + for _, name := range names { + authz := authzs[name] + if authz == nil { + badNames = append(badNames, name) + } else if authz.Expires == nil { + return berrors.InternalServerError("found an authorization with a nil Expires field: id %s", authz.ID) + } else if authz.Expires.Before(now) { + badNames = append(badNames, name) + } else if staleCAA, err := validatedBefore(authz, caaRecheckAfter); err != nil { + return berrors.InternalServerError(err.Error()) + } else if staleCAA { + // Ensure that CAA is rechecked for this name + recheckAuthzs = append(recheckAuthzs, authz) + } else if authz.Expires.Before(caaRecheckTime) { + // Ensure that CAA is rechecked for this name + recheckAuthzs = append(recheckAuthzs, authz) + // This codepath should not be used, but is here as a safety + // net until the new codepath is proven. Increment metric if + // it is used. + ra.recheckCAAUsedAuthzLifetime.Add(1) + } + } + + if len(recheckAuthzs) > 0 { + err := ra.recheckCAA(ctx, recheckAuthzs) + if err != nil { + return err + } + } + + if len(badNames) > 0 { + return berrors.UnauthorizedError( + "authorizations for these names not found or expired: %s", + strings.Join(badNames, ", "), + ) + } + + caaEvent := &finalizationCAACheckEvent{ + Requester: acctID, + Reused: len(authzs) - len(recheckAuthzs), + Rechecked: len(recheckAuthzs), + } + ra.log.InfoObject("FinalizationCaaCheck", caaEvent) + + return nil +} + +// recheckCAA accepts a list of names that need to have their CAA records +// rechecked because their associated authorizations are sufficiently old and +// performs the CAA checks required for each. If any of the rechecks fail an +// error is returned. +func (ra *RegistrationAuthorityImpl) recheckCAA(ctx context.Context, authzs []*core.Authorization) error { + ra.recheckCAACounter.Add(float64(len(authzs))) + + type authzCAAResult struct { + authz *core.Authorization + err error + } + ch := make(chan authzCAAResult, len(authzs)) + for _, authz := range authzs { + go func(authz *core.Authorization) { + name := authz.Identifier.Value + + // If an authorization has multiple valid challenges, + // the type of the first valid challenge is used for + // the purposes of CAA rechecking. + var method string + for _, challenge := range authz.Challenges { + if challenge.Status == core.StatusValid { + method = string(challenge.Type) + break + } + } + if method == "" { + ch <- authzCAAResult{ + authz: authz, + err: berrors.InternalServerError( + "Internal error determining validation method for authorization ID %v (%v)", + authz.ID, name), + } + return + } + + resp, err := ra.caa.IsCAAValid(ctx, &vapb.IsCAAValidRequest{ + Domain: name, + ValidationMethod: method, + AccountURIID: authz.RegistrationID, + }) + if err != nil { + ra.log.AuditErrf("Rechecking CAA: %s", err) + err = berrors.InternalServerError( + "Internal error rechecking CAA for authorization ID %v (%v)", + authz.ID, name, + ) + } else if resp.Problem != nil { + err = berrors.CAAError(resp.Problem.Detail) + } + ch <- authzCAAResult{ + authz: authz, + err: err, + } + }(authz) + } + var subErrors []berrors.SubBoulderError + // Read a recheckResult for each authz from the results channel + for range len(authzs) { + recheckResult := <-ch + // If the result had a CAA boulder error, construct a suberror with the + // identifier from the authorization that was checked. + err := recheckResult.err + if err != nil { + var bErr *berrors.BoulderError + if errors.As(err, &bErr) && bErr.Type == berrors.CAA { + subErrors = append(subErrors, berrors.SubBoulderError{ + Identifier: recheckResult.authz.Identifier, + BoulderError: bErr}) + } else { + return err + } + } + } + if len(subErrors) > 0 { + var detail string + // If there was only one error, then use it as the top level error that is + // returned. + if len(subErrors) == 1 { + return subErrors[0].BoulderError + } + detail = fmt.Sprintf( + "Rechecking CAA for %q and %d more identifiers failed. "+ + "Refer to sub-problems for more information", + subErrors[0].Identifier.Value, + len(subErrors)-1) + return (&berrors.BoulderError{ + Type: berrors.CAA, + Detail: detail, + }).WithSubErrors(subErrors) + } + return nil +} + +// failOrder marks an order as failed by setting the problem details field of +// the order & persisting it through the SA. If an error occurs doing this we +// log it and don't modify the input order. There aren't any alternatives if we +// can't add the error to the order. This function MUST only be called when we +// are already returning an error for another reason. +func (ra *RegistrationAuthorityImpl) failOrder( + ctx context.Context, + order *corepb.Order, + prob *probs.ProblemDetails) { + // Use a separate context with its own timeout, since the error we encountered + // may have been a context cancellation or timeout, and these operations still + // need to succeed. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Second) + defer cancel() + + // Convert the problem to a protobuf problem for the *corepb.Order field + pbProb, err := bgrpc.ProblemDetailsToPB(prob) + if err != nil { + ra.log.AuditErrf("Could not convert order error problem to PB: %q", err) + return + } + + // Assign the protobuf problem to the field and save it via the SA + order.Error = pbProb + _, err = ra.SA.SetOrderError(ctx, &sapb.SetOrderErrorRequest{ + Id: order.Id, + Error: order.Error, + }) + if err != nil { + ra.log.AuditErrf("Could not persist order error: %q", err) + } +} + +// To help minimize the chance that an accountID would be used as an order ID +// (or vice versa) when calling functions that use both we define internal +// `accountID` and `orderID` types so that callers must explicitly cast. +type accountID int64 +type orderID int64 + +// FinalizeOrder accepts a request to finalize an order object and, if possible, +// issues a certificate to satisfy the order. If an order does not have valid, +// unexpired authorizations for all of its associated names an error is +// returned. Similarly we vet that all of the names in the order are acceptable +// based on current policy and return an error if the order can't be fulfilled. +// If successful the order will be returned in processing status for the client +// to poll while awaiting finalization to occur. +func (ra *RegistrationAuthorityImpl) FinalizeOrder(ctx context.Context, req *rapb.FinalizeOrderRequest) (*corepb.Order, error) { + // Step 1: Set up logging/tracing and validate the Order + if req == nil || req.Order == nil || len(req.Csr) == 0 { + return nil, errIncompleteGRPCRequest + } + + logEvent := certificateRequestEvent{ + ID: core.NewToken(), + OrderID: req.Order.Id, + Requester: req.Order.RegistrationID, + RequestTime: ra.clk.Now(), + } + csr, err := ra.validateFinalizeRequest(ctx, req, &logEvent) + if err != nil { + return nil, err + } + + // Observe the age of this order, so we know how quickly most clients complete + // issuance flows. + ra.orderAges.WithLabelValues("FinalizeOrder").Observe(ra.clk.Since(req.Order.Created.AsTime()).Seconds()) + + // Step 2: Set the Order to Processing status + // + // We do this separately from the issuance process itself so that, when we + // switch to doing issuance asynchronously, we aren't lying to the client + // when we say that their order is already Processing. + // + // NOTE(@cpu): After this point any errors that are encountered must update + // the state of the order to invalid by setting the order's error field. + // Otherwise the order will be "stuck" in processing state. It can not be + // finalized because it isn't pending, but we aren't going to process it + // further because we already did and encountered an error. + _, err = ra.SA.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: req.Order.Id}) + if err != nil { + // Fail the order with a server internal error - we weren't able to set the + // status to processing and that's unexpected & weird. + ra.failOrder(ctx, req.Order, probs.ServerInternal("Error setting order processing")) + return nil, err + } + + // Update the order status locally since the SA doesn't return the updated + // order itself after setting the status + order := req.Order + order.Status = string(core.StatusProcessing) + + // Steps 3 (issuance) and 4 (cleanup) are done inside a helper function so + // that we can control whether or not that work happens asynchronously. + if features.Get().AsyncFinalize { + // We do this work in a goroutine so that we can better handle latency from + // getting SCTs and writing the (pre)certificate to the database. This lets + // us return the order in the Processing state to the client immediately, + // prompting them to poll the Order object and wait for it to be put into + // its final state. + // + // We track this goroutine's lifetime in a waitgroup global to this RA, so + // that it can wait for all goroutines to drain during shutdown. + ra.finalizeWG.Add(1) + go func() { + _, err := ra.issueCertificateOuter(ctx, proto.Clone(order).(*corepb.Order), csr, logEvent) + if err != nil { + // We only log here, because this is in a background goroutine with + // no parent goroutine waiting for it to receive the error. + ra.log.AuditErrf("Asynchronous finalization failed: %s", err.Error()) + } + ra.finalizeWG.Done() + }() + return order, nil + } else { + return ra.issueCertificateOuter(ctx, order, csr, logEvent) + } +} + +// validateFinalizeRequest checks that a FinalizeOrder request is fully correct +// and ready for issuance. +func (ra *RegistrationAuthorityImpl) validateFinalizeRequest( + ctx context.Context, + req *rapb.FinalizeOrderRequest, + logEvent *certificateRequestEvent) (*x509.CertificateRequest, error) { + if req.Order.Id <= 0 { + return nil, berrors.MalformedError("invalid order ID: %d", req.Order.Id) + } + + if req.Order.RegistrationID <= 0 { + return nil, berrors.MalformedError("invalid account ID: %d", req.Order.RegistrationID) + } + + if core.AcmeStatus(req.Order.Status) != core.StatusReady { + return nil, berrors.OrderNotReadyError( + "Order's status (%q) is not acceptable for finalization", + req.Order.Status) + } + + // There should never be an order with 0 names at the stage, but we check to + // be on the safe side, throwing an internal server error if this assumption + // is ever violated. + if len(req.Order.Names) == 0 { + return nil, berrors.InternalServerError("Order has no associated names") + } + + // Parse the CSR from the request + csr, err := x509.ParseCertificateRequest(req.Csr) + if err != nil { + return nil, berrors.BadCSRError("unable to parse CSR: %s", err.Error()) + } + + err = csrlib.VerifyCSR(ctx, csr, ra.maxNames, &ra.keyPolicy, ra.PA) + if err != nil { + // VerifyCSR returns berror instances that can be passed through as-is + // without wrapping. + return nil, err + } + + // Dedupe, lowercase and sort both the names from the CSR and the names in the + // order. + csrNames := csrlib.NamesFromCSR(csr).SANs + orderNames := core.UniqueLowerNames(req.Order.Names) + + // Immediately reject the request if the number of names differ + if len(orderNames) != len(csrNames) { + return nil, berrors.UnauthorizedError("Order includes different number of names than CSR specifies") + } + + // Check that the order names and the CSR names are an exact match + for i, name := range orderNames { + if name != csrNames[i] { + return nil, berrors.UnauthorizedError("CSR is missing Order domain %q", name) + } + } + + // Get the originating account for use in the next check. + regPB, err := ra.SA.GetRegistration(ctx, &sapb.RegistrationID{Id: req.Order.RegistrationID}) + if err != nil { + return nil, err + } + + account, err := bgrpc.PbToRegistration(regPB) + if err != nil { + return nil, err + } + + // Make sure they're not using their account key as the certificate key too. + if core.KeyDigestEquals(csr.PublicKey, account.Key) { + return nil, berrors.MalformedError("certificate public key must be different than account key") + } + + // Double-check that all authorizations on this order are also associated with + // the same account as the order itself. + authzs, err := ra.checkOrderAuthorizations(ctx, csrNames, accountID(req.Order.RegistrationID), orderID(req.Order.Id)) + if err != nil { + // Pass through the error without wrapping it because the called functions + // return BoulderError and we don't want to lose the type. + return nil, err + } + + // Collect up a certificateRequestAuthz that stores the ID and challenge type + // of each of the valid authorizations we used for this issuance. + logEventAuthzs := make(map[string]certificateRequestAuthz, len(csrNames)) + for name, authz := range authzs { + // No need to check for error here because we know this same call just + // succeeded inside ra.checkOrderAuthorizations + solvedByChallengeType, _ := authz.SolvedBy() + logEventAuthzs[name] = certificateRequestAuthz{ + ID: authz.ID, + ChallengeType: solvedByChallengeType, + } + authzAge := (ra.authorizationLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds() + ra.authzAges.WithLabelValues("FinalizeOrder", string(authz.Status)).Observe(authzAge) + } + logEvent.Authorizations = logEventAuthzs + + // Mark that we verified the CN and SANs + logEvent.VerifiedFields = []string{"subject.commonName", "subjectAltName"} + + return csr, nil +} + +// issueCertificateOuter exists solely to ensure that all calls to +// issueCertificateInner have their result handled uniformly, no matter what +// return path that inner function takes. It takes ownership of the logEvent, +// mutates it, and is responsible for outputting its final state. +func (ra *RegistrationAuthorityImpl) issueCertificateOuter( + ctx context.Context, + order *corepb.Order, + csr *x509.CertificateRequest, + logEvent certificateRequestEvent, +) (*corepb.Order, error) { + ra.inflightFinalizes.Inc() + defer ra.inflightFinalizes.Dec() + + // Step 3: Issue the Certificate + cert, cpId, err := ra.issueCertificateInner( + ctx, csr, order.CertificateProfileName, accountID(order.RegistrationID), orderID(order.Id)) + + // Step 4: Fail the order if necessary, and update metrics and log fields + var result string + if err != nil { + // The problem is computed using `web.ProblemDetailsForError`, the same + // function the WFE uses to convert between `berrors` and problems. This + // will turn normal expected berrors like berrors.UnauthorizedError into the + // correct `urn:ietf:params:acme:error:unauthorized` problem while not + // letting anything like a server internal error through with sensitive + // info. + ra.failOrder(ctx, order, web.ProblemDetailsForError(err, "Error finalizing order")) + order.Status = string(core.StatusInvalid) + + logEvent.Error = err.Error() + result = "error" + } else { + order.CertificateSerial = core.SerialToString(cert.SerialNumber) + order.Status = string(core.StatusValid) + + ra.namesPerCert.With( + prometheus.Labels{"type": "issued"}, + ).Observe(float64(len(order.Names))) + + ra.newCertCounter.With( + prometheus.Labels{ + "profileName": cpId.name, + "profileHash": hex.EncodeToString(cpId.hash), + }).Inc() + + logEvent.SerialNumber = core.SerialToString(cert.SerialNumber) + logEvent.CommonName = cert.Subject.CommonName + logEvent.Names = cert.DNSNames + logEvent.NotBefore = cert.NotBefore + logEvent.NotAfter = cert.NotAfter + logEvent.CertProfileName = cpId.name + logEvent.CertProfileHash = hex.EncodeToString(cpId.hash) + + result = "successful" + } + + logEvent.ResponseTime = ra.clk.Now() + ra.log.AuditObject(fmt.Sprintf("Certificate request - %s", result), logEvent) + + return order, err +} + +// certProfileID contains the name and hash of a certificate profile returned by +// a CA. +type certProfileID struct { + name string + hash []byte +} + +// issueCertificateInner is part of the [issuance cycle]. +// +// It gets a precertificate from the CA, submits it to CT logs to get SCTs, +// then sends the precertificate and the SCTs to the CA to get a final certificate. +// +// This function is responsible for ensuring that we never try to issue a final +// certificate twice for the same precertificate, because that has the potential +// to create certificates with duplicate serials. For instance, this could +// happen if final certificates were created with different sets of SCTs. This +// function accomplishes that by bailing on issuance if there is any error in +// IssueCertificateForPrecertificate; there are no retries, and serials are +// generated in IssuePrecertificate, so serials with errors are dropped and +// never have final certificates issued for them (because there is a possibility +// that the certificate was actually issued but there was an error returning +// it). +// +// [issuance cycle]: https://github.com/letsencrypt/boulder/blob/main/docs/ISSUANCE-CYCLE.md +func (ra *RegistrationAuthorityImpl) issueCertificateInner( + ctx context.Context, + csr *x509.CertificateRequest, + profileName string, + acctID accountID, + oID orderID) (*x509.Certificate, *certProfileID, error) { + if features.Get().AsyncFinalize { + // If we're in async mode, use a context with a much longer timeout. + var cancel func() + ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), ra.finalizeTimeout) + defer cancel() + } + + // wrapError adds a prefix to an error. If the error is a boulder error then + // the problem detail is updated with the prefix. Otherwise a new error is + // returned with the message prefixed using `fmt.Errorf` + wrapError := func(e error, prefix string) error { + if berr, ok := e.(*berrors.BoulderError); ok { + berr.Detail = fmt.Sprintf("%s: %s", prefix, berr.Detail) + return berr + } + return fmt.Errorf("%s: %s", prefix, e) + } + + issueReq := &capb.IssueCertificateRequest{ + Csr: csr.Raw, + RegistrationID: int64(acctID), + OrderID: int64(oID), + CertProfileName: profileName, + } + // Once we get a precert from IssuePrecertificate, we must attempt issuing + // a final certificate at most once. We achieve that by bailing on any error + // between here and IssueCertificateForPrecertificate. + precert, err := ra.CA.IssuePrecertificate(ctx, issueReq) + if err != nil { + return nil, nil, wrapError(err, "issuing precertificate") + } + + parsedPrecert, err := x509.ParseCertificate(precert.DER) + if err != nil { + return nil, nil, wrapError(err, "parsing precertificate") + } + + scts, err := ra.getSCTs(ctx, precert.DER, parsedPrecert.NotAfter) + if err != nil { + return nil, nil, wrapError(err, "getting SCTs") + } + + cert, err := ra.CA.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{ + DER: precert.DER, + SCTs: scts, + RegistrationID: int64(acctID), + OrderID: int64(oID), + CertProfileHash: precert.CertProfileHash, + }) + if err != nil { + return nil, nil, wrapError(err, "issuing certificate for precertificate") + } + + parsedCertificate, err := x509.ParseCertificate(cert.Der) + if err != nil { + return nil, nil, wrapError(err, "parsing final certificate") + } + + // Asynchronously submit the final certificate to any configured logs + go ra.ctpolicy.SubmitFinalCert(cert.Der, parsedCertificate.NotAfter) + + err = ra.matchesCSR(parsedCertificate, csr) + if err != nil { + ra.certCSRMismatch.Inc() + return nil, nil, err + } + + _, err = ra.SA.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{ + Id: int64(oID), + CertificateSerial: core.SerialToString(parsedCertificate.SerialNumber), + }) + if err != nil { + return nil, nil, wrapError(err, "persisting finalized order") + } + + return parsedCertificate, &certProfileID{name: precert.CertProfileName, hash: precert.CertProfileHash}, nil +} + +func (ra *RegistrationAuthorityImpl) getSCTs(ctx context.Context, cert []byte, expiration time.Time) (core.SCTDERs, error) { + started := ra.clk.Now() + scts, err := ra.ctpolicy.GetSCTs(ctx, cert, expiration) + took := ra.clk.Since(started) + // The final cert has already been issued so actually return it to the + // user even if this fails since we aren't actually doing anything with + // the SCTs yet. + if err != nil { + state := "failure" + if err == context.DeadlineExceeded { + state = "deadlineExceeded" + // Convert the error to a missingSCTsError to communicate the timeout, + // otherwise it will be a generic serverInternalError + err = berrors.MissingSCTsError(err.Error()) + } + ra.log.Warningf("ctpolicy.GetSCTs failed: %s", err) + ra.ctpolicyResults.With(prometheus.Labels{"result": state}).Observe(took.Seconds()) + return nil, err + } + ra.ctpolicyResults.With(prometheus.Labels{"result": "success"}).Observe(took.Seconds()) + return scts, nil +} + +// enforceNameCounts uses the provided count RPC to find a count of certificates +// for each of the names. If the count for any of the names exceeds the limit +// for the given registration then the names out of policy are returned to be +// used for a rate limit error. +func (ra *RegistrationAuthorityImpl) enforceNameCounts(ctx context.Context, names []string, limit ratelimit.RateLimitPolicy, regID int64) ([]string, time.Time, error) { + now := ra.clk.Now() + req := &sapb.CountCertificatesByNamesRequest{ + Names: names, + Range: &sapb.Range{ + Earliest: timestamppb.New(limit.WindowBegin(now)), + Latest: timestamppb.New(now), + }, + } + + response, err := ra.SA.CountCertificatesByNames(ctx, req) + if err != nil { + return nil, time.Time{}, err + } + + if len(response.Counts) == 0 { + return nil, time.Time{}, errIncompleteGRPCResponse + } + + var badNames []string + var metricsData []struct { + overrideKey string + utilization float64 + } + + // Find the names that have counts at or over the threshold. Range + // over the names slice input to ensure the order of badNames will + // return the badNames in the same order they were input. + for _, name := range names { + threshold, overrideKey := limit.GetThreshold(name, regID) + if response.Counts[name] >= threshold { + badNames = append(badNames, name) + } + if overrideKey != "" { + // Name is under threshold due to an override. + utilization := float64(response.Counts[name]+1) / float64(threshold) + metricsData = append(metricsData, struct { + overrideKey string + utilization float64 + }{overrideKey, utilization}) + } + } + + if len(badNames) == 0 { + // All names were under the threshold, emit override utilization metrics. + for _, data := range metricsData { + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerName, data.overrideKey).Set(data.utilization) + } + } + return badNames, response.Earliest.AsTime(), nil +} + +func (ra *RegistrationAuthorityImpl) checkCertificatesPerNameLimit(ctx context.Context, names []string, limit ratelimit.RateLimitPolicy, regID int64) error { + // check if there is already an existing certificate for + // the exact name set we are issuing for. If so bypass the + // the certificatesPerName limit. + exists, err := ra.SA.FQDNSetExists(ctx, &sapb.FQDNSetExistsRequest{Domains: names}) + if err != nil { + return fmt.Errorf("checking renewal exemption for %q: %s", names, err) + } + if exists.Exists { + return nil + } + + tldNames := ratelimits.DomainsForRateLimiting(names) + namesOutOfLimit, earliest, err := ra.enforceNameCounts(ctx, tldNames, limit, regID) + if err != nil { + return fmt.Errorf("checking certificates per name limit for %q: %s", + names, err) + } + + if len(namesOutOfLimit) > 0 { + // Determine the amount of time until the earliest event would fall out + // of the window. + retryAfter := earliest.Add(limit.Window.Duration).Sub(ra.clk.Now()) + retryString := earliest.Add(limit.Window.Duration).Format(time.RFC3339) + + ra.log.Infof("Rate limit exceeded, CertificatesForDomain, regID: %d, domains: %s", regID, strings.Join(namesOutOfLimit, ", ")) + if len(namesOutOfLimit) > 1 { + var subErrors []berrors.SubBoulderError + for _, name := range namesOutOfLimit { + subErrors = append(subErrors, berrors.SubBoulderError{ + Identifier: identifier.DNSIdentifier(name), + BoulderError: berrors.RateLimitError(retryAfter, "too many certificates already issued. Retry after %s", retryString).(*berrors.BoulderError), + }) + } + return berrors.RateLimitError(retryAfter, "too many certificates already issued for multiple names (%q and %d others). Retry after %s", namesOutOfLimit[0], len(namesOutOfLimit), retryString).(*berrors.BoulderError).WithSubErrors(subErrors) + } + return berrors.RateLimitError(retryAfter, "too many certificates already issued for %q. Retry after %s", namesOutOfLimit[0], retryString) + } + + return nil +} + +func (ra *RegistrationAuthorityImpl) checkCertificatesPerFQDNSetLimit(ctx context.Context, names []string, limit ratelimit.RateLimitPolicy, regID int64) error { + names = core.UniqueLowerNames(names) + threshold, overrideKey := limit.GetThreshold(strings.Join(names, ","), regID) + if threshold <= 0 { + // No limit configured. + return nil + } + + prevIssuances, err := ra.SA.FQDNSetTimestampsForWindow(ctx, &sapb.CountFQDNSetsRequest{ + Domains: names, + Window: durationpb.New(limit.Window.Duration), + }) + if err != nil { + return fmt.Errorf("checking duplicate certificate limit for %q: %s", names, err) + } + + if overrideKey != "" { + utilization := float64(len(prevIssuances.Timestamps)) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerFQDNSet, overrideKey).Set(utilization) + } + + issuanceCount := int64(len(prevIssuances.Timestamps)) + if issuanceCount < threshold { + // Issuance in window is below the threshold, no need to limit. + if overrideKey != "" { + utilization := float64(issuanceCount+1) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerFQDNSet, overrideKey).Set(utilization) + } + return nil + } else { + // Evaluate the rate limit using a leaky bucket algorithm. The bucket + // has a capacity of threshold and is refilled at a rate of 1 token per + // limit.Window/threshold from the time of each issuance timestamp. The + // timestamps start from the most recent issuance and go back in time. + now := ra.clk.Now() + nsPerToken := limit.Window.Nanoseconds() / threshold + for i, timestamp := range prevIssuances.Timestamps { + tokensGeneratedSince := now.Add(-time.Duration(int64(i+1) * nsPerToken)) + if timestamp.AsTime().Before(tokensGeneratedSince) { + // We know `i+1` tokens were generated since `tokenGeneratedSince`, + // and only `i` certificates were issued, so there's room to allow + // for an additional issuance. + if overrideKey != "" { + utilization := float64(issuanceCount) / float64(threshold) + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerFQDNSet, overrideKey).Set(utilization) + } + return nil + } + } + retryTime := prevIssuances.Timestamps[0].AsTime().Add(time.Duration(nsPerToken)) + retryAfter := retryTime.Sub(now) + return berrors.DuplicateCertificateError( + retryAfter, + "too many certificates (%d) already issued for this exact set of domains in the last %.0f hours: %s, retry after %s", + threshold, limit.Window.Duration.Hours(), strings.Join(names, ","), retryTime.Format(time.RFC3339), + ) + } +} + +func (ra *RegistrationAuthorityImpl) checkNewOrderLimits(ctx context.Context, names []string, regID int64) error { + newOrdersPerAccountLimits := ra.rlPolicies.NewOrdersPerAccount() + if newOrdersPerAccountLimits.Enabled() { + started := ra.clk.Now() + err := ra.checkNewOrdersPerAccountLimit(ctx, regID, names, newOrdersPerAccountLimits) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.NewOrdersPerAccount, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.NewOrdersPerAccount, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + certNameLimits := ra.rlPolicies.CertificatesPerName() + if certNameLimits.Enabled() { + started := ra.clk.Now() + err := ra.checkCertificatesPerNameLimit(ctx, names, certNameLimits, regID) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerName, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerName, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + fqdnLimitsFast := ra.rlPolicies.CertificatesPerFQDNSetFast() + if fqdnLimitsFast.Enabled() { + started := ra.clk.Now() + err := ra.checkCertificatesPerFQDNSetLimit(ctx, names, fqdnLimitsFast, regID) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerFQDNSetFast, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerFQDNSetFast, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + fqdnLimits := ra.rlPolicies.CertificatesPerFQDNSet() + if fqdnLimits.Enabled() { + started := ra.clk.Now() + err := ra.checkCertificatesPerFQDNSetLimit(ctx, names, fqdnLimits, regID) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerFQDNSet, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.CertificatesPerFQDNSet, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + invalidAuthzPerAccountLimits := ra.rlPolicies.InvalidAuthorizationsPerAccount() + if invalidAuthzPerAccountLimits.Enabled() { + started := ra.clk.Now() + err := ra.checkInvalidAuthorizationLimits(ctx, regID, names, invalidAuthzPerAccountLimits) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.InvalidAuthorizationsPerAccount, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.InvalidAuthorizationsPerAccount, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + + return nil +} + +// UpdateRegistration updates an existing Registration with new values. Caller +// is responsible for making sure that update.Key is only different from base.Key +// if it is being called from the WFE key change endpoint. +// TODO(#5554): Split this into separate methods for updating Contacts vs Key. +func (ra *RegistrationAuthorityImpl) UpdateRegistration(ctx context.Context, req *rapb.UpdateRegistrationRequest) (*corepb.Registration, error) { + // Error if the request is nil, there is no account key or IP address + if req.Base == nil || len(req.Base.Key) == 0 || len(req.Base.InitialIP) == 0 || req.Base.Id == 0 { + return nil, errIncompleteGRPCRequest + } + + err := validateContactsPresent(req.Base.Contact, req.Base.ContactsPresent) + if err != nil { + return nil, err + } + err = validateContactsPresent(req.Update.Contact, req.Update.ContactsPresent) + if err != nil { + return nil, err + } + err = ra.validateContacts(req.Update.Contact) + if err != nil { + return nil, err + } + + update, changed := mergeUpdate(req.Base, req.Update) + if !changed { + // If merging the update didn't actually change the base then our work is + // done, we can return before calling ra.SA.UpdateRegistration since there's + // nothing for the SA to do + return req.Base, nil + } + + _, err = ra.SA.UpdateRegistration(ctx, update) + if err != nil { + // berrors.InternalServerError since the user-data was validated before being + // passed to the SA. + err = berrors.InternalServerError("Could not update registration: %s", err) + return nil, err + } + + return update, nil +} + +func contactsEqual(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + + // If there is an existing contact slice and it has the same length as the + // new contact slice we need to look at each contact to determine if there + // is a change being made. Use `sort.Strings` here to ensure a consistent + // comparison + sort.Strings(a) + sort.Strings(b) + for i := range len(b) { + // If the contact's string representation differs at any index they aren't + // equal + if a[i] != b[i] { + return false + } + } + + // They are equal! + return true +} + +// MergeUpdate returns a new corepb.Registration with the majority of its fields +// copies from the base Registration, and a subset (Contact, Agreement, and Key) +// copied from the update Registration. It also returns a boolean indicating +// whether or not this operation resulted in a Registration which differs from +// the base. +func mergeUpdate(base *corepb.Registration, update *corepb.Registration) (*corepb.Registration, bool) { + var changed bool + + // Start by copying all of the fields. + res := &corepb.Registration{ + Id: base.Id, + Key: base.Key, + Contact: base.Contact, + ContactsPresent: base.ContactsPresent, + Agreement: base.Agreement, + InitialIP: base.InitialIP, + CreatedAt: base.CreatedAt, + Status: base.Status, + } + + // Note: we allow update.Contact to overwrite base.Contact even if the former + // is empty in order to allow users to remove the contact associated with + // a registration. If the update has ContactsPresent set to false, then we + // know it is not attempting to update the contacts field. + if update.ContactsPresent && !contactsEqual(base.Contact, update.Contact) { + res.Contact = update.Contact + res.ContactsPresent = update.ContactsPresent + changed = true + } + + if len(update.Agreement) > 0 && update.Agreement != base.Agreement { + res.Agreement = update.Agreement + changed = true + } + + if len(update.Key) > 0 { + if len(update.Key) != len(base.Key) { + res.Key = update.Key + changed = true + } else { + for i := range len(base.Key) { + if update.Key[i] != base.Key[i] { + res.Key = update.Key + changed = true + break + } + } + } + } + + return res, changed +} + +// recordValidation records an authorization validation event, +// it should only be used on v2 style authorizations. +func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authID string, authExpires *time.Time, challenge *core.Challenge) error { + authzID, err := strconv.ParseInt(authID, 10, 64) + if err != nil { + return err + } + var expires time.Time + if challenge.Status == core.StatusInvalid { + expires = *authExpires + } else { + expires = ra.clk.Now().Add(ra.authorizationLifetime) + } + vr, err := bgrpc.ValidationResultToPB(challenge.ValidationRecord, challenge.Error) + if err != nil { + return err + } + var validated *timestamppb.Timestamp + if challenge.Validated != nil { + validated = timestamppb.New(*challenge.Validated) + } + _, err = ra.SA.FinalizeAuthorization2(ctx, &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + Status: string(challenge.Status), + Expires: timestamppb.New(expires), + Attempted: string(challenge.Type), + AttemptedAt: validated, + ValidationRecords: vr.Records, + ValidationError: vr.Problems, + }) + return err +} + +func (ra *RegistrationAuthorityImpl) countFailedValidation(ctx context.Context, regId int64, name string) { + if ra.limiter == nil || ra.txnBuilder == nil { + // Limiter is disabled. + return + } + + txn, err := ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountSpendOnlyTransaction(regId, name) + if err != nil { + ra.log.Errf("constructing rate limit transaction for the %s rate limit: %s", ratelimits.FailedAuthorizationsPerDomainPerAccount, err) + } + + _, err = ra.limiter.Spend(ctx, txn) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + ra.log.Errf("checking the %s rate limit: %s", ratelimits.FailedAuthorizationsPerDomainPerAccount, err) + } +} + +// PerformValidation initiates validation for a specific challenge associated +// with the given base authorization. The authorization and challenge are +// updated based on the results. +func (ra *RegistrationAuthorityImpl) PerformValidation( + ctx context.Context, + req *rapb.PerformValidationRequest) (*corepb.Authorization, error) { + + // Clock for start of PerformValidation. + vStart := ra.clk.Now() + + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.Authz == nil || req.Authz.Id == "" || req.Authz.Identifier == "" || req.Authz.Status == "" || core.IsAnyNilOrZero(req.Authz.Expires) { + return nil, errIncompleteGRPCRequest + } + + authz, err := bgrpc.PBToAuthz(req.Authz) + if err != nil { + return nil, err + } + + // Refuse to update expired authorizations + if authz.Expires == nil || authz.Expires.Before(ra.clk.Now()) { + return nil, berrors.MalformedError("expired authorization") + } + + challIndex := int(req.ChallengeIndex) + if challIndex >= len(authz.Challenges) { + return nil, + berrors.MalformedError("invalid challenge index '%d'", challIndex) + } + + ch := &authz.Challenges[challIndex] + + // This challenge type may have been disabled since the challenge was created. + if !ra.PA.ChallengeTypeEnabled(ch.Type) { + return nil, berrors.MalformedError("challenge type %q no longer allowed", ch.Type) + } + + // We expect some clients to try and update a challenge for an authorization + // that is already valid. In this case we don't need to process the + // challenge update. It wouldn't be helpful, the overall authorization is + // already good! We return early for the valid authz reuse case. + if authz.Status == core.StatusValid { + return req.Authz, nil + } + + if authz.Status != core.StatusPending { + return nil, berrors.MalformedError("authorization must be pending") + } + + // Look up the account key for this authorization + regPB, err := ra.SA.GetRegistration(ctx, &sapb.RegistrationID{Id: authz.RegistrationID}) + if err != nil { + return nil, berrors.InternalServerError(err.Error()) + } + reg, err := bgrpc.PbToRegistration(regPB) + if err != nil { + return nil, berrors.InternalServerError(err.Error()) + } + + // Compute the key authorization field based on the registration key + expectedKeyAuthorization, err := ch.ExpectedKeyAuthorization(reg.Key) + if err != nil { + return nil, berrors.InternalServerError("could not compute expected key authorization value") + } + + ch.ProvidedKeyAuthorization = expectedKeyAuthorization + + // Double check before sending to VA + if cErr := ch.CheckPending(); cErr != nil { + return nil, berrors.MalformedError(cErr.Error()) + } + + // Dispatch to the VA for service + vaCtx := context.Background() + go func(authz core.Authorization) { + // We will mutate challenges later in this goroutine to change status and + // add error, but we also return a copy of authz immediately. To avoid a + // data race, make a copy of the challenges slice here for mutation. + challenges := make([]core.Challenge, len(authz.Challenges)) + copy(challenges, authz.Challenges) + authz.Challenges = challenges + chall, _ := bgrpc.ChallengeToPB(authz.Challenges[challIndex]) + req := vapb.PerformValidationRequest{ + Domain: authz.Identifier.Value, + Challenge: chall, + Authz: &vapb.AuthzMeta{ + Id: authz.ID, + RegID: authz.RegistrationID, + }, + ExpectedKeyAuthorization: expectedKeyAuthorization, + } + res, err := ra.VA.PerformValidation(vaCtx, &req) + challenge := &authz.Challenges[challIndex] + var prob *probs.ProblemDetails + if err != nil { + prob = probs.ServerInternal("Could not communicate with VA") + ra.log.AuditErrf("Could not communicate with VA: %s", err) + } else { + if res.Problems != nil { + prob, err = bgrpc.PBToProblemDetails(res.Problems) + if err != nil { + prob = probs.ServerInternal("Could not communicate with VA") + ra.log.AuditErrf("Could not communicate with VA: %s", err) + } + } + // Save the updated records + records := make([]core.ValidationRecord, len(res.Records)) + for i, r := range res.Records { + records[i], err = bgrpc.PBToValidationRecord(r) + if err != nil { + prob = probs.ServerInternal("Records for validation corrupt") + } + } + challenge.ValidationRecord = records + } + if !challenge.RecordsSane() && prob == nil { + prob = probs.ServerInternal("Records for validation failed sanity check") + } + + if prob != nil { + challenge.Status = core.StatusInvalid + challenge.Error = prob + + // TODO(#5545): Spending can be async until key-value rate limits + // are authoritative. This saves us from adding latency to each + // request. Goroutines spun out below will respect a context + // deadline set by the ratelimits package and cannot be prematurely + // canceled by the requester. + go ra.countFailedValidation(vaCtx, authz.RegistrationID, authz.Identifier.Value) + } else { + challenge.Status = core.StatusValid + } + challenge.Validated = &vStart + authz.Challenges[challIndex] = *challenge + + err = ra.recordValidation(vaCtx, authz.ID, authz.Expires, challenge) + if err != nil { + if errors.Is(err, berrors.AlreadyRevoked) { + ra.log.Infof("Didn't record already-finalized validation: regID=[%d] authzID=[%s] err=[%s]", + authz.RegistrationID, authz.ID, err) + } else { + ra.log.AuditErrf("Failed to record validation: regID=[%d] authzID=[%s] err=[%s]", + authz.RegistrationID, authz.ID, err) + } + } + }(authz) + return bgrpc.AuthzToPB(authz) +} + +// revokeCertificate updates the database to mark the certificate as revoked, +// with the given reason and current timestamp. +func (ra *RegistrationAuthorityImpl) revokeCertificate(ctx context.Context, serial *big.Int, issuerID issuance.NameID, reason revocation.Reason) error { + serialString := core.SerialToString(serial) + + _, err := ra.SA.RevokeCertificate(ctx, &sapb.RevokeCertificateRequest{ + Serial: serialString, + Reason: int64(reason), + Date: timestamppb.New(ra.clk.Now()), + IssuerID: int64(issuerID), + }) + if err != nil { + return err + } + + ra.revocationReasonCounter.WithLabelValues(revocation.ReasonToString[reason]).Inc() + return nil +} + +// updateRevocationForKeyCompromise updates the database to mark the certificate +// as revoked, with the given reason and current timestamp. This only works for +// certificates that were previously revoked for a reason other than +// keyCompromise, and which are now being updated to keyCompromise instead. +func (ra *RegistrationAuthorityImpl) updateRevocationForKeyCompromise(ctx context.Context, serial *big.Int, issuerID issuance.NameID) error { + serialString := core.SerialToString(serial) + + status, err := ra.SA.GetCertificateStatus(ctx, &sapb.Serial{Serial: serialString}) + if err != nil { + return berrors.NotFoundError("unable to confirm that serial %q was ever issued: %s", serialString, err) + } + + if status.Status != string(core.OCSPStatusRevoked) { + // Internal server error, because we shouldn't be in the function at all + // unless the cert was already revoked. + return fmt.Errorf("unable to re-revoke serial %q which is not currently revoked", serialString) + } + if status.RevokedReason == ocsp.KeyCompromise { + return berrors.AlreadyRevokedError("unable to re-revoke serial %q which is already revoked for keyCompromise", serialString) + } + + _, err = ra.SA.UpdateRevokedCertificate(ctx, &sapb.RevokeCertificateRequest{ + Serial: serialString, + Reason: int64(ocsp.KeyCompromise), + Date: timestamppb.New(ra.clk.Now()), + Backdate: status.RevokedDate, + IssuerID: int64(issuerID), + }) + if err != nil { + return err + } + + ra.revocationReasonCounter.WithLabelValues(revocation.ReasonToString[ocsp.KeyCompromise]).Inc() + return nil +} + +// purgeOCSPCache makes a request to akamai-purger to purge the cache entries +// for the given certificate. +func (ra *RegistrationAuthorityImpl) purgeOCSPCache(ctx context.Context, cert *x509.Certificate, issuerID issuance.NameID) error { + issuer, ok := ra.issuersByNameID[issuerID] + if !ok { + return fmt.Errorf("unable to identify issuer of cert with serial %q", core.SerialToString(cert.SerialNumber)) + } + + purgeURLs, err := akamai.GeneratePurgeURLs(cert, issuer.Certificate) + if err != nil { + return err + } + + _, err = ra.purger.Purge(ctx, &akamaipb.PurgeRequest{Urls: purgeURLs}) + if err != nil { + return err + } + + return nil +} + +// RevokeCertByApplicant revokes the certificate in question. It allows any +// revocation reason from (0, 1, 3, 4, 5, 9), because Subscribers are allowed to +// request any revocation reason for their own certificates. However, if the +// requesting RegID is an account which has authorizations for all names in the +// cert but is *not* the original subscriber, it overrides the revocation reason +// to be 5 (cessationOfOperation), because that code is used to cover instances +// where "the certificate subscriber no longer owns the domain names in the +// certificate". It does not add the key to the blocked keys list, even if +// reason 1 (keyCompromise) is requested, as it does not demonstrate said +// compromise. It attempts to purge the certificate from the Akamai cache, but +// it does not hard-fail if doing so is not successful, because the cache will +// drop the old OCSP response in less than 24 hours anyway. +func (ra *RegistrationAuthorityImpl) RevokeCertByApplicant(ctx context.Context, req *rapb.RevokeCertByApplicantRequest) (*emptypb.Empty, error) { + if req == nil || req.Cert == nil || req.RegID == 0 { + return nil, errIncompleteGRPCRequest + } + + if _, present := revocation.UserAllowedReasons[revocation.Reason(req.Code)]; !present { + return nil, berrors.BadRevocationReasonError(req.Code) + } + + cert, err := x509.ParseCertificate(req.Cert) + if err != nil { + return nil, err + } + + serialString := core.SerialToString(cert.SerialNumber) + + logEvent := certificateRevocationEvent{ + ID: core.NewToken(), + SerialNumber: serialString, + Reason: req.Code, + Method: "applicant", + RequesterID: req.RegID, + } + + // Below this point, do not re-declare `err` (i.e. type `err :=`) in a + // nested scope. Doing so will create a new `err` variable that is not + // captured by this closure. + defer func() { + if err != nil { + logEvent.Error = err.Error() + } + ra.log.AuditObject("Revocation request:", logEvent) + }() + + metadata, err := ra.SA.GetSerialMetadata(ctx, &sapb.Serial{Serial: serialString}) + if err != nil { + return nil, err + } + + if req.RegID == metadata.RegistrationID { + // The requester is the original subscriber. They can revoke for any reason. + logEvent.Method = "subscriber" + } else { + // The requester is a different account. We need to confirm that they have + // authorizations for all names in the cert. + logEvent.Method = "control" + + var authzMapPB *sapb.Authorizations + authzMapPB, err = ra.SA.GetValidAuthorizations2(ctx, &sapb.GetValidAuthorizationsRequest{ + RegistrationID: req.RegID, + Domains: cert.DNSNames, + Now: timestamppb.New(ra.clk.Now()), + }) + if err != nil { + return nil, err + } + + m := make(map[string]struct{}) + for _, authz := range authzMapPB.Authz { + m[authz.Domain] = struct{}{} + } + for _, name := range cert.DNSNames { + if _, present := m[name]; !present { + return nil, berrors.UnauthorizedError("requester does not control all names in cert with serial %q", serialString) + } + } + + // Applicants who are not the original Subscriber are not allowed to + // revoke for any reason other than cessationOfOperation, which covers + // circumstances where "the certificate subscriber no longer owns the + // domain names in the certificate". Override the reason code to match. + req.Code = ocsp.CessationOfOperation + logEvent.Reason = req.Code + } + + issuerID := issuance.IssuerNameID(cert) + err = ra.revokeCertificate( + ctx, + cert.SerialNumber, + issuerID, + revocation.Reason(req.Code), + ) + if err != nil { + return nil, err + } + + // Don't propagate purger errors to the client. + _ = ra.purgeOCSPCache(ctx, cert, issuerID) + + return &emptypb.Empty{}, nil +} + +// addToBlockedKeys initiates a GRPC call to have the Base64-encoded SHA256 +// digest of a provided public key added to the blockedKeys table. +func (ra *RegistrationAuthorityImpl) addToBlockedKeys(ctx context.Context, key crypto.PublicKey, src string, comment string) error { + var digest core.Sha256Digest + digest, err := core.KeyDigest(key) + if err != nil { + return err + } + + // Add the public key to the blocked keys list. + _, err = ra.SA.AddBlockedKey(ctx, &sapb.AddBlockedKeyRequest{ + KeyHash: digest[:], + Added: timestamppb.New(ra.clk.Now()), + Source: src, + Comment: comment, + }) + if err != nil { + return err + } + + return nil +} + +// RevokeCertByKey revokes the certificate in question. It always uses +// reason code 1 (keyCompromise). It ensures that they public key is added to +// the blocked keys list, even if revocation otherwise fails. It attempts to +// purge the certificate from the Akamai cache, but it does not hard-fail if +// doing so is not successful, because the cache will drop the old OCSP response +// in less than 24 hours anyway. +func (ra *RegistrationAuthorityImpl) RevokeCertByKey(ctx context.Context, req *rapb.RevokeCertByKeyRequest) (*emptypb.Empty, error) { + if req == nil || req.Cert == nil { + return nil, errIncompleteGRPCRequest + } + + cert, err := x509.ParseCertificate(req.Cert) + if err != nil { + return nil, err + } + + issuerID := issuance.IssuerNameID(cert) + + logEvent := certificateRevocationEvent{ + ID: core.NewToken(), + SerialNumber: core.SerialToString(cert.SerialNumber), + Reason: ocsp.KeyCompromise, + Method: "key", + RequesterID: 0, + } + + // Below this point, do not re-declare `err` (i.e. type `err :=`) in a + // nested scope. Doing so will create a new `err` variable that is not + // captured by this closure. + defer func() { + if err != nil { + logEvent.Error = err.Error() + } + ra.log.AuditObject("Revocation request:", logEvent) + }() + + // We revoke the cert before adding it to the blocked keys list, to avoid a + // race between this and the bad-key-revoker. But we don't check the error + // from this operation until after we add the key to the blocked keys list, + // since that addition needs to happen no matter what. + revokeErr := ra.revokeCertificate( + ctx, + cert.SerialNumber, + issuerID, + revocation.Reason(ocsp.KeyCompromise), + ) + + // Failing to add the key to the blocked keys list is a worse failure than + // failing to revoke in the first place, because it means that + // bad-key-revoker won't revoke the cert anyway. + err = ra.addToBlockedKeys(ctx, cert.PublicKey, "API", "") + if err != nil { + return nil, err + } + + // Check the error returned from revokeCertificate itself. + err = revokeErr + if err == nil { + // If the revocation and blocked keys list addition were successful, then + // just purge and return. + // Don't propagate purger errors to the client. + _ = ra.purgeOCSPCache(ctx, cert, issuerID) + return &emptypb.Empty{}, nil + } else if errors.Is(err, berrors.AlreadyRevoked) { + // If it was an AlreadyRevoked error, try to re-revoke the cert in case + // it was revoked for a reason other than keyCompromise. + err = ra.updateRevocationForKeyCompromise(ctx, cert.SerialNumber, issuerID) + + // Perform an Akamai cache purge to handle occurrences of a client + // previously successfully revoking a certificate, but the cache purge had + // unexpectedly failed. Allows clients to re-attempt revocation and purge the + // Akamai cache. + _ = ra.purgeOCSPCache(ctx, cert, issuerID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil + } else { + // Error out if the error was anything other than AlreadyRevoked. + return nil, err + } +} + +// AdministrativelyRevokeCertificate terminates trust in the certificate +// provided and does not require the registration ID of the requester since this +// method is only called from the admin-revoker tool. It trusts that the admin +// is doing the right thing, so if the requested reason is keyCompromise, it +// blocks the key from future issuance even though compromise has not been +// demonstrated here. It purges the certificate from the Akamai cache, and +// returns an error if that purge fails, since this method may be called late +// in the BRs-mandated revocation timeframe. +func (ra *RegistrationAuthorityImpl) AdministrativelyRevokeCertificate(ctx context.Context, req *rapb.AdministrativelyRevokeCertificateRequest) (*emptypb.Empty, error) { + if req == nil || req.AdminName == "" { + return nil, errIncompleteGRPCRequest + } + if req.Serial == "" { + return nil, errIncompleteGRPCRequest + } + + reasonCode := revocation.Reason(req.Code) + if _, present := revocation.AdminAllowedReasons[reasonCode]; !present { + return nil, fmt.Errorf("cannot revoke for reason %d", reasonCode) + } + if req.SkipBlockKey && reasonCode != ocsp.KeyCompromise { + return nil, fmt.Errorf("cannot skip key blocking for reasons other than KeyCompromise") + } + if reasonCode == ocsp.KeyCompromise && req.Malformed { + return nil, fmt.Errorf("cannot revoke malformed certificate for KeyCompromise") + } + + logEvent := certificateRevocationEvent{ + ID: core.NewToken(), + SerialNumber: req.Serial, + Reason: req.Code, + Method: "admin", + AdminName: req.AdminName, + } + + // Below this point, do not re-declare `err` (i.e. type `err :=`) in a + // nested scope. Doing so will create a new `err` variable that is not + // captured by this closure. + var err error + defer func() { + if err != nil { + logEvent.Error = err.Error() + } + ra.log.AuditObject("Revocation request:", logEvent) + }() + + var cert *x509.Certificate + var issuerID issuance.NameID + if req.Cert != nil { + // If the incoming request includes a certificate body, just use that and + // avoid doing any database queries. This code path is deprecated and will + // be removed when req.Cert is removed. + cert, err = x509.ParseCertificate(req.Cert) + if err != nil { + return nil, err + } + issuerID = issuance.IssuerNameID(cert) + } else if !req.Malformed { + // As long as we don't believe the cert will be malformed, we should + // get the precertificate so we can block its pubkey if necessary and purge + // the akamai OCSP cache. + var certPB *corepb.Certificate + certPB, err = ra.SA.GetLintPrecertificate(ctx, &sapb.Serial{Serial: req.Serial}) + if err != nil { + return nil, err + } + // Note that, although the thing we're parsing here is actually a linting + // precertificate, it has identical issuer info (and therefore an identical + // issuer NameID) to the real thing. + cert, err = x509.ParseCertificate(certPB.Der) + if err != nil { + return nil, err + } + issuerID = issuance.IssuerNameID(cert) + } else { + // But if the cert is malformed, we at least still need its IssuerID. + var status *corepb.CertificateStatus + status, err = ra.SA.GetCertificateStatus(ctx, &sapb.Serial{Serial: req.Serial}) + if err != nil { + return nil, fmt.Errorf("unable to confirm that serial %q was ever issued: %w", req.Serial, err) + } + issuerID = issuance.NameID(status.IssuerID) + } + + var serialInt *big.Int + serialInt, err = core.StringToSerial(req.Serial) + if err != nil { + return nil, err + } + + err = ra.revokeCertificate(ctx, serialInt, issuerID, revocation.Reason(req.Code)) + // Perform an Akamai cache purge to handle occurrences of a client + // successfully revoking a certificate, but the initial cache purge failing. + if errors.Is(err, berrors.AlreadyRevoked) { + if cert != nil { + err = ra.purgeOCSPCache(ctx, cert, issuerID) + if err != nil { + err = fmt.Errorf("OCSP cache purge for already revoked serial %v failed: %w", serialInt, err) + return nil, err + } + } + } + if err != nil { + if req.Code == ocsp.KeyCompromise && errors.Is(err, berrors.AlreadyRevoked) { + err = ra.updateRevocationForKeyCompromise(ctx, serialInt, issuerID) + if err != nil { + return nil, err + } + } + return nil, err + } + + if req.Code == ocsp.KeyCompromise && !req.SkipBlockKey { + if cert == nil { + return nil, errors.New("revoking for key compromise requires providing the certificate's DER") + } + err = ra.addToBlockedKeys(ctx, cert.PublicKey, "admin-revoker", fmt.Sprintf("revoked by %s", req.AdminName)) + if err != nil { + return nil, err + } + } + + if cert != nil { + err = ra.purgeOCSPCache(ctx, cert, issuerID) + if err != nil { + err = fmt.Errorf("OCSP cache purge for serial %v failed: %w", serialInt, err) + return nil, err + } + } + + return &emptypb.Empty{}, nil +} + +// DeactivateRegistration deactivates a valid registration +func (ra *RegistrationAuthorityImpl) DeactivateRegistration(ctx context.Context, reg *corepb.Registration) (*emptypb.Empty, error) { + if reg == nil || reg.Id == 0 { + return nil, errIncompleteGRPCRequest + } + if reg.Status != string(core.StatusValid) { + return nil, berrors.MalformedError("only valid registrations can be deactivated") + } + _, err := ra.SA.DeactivateRegistration(ctx, &sapb.RegistrationID{Id: reg.Id}) + if err != nil { + return nil, berrors.InternalServerError(err.Error()) + } + return &emptypb.Empty{}, nil +} + +// DeactivateAuthorization deactivates a currently valid authorization +func (ra *RegistrationAuthorityImpl) DeactivateAuthorization(ctx context.Context, req *corepb.Authorization) (*emptypb.Empty, error) { + if req == nil || req.Id == "" || req.Status == "" { + return nil, errIncompleteGRPCRequest + } + authzID, err := strconv.ParseInt(req.Id, 10, 64) + if err != nil { + return nil, err + } + if _, err := ra.SA.DeactivateAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}); err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +// GenerateOCSP looks up a certificate's status, then requests a signed OCSP +// response for it from the CA. If the certificate status is not available +// or the certificate is expired, it returns berrors.NotFoundError. +func (ra *RegistrationAuthorityImpl) GenerateOCSP(ctx context.Context, req *rapb.GenerateOCSPRequest) (*capb.OCSPResponse, error) { + status, err := ra.SA.GetCertificateStatus(ctx, &sapb.Serial{Serial: req.Serial}) + if errors.Is(err, berrors.NotFound) { + _, err := ra.SA.GetSerialMetadata(ctx, &sapb.Serial{Serial: req.Serial}) + if errors.Is(err, berrors.NotFound) { + return nil, berrors.UnknownSerialError() + } else { + return nil, berrors.NotFoundError("certificate not found") + } + } else if err != nil { + return nil, err + } + + // If we get an OCSP query for a certificate where the status is still + // OCSPStatusNotReady, that means an error occurred, not here but at issuance + // time. Specifically, we succeeded in storing the linting certificate (and + // corresponding certificateStatus row), but failed before calling + // SetCertificateStatusReady. We expect this to be rare, and we expect such + // certificates not to get OCSP queries, so InternalServerError is appropriate. + if status.Status == string(core.OCSPStatusNotReady) { + return nil, errors.New("serial belongs to a certificate that errored during issuance") + } + + if ra.clk.Now().After(status.NotAfter.AsTime()) { + return nil, berrors.NotFoundError("certificate is expired") + } + + return ra.OCSP.GenerateOCSP(ctx, &capb.GenerateOCSPRequest{ + Serial: req.Serial, + Status: status.Status, + Reason: int32(status.RevokedReason), + RevokedAt: status.RevokedDate, + IssuerID: status.IssuerID, + }) +} + +// NewOrder creates a new order object +func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.NewOrderRequest) (*corepb.Order, error) { + if req == nil || req.RegistrationID == 0 { + return nil, errIncompleteGRPCRequest + } + + newOrder := &sapb.NewOrderRequest{ + RegistrationID: req.RegistrationID, + Names: core.UniqueLowerNames(req.Names), + ReplacesSerial: req.ReplacesSerial, + } + + if len(newOrder.Names) > ra.maxNames { + return nil, berrors.MalformedError( + "Order cannot contain more than %d DNS names", ra.maxNames) + } + + // Validate that our policy allows issuing for each of the names in the order + err := ra.PA.WillingToIssue(newOrder.Names) + if err != nil { + return nil, err + } + + err = wildcardOverlap(newOrder.Names) + if err != nil { + return nil, err + } + + // See if there is an existing unexpired pending (or ready) order that can be reused + // for this account + existingOrder, err := ra.SA.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: newOrder.RegistrationID, + Names: newOrder.Names, + }) + // If there was an error and it wasn't an acceptable "NotFound" error, return + // immediately + if err != nil && !errors.Is(err, berrors.NotFound) { + return nil, err + } + + // If there was an order, make sure it has expected fields and return it + // Error if an incomplete order is returned. + if existingOrder != nil { + // Check to see if the expected fields of the existing order are set. + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if existingOrder.Id == 0 || existingOrder.Status == "" || existingOrder.RegistrationID == 0 || len(existingOrder.Names) == 0 || core.IsAnyNilOrZero(existingOrder.Created, existingOrder.Expires) { + return nil, errIncompleteGRPCResponse + } + // Track how often we reuse an existing order and how old that order is. + ra.orderAges.WithLabelValues("NewOrder").Observe(ra.clk.Since(existingOrder.Created.AsTime()).Seconds()) + return existingOrder, nil + } + + // Renewal orders, indicated by ARI, are exempt from NewOrder rate limits. + if !req.LimitsExempt { + + // Check if there is rate limit space for issuing a certificate. + err = ra.checkNewOrderLimits(ctx, newOrder.Names, newOrder.RegistrationID) + if err != nil { + return nil, err + } + } + + // An order's lifetime is effectively bound by the shortest remaining lifetime + // of its associated authorizations. For that reason it would be Uncool if + // `sa.GetAuthorizations` returned an authorization that was very close to + // expiry. The resulting pending order that references it would itself end up + // expiring very soon. + // To prevent this we only return authorizations that are at least 1 day away + // from expiring. + authzExpiryCutoff := ra.clk.Now().AddDate(0, 0, 1) + + getAuthReq := &sapb.GetAuthorizationsRequest{ + RegistrationID: newOrder.RegistrationID, + Now: timestamppb.New(authzExpiryCutoff), + Domains: newOrder.Names, + } + existingAuthz, err := ra.SA.GetAuthorizations2(ctx, getAuthReq) + if err != nil { + return nil, err + } + + // Collect up the authorizations we found into a map keyed by the domains the + // authorizations correspond to + nameToExistingAuthz := make(map[string]*corepb.Authorization, len(newOrder.Names)) + for _, v := range existingAuthz.Authz { + nameToExistingAuthz[v.Domain] = v.Authz + } + + // For each of the names in the order, if there is an acceptable + // existing authz, append it to the order to reuse it. Otherwise track + // that there is a missing authz for that name. + var missingAuthzNames []string + for _, name := range newOrder.Names { + // If there isn't an existing authz, note that its missing and continue + if _, exists := nameToExistingAuthz[name]; !exists { + missingAuthzNames = append(missingAuthzNames, name) + continue + } + authz := nameToExistingAuthz[name] + authzAge := (ra.authorizationLifetime - authz.Expires.AsTime().Sub(ra.clk.Now())).Seconds() + // If the identifier is a wildcard and the existing authz only has one + // DNS-01 type challenge we can reuse it. In theory we will + // never get back an authorization for a domain with a wildcard prefix + // that doesn't meet this criteria from SA.GetAuthorizations but we verify + // again to be safe. + if strings.HasPrefix(name, "*.") && + len(authz.Challenges) == 1 && core.AcmeChallenge(authz.Challenges[0].Type) == core.ChallengeTypeDNS01 { + authzID, err := strconv.ParseInt(authz.Id, 10, 64) + if err != nil { + return nil, err + } + newOrder.V2Authorizations = append(newOrder.V2Authorizations, authzID) + ra.authzAges.WithLabelValues("NewOrder", authz.Status).Observe(authzAge) + continue + } else if !strings.HasPrefix(name, "*.") { + // If the identifier isn't a wildcard, we can reuse any authz + authzID, err := strconv.ParseInt(authz.Id, 10, 64) + if err != nil { + return nil, err + } + newOrder.V2Authorizations = append(newOrder.V2Authorizations, authzID) + ra.authzAges.WithLabelValues("NewOrder", authz.Status).Observe(authzAge) + continue + } + + // Delete the authz from the nameToExistingAuthz map since we are not reusing it. + delete(nameToExistingAuthz, name) + // If we reached this point then the existing authz was not acceptable for + // reuse and we need to mark the name as requiring a new pending authz + missingAuthzNames = append(missingAuthzNames, name) + } + + // Renewal orders, indicated by ARI, are exempt from NewOrder rate limits. + if len(missingAuthzNames) > 0 && !req.LimitsExempt { + pendingAuthzLimits := ra.rlPolicies.PendingAuthorizationsPerAccount() + if pendingAuthzLimits.Enabled() { + // The order isn't fully authorized we need to check that the client + // has rate limit room for more pending authorizations. + started := ra.clk.Now() + err := ra.checkPendingAuthorizationLimit(ctx, newOrder.RegistrationID, pendingAuthzLimits) + elapsed := ra.clk.Since(started) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + ra.rlCheckLatency.WithLabelValues(ratelimit.PendingAuthorizationsPerAccount, ratelimits.Denied).Observe(elapsed.Seconds()) + } + return nil, err + } + ra.rlCheckLatency.WithLabelValues(ratelimit.PendingAuthorizationsPerAccount, ratelimits.Allowed).Observe(elapsed.Seconds()) + } + } + + // Loop through each of the names missing authzs and create a new pending + // authorization for each. + var newAuthzs []*corepb.Authorization + for _, name := range missingAuthzNames { + pb, err := ra.createPendingAuthz(newOrder.RegistrationID, identifier.ACMEIdentifier{ + Type: identifier.DNS, + Value: name, + }) + if err != nil { + return nil, err + } + newAuthzs = append(newAuthzs, pb) + ra.authzAges.WithLabelValues("NewOrder", pb.Status).Observe(0) + } + + // Start with the order's own expiry as the minExpiry. We only care + // about authz expiries that are sooner than the order's expiry + minExpiry := ra.clk.Now().Add(ra.orderLifetime) + + // Check the reused authorizations to see if any have an expiry before the + // minExpiry (the order's lifetime) + for _, authz := range nameToExistingAuthz { + // An authz without an expiry is an unexpected internal server event + if core.IsAnyNilOrZero(authz.Expires) { + return nil, berrors.InternalServerError( + "SA.GetAuthorizations returned an authz (%s) with zero expiry", + authz.Id) + } + // If the reused authorization expires before the minExpiry, it's expiry + // is the new minExpiry. + authzExpiry := authz.Expires.AsTime() + if authzExpiry.Before(minExpiry) { + minExpiry = authzExpiry + } + } + // If the newly created pending authz's have an expiry closer than the + // minExpiry the minExpiry is the pending authz expiry. + if len(newAuthzs) > 0 { + newPendingAuthzExpires := ra.clk.Now().Add(ra.pendingAuthorizationLifetime) + if newPendingAuthzExpires.Before(minExpiry) { + minExpiry = newPendingAuthzExpires + } + } + // Set the order's expiry to the minimum expiry. The db doesn't store + // sub-second values, so truncate here. + newOrder.Expires = timestamppb.New(minExpiry.Truncate(time.Second)) + + newOrderAndAuthzsReq := &sapb.NewOrderAndAuthzsRequest{ + NewOrder: newOrder, + NewAuthzs: newAuthzs, + } + storedOrder, err := ra.SA.NewOrderAndAuthzs(ctx, newOrderAndAuthzsReq) + if err != nil { + return nil, err + } + + if core.IsAnyNilOrZero(storedOrder.Id, storedOrder.Status, storedOrder.RegistrationID, storedOrder.Names, storedOrder.Created, storedOrder.Expires) { + return nil, errIncompleteGRPCResponse + } + ra.orderAges.WithLabelValues("NewOrder").Observe(0) + + // Note how many names are being requested in this certificate order. + ra.namesPerCert.With(prometheus.Labels{"type": "requested"}).Observe(float64(len(storedOrder.Names))) + + return storedOrder, nil +} + +// createPendingAuthz checks that a name is allowed for issuance and creates the +// necessary challenges for it and puts this and all of the relevant information +// into a corepb.Authorization for transmission to the SA to be stored +func (ra *RegistrationAuthorityImpl) createPendingAuthz(reg int64, identifier identifier.ACMEIdentifier) (*corepb.Authorization, error) { + authz := &corepb.Authorization{ + Identifier: identifier.Value, + RegistrationID: reg, + Status: string(core.StatusPending), + Expires: timestamppb.New(ra.clk.Now().Add(ra.pendingAuthorizationLifetime).Truncate(time.Second)), + } + + // Create challenges. The WFE will update them with URIs before sending them out. + challenges, err := ra.PA.ChallengesFor(identifier) + if err != nil { + // The only time ChallengesFor errors it is a fatal configuration error + // where challenges required by policy for an identifier are not enabled. We + // want to treat this as an internal server error. + return nil, berrors.InternalServerError(err.Error()) + } + // Check each challenge for sanity. + for _, challenge := range challenges { + err := challenge.CheckPending() + if err != nil { + // berrors.InternalServerError because we generated these challenges, they should + // be OK. + err = berrors.InternalServerError("challenge didn't pass sanity check: %+v", challenge) + return nil, err + } + challPB, err := bgrpc.ChallengeToPB(challenge) + if err != nil { + return nil, err + } + authz.Challenges = append(authz.Challenges, challPB) + } + return authz, nil +} + +// wildcardOverlap takes a slice of domain names and returns an error if any of +// them is a non-wildcard FQDN that overlaps with a wildcard domain in the map. +func wildcardOverlap(dnsNames []string) error { + nameMap := make(map[string]bool, len(dnsNames)) + for _, v := range dnsNames { + nameMap[v] = true + } + for name := range nameMap { + if name[0] == '*' { + continue + } + labels := strings.Split(name, ".") + labels[0] = "*" + if nameMap[strings.Join(labels, ".")] { + return berrors.MalformedError( + "Domain name %q is redundant with a wildcard domain in the same request. Remove one or the other from the certificate request.", name) + } + } + return nil +} + +// validateContactsPresent will return an error if the contacts []string +// len is greater than zero and the contactsPresent bool is false. We +// don't care about any other cases. If the length of the contacts is zero +// and contactsPresent is true, it seems like a mismatch but we have to +// assume that the client is requesting to update the contacts field with +// by removing the existing contacts value so we don't want to return an +// error here. +func validateContactsPresent(contacts []string, contactsPresent bool) error { + if len(contacts) > 0 && !contactsPresent { + return berrors.InternalServerError("account contacts present but contactsPresent false") + } + return nil +} + +func (ra *RegistrationAuthorityImpl) DrainFinalize() { + ra.finalizeWG.Wait() +} + +// UnpauseAccount receives a validated account unpause request from the SFE and +// instructs the SA to unpause that account. If the account cannot be unpaused, +// an error is returned. +func (ra *RegistrationAuthorityImpl) UnpauseAccount(ctx context.Context, request *rapb.UnpauseAccountRequest) (*emptypb.Empty, error) { + if core.IsAnyNilOrZero(request.RegistrationID) { + return nil, errIncompleteGRPCRequest + } + + return nil, status.Errorf(codes.Unimplemented, "method UnpauseAccount not implemented") +} diff --git a/third-party/github.com/letsencrypt/boulder/ra/ra_test.go b/third-party/github.com/letsencrypt/boulder/ra/ra_test.go new file mode 100644 index 000000000..ee69e54bd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ra/ra_test.go @@ -0,0 +1,4540 @@ +package ra + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + mrand "math/rand" + "net" + "os" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + ctasn1 "github.com/google/certificate-transparency-go/asn1" + ctx509 "github.com/google/certificate-transparency-go/x509" + ctpkix "github.com/google/certificate-transparency-go/x509/pkix" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "github.com/weppos/publicsuffix-go/publicsuffix" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/ctpolicy" + "github.com/letsencrypt/boulder/ctpolicy/loglist" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/policy" + pubpb "github.com/letsencrypt/boulder/publisher/proto" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/ratelimit" + "github.com/letsencrypt/boulder/ratelimits" + bredis "github.com/letsencrypt/boulder/redis" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + isa "github.com/letsencrypt/boulder/test/inmem/sa" + "github.com/letsencrypt/boulder/test/vars" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +func createPendingAuthorization(t *testing.T, sa sapb.StorageAuthorityClient, domain string, exp time.Time) *corepb.Authorization { + t.Helper() + + authz := core.Authorization{ + Identifier: identifier.DNSIdentifier(domain), + RegistrationID: Registration.Id, + Status: "pending", + Expires: &exp, + Challenges: []core.Challenge{ + { + Token: core.NewToken(), + Type: core.ChallengeTypeHTTP01, + Status: core.StatusPending, + }, + { + Token: core.NewToken(), + Type: core.ChallengeTypeDNS01, + Status: core.StatusPending, + }, + { + Token: core.NewToken(), + Type: core.ChallengeTypeTLSALPN01, + Status: core.StatusPending, + }, + }, + } + authzPB, err := bgrpc.AuthzToPB(authz) + test.AssertNotError(t, err, "AuthzToPB failed") + + res, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: []string{domain}, + }, + NewAuthzs: []*corepb.Authorization{authzPB}, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + return getAuthorization(t, fmt.Sprint(res.V2Authorizations[0]), sa) +} + +func createFinalizedAuthorization(t *testing.T, sa sapb.StorageAuthorityClient, domain string, exp time.Time, chall core.AcmeChallenge, attemptedAt time.Time) int64 { + t.Helper() + pending := createPendingAuthorization(t, sa, domain, exp) + pendingID, err := strconv.ParseInt(pending.Id, 10, 64) + test.AssertNotError(t, err, "strconv.ParseInt failed") + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: pendingID, + Status: "valid", + Expires: timestamppb.New(exp), + Attempted: string(chall), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorizations2 failed") + return pendingID +} + +func getAuthorization(t *testing.T, id string, sa sapb.StorageAuthorityClient) *corepb.Authorization { + t.Helper() + idInt, err := strconv.ParseInt(id, 10, 64) + test.AssertNotError(t, err, "strconv.ParseInt failed") + dbAuthz, err := sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: idInt}) + test.AssertNotError(t, err, "Could not fetch authorization from database") + return dbAuthz +} + +func dnsChallIdx(t *testing.T, challenges []*corepb.Challenge) int64 { + t.Helper() + var challIdx int64 + var set bool + for i, ch := range challenges { + if core.AcmeChallenge(ch.Type) == core.ChallengeTypeDNS01 { + challIdx = int64(i) + set = true + break + } + } + if !set { + t.Errorf("dnsChallIdx didn't find challenge of type DNS-01") + } + return challIdx +} + +func numAuthorizations(o *corepb.Order) int { + return len(o.V2Authorizations) +} + +type DummyValidationAuthority struct { + performValidationRequest chan *vapb.PerformValidationRequest + PerformValidationRequestResultError error + PerformValidationRequestResultReturn *vapb.ValidationResult +} + +func (dva *DummyValidationAuthority) PerformValidation(ctx context.Context, req *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + dva.performValidationRequest <- req + return dva.PerformValidationRequestResultReturn, dva.PerformValidationRequestResultError +} + +var ( + // These values we simulate from the client + AccountKeyJSONA = []byte(`{ + "kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB" + }`) + AccountKeyA = jose.JSONWebKey{} + + AccountKeyJSONB = []byte(`{ + "kty":"RSA", + "n":"z8bp-jPtHt4lKBqepeKF28g_QAEOuEsCIou6sZ9ndsQsEjxEOQxQ0xNOQezsKa63eogw8YS3vzjUcPP5BJuVzfPfGd5NVUdT-vSSwxk3wvk_jtNqhrpcoG0elRPQfMVsQWmxCAXCVRz3xbcFI8GTe-syynG3l-g1IzYIIZVNI6jdljCZML1HOMTTW4f7uJJ8mM-08oQCeHbr5ejK7O2yMSSYxW03zY-Tj1iVEebROeMv6IEEJNFSS4yM-hLpNAqVuQxFGetwtwjDMC1Drs1dTWrPuUAAjKGrP151z1_dE74M5evpAhZUmpKv1hY-x85DC6N0hFPgowsanmTNNiV75w", + "e":"AQAB" + }`) + AccountKeyB = jose.JSONWebKey{} + + AccountKeyJSONC = []byte(`{ + "kty":"RSA", + "n":"rFH5kUBZrlPj73epjJjyCxzVzZuV--JjKgapoqm9pOuOt20BUTdHqVfC2oDclqM7HFhkkX9OSJMTHgZ7WaVqZv9u1X2yjdx9oVmMLuspX7EytW_ZKDZSzL-sCOFCuQAuYKkLbsdcA3eHBK_lwc4zwdeHFMKIulNvLqckkqYB9s8GpgNXBDIQ8GjR5HuJke_WUNjYHSd8jY1LU9swKWsLQe2YoQUz_ekQvBvBCoaFEtrtRaSJKNLIVDObXFr2TLIiFiM0Em90kK01-eQ7ZiruZTKomll64bRFPoNo4_uwubddg3xTqur2vdF3NyhTrYdvAgTem4uC0PFjEQ1bK_djBQ", + "e":"AQAB" + }`) + AccountKeyC = jose.JSONWebKey{} + + // These values we simulate from the client + AccountPrivateKeyJSON = []byte(`{ + "kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB", + "d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqijwp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBznbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFzme1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q", + "p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPVnwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqVWlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs", + "q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyumqjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgxkIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk", + "dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oimYwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_NmtuYZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0", + "dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUUvMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk", + "qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzgUIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rxyR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU" + }`) + AccountPrivateKey = jose.JSONWebKey{} + + ShortKeyJSON = []byte(`{ + "e": "AQAB", + "kty": "RSA", + "n": "tSwgy3ORGvc7YJI9B2qqkelZRUC6F1S5NwXFvM4w5-M0TsxbFsH5UH6adigV0jzsDJ5imAechcSoOhAh9POceCbPN1sTNwLpNbOLiQQ7RD5mY_" + }`) + + ShortKey = jose.JSONWebKey{} + + ResponseIndex = 0 + + ExampleCSR = &x509.CertificateRequest{} + + Registration = &corepb.Registration{Id: 1} + + Identifier = "not-example.com" + + log = blog.UseMock() +) + +var ctx = context.Background() + +// dummyRateLimitConfig satisfies the rl.RateLimitConfig interface while +// allowing easy mocking of the individual RateLimitPolicy's +type dummyRateLimitConfig struct { + CertificatesPerNamePolicy ratelimit.RateLimitPolicy + RegistrationsPerIPPolicy ratelimit.RateLimitPolicy + RegistrationsPerIPRangePolicy ratelimit.RateLimitPolicy + PendingAuthorizationsPerAccountPolicy ratelimit.RateLimitPolicy + NewOrdersPerAccountPolicy ratelimit.RateLimitPolicy + InvalidAuthorizationsPerAccountPolicy ratelimit.RateLimitPolicy + CertificatesPerFQDNSetPolicy ratelimit.RateLimitPolicy + CertificatesPerFQDNSetFastPolicy ratelimit.RateLimitPolicy +} + +func (r *dummyRateLimitConfig) CertificatesPerName() ratelimit.RateLimitPolicy { + return r.CertificatesPerNamePolicy +} + +func (r *dummyRateLimitConfig) RegistrationsPerIP() ratelimit.RateLimitPolicy { + return r.RegistrationsPerIPPolicy +} + +func (r *dummyRateLimitConfig) RegistrationsPerIPRange() ratelimit.RateLimitPolicy { + return r.RegistrationsPerIPRangePolicy +} + +func (r *dummyRateLimitConfig) PendingAuthorizationsPerAccount() ratelimit.RateLimitPolicy { + return r.PendingAuthorizationsPerAccountPolicy +} + +func (r *dummyRateLimitConfig) NewOrdersPerAccount() ratelimit.RateLimitPolicy { + return r.NewOrdersPerAccountPolicy +} + +func (r *dummyRateLimitConfig) InvalidAuthorizationsPerAccount() ratelimit.RateLimitPolicy { + return r.InvalidAuthorizationsPerAccountPolicy +} + +func (r *dummyRateLimitConfig) CertificatesPerFQDNSet() ratelimit.RateLimitPolicy { + return r.CertificatesPerFQDNSetPolicy +} + +func (r *dummyRateLimitConfig) CertificatesPerFQDNSetFast() ratelimit.RateLimitPolicy { + return r.CertificatesPerFQDNSetFastPolicy +} + +func (r *dummyRateLimitConfig) LoadPolicies(contents []byte) error { + return nil // NOP - unrequired behaviour for this mock +} + +func parseAndMarshalIP(t *testing.T, ip string) []byte { + ipBytes, err := net.ParseIP(ip).MarshalText() + test.AssertNotError(t, err, "failed to marshal ip") + return ipBytes +} + +func newAcctKey(t *testing.T) []byte { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwk := &jose.JSONWebKey{Key: key.Public()} + acctKey, err := jwk.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + return acctKey +} + +func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAuthorityClient, *RegistrationAuthorityImpl, clock.FakeClock, func()) { + err := json.Unmarshal(AccountKeyJSONA, &AccountKeyA) + test.AssertNotError(t, err, "Failed to unmarshal public JWK") + err = json.Unmarshal(AccountKeyJSONB, &AccountKeyB) + test.AssertNotError(t, err, "Failed to unmarshal public JWK") + err = json.Unmarshal(AccountKeyJSONC, &AccountKeyC) + test.AssertNotError(t, err, "Failed to unmarshal public JWK") + + err = json.Unmarshal(AccountPrivateKeyJSON, &AccountPrivateKey) + test.AssertNotError(t, err, "Failed to unmarshal private JWK") + + err = json.Unmarshal(ShortKeyJSON, &ShortKey) + test.AssertNotError(t, err, "Failed to unmarshal JWK") + + fc := clock.NewFake() + // Set to some non-zero time. + fc.Set(time.Date(2020, 3, 4, 5, 0, 0, 0, time.UTC)) + + dbMap, err := sa.DBMapForTest(vars.DBConnSA) + if err != nil { + t.Fatalf("Failed to create dbMap: %s", err) + } + ssa, err := sa.NewSQLStorageAuthority(dbMap, dbMap, nil, 1, 0, fc, log, metrics.NoopRegisterer) + if err != nil { + t.Fatalf("Failed to create SA: %s", err) + } + sa := &isa.SA{Impl: ssa} + + saDBCleanUp := test.ResetBoulderTestDatabase(t) + + va := &DummyValidationAuthority{ + performValidationRequest: make(chan *vapb.PerformValidationRequest, 1), + } + + pa, err := policy.New(map[core.AcmeChallenge]bool{ + core.ChallengeTypeHTTP01: true, + core.ChallengeTypeDNS01: true, + }, blog.NewMock()) + test.AssertNotError(t, err, "Couldn't create PA") + err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml") + test.AssertNotError(t, err, "Couldn't set hostname policy") + + stats := metrics.NoopRegisterer + + ca := &mocks.MockCA{ + PEM: eeCertPEM, + } + cleanUp := func() { + saDBCleanUp() + } + + block, _ := pem.Decode(CSRPEM) + ExampleCSR, _ = x509.ParseCertificateRequest(block.Bytes) + + initialIP, err := net.ParseIP("3.2.3.3").MarshalText() + test.AssertNotError(t, err, "Couldn't create initial IP") + Registration, _ = ssa.NewRegistration(ctx, &corepb.Registration{ + Key: AccountKeyJSONA, + InitialIP: initialIP, + Status: string(core.StatusValid), + }) + + ctp := ctpolicy.New(&mocks.PublisherClient{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + }, nil, nil, 0, log, metrics.NoopRegisterer) + + var limiter *ratelimits.Limiter + var txnBuilder *ratelimits.TransactionBuilder + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + rc := bredis.Config{ + Username: "unittest-rw", + TLS: cmd.TLSConfig{ + CACertFile: "../test/certs/ipki/minica.pem", + CertFile: "../test/certs/ipki/localhost/cert.pem", + KeyFile: "../test/certs/ipki/localhost/key.pem", + }, + Lookups: []cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + LookupDNSAuthority: "consul.service.consul", + } + rc.PasswordConfig = cmd.PasswordConfig{ + PasswordFile: "../test/secrets/ratelimits_redis_password", + } + ring, err := bredis.NewRingFromConfig(rc, stats, log) + test.AssertNotError(t, err, "making redis ring client") + source := ratelimits.NewRedisSource(ring.Ring, fc, stats) + test.AssertNotNil(t, source, "source should not be nil") + limiter, err = ratelimits.NewLimiter(fc, source, stats) + test.AssertNotError(t, err, "making limiter") + txnBuilder, err = ratelimits.NewTransactionBuilder("../test/config-next/wfe2-ratelimit-defaults.yml", "") + test.AssertNotError(t, err, "making transaction composer") + } + + testKeyPolicy, err := goodkey.NewPolicy(nil, nil) + test.AssertNotError(t, err, "making keypolicy") + + ra := NewRegistrationAuthorityImpl( + fc, log, stats, + 1, testKeyPolicy, limiter, txnBuilder, 100, + 300*24*time.Hour, 7*24*time.Hour, + nil, noopCAA{}, + 0, 5*time.Minute, + ctp, nil, nil) + ra.SA = sa + ra.VA = va + ra.CA = ca + ra.OCSP = &mocks.MockOCSPGenerator{} + ra.PA = pa + return va, sa, ra, fc, cleanUp +} + +func TestValidateContacts(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ansible := "ansible:earth.sol.milkyway.laniakea/letsencrypt" + validEmail := "mailto:admin@email.com" + otherValidEmail := "mailto:other-admin@email.com" + malformedEmail := "mailto:admin.com" + nonASCII := "mailto:señor@email.com" + unparsable := "mailto:a@email.com, b@email.com" + forbidden := "mailto:a@example.org" + + err := ra.validateContacts([]string{}) + test.AssertNotError(t, err, "No Contacts") + + err = ra.validateContacts([]string{validEmail, otherValidEmail}) + test.AssertError(t, err, "Too Many Contacts") + + err = ra.validateContacts([]string{validEmail}) + test.AssertNotError(t, err, "Valid Email") + + err = ra.validateContacts([]string{malformedEmail}) + test.AssertError(t, err, "Malformed Email") + + err = ra.validateContacts([]string{ansible}) + test.AssertError(t, err, "Unknown scheme") + + err = ra.validateContacts([]string{""}) + test.AssertError(t, err, "Empty URL") + + err = ra.validateContacts([]string{nonASCII}) + test.AssertError(t, err, "Non ASCII email") + + err = ra.validateContacts([]string{unparsable}) + test.AssertError(t, err, "Unparsable email") + + err = ra.validateContacts([]string{forbidden}) + test.AssertError(t, err, "Forbidden email") + + err = ra.validateContacts([]string{"mailto:admin@localhost"}) + test.AssertError(t, err, "Forbidden email") + + err = ra.validateContacts([]string{"mailto:admin@example.not.a.iana.suffix"}) + test.AssertError(t, err, "Forbidden email") + + err = ra.validateContacts([]string{"mailto:admin@1.2.3.4"}) + test.AssertError(t, err, "Forbidden email") + + err = ra.validateContacts([]string{"mailto:admin@[1.2.3.4]"}) + test.AssertError(t, err, "Forbidden email") + + err = ra.validateContacts([]string{"mailto:admin@a.com?no-reminder-emails"}) + test.AssertError(t, err, "No hfields in email") + + err = ra.validateContacts([]string{"mailto:example@a.com?"}) + test.AssertError(t, err, "No hfields in email") + + err = ra.validateContacts([]string{"mailto:example@a.com#"}) + test.AssertError(t, err, "No fragment") + + err = ra.validateContacts([]string{"mailto:example@a.com#optional"}) + test.AssertError(t, err, "No fragment") + + // The registrations.contact field is VARCHAR(191). 175 'a' characters plus + // the prefix "mailto:" and the suffix "@a.com" makes exactly 191 bytes of + // encoded JSON. The correct size to hit our maximum DB field length. + var longStringBuf strings.Builder + longStringBuf.WriteString("mailto:") + for range 175 { + longStringBuf.WriteRune('a') + } + longStringBuf.WriteString("@a.com") + + err = ra.validateContacts([]string{longStringBuf.String()}) + test.AssertError(t, err, "Too long contacts") +} + +func TestNewRegistration(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + mailto := "mailto:foo@letsencrypt.org" + acctKeyB, err := AccountKeyB.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + input := &corepb.Registration{ + Contact: []string{mailto}, + ContactsPresent: true, + Key: acctKeyB, + InitialIP: parseAndMarshalIP(t, "7.6.6.5"), + } + + result, err := ra.NewRegistration(ctx, input) + if err != nil { + t.Fatalf("could not create new registration: %s", err) + } + test.AssertByteEquals(t, result.Key, acctKeyB) + test.Assert(t, len(result.Contact) == 1, "Wrong number of contacts") + test.Assert(t, mailto == (result.Contact)[0], "Contact didn't match") + test.Assert(t, result.Agreement == "", "Agreement didn't default empty") + + reg, err := sa.GetRegistration(ctx, &sapb.RegistrationID{Id: result.Id}) + test.AssertNotError(t, err, "Failed to retrieve registration") + test.AssertByteEquals(t, reg.Key, acctKeyB) +} + +func TestNewRegistrationContactsPresent(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + testCases := []struct { + Name string + Reg *corepb.Registration + ExpectedErr error + }{ + { + Name: "No contacts provided by client ContactsPresent false", + Reg: &corepb.Registration{ + Key: newAcctKey(t), + InitialIP: parseAndMarshalIP(t, "7.6.6.5"), + }, + ExpectedErr: nil, + }, + { + Name: "Empty contact provided by client ContactsPresent true", + Reg: &corepb.Registration{ + Contact: []string{}, + ContactsPresent: true, + Key: newAcctKey(t), + InitialIP: parseAndMarshalIP(t, "7.6.6.4"), + }, + ExpectedErr: nil, + }, + { + Name: "Valid contact provided by client ContactsPresent true", + Reg: &corepb.Registration{ + Contact: []string{"mailto:foo@letsencrypt.org"}, + ContactsPresent: true, + Key: newAcctKey(t), + InitialIP: parseAndMarshalIP(t, "7.6.4.3"), + }, + ExpectedErr: nil, + }, + { + Name: "Valid contact provided by client ContactsPresent false", + Reg: &corepb.Registration{ + Contact: []string{"mailto:foo@letsencrypt.org"}, + ContactsPresent: false, + Key: newAcctKey(t), + InitialIP: parseAndMarshalIP(t, "7.6.6.2"), + }, + ExpectedErr: fmt.Errorf("account contacts present but contactsPresent false"), + }, + } + // For each test case we check that the NewRegistration works as + // intended with variations of Contact and ContactsPresent fields + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // Create new registration + _, err := ra.NewRegistration(ctx, tc.Reg) + // Check error output + if tc.ExpectedErr == nil { + test.AssertNotError(t, err, "expected no error for NewRegistration") + } else { + test.AssertError(t, err, "expected error for NewRegistration") + test.AssertEquals(t, err.Error(), tc.ExpectedErr.Error()) + } + }) + } +} + +type mockSAFailsNewRegistration struct { + sapb.StorageAuthorityClient +} + +func (sa *mockSAFailsNewRegistration) NewRegistration(_ context.Context, _ *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) { + return &corepb.Registration{}, fmt.Errorf("too bad") +} + +func TestNewRegistrationSAFailure(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.SA = &mockSAFailsNewRegistration{} + acctKeyB, err := AccountKeyB.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + input := corepb.Registration{ + Contact: []string{"mailto:test@example.com"}, + ContactsPresent: true, + Key: acctKeyB, + InitialIP: parseAndMarshalIP(t, "7.6.6.5"), + } + result, err := ra.NewRegistration(ctx, &input) + if err == nil { + t.Fatalf("NewRegistration should have failed when SA.NewRegistration failed %#v", result.Key) + } +} + +func TestNewRegistrationNoFieldOverwrite(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + mailto := "mailto:foo@letsencrypt.org" + acctKeyC, err := AccountKeyC.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + input := &corepb.Registration{ + Id: 23, + Key: acctKeyC, + Contact: []string{mailto}, + ContactsPresent: true, + Agreement: "I agreed", + InitialIP: parseAndMarshalIP(t, "5.0.5.0"), + } + + result, err := ra.NewRegistration(ctx, input) + test.AssertNotError(t, err, "Could not create new registration") + test.Assert(t, result.Id != 23, "ID shouldn't be set by user") + // TODO: Enable this test case once we validate terms agreement. + //test.Assert(t, result.Agreement != "I agreed", "Agreement shouldn't be set with invalid URL") +} + +func TestNewRegistrationBadKey(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + mailto := "mailto:foo@letsencrypt.org" + shortKey, err := ShortKey.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + input := &corepb.Registration{ + Contact: []string{mailto}, + ContactsPresent: true, + Key: shortKey, + } + _, err = ra.NewRegistration(ctx, input) + test.AssertError(t, err, "Should have rejected authorization with short key") +} + +func TestNewRegistrationRateLimit(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Specify a dummy rate limit policy that allows 1 registration per exact IP + // match, and 2 per range. + ra.rlPolicies = &dummyRateLimitConfig{ + RegistrationsPerIPPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 24 * 90 * time.Hour}, + }, + RegistrationsPerIPRangePolicy: ratelimit.RateLimitPolicy{ + Threshold: 2, + Window: config.Duration{Duration: 24 * 90 * time.Hour}, + }, + } + + // Create one registration for an IPv4 address + mailto := "mailto:foo@letsencrypt.org" + reg := &corepb.Registration{ + Contact: []string{mailto}, + ContactsPresent: true, + Key: newAcctKey(t), + InitialIP: parseAndMarshalIP(t, "7.6.6.5"), + } + // There should be no errors - it is within the RegistrationsPerIP rate limit + _, err := ra.NewRegistration(ctx, reg) + test.AssertNotError(t, err, "Unexpected error adding new IPv4 registration") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "decision": ratelimits.Allowed}, 1) + // There are no overrides for this IP, so the override usage gauge should + // contain 0 entries with labels matching it. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "override_key": "7.6.6.5"}, 0) + + // Create another registration for the same IPv4 address by changing the key + reg.Key = newAcctKey(t) + + // There should be an error since a 2nd registration will exceed the + // RegistrationsPerIP rate limit + _, err = ra.NewRegistration(ctx, reg) + test.AssertError(t, err, "No error adding duplicate IPv4 registration") + test.AssertEquals(t, err.Error(), "too many registrations for this IP: see https://letsencrypt.org/docs/too-many-registrations-for-this-ip/") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "decision": ratelimits.Denied}, 1) + + // Create a registration for an IPv6 address + reg.Key = newAcctKey(t) + reg.InitialIP = parseAndMarshalIP(t, "2001:cdba:1234:5678:9101:1121:3257:9652") + + // There should be no errors - it is within the RegistrationsPerIP rate limit + _, err = ra.NewRegistration(ctx, reg) + test.AssertNotError(t, err, "Unexpected error adding a new IPv6 registration") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "decision": ratelimits.Allowed}, 2) + + // Create a 2nd registration for the IPv6 address by changing the key + reg.Key = newAcctKey(t) + + // There should be an error since a 2nd reg for the same IPv6 address will + // exceed the RegistrationsPerIP rate limit + _, err = ra.NewRegistration(ctx, reg) + test.AssertError(t, err, "No error adding duplicate IPv6 registration") + test.AssertEquals(t, err.Error(), "too many registrations for this IP: see https://letsencrypt.org/docs/too-many-registrations-for-this-ip/") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "decision": ratelimits.Denied}, 2) + + // Create a registration for an IPv6 address in the same /48 + reg.Key = newAcctKey(t) + reg.InitialIP = parseAndMarshalIP(t, "2001:cdba:1234:5678:9101:1121:3257:9653") + + // There should be no errors since two IPv6 addresses in the same /48 is + // within the RegistrationsPerIPRange limit + _, err = ra.NewRegistration(ctx, reg) + test.AssertNotError(t, err, "Unexpected error adding second IPv6 registration in the same /48") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIPRange, "decision": ratelimits.Allowed}, 2) + + // Create a registration for yet another IPv6 address in the same /48 + reg.Key = newAcctKey(t) + reg.InitialIP = parseAndMarshalIP(t, "2001:cdba:1234:5678:9101:1121:3257:9654") + + // There should be an error since three registrations within the same IPv6 + // /48 is outside of the RegistrationsPerIPRange limit + _, err = ra.NewRegistration(ctx, reg) + test.AssertError(t, err, "No error adding a third IPv6 registration in the same /48") + test.AssertEquals(t, err.Error(), "too many registrations for this IP range: see https://letsencrypt.org/docs/rate-limits/") + test.AssertMetricWithLabelsEquals(t, ra.rlCheckLatency, prometheus.Labels{"limit": ratelimit.RegistrationsPerIPRange, "decision": ratelimits.Denied}, 1) +} + +func TestRegistrationsPerIPOverrideUsage(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + regIP := net.ParseIP("4.5.6.7") + rlp := ratelimit.RateLimitPolicy{ + Threshold: 2, + Window: config.Duration{Duration: 23 * time.Hour}, + Overrides: map[string]int64{ + regIP.String(): 3, + }, + } + + mockCounterAlwaysTwo := func(context.Context, *sapb.CountRegistrationsByIPRequest, ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{Count: 2}, nil + } + + // No error expected, the count of existing registrations for "4.5.6.7" + // should be 1 below the override threshold. + err := ra.checkRegistrationIPLimit(ctx, rlp, regIP, mockCounterAlwaysTwo) + test.AssertNotError(t, err, "Unexpected error checking RegistrationsPerIPRange limit") + + // Accounting for the anticipated issuance, we expect "4.5.6.7" to be at + // 100% of their override threshold. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "override_key": regIP.String()}, 1) + + mockCounterAlwaysThree := func(context.Context, *sapb.CountRegistrationsByIPRequest, ...grpc.CallOption) (*sapb.Count, error) { + return &sapb.Count{Count: 3}, nil + } + + // Error expected, the count of existing registrations for "4.5.6.7" should + // be exactly at the threshold. + err = ra.checkRegistrationIPLimit(ctx, rlp, regIP, mockCounterAlwaysThree) + test.AssertError(t, err, "Expected error checking RegistrationsPerIPRange limit") + + // Expecting 100% of the override for "4.5.6.7" to be utilized. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.RegistrationsPerIP, "override_key": regIP.String()}, 1) +} + +type NoUpdateSA struct { + sapb.StorageAuthorityClient +} + +func (sa NoUpdateSA) UpdateRegistration(_ context.Context, _ *corepb.Registration, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, fmt.Errorf("UpdateRegistration() is mocked to always error") +} + +func TestUpdateRegistrationSame(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + mailto := "mailto:foo@letsencrypt.org" + + // Make a new registration with AccountKeyC and a Contact + acctKeyC, err := AccountKeyC.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + reg := &corepb.Registration{ + Key: acctKeyC, + Contact: []string{mailto}, + ContactsPresent: true, + Agreement: "I agreed", + InitialIP: parseAndMarshalIP(t, "5.0.5.0"), + } + result, err := ra.NewRegistration(ctx, reg) + test.AssertNotError(t, err, "Could not create new registration") + + // Switch to a mock SA that will always error if UpdateRegistration() is called + ra.SA = &NoUpdateSA{} + + // Make an update to the registration with the same Contact & Agreement values. + updateSame := &corepb.Registration{ + Id: result.Id, + Key: acctKeyC, + Contact: []string{mailto}, + ContactsPresent: true, + Agreement: "I agreed", + } + + // The update operation should *not* error, even with the NoUpdateSA because + // UpdateRegistration() should not be called when the update content doesn't + // actually differ from the existing content + _, err = ra.UpdateRegistration(ctx, &rapb.UpdateRegistrationRequest{Base: result, Update: updateSame}) + test.AssertNotError(t, err, "Error updating registration") +} + +func TestPerformValidationExpired(t *testing.T) { + _, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + authz := createPendingAuthorization(t, sa, Identifier, fc.Now().Add(-2*time.Hour)) + + _, err := ra.PerformValidation(ctx, &rapb.PerformValidationRequest{ + Authz: authz, + ChallengeIndex: int64(ResponseIndex), + }) + test.AssertError(t, err, "Updated expired authorization") +} + +func TestPerformValidationAlreadyValid(t *testing.T) { + va, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Create a finalized authorization + exp := ra.clk.Now().Add(365 * 24 * time.Hour) + authz := core.Authorization{ + ID: "1337", + Identifier: identifier.DNSIdentifier("not-example.com"), + RegistrationID: 1, + Status: "valid", + Expires: &exp, + Challenges: []core.Challenge{ + { + Token: core.NewToken(), + Type: core.ChallengeTypeHTTP01, + Status: core.StatusPending, + }, + }, + } + authzPB, err := bgrpc.AuthzToPB(authz) + test.AssertNotError(t, err, "bgrpc.AuthzToPB failed") + + va.PerformValidationRequestResultReturn = &vapb.ValidationResult{ + Records: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("192.168.0.1"), + Hostname: "example.com", + Port: "8080", + Url: "http://example.com/", + }, + }, + Problems: nil, + } + + // A subsequent call to perform validation should return nil due + // to being short-circuited because of valid authz reuse. + val, err := ra.PerformValidation(ctx, &rapb.PerformValidationRequest{ + Authz: authzPB, + ChallengeIndex: int64(ResponseIndex), + }) + test.Assert(t, core.AcmeStatus(val.Status) == core.StatusValid, "Validation should have been valid") + test.AssertNotError(t, err, "Error was not nil, but should have been nil") +} + +func TestPerformValidationSuccess(t *testing.T) { + va, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + // We know this is OK because of TestNewAuthorization + authzPB := createPendingAuthorization(t, sa, Identifier, fc.Now().Add(12*time.Hour)) + + va.PerformValidationRequestResultReturn = &vapb.ValidationResult{ + Records: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("192.168.0.1"), + Hostname: "example.com", + Port: "8080", + Url: "http://example.com/", + ResolverAddrs: []string{"rebound"}, + }, + }, + Problems: nil, + } + + var remainingFailedValidations int64 + var rlTxns []ratelimits.Transaction + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + // Gather a baseline for the rate limit. + var err error + rlTxns, err = ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(authzPB.RegistrationID, []string{Identifier}, 100) + test.AssertNotError(t, err, "FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions failed") + + d, err := ra.limiter.BatchSpend(ctx, rlTxns) + test.AssertNotError(t, err, "BatchSpend failed") + remainingFailedValidations = d.Remaining + } + + now := fc.Now() + challIdx := dnsChallIdx(t, authzPB.Challenges) + authzPB, err := ra.PerformValidation(ctx, &rapb.PerformValidationRequest{ + Authz: authzPB, + ChallengeIndex: challIdx, + }) + test.AssertNotError(t, err, "PerformValidation failed") + + var vaRequest *vapb.PerformValidationRequest + select { + case r := <-va.performValidationRequest: + vaRequest = r + case <-time.After(time.Second): + t.Fatal("Timed out waiting for DummyValidationAuthority.PerformValidation to complete") + } + + // Verify that the VA got the request, and it's the same as the others + test.AssertEquals(t, authzPB.Challenges[challIdx].Type, vaRequest.Challenge.Type) + test.AssertEquals(t, authzPB.Challenges[challIdx].Token, vaRequest.Challenge.Token) + + // Sleep so the RA has a chance to write to the SA + time.Sleep(100 * time.Millisecond) + + dbAuthzPB := getAuthorization(t, authzPB.Id, sa) + t.Log("dbAuthz:", dbAuthzPB) + + // Verify that the responses are reflected + challIdx = dnsChallIdx(t, dbAuthzPB.Challenges) + challenge, err := bgrpc.PBToChallenge(dbAuthzPB.Challenges[challIdx]) + test.AssertNotError(t, err, "Failed to marshall corepb.Challenge to core.Challenge.") + + test.AssertNotNil(t, vaRequest.Challenge, "Request passed to VA has no challenge") + test.Assert(t, challenge.Status == core.StatusValid, "challenge was not marked as valid") + + // The DB authz's expiry should be equal to the current time plus the + // configured authorization lifetime + test.AssertEquals(t, dbAuthzPB.Expires.AsTime(), now.Add(ra.authorizationLifetime)) + + // Check that validated timestamp was recorded, stored, and retrieved + expectedValidated := fc.Now() + test.Assert(t, *challenge.Validated == expectedValidated, "Validated timestamp incorrect or missing") + + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + // The failed validations bucket should be identical to the baseline. + d, err := ra.limiter.BatchSpend(ctx, rlTxns) + test.AssertNotError(t, err, "BatchSpend failed") + test.AssertEquals(t, d.Remaining, remainingFailedValidations) + } +} + +func TestPerformValidationVAError(t *testing.T) { + va, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + authzPB := createPendingAuthorization(t, sa, Identifier, fc.Now().Add(12*time.Hour)) + + var remainingFailedValidations int64 + var rlTxns []ratelimits.Transaction + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + // Gather a baseline for the rate limit. + var err error + rlTxns, err = ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(authzPB.RegistrationID, []string{Identifier}, 100) + test.AssertNotError(t, err, "FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions failed") + + d, err := ra.limiter.BatchSpend(ctx, rlTxns) + test.AssertNotError(t, err, "BatchSpend failed") + remainingFailedValidations = d.Remaining + } + + va.PerformValidationRequestResultError = fmt.Errorf("Something went wrong") + + challIdx := dnsChallIdx(t, authzPB.Challenges) + authzPB, err := ra.PerformValidation(ctx, &rapb.PerformValidationRequest{ + Authz: authzPB, + ChallengeIndex: challIdx, + }) + + test.AssertNotError(t, err, "PerformValidation completely failed") + + var vaRequest *vapb.PerformValidationRequest + select { + case r := <-va.performValidationRequest: + vaRequest = r + case <-time.After(time.Second): + t.Fatal("Timed out waiting for DummyValidationAuthority.PerformValidation to complete") + } + + // Verify that the VA got the request, and it's the same as the others + test.AssertEquals(t, authzPB.Challenges[challIdx].Type, vaRequest.Challenge.Type) + test.AssertEquals(t, authzPB.Challenges[challIdx].Token, vaRequest.Challenge.Token) + + // Sleep so the RA has a chance to write to the SA + time.Sleep(100 * time.Millisecond) + + dbAuthzPB := getAuthorization(t, authzPB.Id, sa) + t.Log("dbAuthz:", dbAuthzPB) + + // Verify that the responses are reflected + challIdx = dnsChallIdx(t, dbAuthzPB.Challenges) + challenge, err := bgrpc.PBToChallenge(dbAuthzPB.Challenges[challIdx]) + test.AssertNotError(t, err, "Failed to marshall corepb.Challenge to core.Challenge.") + test.Assert(t, challenge.Status == core.StatusInvalid, "challenge was not marked as invalid") + test.AssertContains(t, challenge.Error.Error(), "Could not communicate with VA") + test.Assert(t, challenge.ValidationRecord == nil, "challenge had a ValidationRecord") + + // Check that validated timestamp was recorded, stored, and retrieved + expectedValidated := fc.Now() + test.Assert(t, *challenge.Validated == expectedValidated, "Validated timestamp incorrect or missing") + + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + // The failed validations bucket should have been decremented by 1. + d, err := ra.limiter.BatchSpend(ctx, rlTxns) + test.AssertNotError(t, err, "BatchSpend failed") + test.AssertEquals(t, d.Remaining, remainingFailedValidations-1) + } +} + +func TestCertificateKeyNotEqualAccountKey(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + exp := ra.clk.Now().Add(365 * 24 * time.Hour) + + authzID := createFinalizedAuthorization(t, sa, "www.example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: []string{"www.example.com"}, + V2Authorizations: []int64{authzID}, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs, ready status") + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + // Registration has key == AccountKeyA + PublicKey: AccountKeyA.Key, + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"www.example.com"}, + }, AccountPrivateKey.Key) + test.AssertNotError(t, err, "Failed to sign CSR") + + _, err = ra.FinalizeOrder(ctx, &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Status: string(core.StatusReady), + Names: []string{"www.example.com"}, + Id: order.Id, + RegistrationID: Registration.Id, + }, + Csr: csrBytes, + }) + test.AssertError(t, err, "Should have rejected cert with key = account key") + test.AssertEquals(t, err.Error(), "certificate public key must be different than account key") +} + +func TestNewOrderRateLimiting(t *testing.T) { + _, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.orderLifetime = 5 * 24 * time.Hour + + // Create a dummy rate limit config that sets a NewOrdersPerAccount rate + // limit with a very low threshold/short window + rateLimitDuration := 5 * time.Minute + ra.rlPolicies = &dummyRateLimitConfig{ + NewOrdersPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: rateLimitDuration}, + }, + } + + orderOne := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"first.example.com"}, + } + orderTwo := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"second.example.com"}, + } + + // To start, it should be possible to create a new order + _, err := ra.NewOrder(ctx, orderOne) + test.AssertNotError(t, err, "NewOrder for orderOne failed") + + // Advance the clock 1s to separate the orders in time + fc.Add(time.Second) + + // Creating an order immediately after the first with different names + // should fail + _, err = ra.NewOrder(ctx, orderTwo) + test.AssertError(t, err, "NewOrder for orderTwo succeeded, should have been ratelimited") + + // Creating the first order again should succeed because of order reuse, no + // new pending order is produced. + _, err = ra.NewOrder(ctx, orderOne) + test.AssertNotError(t, err, "Reuse of orderOne failed") + + // Insert a specific certificate into the database, then create an order for + // the same set of names. This order should succeed because it's a renewal. + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "generating test key") + fakeCert := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"renewing.example.com"}, + NotBefore: fc.Now().Add(-time.Hour), + NotAfter: fc.Now().Add(time.Hour), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + certDER, err := x509.CreateCertificate(rand.Reader, fakeCert, fakeCert, testKey.Public(), testKey) + test.AssertNotError(t, err, "generating test certificate") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: certDER, + RegID: Registration.Id, + Issued: timestamppb.New(fc.Now().Add(-time.Hour)), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Adding test certificate") + + _, err = ra.NewOrder(ctx, &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"renewing.example.com"}, + }) + test.AssertNotError(t, err, "Renewal of orderRenewal failed") + + // Advancing the clock by 2 * the rate limit duration should allow orderTwo to + // succeed + fc.Add(2 * rateLimitDuration) + _, err = ra.NewOrder(ctx, orderTwo) + test.AssertNotError(t, err, "NewOrder for orderTwo failed after advancing clock") +} + +// TestEarlyOrderRateLimiting tests that NewOrder applies the certificates per +// name/per FQDN rate limits against the order names. +func TestEarlyOrderRateLimiting(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.orderLifetime = 5 * 24 * time.Hour + + rateLimitDuration := 5 * time.Minute + + domain := "early-ratelimit-example.com" + + // Set a mock RL policy with a CertificatesPerName threshold for the domain + // name so low if it were enforced it would prevent a new order for any names. + ra.rlPolicies = &dummyRateLimitConfig{ + CertificatesPerNamePolicy: ratelimit.RateLimitPolicy{ + Threshold: 10, + Window: config.Duration{Duration: rateLimitDuration}, + // Setting the Threshold to 0 skips applying the rate limit. Setting an + // override to 0 does the trick. + Overrides: map[string]int64{ + domain: 0, + }, + }, + NewOrdersPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 10, + Window: config.Duration{Duration: rateLimitDuration}, + }, + } + + // Request an order for the test domain + newOrder := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{domain}, + } + + // With the feature flag enabled the NewOrder request should fail because of + // the CertificatesPerNamePolicy. + _, err := ra.NewOrder(ctx, newOrder) + test.AssertError(t, err, "NewOrder did not apply cert rate limits with feature flag enabled") + + var bErr *berrors.BoulderError + test.Assert(t, errors.As(err, &bErr), "NewOrder did not return a boulder error") + test.AssertEquals(t, bErr.RetryAfter, rateLimitDuration) + + // The err should be the expected rate limit error + expected := "too many certificates already issued for \"early-ratelimit-example.com\". Retry after 2020-03-04T05:05:00Z: see https://letsencrypt.org/docs/rate-limits/" + test.AssertEquals(t, bErr.Error(), expected) +} + +// mockInvalidAuthorizationsAuthority is a mock which claims that the given +// domain has one invalid authorization. +type mockInvalidAuthorizationsAuthority struct { + sapb.StorageAuthorityClient + domainWithFailures string +} + +func (sa *mockInvalidAuthorizationsAuthority) CountInvalidAuthorizations2(ctx context.Context, req *sapb.CountInvalidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + if req.Hostname == sa.domainWithFailures { + return &sapb.Count{Count: 1}, nil + } else { + return &sapb.Count{}, nil + } +} + +func TestAuthzFailedRateLimitingNewOrder(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.rlPolicies = &dummyRateLimitConfig{ + InvalidAuthorizationsPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 1 * time.Hour}, + }, + } + + limit := ra.rlPolicies.InvalidAuthorizationsPerAccount() + ra.SA = &mockInvalidAuthorizationsAuthority{domainWithFailures: "all.i.do.is.lose.com"} + err := ra.checkInvalidAuthorizationLimits(ctx, Registration.Id, + []string{"charlie.brown.com", "all.i.do.is.lose.com"}, limit) + test.AssertError(t, err, "checkInvalidAuthorizationLimits did not encounter expected rate limit error") + test.AssertEquals(t, err.Error(), "too many failed authorizations recently: see https://letsencrypt.org/docs/failed-validation-limit/") +} + +type mockSAWithNameCounts struct { + sapb.StorageAuthorityClient + nameCounts *sapb.CountByNames + t *testing.T + clk clock.FakeClock +} + +func (m *mockSAWithNameCounts) CountCertificatesByNames(ctx context.Context, req *sapb.CountCertificatesByNamesRequest, _ ...grpc.CallOption) (*sapb.CountByNames, error) { + expectedLatest := m.clk.Now() + if req.Range.Latest.AsTime() != expectedLatest { + m.t.Errorf("incorrect latest: got '%v', expected '%v'", req.Range.Latest.AsTime(), expectedLatest) + } + expectedEarliest := m.clk.Now().Add(-23 * time.Hour) + if req.Range.Earliest.AsTime() != expectedEarliest { + m.t.Errorf("incorrect earliest: got '%v', expected '%v'", req.Range.Earliest.AsTime(), expectedEarliest) + } + counts := make(map[string]int64) + for _, name := range req.Names { + if count, ok := m.nameCounts.Counts[name]; ok { + counts[name] = count + } + } + return &sapb.CountByNames{Counts: counts}, nil +} + +// FQDNSetExists is a mock which always returns false, so the test requests +// aren't considered to be renewals. +func (m *mockSAWithNameCounts) FQDNSetExists(ctx context.Context, req *sapb.FQDNSetExistsRequest, _ ...grpc.CallOption) (*sapb.Exists, error) { + return &sapb.Exists{Exists: false}, nil +} + +func TestCheckCertificatesPerNameLimit(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + rlp := ratelimit.RateLimitPolicy{ + Threshold: 3, + Window: config.Duration{Duration: 23 * time.Hour}, + Overrides: map[string]int64{ + "bigissuer.com": 100, + "smallissuer.co.uk": 1, + }, + } + + mockSA := &mockSAWithNameCounts{ + nameCounts: &sapb.CountByNames{Counts: map[string]int64{"example.com": 1}}, + clk: fc, + t: t, + } + + ra.SA = mockSA + + // One base domain, below threshold + err := ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "example.com"}, rlp, 99) + test.AssertNotError(t, err, "rate limited example.com incorrectly") + + // Two base domains, one above threshold, one below + mockSA.nameCounts.Counts["example.com"] = 10 + mockSA.nameCounts.Counts["good-example.com"] = 1 + err = ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "example.com", "good-example.com"}, rlp, 99) + test.AssertError(t, err, "incorrectly failed to rate limit example.com") + test.AssertErrorIs(t, err, berrors.RateLimit) + // There are no overrides for "example.com", so the override usage gauge + // should contain 0 entries with labels matching it. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.CertificatesPerName, "override_key": "example.com"}, 0) + // Verify it has no sub errors as there is only one bad name + test.AssertEquals(t, err.Error(), "too many certificates already issued for \"example.com\". Retry after 1970-01-01T23:00:00Z: see https://letsencrypt.org/docs/rate-limits/") + var bErr *berrors.BoulderError + test.AssertErrorWraps(t, err, &bErr) + test.AssertEquals(t, len(bErr.SubErrors), 0) + + // Three base domains, two above threshold, one below + mockSA.nameCounts.Counts["example.com"] = 10 + mockSA.nameCounts.Counts["other-example.com"] = 10 + mockSA.nameCounts.Counts["good-example.com"] = 1 + err = ra.checkCertificatesPerNameLimit(ctx, []string{"example.com", "other-example.com", "good-example.com"}, rlp, 99) + test.AssertError(t, err, "incorrectly failed to rate limit example.com, other-example.com") + test.AssertErrorIs(t, err, berrors.RateLimit) + // Verify it has two sub errors as there are two bad names + test.AssertEquals(t, err.Error(), "too many certificates already issued for multiple names (\"example.com\" and 2 others). Retry after 1970-01-01T23:00:00Z: see https://letsencrypt.org/docs/rate-limits/") + test.AssertErrorWraps(t, err, &bErr) + test.AssertEquals(t, len(bErr.SubErrors), 2) + + // SA misbehaved and didn't send back a count for every input name + err = ra.checkCertificatesPerNameLimit(ctx, []string{"zombo.com", "www.example.com", "example.com"}, rlp, 99) + test.AssertError(t, err, "incorrectly failed to error on misbehaving SA") + + // Two base domains, one above threshold but with an override. + mockSA.nameCounts.Counts["example.com"] = 0 + mockSA.nameCounts.Counts["bigissuer.com"] = 50 + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerName, "bigissuer.com").Set(.5) + err = ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "subdomain.bigissuer.com"}, rlp, 99) + test.AssertNotError(t, err, "incorrectly rate limited bigissuer") + // "bigissuer.com" has an override of 100 and they've issued 50. Accounting + // for the anticipated issuance, we expect to see 51% utilization. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.CertificatesPerName, "override_key": "bigissuer.com"}, .51) + + // Two base domains, one above its override + mockSA.nameCounts.Counts["example.com"] = 10 + mockSA.nameCounts.Counts["bigissuer.com"] = 100 + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerName, "bigissuer.com").Set(1) + err = ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "subdomain.bigissuer.com"}, rlp, 99) + test.AssertError(t, err, "incorrectly failed to rate limit bigissuer") + test.AssertErrorIs(t, err, berrors.RateLimit) + // "bigissuer.com" has an override of 100 and they've issued 100. They're + // already at 100% utilization, so we expect to see 100% utilization. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.CertificatesPerName, "override_key": "bigissuer.com"}, 1) + + // One base domain, above its override (which is below threshold) + mockSA.nameCounts.Counts["smallissuer.co.uk"] = 1 + ra.rlOverrideUsageGauge.WithLabelValues(ratelimit.CertificatesPerName, "smallissuer.co.uk").Set(1) + err = ra.checkCertificatesPerNameLimit(ctx, []string{"www.smallissuer.co.uk"}, rlp, 99) + test.AssertError(t, err, "incorrectly failed to rate limit smallissuer") + test.AssertErrorIs(t, err, berrors.RateLimit) + // "smallissuer.co.uk" has an override of 1 and they've issued 1. They're + // already at 100% utilization, so we expect to see 100% utilization. + test.AssertMetricWithLabelsEquals(t, ra.rlOverrideUsageGauge, prometheus.Labels{"limit": ratelimit.CertificatesPerName, "override_key": "smallissuer.co.uk"}, 1) +} + +// TestCheckExactCertificateLimit tests that the duplicate certificate limit +// applied to FQDN sets is respected. +func TestCheckExactCertificateLimit(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Create a rate limit with a small threshold + const dupeCertLimit = 3 + rlp := ratelimit.RateLimitPolicy{ + Threshold: dupeCertLimit, + Window: config.Duration{Duration: 24 * time.Hour}, + } + + // Create a mock SA that has a count of already issued certificates for some + // test names + firstIssuanceTimestamp := ra.clk.Now().Add(-rlp.Window.Duration) + fITS2 := firstIssuanceTimestamp.Add(time.Hour * 23) + fITS3 := firstIssuanceTimestamp.Add(time.Hour * 16) + fITS4 := firstIssuanceTimestamp.Add(time.Hour * 8) + issuanceTimestampsNS := []int64{ + fITS2.UnixNano(), + fITS3.UnixNano(), + fITS4.UnixNano(), + firstIssuanceTimestamp.UnixNano(), + } + issuanceTimestamps := []*timestamppb.Timestamp{ + timestamppb.New(fITS2), + timestamppb.New(fITS3), + timestamppb.New(fITS4), + timestamppb.New(firstIssuanceTimestamp), + } + // Our window is 24 hours and our threshold is 3 issuance. If our most + // recent issuance was 1 hour ago, we expect the next token to be available + // 8 hours from issuance time or 7 hours from now. + expectRetryAfterNS := time.Unix(0, issuanceTimestampsNS[0]).Add(time.Hour * 8).Format(time.RFC3339) + expectRetryAfter := issuanceTimestamps[0].AsTime().Add(time.Hour * 8).Format(time.RFC3339) + test.AssertEquals(t, expectRetryAfterNS, expectRetryAfter) + ra.SA = &mockSAWithFQDNSet{ + issuanceTimestamps: map[string]*sapb.Timestamps{ + "none.example.com": {Timestamps: []*timestamppb.Timestamp{}}, + "under.example.com": {Timestamps: issuanceTimestamps[3:3]}, + "equalbutvalid.example.com": {Timestamps: issuanceTimestamps[1:3]}, + "over.example.com": {Timestamps: issuanceTimestamps[0:3]}, + }, + t: t, + } + + testCases := []struct { + Name string + Domain string + ExpectedErr error + }{ + { + Name: "FQDN set issuances none", + Domain: "none.example.com", + ExpectedErr: nil, + }, + { + Name: "FQDN set issuances less than limit", + Domain: "under.example.com", + ExpectedErr: nil, + }, + { + Name: "FQDN set issuances equal to limit", + Domain: "equalbutvalid.example.com", + ExpectedErr: nil, + }, + { + Name: "FQDN set issuances above limit NS", + Domain: "over.example.com", + ExpectedErr: fmt.Errorf( + "too many certificates (3) already issued for this exact set of domains in the last 24 hours: over.example.com, retry after %s: see https://letsencrypt.org/docs/duplicate-certificate-limit/", + expectRetryAfterNS, + ), + }, + { + Name: "FQDN set issuances above limit", + Domain: "over.example.com", + ExpectedErr: fmt.Errorf( + "too many certificates (3) already issued for this exact set of domains in the last 24 hours: over.example.com, retry after %s: see https://letsencrypt.org/docs/duplicate-certificate-limit/", + expectRetryAfter, + ), + }, + } + + // For each test case we check that the certificatesPerFQDNSetLimit is applied + // as we expect + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := ra.checkCertificatesPerFQDNSetLimit(ctx, []string{tc.Domain}, rlp, 0) + if tc.ExpectedErr == nil { + test.AssertNotError(t, result, fmt.Sprintf("Expected no error for %q", tc.Domain)) + } else { + test.AssertError(t, result, fmt.Sprintf("Expected error for %q", tc.Domain)) + test.AssertEquals(t, result.Error(), tc.ExpectedErr.Error()) + } + }) + } +} + +func TestRegistrationUpdate(t *testing.T) { + oldURL := "http://old.invalid" + newURL := "http://new.invalid" + base := &corepb.Registration{ + Id: 1, + Contact: []string{oldURL}, + Agreement: "", + } + update := &corepb.Registration{ + Contact: []string{newURL}, + ContactsPresent: true, + Agreement: "totally!", + } + + res, changed := mergeUpdate(base, update) + test.AssertEquals(t, changed, true) + test.AssertEquals(t, res.Contact[0], update.Contact[0]) + test.AssertEquals(t, res.Agreement, update.Agreement) + + // Make sure that a `MergeUpdate` call with an empty string doesn't produce an + // error and results in a change to the base reg. + emptyUpdate := &corepb.Registration{ + Contact: []string{""}, + ContactsPresent: true, + Agreement: "totally!", + } + _, changed = mergeUpdate(res, emptyUpdate) + test.AssertEquals(t, changed, true) +} + +func TestRegistrationContactUpdate(t *testing.T) { + contactURL := "mailto://example@example.com" + + // Test that a registration contact can be removed by updating with an empty + // Contact slice. + base := &corepb.Registration{ + Id: 1, + Contact: []string{contactURL}, + Agreement: "totally!", + } + update := &corepb.Registration{ + Id: 1, + Contact: []string{}, + ContactsPresent: true, + Agreement: "totally!", + } + res, changed := mergeUpdate(base, update) + test.AssertEquals(t, changed, true) + test.Assert(t, len(res.Contact) == 0, "Contact was not deleted in update") + + // Test that a registration contact isn't changed when an update is performed + // with no Contact field + base = &corepb.Registration{ + Id: 1, + Contact: []string{contactURL}, + Agreement: "totally!", + } + update = &corepb.Registration{ + Id: 1, + Agreement: "totally!", + } + res, changed = mergeUpdate(base, update) + test.AssertEquals(t, changed, false) + test.Assert(t, len(res.Contact) == 1, "len(Contact) was updated unexpectedly") + test.Assert(t, (res.Contact)[0] == contactURL, "Contact was changed unexpectedly") +} + +func TestRegistrationKeyUpdate(t *testing.T) { + oldKey, err := rsa.GenerateKey(rand.Reader, 512) + test.AssertNotError(t, err, "rsa.GenerateKey() for oldKey failed") + oldKeyJSON, err := jose.JSONWebKey{Key: oldKey}.MarshalJSON() + test.AssertNotError(t, err, "MarshalJSON for oldKey failed") + + base := &corepb.Registration{Key: oldKeyJSON} + update := &corepb.Registration{} + _, changed := mergeUpdate(base, update) + test.Assert(t, !changed, "mergeUpdate changed the key with empty update") + + newKey, err := rsa.GenerateKey(rand.Reader, 1024) + test.AssertNotError(t, err, "rsa.GenerateKey() for newKey failed") + newKeyJSON, err := jose.JSONWebKey{Key: newKey}.MarshalJSON() + test.AssertNotError(t, err, "MarshalJSON for newKey failed") + + update = &corepb.Registration{Key: newKeyJSON} + res, changed := mergeUpdate(base, update) + test.Assert(t, changed, "mergeUpdate didn't change the key with non-empty update") + test.AssertByteEquals(t, res.Key, update.Key) +} + +// A mockSAWithFQDNSet is a mock StorageAuthority that supports +// CountCertificatesByName as well as FQDNSetExists. This allows testing +// checkCertificatesPerNameRateLimit's FQDN exemption logic. +type mockSAWithFQDNSet struct { + sapb.StorageAuthorityClient + fqdnSet map[string]bool + issuanceTimestamps map[string]*sapb.Timestamps + + t *testing.T +} + +// Construct the FQDN Set key the same way as the SA (by using +// `core.UniqueLowerNames`, joining the names with a `,` and hashing them) +// but return a string so it can be used as a key in m.fqdnSet. +func (m mockSAWithFQDNSet) hashNames(names []string) string { + names = core.UniqueLowerNames(names) + hash := sha256.Sum256([]byte(strings.Join(names, ","))) + return string(hash[:]) +} + +// Add a set of domain names to the FQDN set +func (m mockSAWithFQDNSet) addFQDNSet(names []string) { + hash := m.hashNames(names) + m.fqdnSet[hash] = true +} + +// Search for a set of domain names in the FQDN set map +func (m mockSAWithFQDNSet) FQDNSetExists(_ context.Context, req *sapb.FQDNSetExistsRequest, _ ...grpc.CallOption) (*sapb.Exists, error) { + hash := m.hashNames(req.Domains) + if _, exists := m.fqdnSet[hash]; exists { + return &sapb.Exists{Exists: true}, nil + } + return &sapb.Exists{Exists: false}, nil +} + +// Return a map of domain -> certificate count. +func (m mockSAWithFQDNSet) CountCertificatesByNames(ctx context.Context, req *sapb.CountCertificatesByNamesRequest, _ ...grpc.CallOption) (*sapb.CountByNames, error) { + counts := make(map[string]int64) + for _, name := range req.Names { + entry, ok := m.issuanceTimestamps[name] + if ok { + counts[name] = int64(len(entry.Timestamps)) + } + } + return &sapb.CountByNames{Counts: counts}, nil +} + +func (m mockSAWithFQDNSet) CountFQDNSets(_ context.Context, req *sapb.CountFQDNSetsRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + var total int64 + for _, name := range req.Domains { + entry, ok := m.issuanceTimestamps[name] + if ok { + total += int64(len(entry.Timestamps)) + } + } + return &sapb.Count{Count: total}, nil +} + +func (m mockSAWithFQDNSet) FQDNSetTimestampsForWindow(_ context.Context, req *sapb.CountFQDNSetsRequest, _ ...grpc.CallOption) (*sapb.Timestamps, error) { + if len(req.Domains) == 1 { + return m.issuanceTimestamps[req.Domains[0]], nil + } else { + return nil, fmt.Errorf("FQDNSetTimestampsForWindow mock only supports a single domain") + } +} + +// Tests for boulder issue 1925[0] - that the `checkCertificatesPerNameLimit` +// properly honours the FQDNSet exemption. E.g. that if a set of domains has +// reached the certificates per name rate limit policy threshold but the exact +// same set of FQDN's was previously issued, then it should not be considered +// over the certificates per name limit. +// +// [0] https://github.com/letsencrypt/boulder/issues/1925 +func TestCheckFQDNSetRateLimitOverride(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Simple policy that only allows 1 certificate per name. + certsPerNamePolicy := ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 24 * time.Hour}, + } + + // Create a mock SA that has both name counts and an FQDN set + ts := timestamppb.New(ra.clk.Now()) + mockSA := &mockSAWithFQDNSet{ + issuanceTimestamps: map[string]*sapb.Timestamps{ + "example.com": {Timestamps: []*timestamppb.Timestamp{ts, ts}}, + "zombo.com": {Timestamps: []*timestamppb.Timestamp{ts, ts}}, + }, + fqdnSet: map[string]bool{}, + t: t, + } + ra.SA = mockSA + + // First check that without a pre-existing FQDN set that the provided set of + // names is rate limited due to being over the certificates per name limit for + // "example.com" and "zombo.com" + err := ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "example.com", "www.zombo.com"}, certsPerNamePolicy, 99) + test.AssertError(t, err, "certificate per name rate limit not applied correctly") + + // Now add a FQDN set entry for these domains + mockSA.addFQDNSet([]string{"www.example.com", "example.com", "www.zombo.com"}) + + // A subsequent check against the certificates per name limit should now be OK + // - there exists a FQDN set and so the exemption to this particular limit + // comes into effect. + err = ra.checkCertificatesPerNameLimit(ctx, []string{"www.example.com", "example.com", "www.zombo.com"}, certsPerNamePolicy, 99) + test.AssertNotError(t, err, "FQDN set certificate per name exemption not applied correctly") +} + +// TestExactPublicSuffixCertLimit tests the behaviour of issue #2681 with and +// without the feature flag for the fix enabled. +// See https://github.com/letsencrypt/boulder/issues/2681 +func TestExactPublicSuffixCertLimit(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + // Simple policy that only allows 2 certificates per name. + certsPerNamePolicy := ratelimit.RateLimitPolicy{ + Threshold: 2, + Window: config.Duration{Duration: 23 * time.Hour}, + } + + // We use "dedyn.io" and "dynv6.net" domains for the test on the implicit + // assumption that both domains are present on the public suffix list. + // Quickly verify that this is true before continuing with the rest of the test. + _, err := publicsuffix.Domain("dedyn.io") + test.AssertError(t, err, "dedyn.io was not on the public suffix list, invaliding the test") + _, err = publicsuffix.Domain("dynv6.net") + test.AssertError(t, err, "dynv6.net was not on the public suffix list, invaliding the test") + + // Back the mock SA with counts as if so far we have issued the following + // certificates for the following domains: + // - test.dedyn.io (once) + // - test2.dedyn.io (once) + // - dynv6.net (twice) + mockSA := &mockSAWithNameCounts{ + nameCounts: &sapb.CountByNames{ + Counts: map[string]int64{ + "test.dedyn.io": 1, + "test2.dedyn.io": 1, + "test3.dedyn.io": 0, + "dedyn.io": 0, + "dynv6.net": 2, + }, + }, + clk: fc, + t: t, + } + ra.SA = mockSA + + // Trying to issue for "test3.dedyn.io" and "dedyn.io" should succeed because + // test3.dedyn.io has no certificates and "dedyn.io" is an exact public suffix + // match with no certificates issued for it. + err = ra.checkCertificatesPerNameLimit(ctx, []string{"test3.dedyn.io", "dedyn.io"}, certsPerNamePolicy, 99) + test.AssertNotError(t, err, "certificate per name rate limit not applied correctly") + + // Trying to issue for "test3.dedyn.io" and "dynv6.net" should fail because + // "dynv6.net" is an exact public suffix match with 2 certificates issued for + // it. + err = ra.checkCertificatesPerNameLimit(ctx, []string{"test3.dedyn.io", "dynv6.net"}, certsPerNamePolicy, 99) + test.AssertError(t, err, "certificate per name rate limit not applied correctly") +} + +func TestDeactivateAuthorization(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + exp := ra.clk.Now().Add(365 * 24 * time.Hour) + authzID := createFinalizedAuthorization(t, sa, "not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + dbAuthzPB := getAuthorization(t, fmt.Sprint(authzID), sa) + _, err := ra.DeactivateAuthorization(ctx, dbAuthzPB) + test.AssertNotError(t, err, "Could not deactivate authorization") + deact, err := sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "Could not get deactivated authorization with ID "+dbAuthzPB.Id) + test.AssertEquals(t, deact.Status, string(core.StatusDeactivated)) +} + +func TestDeactivateRegistration(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Deactivate failure because incomplete registration provided + _, err := ra.DeactivateRegistration(context.Background(), &corepb.Registration{}) + test.AssertDeepEquals(t, err, fmt.Errorf("incomplete gRPC request message")) + + // Deactivate failure because registration status already deactivated + _, err = ra.DeactivateRegistration(context.Background(), + &corepb.Registration{Id: 1, Status: string(core.StatusDeactivated)}) + test.AssertError(t, err, "DeactivateRegistration failed with a non-valid registration") + + // Deactivate success with valid registration + _, err = ra.DeactivateRegistration(context.Background(), + &corepb.Registration{Id: 1, Status: string(core.StatusValid)}) + test.AssertNotError(t, err, "DeactivateRegistration failed") + + // Check db to make sure account is deactivated + dbReg, err := ra.SA.GetRegistration(context.Background(), &sapb.RegistrationID{Id: 1}) + test.AssertNotError(t, err, "GetRegistration failed") + test.AssertEquals(t, dbReg.Status, string(core.StatusDeactivated)) +} + +// noopCAA implements caaChecker, always returning nil +type noopCAA struct{} + +func (cr noopCAA) IsCAAValid( + ctx context.Context, + in *vapb.IsCAAValidRequest, + opts ...grpc.CallOption, +) (*vapb.IsCAAValidResponse, error) { + return &vapb.IsCAAValidResponse{}, nil +} + +// caaRecorder implements caaChecker, always returning nil, but recording the +// names it was called for. +type caaRecorder struct { + sync.Mutex + names map[string]bool +} + +func (cr *caaRecorder) IsCAAValid( + ctx context.Context, + in *vapb.IsCAAValidRequest, + opts ...grpc.CallOption, +) (*vapb.IsCAAValidResponse, error) { + cr.Lock() + defer cr.Unlock() + cr.names[in.Domain] = true + return &vapb.IsCAAValidResponse{}, nil +} + +// Test that the right set of domain names have their CAA rechecked, based on +// their `Validated` (attemptedAt in the database) timestamp. +func TestRecheckCAADates(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + recorder := &caaRecorder{names: make(map[string]bool)} + ra.caa = recorder + ra.authorizationLifetime = 15 * time.Hour + + recentValidated := fc.Now().Add(-1 * time.Hour) + recentExpires := fc.Now().Add(15 * time.Hour) + olderValidated := fc.Now().Add(-8 * time.Hour) + olderExpires := fc.Now().Add(5 * time.Hour) + makeIdentifier := func(name string) identifier.ACMEIdentifier { + return identifier.ACMEIdentifier{ + Type: identifier.DNS, + Value: name, + } + } + + authzs := map[string]*core.Authorization{ + "recent.com": { + Identifier: makeIdentifier("recent.com"), + Expires: &recentExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &recentValidated, + }, + }, + }, + "older.com": { + Identifier: makeIdentifier("older.com"), + Expires: &olderExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &olderValidated, + }, + }, + }, + "older2.com": { + Identifier: makeIdentifier("older2.com"), + Expires: &olderExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &olderValidated, + }, + }, + }, + "wildcard.com": { + Identifier: makeIdentifier("wildcard.com"), + Expires: &olderExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &olderValidated, + }, + }, + }, + "*.wildcard.com": { + Identifier: makeIdentifier("*.wildcard.com"), + Expires: &olderExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &olderValidated, + }, + }, + }, + "twochallenges.com": { + ID: "twochal", + Identifier: makeIdentifier("twochallenges.com"), + Expires: &recentExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: &olderValidated, + }, + { + Status: core.StatusValid, + Type: core.ChallengeTypeDNS01, + Token: "exampleToken", + Validated: &olderValidated, + }, + }, + }, + "nochallenges.com": { + ID: "nochal", + Identifier: makeIdentifier("nochallenges.com"), + Expires: &recentExpires, + Challenges: []core.Challenge{}, + }, + "novalidationtime.com": { + ID: "noval", + Identifier: makeIdentifier("novalidationtime.com"), + Expires: &recentExpires, + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + Validated: nil, + }, + }, + }, + } + + // NOTE: The names provided here correspond to authorizations in the + // `mockSAWithRecentAndOlder` + names := []string{"recent.com", "older.com", "older2.com", "wildcard.com", "*.wildcard.com"} + err := ra.checkAuthorizationsCAA(context.Background(), Registration.Id, names, authzs, fc.Now()) + // We expect that there is no error rechecking authorizations for these names + if err != nil { + t.Errorf("expected nil err, got %s", err) + } + + // Should error if a authorization has `!= 1` challenge + err = ra.checkAuthorizationsCAA(context.Background(), Registration.Id, []string{"twochallenges.com"}, authzs, fc.Now()) + test.AssertEquals(t, err.Error(), "authorization has incorrect number of challenges. 1 expected, 2 found for: id twochal") + + // Should error if a authorization has `!= 1` challenge + err = ra.checkAuthorizationsCAA(context.Background(), Registration.Id, []string{"nochallenges.com"}, authzs, fc.Now()) + test.AssertEquals(t, err.Error(), "authorization has incorrect number of challenges. 1 expected, 0 found for: id nochal") + + // Should error if authorization's challenge has no validated timestamp + err = ra.checkAuthorizationsCAA(context.Background(), Registration.Id, []string{"novalidationtime.com"}, authzs, fc.Now()) + test.AssertEquals(t, err.Error(), "authorization's challenge has no validated timestamp for: id noval") + + // Test to make sure the authorization lifetime codepath was not used + // to determine if CAA needed recheck. + test.AssertMetricWithLabelsEquals(t, ra.recheckCAAUsedAuthzLifetime, prometheus.Labels{}, 0) + + // We expect that "recent.com" is not checked because its mock authorization + // isn't expired + if _, present := recorder.names["recent.com"]; present { + t.Errorf("Rechecked CAA unnecessarily for recent.com") + } + + // We expect that "older.com" is checked + if _, present := recorder.names["older.com"]; !present { + t.Errorf("Failed to recheck CAA for older.com") + } + + // We expect that "older2.com" is checked + if _, present := recorder.names["older2.com"]; !present { + t.Errorf("Failed to recheck CAA for older2.com") + } + + // We expect that the "wildcard.com" domain (without the `*.` prefix) is checked. + if _, present := recorder.names["wildcard.com"]; !present { + t.Errorf("Failed to recheck CAA for wildcard.com") + } + + // We expect that "*.wildcard.com" is checked (with the `*.` prefix, because + // it is stripped at a lower layer than we are testing) + if _, present := recorder.names["*.wildcard.com"]; !present { + t.Errorf("Failed to recheck CAA for *.wildcard.com") + } +} + +type caaFailer struct{} + +func (cf *caaFailer) IsCAAValid( + ctx context.Context, + in *vapb.IsCAAValidRequest, + opts ...grpc.CallOption, +) (*vapb.IsCAAValidResponse, error) { + cvrpb := &vapb.IsCAAValidResponse{} + switch in.Domain { + case "a.com": + cvrpb.Problem = &corepb.ProblemDetails{ + Detail: "CAA invalid for a.com", + } + case "c.com": + cvrpb.Problem = &corepb.ProblemDetails{ + Detail: "CAA invalid for c.com", + } + case "d.com": + return nil, fmt.Errorf("Error checking CAA for d.com") + } + return cvrpb, nil +} + +func TestRecheckCAAEmpty(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + err := ra.recheckCAA(context.Background(), nil) + test.AssertNotError(t, err, "expected nil") +} + +func makeHTTP01Authorization(domain string) *core.Authorization { + return &core.Authorization{ + Identifier: identifier.ACMEIdentifier{Type: identifier.DNS, Value: domain}, + Challenges: []core.Challenge{{Status: core.StatusValid, Type: core.ChallengeTypeHTTP01}}, + } +} + +func TestRecheckCAASuccess(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + authzs := []*core.Authorization{ + makeHTTP01Authorization("a.com"), + makeHTTP01Authorization("b.com"), + makeHTTP01Authorization("c.com"), + } + err := ra.recheckCAA(context.Background(), authzs) + test.AssertNotError(t, err, "expected nil") +} + +func TestRecheckCAAFail(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.caa = &caaFailer{} + authzs := []*core.Authorization{ + makeHTTP01Authorization("a.com"), + makeHTTP01Authorization("b.com"), + makeHTTP01Authorization("c.com"), + } + err := ra.recheckCAA(context.Background(), authzs) + + test.AssertError(t, err, "expected err, got nil") + var berr *berrors.BoulderError + test.AssertErrorWraps(t, err, &berr) + test.AssertErrorIs(t, berr, berrors.CAA) + test.AssertEquals(t, len(berr.SubErrors), 2) + + // We don't know whether the asynchronous a.com or c.com CAA recheck will fail + // first. Whichever does will be mentioned in the top level problem detail. + expectedDetailRegex := regexp.MustCompile( + `Rechecking CAA for "(?:a\.com|c\.com)" and 1 more identifiers failed. Refer to sub-problems for more information`, + ) + if !expectedDetailRegex.MatchString(berr.Detail) { + t.Errorf("expected suberror detail to match expected regex, got %q", err) + } + + // There should be a sub error for both a.com and c.com with the correct type + subErrMap := make(map[string]berrors.SubBoulderError, len(berr.SubErrors)) + for _, subErr := range berr.SubErrors { + subErrMap[subErr.Identifier.Value] = subErr + } + subErrA, foundA := subErrMap["a.com"] + subErrB, foundB := subErrMap["c.com"] + test.AssertEquals(t, foundA, true) + test.AssertEquals(t, foundB, true) + test.AssertEquals(t, subErrA.Type, berrors.CAA) + test.AssertEquals(t, subErrB.Type, berrors.CAA) + + // Recheck CAA with just one bad authz + authzs = []*core.Authorization{ + makeHTTP01Authorization("a.com"), + } + err = ra.recheckCAA(context.Background(), authzs) + // It should error + test.AssertError(t, err, "expected err from recheckCAA") + // It should be a berror + test.AssertErrorWraps(t, err, &berr) + // There should be *no* suberrors because there was only one overall error + test.AssertEquals(t, len(berr.SubErrors), 0) +} + +func TestRecheckCAAInternalServerError(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.caa = &caaFailer{} + authzs := []*core.Authorization{ + makeHTTP01Authorization("a.com"), + makeHTTP01Authorization("b.com"), + makeHTTP01Authorization("d.com"), + } + err := ra.recheckCAA(context.Background(), authzs) + test.AssertError(t, err, "expected err, got nil") + test.AssertErrorIs(t, err, berrors.InternalServer) +} + +func TestNewOrder(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + ra.orderLifetime = time.Hour + + now := fc.Now() + orderA, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"b.com", "a.com", "a.com", "C.COM"}, + }) + test.AssertNotError(t, err, "ra.NewOrder failed") + test.AssertEquals(t, orderA.RegistrationID, int64(1)) + test.AssertEquals(t, orderA.Expires.AsTime(), now.Add(time.Hour)) + test.AssertEquals(t, len(orderA.Names), 3) + // We expect the order names to have been sorted, deduped, and lowercased + test.AssertDeepEquals(t, orderA.Names, []string{"a.com", "b.com", "c.com"}) + test.AssertEquals(t, orderA.Id, int64(1)) + test.AssertEquals(t, numAuthorizations(orderA), 3) + + // Reuse all existing authorizations + now = fc.Now() + orderB, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"b.com", "a.com", "C.COM"}, + }) + test.AssertNotError(t, err, "ra.NewOrder failed") + test.AssertEquals(t, orderB.RegistrationID, int64(1)) + test.AssertEquals(t, orderB.Expires.AsTime(), now.Add(time.Hour)) + // We expect orderB's ID to match orderA's because of pending order reuse + test.AssertEquals(t, orderB.Id, orderA.Id) + test.AssertEquals(t, len(orderB.Names), 3) + test.AssertDeepEquals(t, orderB.Names, []string{"a.com", "b.com", "c.com"}) + test.AssertEquals(t, numAuthorizations(orderB), 3) + test.AssertDeepEquals(t, orderB.V2Authorizations, orderA.V2Authorizations) + + // Reuse all of the existing authorizations from the previous order and + // add a new one + orderA.Names = append(orderA.Names, "d.com") + now = fc.Now() + orderC, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: orderA.Names, + }) + test.AssertNotError(t, err, "ra.NewOrder failed") + test.AssertEquals(t, orderC.RegistrationID, int64(1)) + test.AssertEquals(t, orderC.Expires.AsTime(), now.Add(time.Hour)) + test.AssertEquals(t, len(orderC.Names), 4) + test.AssertDeepEquals(t, orderC.Names, []string{"a.com", "b.com", "c.com", "d.com"}) + // We expect orderC's ID to not match orderA/orderB's because it is for + // a different set of names + test.AssertNotEquals(t, orderC.Id, orderA.Id) + test.AssertEquals(t, numAuthorizations(orderC), 4) + // Abuse the order of the queries used to extract the reused authorizations + existing := orderC.V2Authorizations[:3] + test.AssertDeepEquals(t, existing, orderA.V2Authorizations) + + _, err = ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"a"}, + }) + test.AssertError(t, err, "NewOrder with invalid names did not error") + test.AssertEquals(t, err.Error(), "Cannot issue for \"a\": Domain name needs at least one dot") +} + +// TestNewOrderReuse tests that subsequent requests by an ACME account to create +// an identical order results in only one order being created & subsequently +// reused. +func TestNewOrderReuse(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + ctx := context.Background() + names := []string{"zombo.com", "welcome.to.zombo.com"} + + // Configure the RA to use a short order lifetime + ra.orderLifetime = time.Hour + // Create a var with two times the order lifetime to reference later + doubleLifetime := ra.orderLifetime * 2 + + // Create an initial request with regA and names + orderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: names, + } + + // Create a second registration to reference + acctKeyB, err := AccountKeyB.MarshalJSON() + test.AssertNotError(t, err, "failed to marshal account key") + input := &corepb.Registration{ + Key: acctKeyB, + InitialIP: parseAndMarshalIP(t, "42.42.42.42"), + } + secondReg, err := ra.NewRegistration(ctx, input) + test.AssertNotError(t, err, "Error creating a second test registration") + // First, add an order with `names` for regA + firstOrder, err := ra.NewOrder(context.Background(), orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an initial order for regA failed") + // It should have an ID + test.AssertNotNil(t, firstOrder.Id, "Initial order had a nil ID") + + testCases := []struct { + Name string + OrderReq *rapb.NewOrderRequest + ExpectReuse bool + AdvanceClock *time.Duration + }{ + { + Name: "Duplicate order, same regID", + OrderReq: orderReq, + // We expect reuse since the order matches firstOrder + ExpectReuse: true, + }, + { + Name: "Subset of order names, same regID", + OrderReq: &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{names[1]}, + }, + // We do not expect reuse because the order names don't match firstOrder + ExpectReuse: false, + }, + { + Name: "Duplicate order, different regID", + OrderReq: &rapb.NewOrderRequest{ + RegistrationID: secondReg.Id, + Names: names, + }, + // We do not expect reuse because the order regID differs from firstOrder + ExpectReuse: false, + }, + { + Name: "Duplicate order, same regID, first expired", + OrderReq: orderReq, + AdvanceClock: &doubleLifetime, + // We do not expect reuse because firstOrder has expired + ExpectReuse: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // If the testcase specifies, advance the clock before adding the order + if tc.AdvanceClock != nil { + _ = fc.Now().Add(*tc.AdvanceClock) + } + // Add the order for the test request + order, err := ra.NewOrder(ctx, tc.OrderReq) + // It shouldn't fail + test.AssertNotError(t, err, "NewOrder returned an unexpected error") + // The order should not have a nil ID + test.AssertNotNil(t, order.Id, "NewOrder returned an order with a nil Id") + + if tc.ExpectReuse { + // If we expected order reuse for this testcase assert that the order + // has the same ID as the firstOrder + test.AssertEquals(t, firstOrder.Id, order.Id) + } else { + // Otherwise assert that the order doesn't have the same ID as the + // firstOrder + test.AssertNotEquals(t, firstOrder.Id, order.Id) + } + }) + } +} + +func TestNewOrderReuseInvalidAuthz(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ctx := context.Background() + names := []string{"zombo.com"} + + // Create an initial request with regA and names + orderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: names, + } + + // First, add an order with `names` for regA + order, err := ra.NewOrder(ctx, orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an initial order for regA failed") + // It should have an ID + test.AssertNotNil(t, order.Id, "Initial order had a nil ID") + // It should have one authorization + test.AssertEquals(t, numAuthorizations(order), 1) + + _, err = ra.SA.FinalizeAuthorization2(ctx, &sapb.FinalizeAuthorizationRequest{ + Id: order.V2Authorizations[0], + Status: string(core.StatusInvalid), + Expires: order.Expires, + Attempted: string(core.ChallengeTypeDNS01), + AttemptedAt: timestamppb.New(ra.clk.Now()), + }) + test.AssertNotError(t, err, "FinalizeAuthorization2 failed") + + // The order associated with the authz should now be invalid + updatedOrder, err := ra.SA.GetOrder(ctx, &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "Error getting order to check status") + test.AssertEquals(t, updatedOrder.Status, "invalid") + + // Create a second order for the same names/regID + secondOrder, err := ra.NewOrder(ctx, orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an initial order for regA failed") + // It should have a different ID than the first now-invalid order + test.AssertNotEquals(t, secondOrder.Id, order.Id) + // It should be status pending + test.AssertEquals(t, secondOrder.Status, "pending") + test.AssertEquals(t, numAuthorizations(secondOrder), 1) + // It should have a different authorization than the first order's now-invalid authorization + test.AssertNotEquals(t, secondOrder.V2Authorizations[0], order.V2Authorizations[0]) +} + +// mockSACountPendingFails has a CountPendingAuthorizations2 implementation +// that always returns error +type mockSACountPendingFails struct { + sapb.StorageAuthorityClient +} + +func (mock *mockSACountPendingFails) CountPendingAuthorizations2(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*sapb.Count, error) { + return nil, errors.New("counting is slow and boring") +} + +// Ensure that we don't bother to call the SA to count pending authorizations +// when an "unlimited" limit is set. +func TestPendingAuthorizationsUnlimited(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.rlPolicies = &dummyRateLimitConfig{ + PendingAuthorizationsPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 24 * time.Hour}, + RegistrationOverrides: map[int64]int64{ + 13: -1, + }, + }, + } + + ra.SA = &mockSACountPendingFails{} + + limit := ra.rlPolicies.PendingAuthorizationsPerAccount() + err := ra.checkPendingAuthorizationLimit(context.Background(), 13, limit) + test.AssertNotError(t, err, "checking pending authorization limit") +} + +// An authority that returns nonzero failures for CountInvalidAuthorizations2, +// and also returns existing authzs for the same domain from GetAuthorizations2 +type mockInvalidPlusValidAuthzAuthority struct { + mockSAWithAuthzs + domainWithFailures string +} + +func (sa *mockInvalidPlusValidAuthzAuthority) CountInvalidAuthorizations2(ctx context.Context, req *sapb.CountInvalidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + if req.Hostname == sa.domainWithFailures { + return &sapb.Count{Count: 1}, nil + } else { + return &sapb.Count{}, nil + } +} + +// Test that the failed authorizations limit is checked before authz reuse. +func TestNewOrderCheckFailedAuthorizationsFirst(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + // Create an order (and thus a pending authz) for example.com + ctx := context.Background() + order, err := ra.NewOrder(ctx, &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"example.com"}, + }) + test.AssertNotError(t, err, "adding an initial order for regA") + test.AssertNotNil(t, order.Id, "initial order had a nil ID") + test.AssertEquals(t, numAuthorizations(order), 1) + + // Now treat example.com as if it had a recent failure, but also a valid authz. + expires := clk.Now().Add(24 * time.Hour) + ra.SA = &mockInvalidPlusValidAuthzAuthority{ + mockSAWithAuthzs: mockSAWithAuthzs{ + authzs: map[string]*core.Authorization{ + "example.com": { + ID: "1", + Identifier: identifier.DNSIdentifier("example.com"), + RegistrationID: Registration.Id, + Expires: &expires, + Status: "valid", + Challenges: []core.Challenge{ + { + Type: core.ChallengeTypeHTTP01, + Status: core.StatusValid, + }, + }, + }, + }, + }, + domainWithFailures: "example.com", + } + + // Set a very restrictive police for invalid authorizations - one failure + // and you're done for a day. + ra.rlPolicies = &dummyRateLimitConfig{ + InvalidAuthorizationsPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 24 * time.Hour}, + }, + } + + // Creating an order for example.com should error with the "too many failed + // authorizations recently" error. + _, err = ra.NewOrder(ctx, &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"example.com"}, + }) + + test.AssertError(t, err, "expected error for domain with too many failures") + test.AssertEquals(t, err.Error(), "too many failed authorizations recently: see https://letsencrypt.org/docs/failed-validation-limit/") +} + +// mockSAWithAuthzs has a GetAuthorizations2 method that returns the protobuf +// version of its authzs struct member. It also has a fake GetOrderForNames +// which always fails, and a fake NewOrderAndAuthzs which always succeeds, to +// facilitate the full execution of RA.NewOrder. +type mockSAWithAuthzs struct { + sapb.StorageAuthorityClient + authzs map[string]*core.Authorization +} + +// GetOrderForNames is a mock which always returns NotFound so that NewOrder +// proceeds to attempt authz reuse instead of wholesale order reuse. +func (msa *mockSAWithAuthzs) GetOrderForNames(ctx context.Context, req *sapb.GetOrderForNamesRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + return nil, berrors.NotFoundError("no such order") +} + +// GetAuthorizations2 returns a _bizarre_ authorization for "*.zombo.com" that +// was validated by HTTP-01. This should never happen in real life since the +// name is a wildcard. We use this mock to test that we reject this bizarre +// situation correctly. +func (msa *mockSAWithAuthzs) GetAuthorizations2(ctx context.Context, req *sapb.GetAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + resp := &sapb.Authorizations{} + for k, v := range msa.authzs { + authzPB, err := bgrpc.AuthzToPB(*v) + if err != nil { + return nil, err + } + resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: k, Authz: authzPB}) + } + return resp, nil +} + +// NewOrderAndAuthzs is a mock which just reflects the incoming request back, +// pretending to have created new db rows for the requested newAuthzs. +func (msa *mockSAWithAuthzs) NewOrderAndAuthzs(ctx context.Context, req *sapb.NewOrderAndAuthzsRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + authzIDs := req.NewOrder.V2Authorizations + for range req.NewAuthzs { + authzIDs = append(authzIDs, mrand.Int63()) + } + return &corepb.Order{ + // Fields from the input new order request. + RegistrationID: req.NewOrder.RegistrationID, + Expires: req.NewOrder.Expires, + Names: req.NewOrder.Names, + V2Authorizations: authzIDs, + CertificateProfileName: req.NewOrder.CertificateProfileName, + // Mock new fields generated by the database transaction. + Id: mrand.Int63(), + Created: timestamppb.Now(), + // A new order is never processing because it can't have been finalized yet. + BeganProcessing: false, + Status: string(core.StatusPending), + }, nil +} + +// TestNewOrderAuthzReuseSafety checks that the RA's safety check for reusing an +// authorization for a new-order request with a wildcard name works correctly. +// We want to ensure that we never reuse a non-Wildcard authorization (e.g. one +// with more than just a DNS-01 challenge) for a wildcard name. See Issue #3420 +// for background - this safety check was previously broken! +// https://github.com/letsencrypt/boulder/issues/3420 +func TestNewOrderAuthzReuseSafety(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ctx := context.Background() + names := []string{"*.zombo.com"} + + // Use a mock SA that always returns a valid HTTP-01 authz for the name + // "zombo.com" + expires := time.Now() + ra.SA = &mockSAWithAuthzs{ + authzs: map[string]*core.Authorization{ + "*.zombo.com": { + // A static fake ID we can check for in a unit test + ID: "1", + Identifier: identifier.DNSIdentifier("*.zombo.com"), + RegistrationID: Registration.Id, + // Authz is valid + Status: "valid", + Expires: &expires, + Challenges: []core.Challenge{ + // HTTP-01 challenge is valid + { + Type: core.ChallengeTypeHTTP01, // The dreaded HTTP-01! X__X + Status: core.StatusValid, + }, + // DNS-01 challenge is pending + { + Type: core.ChallengeTypeDNS01, + Status: core.StatusPending, + }, + }, + }, + "zombo.com": { + // A static fake ID we can check for in a unit test + ID: "2", + Identifier: identifier.DNSIdentifier("zombo.com"), + RegistrationID: Registration.Id, + // Authz is valid + Status: "valid", + Expires: &expires, + Challenges: []core.Challenge{ + // HTTP-01 challenge is valid + { + Type: core.ChallengeTypeHTTP01, + Status: core.StatusValid, + }, + // DNS-01 challenge is pending + { + Type: core.ChallengeTypeDNS01, + Status: core.StatusPending, + }, + }, + }, + }, + } + + // Create an initial request with regA and names + orderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: names, + } + + // Create an order for that request + order, err := ra.NewOrder(ctx, orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an initial order for regA failed") + test.AssertEquals(t, numAuthorizations(order), 1) + // It should *not* be the bad authorization! + test.AssertNotEquals(t, order.V2Authorizations[0], int64(1)) +} + +func TestNewOrderWildcard(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.orderLifetime = time.Hour + + orderNames := []string{"example.com", "*.welcome.zombo.com"} + wildcardOrderRequest := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: orderNames, + } + + order, err := ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for a wildcard order request") + + // We expect the order to be pending + test.AssertEquals(t, order.Status, string(core.StatusPending)) + // We expect the order to have two names + test.AssertEquals(t, len(order.Names), 2) + // We expect the order to have the names we requested + test.AssertDeepEquals(t, + core.UniqueLowerNames(order.Names), + core.UniqueLowerNames(orderNames)) + test.AssertEquals(t, numAuthorizations(order), 2) + + // Check each of the authz IDs in the order + for _, authzID := range order.V2Authorizations { + // We should be able to retrieve the authz from the db without error + authzID := authzID + authzPB, err := ra.SA.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + authz, err := bgrpc.PBToAuthz(authzPB) + test.AssertNotError(t, err, "bgrpc.PBToAuthz failed") + + // We expect the authz is in Pending status + test.AssertEquals(t, authz.Status, core.StatusPending) + + name := authz.Identifier.Value + switch name { + case "*.welcome.zombo.com": + // If the authz is for *.welcome.zombo.com, we expect that it only has one + // pending challenge with DNS-01 type + test.AssertEquals(t, len(authz.Challenges), 1) + test.AssertEquals(t, authz.Challenges[0].Status, core.StatusPending) + test.AssertEquals(t, authz.Challenges[0].Type, core.ChallengeTypeDNS01) + case "example.com": + // If the authz is for example.com, we expect it has normal challenges + test.AssertEquals(t, len(authz.Challenges), 2) + default: + t.Fatalf("Received an authorization for a name not requested: %q", name) + } + } + + // An order for a base domain and a wildcard for the same base domain should + // return just 2 authz's, one for the wildcard with a DNS-01 + // challenge and one for the base domain with the normal challenges. + orderNames = []string{"zombo.com", "*.zombo.com"} + wildcardOrderRequest = &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: orderNames, + } + order, err = ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for a wildcard order request") + + // We expect the order to be pending + test.AssertEquals(t, order.Status, string(core.StatusPending)) + // We expect the order to have two names + test.AssertEquals(t, len(order.Names), 2) + // We expect the order to have the names we requested + test.AssertDeepEquals(t, + core.UniqueLowerNames(order.Names), + core.UniqueLowerNames(orderNames)) + test.AssertEquals(t, numAuthorizations(order), 2) + + for _, authzID := range order.V2Authorizations { + // We should be able to retrieve the authz from the db without error + authzID := authzID + authzPB, err := ra.SA.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + authz, err := bgrpc.PBToAuthz(authzPB) + test.AssertNotError(t, err, "bgrpc.PBToAuthz failed") + // We expect the authz is in Pending status + test.AssertEquals(t, authz.Status, core.StatusPending) + switch authz.Identifier.Value { + case "zombo.com": + // We expect that the base domain identifier auth has the normal number of + // challenges + test.AssertEquals(t, len(authz.Challenges), 2) + case "*.zombo.com": + // We expect that the wildcard identifier auth has only a pending + // DNS-01 type challenge + test.AssertEquals(t, len(authz.Challenges), 1) + test.AssertEquals(t, authz.Challenges[0].Status, core.StatusPending) + test.AssertEquals(t, authz.Challenges[0].Type, core.ChallengeTypeDNS01) + default: + t.Fatal("Unexpected authorization value returned from new-order") + } + } + + // Make an order for a single domain, no wildcards. This will create a new + // pending authz for the domain + normalOrderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"everything.is.possible.zombo.com"}, + } + normalOrder, err := ra.NewOrder(context.Background(), normalOrderReq) + test.AssertNotError(t, err, "NewOrder failed for a normal non-wildcard order") + + test.AssertEquals(t, numAuthorizations(normalOrder), 1) + // We expect the order is in Pending status + test.AssertEquals(t, order.Status, string(core.StatusPending)) + var authz core.Authorization + authzPB, err := ra.SA.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: normalOrder.V2Authorizations[0]}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + authz, err = bgrpc.PBToAuthz(authzPB) + test.AssertNotError(t, err, "bgrpc.PBToAuthz failed") + // We expect the authz is in Pending status + test.AssertEquals(t, authz.Status, core.StatusPending) + // We expect the authz is for the identifier the correct domain + test.AssertEquals(t, authz.Identifier.Value, "everything.is.possible.zombo.com") + // We expect the authz has the normal # of challenges + test.AssertEquals(t, len(authz.Challenges), 2) + + // Now submit an order request for a wildcard of the domain we just created an + // order for. We should **NOT** reuse the authorization from the previous + // order since we now require a DNS-01 challenge for the `*.` prefixed name. + orderNames = []string{"*.everything.is.possible.zombo.com"} + wildcardOrderRequest = &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: orderNames, + } + order, err = ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for a wildcard order request") + // We expect the order is in Pending status + test.AssertEquals(t, order.Status, string(core.StatusPending)) + test.AssertEquals(t, numAuthorizations(order), 1) + // The authz should be a different ID than the previous authz + test.AssertNotEquals(t, order.V2Authorizations[0], normalOrder.V2Authorizations[0]) + // We expect the authorization is available + authzPB, err = ra.SA.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: order.V2Authorizations[0]}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + authz, err = bgrpc.PBToAuthz(authzPB) + test.AssertNotError(t, err, "bgrpc.PBToAuthz failed") + // We expect the authz is in Pending status + test.AssertEquals(t, authz.Status, core.StatusPending) + // We expect the authz is for a identifier with the correct domain + test.AssertEquals(t, authz.Identifier.Value, "*.everything.is.possible.zombo.com") + // We expect the authz has only one challenge + test.AssertEquals(t, len(authz.Challenges), 1) + // We expect the one challenge is pending + test.AssertEquals(t, authz.Challenges[0].Status, core.StatusPending) + // We expect that the one challenge is a DNS01 type challenge + test.AssertEquals(t, authz.Challenges[0].Type, core.ChallengeTypeDNS01) + + // Submit an identical wildcard order request + dupeOrder, err := ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for a wildcard order request") + // We expect the order is in Pending status + test.AssertEquals(t, dupeOrder.Status, string(core.StatusPending)) + test.AssertEquals(t, numAuthorizations(dupeOrder), 1) + // The authz should be the same ID as the previous order's authz. We already + // checked that order.Authorizations[0] only has a DNS-01 challenge above so + // we don't need to recheck that here. + test.AssertEquals(t, dupeOrder.V2Authorizations[0], order.V2Authorizations[0]) +} + +func TestNewOrderExpiry(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ctx := context.Background() + names := []string{"zombo.com"} + + // Set the order lifetime to 48 hours. + ra.orderLifetime = 48 * time.Hour + + // Use an expiry that is sooner than the configured order expiry but greater + // than 24 hours away. + fakeAuthzExpires := clk.Now().Add(35 * time.Hour) + + // Use a mock SA that always returns a soon-to-be-expired valid authz for + // "zombo.com". + ra.SA = &mockSAWithAuthzs{ + authzs: map[string]*core.Authorization{ + "zombo.com": { + // A static fake ID we can check for in a unit test + ID: "1", + Identifier: identifier.DNSIdentifier("zombo.com"), + RegistrationID: Registration.Id, + Expires: &fakeAuthzExpires, + Status: "valid", + Challenges: []core.Challenge{ + { + Type: core.ChallengeTypeHTTP01, + Status: core.StatusValid, + }, + }, + }, + }, + } + + // Create an initial request with regA and names + orderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: names, + } + + // Create an order for that request + order, err := ra.NewOrder(ctx, orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an order for regA failed") + test.AssertEquals(t, numAuthorizations(order), 1) + // It should be the fake near-expired-authz authz + test.AssertEquals(t, order.V2Authorizations[0], int64(1)) + // The order's expiry should be the fake authz's expiry since it is sooner + // than the order's own expiry. + test.AssertEquals(t, order.Expires.AsTime(), fakeAuthzExpires) + + // Set the order lifetime to be lower than the fakeAuthzLifetime + ra.orderLifetime = 12 * time.Hour + expectedOrderExpiry := clk.Now().Add(ra.orderLifetime) + // Create the order again + order, err = ra.NewOrder(ctx, orderReq) + // It shouldn't fail + test.AssertNotError(t, err, "Adding an order for regA failed") + test.AssertEquals(t, numAuthorizations(order), 1) + // It should be the fake near-expired-authz authz + test.AssertEquals(t, order.V2Authorizations[0], int64(1)) + // The order's expiry should be the order's own expiry since it is sooner than + // the fake authz's expiry. + test.AssertEquals(t, order.Expires.AsTime(), expectedOrderExpiry) +} + +func TestFinalizeOrder(t *testing.T) { + _, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + ra.orderLifetime = time.Hour + + // Create one finalized authorization for not-example.com and one finalized + // authorization for www.not-example.org + now := ra.clk.Now() + exp := now.Add(365 * 24 * time.Hour) + authzIDA := createFinalizedAuthorization(t, sa, "not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + authzIDB := createFinalizedAuthorization(t, sa, "www.not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + + policyForbidCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"example.org"}, + }, testKey) + test.AssertNotError(t, err, "Error creating policy forbid CSR") + + oneDomainCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"a.com"}, + }, testKey) + test.AssertNotError(t, err, "Error creating CSR with one DNS name") + + twoDomainCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"a.com", "b.com"}, + }, testKey) + test.AssertNotError(t, err, "Error creating CSR with two DNS names") + + validCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.Public(), + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"not-example.com", "www.not-example.com"}, + }, testKey) + test.AssertNotError(t, err, "Error creating CSR with authorized names") + + expectedCert := &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: []string{"not-example.com", "www.not-example.com"}, + PublicKey: testKey.Public(), + NotBefore: fc.Now(), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + certDER, err := x509.CreateCertificate(rand.Reader, expectedCert, expectedCert, testKey.Public(), testKey) + test.AssertNotError(t, err, "failed to construct test certificate") + ra.CA.(*mocks.MockCA).PEM = pem.EncodeToMemory(&pem.Block{Bytes: certDER, Type: "CERTIFICATE"}) + + fakeRegID := int64(0xB00) + + // NOTE(@cpu): We use unique `names` for each of these orders because + // otherwise only *one* order is created & reused. The first test case to + // finalize the order will put it into processing state and the other tests + // will fail because you can't finalize an order that is already being + // processed. + // Add a new order for the fake reg ID + fakeRegOrder, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"001.example.com"}, + }) + test.AssertNotError(t, err, "Could not add test order for fake reg ID order ID") + + missingAuthzOrder, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"002.example.com"}, + }) + test.AssertNotError(t, err, "Could not add test order for missing authz order ID") + + validatedOrder, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: []string{"not-example.com", "www.not-example.com"}, + V2Authorizations: []int64{authzIDA, authzIDB}, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs, ready status") + + testCases := []struct { + Name string + OrderReq *rapb.FinalizeOrderRequest + ExpectedErrMsg string + ExpectIssuance bool + }{ + { + Name: "No id in order", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{}, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: "invalid order ID: 0", + }, + { + Name: "No account id in order", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: "invalid account ID: 0", + }, + { + Name: "No names in order", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{}, + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: "Order has no associated names", + }, + { + Name: "Wrong order state (valid)", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusValid), + Names: []string{"a.com"}, + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: `Order's status ("valid") is not acceptable for finalization`, + }, + { + Name: "Wrong order state (pending)", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusPending), + Names: []string{"a.com"}, + }, + Csr: oneDomainCSR, + }, + ExpectIssuance: false, + ExpectedErrMsg: `Order's status ("pending") is not acceptable for finalization`, + }, + { + Name: "Invalid CSR", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{"a.com"}, + }, + Csr: []byte{0xC0, 0xFF, 0xEE}, + }, + ExpectedErrMsg: "unable to parse CSR: asn1: syntax error: truncated tag or length", + }, + { + Name: "CSR and Order with diff number of names", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{"a.com", "b.com"}, + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: "Order includes different number of names than CSR specifies", + }, + { + Name: "CSR and Order with diff number of names (other way)", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{"a.com"}, + }, + Csr: twoDomainCSR, + }, + ExpectedErrMsg: "Order includes different number of names than CSR specifies", + }, + { + Name: "CSR missing an order name", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{"foobar.com"}, + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: "CSR is missing Order domain \"foobar.com\"", + }, + { + Name: "CSR with policy forbidden name", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Id: 1, + RegistrationID: 1, + Status: string(core.StatusReady), + Names: []string{"example.org"}, + Expires: timestamppb.New(exp), + CertificateSerial: "", + BeganProcessing: false, + }, + Csr: policyForbidCSR, + }, + ExpectedErrMsg: "Cannot issue for \"example.org\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy", + }, + { + Name: "Order with missing registration", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Status: string(core.StatusReady), + Names: []string{"a.com"}, + Id: fakeRegOrder.Id, + RegistrationID: fakeRegID, + Expires: timestamppb.New(exp), + CertificateSerial: "", + BeganProcessing: false, + Created: timestamppb.New(now), + }, + Csr: oneDomainCSR, + }, + ExpectedErrMsg: fmt.Sprintf("registration with ID '%d' not found", fakeRegID), + }, + { + Name: "Order with missing authorizations", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: &corepb.Order{ + Status: string(core.StatusReady), + Names: []string{"a.com", "b.com"}, + Id: missingAuthzOrder.Id, + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + CertificateSerial: "", + BeganProcessing: false, + Created: timestamppb.New(now), + }, + Csr: twoDomainCSR, + }, + ExpectedErrMsg: "authorizations for these names not found or expired: a.com, b.com", + }, + { + Name: "Order with correct authorizations, ready status", + OrderReq: &rapb.FinalizeOrderRequest{ + Order: validatedOrder, + Csr: validCSR, + }, + ExpectIssuance: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + _, result := ra.FinalizeOrder(context.Background(), tc.OrderReq) + // If we don't expect issuance we expect an error + if !tc.ExpectIssuance { + // Check that the error happened and the message matches expected + test.AssertError(t, result, "FinalizeOrder did not fail when expected to") + test.AssertEquals(t, result.Error(), tc.ExpectedErrMsg) + } else { + // Otherwise we expect an issuance and no error + test.AssertNotError(t, result, fmt.Sprintf("FinalizeOrder result was %#v, expected nil", result)) + // Check that the order now has a serial for the issued certificate + updatedOrder, err := sa.GetOrder( + context.Background(), + &sapb.OrderRequest{Id: tc.OrderReq.Order.Id}) + test.AssertNotError(t, err, "Error getting order to check serial") + test.AssertNotEquals(t, updatedOrder.CertificateSerial, "") + test.AssertEquals(t, updatedOrder.Status, "valid") + test.AssertEquals(t, updatedOrder.Expires.AsTime(), exp) + } + }) + } +} + +func TestFinalizeOrderWithMixedSANAndCN(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + ra.orderLifetime = time.Hour + + // Pick an expiry in the future + now := ra.clk.Now() + exp := now.Add(365 * 24 * time.Hour) + + // Create one finalized authorization for Registration.Id for not-example.com and + // one finalized authorization for Registration.Id for www.not-example.org + authzIDA := createFinalizedAuthorization(t, sa, "not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + authzIDB := createFinalizedAuthorization(t, sa, "www.not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + + // Create a new order to finalize with names in SAN and CN + mixedOrder, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: []string{"not-example.com", "www.not-example.com"}, + V2Authorizations: []int64{authzIDA, authzIDB}, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs") + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + mixedCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: []string{"www.not-example.com"}, + }, testKey) + test.AssertNotError(t, err, "Could not create mixed CSR") + + template := &x509.Certificate{ + SerialNumber: big.NewInt(12), + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: []string{"www.not-example.com", "not-example.com"}, + NotBefore: time.Now(), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + cert, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create mixed cert") + + ra.CA = &mocks.MockCA{ + PEM: pem.EncodeToMemory(&pem.Block{ + Bytes: cert, + }), + } + + _, result := ra.FinalizeOrder(context.Background(), &rapb.FinalizeOrderRequest{Order: mixedOrder, Csr: mixedCSR}) + test.AssertNotError(t, result, "FinalizeOrder failed") + // Check that the order now has a serial for the issued certificate + updatedOrder, err := sa.GetOrder( + context.Background(), + &sapb.OrderRequest{Id: mixedOrder.Id}) + test.AssertNotError(t, err, "Error getting order to check serial") + test.AssertNotEquals(t, updatedOrder.CertificateSerial, "") + test.AssertEquals(t, updatedOrder.Status, "valid") +} + +func TestFinalizeOrderWildcard(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Pick an expiry in the future + now := ra.clk.Now() + exp := now.Add(365 * 24 * time.Hour) + + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Error creating test RSA key") + wildcardCSR, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"*.zombo.com"}, + }, testKey) + test.AssertNotError(t, err, "Error creating CSR with wildcard DNS name") + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + Subject: pkix.Name{CommonName: "*.zombo.com"}, + DNSNames: []string{"*.zombo.com"}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Error creating test certificate") + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + // Set up a mock CA capable of giving back a cert for the wildcardCSR above + ca := &mocks.MockCA{ + PEM: certPEM, + } + ra.CA = ca + + // Create a new order for a wildcard domain + orderNames := []string{"*.zombo.com"} + wildcardOrderRequest := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: orderNames, + } + order, err := ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for wildcard domain order") + + // Create one standard finalized authorization for Registration.Id for zombo.com + _ = createFinalizedAuthorization(t, sa, "zombo.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + + // Finalizing the order should *not* work since the existing validated authz + // is not a special DNS-01-Wildcard challenge authz, so the order will be + // "pending" not "ready". + finalizeReq := &rapb.FinalizeOrderRequest{ + Order: order, + Csr: wildcardCSR, + } + _, err = ra.FinalizeOrder(context.Background(), finalizeReq) + test.AssertError(t, err, "FinalizeOrder did not fail for unauthorized "+ + "wildcard order") + test.AssertEquals(t, err.Error(), + `Order's status ("pending") is not acceptable for finalization`) + + // Creating another order for the wildcard name + validOrder, err := ra.NewOrder(context.Background(), wildcardOrderRequest) + test.AssertNotError(t, err, "NewOrder failed for wildcard domain order") + test.AssertEquals(t, numAuthorizations(validOrder), 1) + // We expect to be able to get the authorization by ID + _, err = sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: validOrder.V2Authorizations[0]}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + + // Finalize the authorization with the challenge validated + expires := now.Add(time.Hour * 24 * 7) + _, err = sa.FinalizeAuthorization2(ctx, &sapb.FinalizeAuthorizationRequest{ + Id: validOrder.V2Authorizations[0], + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeDNS01), + AttemptedAt: timestamppb.New(now), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + + // Refresh the order so the SA sets its status + validOrder, err = sa.GetOrder(ctx, &sapb.OrderRequest{ + Id: validOrder.Id, + }) + test.AssertNotError(t, err, "Could not refresh valid order from SA") + + // Now it should be possible to finalize the order + finalizeReq = &rapb.FinalizeOrderRequest{ + Order: validOrder, + Csr: wildcardCSR, + } + _, err = ra.FinalizeOrder(context.Background(), finalizeReq) + test.AssertNotError(t, err, "FinalizeOrder failed for authorized "+ + "wildcard order") +} + +func TestIssueCertificateAuditLog(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + // Set up order and authz expiries + ra.orderLifetime = 24 * time.Hour + exp := ra.clk.Now().Add(24 * time.Hour) + + // Make some valid authorizations for some names using different challenge types + names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"} + challs := []core.AcmeChallenge{core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01} + var authzIDs []int64 + for i, name := range names { + authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, challs[i], ra.clk.Now())) + } + + // Create a pending order for all of the names + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: names, + V2Authorizations: authzIDs, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs") + + // Generate a CSR covering the order names with a random RSA key + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: names, + }, testKey) + test.AssertNotError(t, err, "Could not create test order CSR") + + // Create a mock certificate for the fake CA to return + template := &x509.Certificate{ + SerialNumber: big.NewInt(12), + Subject: pkix.Name{ + CommonName: "not-example.com", + }, + DNSNames: names, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + cert, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create mock cert for test CA") + + // Set up the RA's CA with a mock that returns the cert from above + ra.CA = &mocks.MockCA{ + PEM: pem.EncodeToMemory(&pem.Block{ + Bytes: cert, + }), + } + + // The mock cert needs to be parsed to get its notbefore/notafter dates + parsedCerts, err := x509.ParseCertificates(cert) + test.AssertNotError(t, err, "Failed to parse mock cert DER bytes") + test.AssertEquals(t, len(parsedCerts), 1) + parsedCert := parsedCerts[0] + + // Cast the RA's mock log so we can ensure its cleared and can access the + // matched log lines + mockLog := ra.log.(*blog.Mock) + mockLog.Clear() + + // Finalize the order with the CSR + order.Status = string(core.StatusReady) + _, err = ra.FinalizeOrder(context.Background(), &rapb.FinalizeOrderRequest{ + Order: order, + Csr: csr, + }) + test.AssertNotError(t, err, "Error finalizing test order") + + // Get the logged lines from the audit logger + loglines := mockLog.GetAllMatching("Certificate request - successful JSON=") + + // There should be exactly 1 matching log line + test.AssertEquals(t, len(loglines), 1) + // Strip away the stuff before 'JSON=' + jsonContent := strings.TrimPrefix(loglines[0], "INFO: [AUDIT] Certificate request - successful JSON=") + + // Unmarshal the JSON into a certificate request event object + var event certificateRequestEvent + err = json.Unmarshal([]byte(jsonContent), &event) + // The JSON should unmarshal without error + test.AssertNotError(t, err, "Error unmarshalling logged JSON issuance event") + // The event should have no error + test.AssertEquals(t, event.Error, "") + // The event requester should be the expected reg ID + test.AssertEquals(t, event.Requester, Registration.Id) + // The event order ID should be the expected order ID + test.AssertEquals(t, event.OrderID, order.Id) + // The event serial number should be the expected serial number + test.AssertEquals(t, event.SerialNumber, core.SerialToString(template.SerialNumber)) + // The event verified fields should be the expected value + test.AssertDeepEquals(t, event.VerifiedFields, []string{"subject.commonName", "subjectAltName"}) + // The event CommonName should match the expected common name + test.AssertEquals(t, event.CommonName, "not-example.com") + // The event names should match the order names + test.AssertDeepEquals(t, core.UniqueLowerNames(event.Names), core.UniqueLowerNames(order.Names)) + // The event's NotBefore and NotAfter should match the cert's + test.AssertEquals(t, event.NotBefore, parsedCert.NotBefore) + test.AssertEquals(t, event.NotAfter, parsedCert.NotAfter) + + // There should be one event Authorization entry for each name + test.AssertEquals(t, len(event.Authorizations), len(names)) + + // Check the authz entry for each name + for i, name := range names { + authzEntry := event.Authorizations[name] + // The authz entry should have the correct authz ID + test.AssertEquals(t, authzEntry.ID, fmt.Sprintf("%d", authzIDs[i])) + // The authz entry should have the correct challenge type + test.AssertEquals(t, authzEntry.ChallengeType, challs[i]) + } +} + +func TestIssueCertificateCAACheckLog(t *testing.T) { + _, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + // Set up order and authz expiries. + ra.orderLifetime = 24 * time.Hour + ra.authorizationLifetime = 15 * time.Hour + + exp := fc.Now().Add(24 * time.Hour) + recent := fc.Now().Add(-1 * time.Hour) + older := fc.Now().Add(-8 * time.Hour) + + // Make some valid authzs for four names. Half of them were validated + // recently and half were validated in excess of our CAA recheck time. + names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"} + var authzIDs []int64 + for i, name := range names { + attemptedAt := older + if i%2 == 0 { + attemptedAt = recent + } + authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, attemptedAt)) + } + + // Create a pending order for all of the names. + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: names, + V2Authorizations: authzIDs, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs") + + // Generate a CSR covering the order names with a random RSA key. + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: names, + }, testKey) + test.AssertNotError(t, err, "Could not create test order CSR") + + // Create a mock certificate for the fake CA to return. + template := &x509.Certificate{ + SerialNumber: big.NewInt(12), + Subject: pkix.Name{ + CommonName: "not-example.com", + }, + DNSNames: names, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + cert, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create mock cert for test CA") + + // Set up the RA's CA with a mock that returns the cert from above. + ra.CA = &mocks.MockCA{ + PEM: pem.EncodeToMemory(&pem.Block{ + Bytes: cert, + }), + } + + // Cast the RA's mock log so we can ensure its cleared and can access the + // matched log lines. + mockLog := ra.log.(*blog.Mock) + mockLog.Clear() + + // Finalize the order with the CSR. + order.Status = string(core.StatusReady) + _, err = ra.FinalizeOrder(context.Background(), &rapb.FinalizeOrderRequest{ + Order: order, + Csr: csr, + }) + test.AssertNotError(t, err, "Error finalizing test order") + + // Get the logged lines from the mock logger. + loglines := mockLog.GetAllMatching("FinalizationCaaCheck JSON=") + // There should be exactly 1 matching log line. + test.AssertEquals(t, len(loglines), 1) + + // Strip away the stuff before 'JSON='. + jsonContent := strings.TrimPrefix(loglines[0], "INFO: FinalizationCaaCheck JSON=") + + // Unmarshal the JSON into an event object. + var event finalizationCAACheckEvent + err = json.Unmarshal([]byte(jsonContent), &event) + // The JSON should unmarshal without error. + test.AssertNotError(t, err, "Error unmarshalling logged JSON issuance event.") + // The event requester should be the expected registration ID. + test.AssertEquals(t, event.Requester, Registration.Id) + // The event should have the expected number of Authzs where CAA was reused. + test.AssertEquals(t, event.Reused, 2) + // The event should have the expected number of Authzs where CAA was + // rechecked. + test.AssertEquals(t, event.Rechecked, 2) +} + +// TestUpdateMissingAuthorization tests the race condition where a challenge is +// updated to valid concurrently with another attempt to have the challenge +// updated. Previously this would return a `berrors.InternalServer` error when +// the row was found missing from `pendingAuthorizations` by the 2nd update +// since the 1st had already deleted it. We accept this may happen and now test +// for a `berrors.NotFound` error return. +// +// See https://github.com/letsencrypt/boulder/issues/3201 +func TestUpdateMissingAuthorization(t *testing.T) { + _, sa, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + ctx := context.Background() + + authzPB := createPendingAuthorization(t, sa, Identifier, fc.Now().Add(12*time.Hour)) + authz, err := bgrpc.PBToAuthz(authzPB) + test.AssertNotError(t, err, "failed to deserialize authz") + + // Twiddle the authz to pretend its been validated by the VA + authz.Challenges[0].Status = "valid" + err = ra.recordValidation(ctx, authz.ID, authz.Expires, &authz.Challenges[0]) + test.AssertNotError(t, err, "ra.recordValidation failed") + + // Try to record the same validation a second time. + err = ra.recordValidation(ctx, authz.ID, authz.Expires, &authz.Challenges[0]) + test.AssertError(t, err, "ra.recordValidation didn't fail") + test.AssertErrorIs(t, err, berrors.NotFound) +} + +func TestPerformValidationBadChallengeType(t *testing.T) { + _, _, ra, fc, cleanUp := initAuthorities(t) + defer cleanUp() + pa, err := policy.New(map[core.AcmeChallenge]bool{}, blog.NewMock()) + test.AssertNotError(t, err, "Couldn't create PA") + ra.PA = pa + + exp := fc.Now().Add(10 * time.Hour) + authz := core.Authorization{ + ID: "1337", + Identifier: identifier.DNSIdentifier("not-example.com"), + RegistrationID: 1, + Status: "valid", + Challenges: []core.Challenge{ + { + Status: core.StatusValid, + Type: core.ChallengeTypeHTTP01, + Token: "exampleToken", + }, + }, + Expires: &exp, + } + authzPB, err := bgrpc.AuthzToPB(authz) + test.AssertNotError(t, err, "AuthzToPB failed") + + _, err = ra.PerformValidation(context.Background(), &rapb.PerformValidationRequest{ + Authz: authzPB, + ChallengeIndex: 0, + }) + test.AssertError(t, err, "ra.PerformValidation allowed a update to a authorization") + test.AssertEquals(t, err.Error(), "challenge type \"http-01\" no longer allowed") +} + +type timeoutPub struct { +} + +func (mp *timeoutPub) SubmitToSingleCTWithResult(_ context.Context, _ *pubpb.Request, _ ...grpc.CallOption) (*pubpb.Result, error) { + return nil, context.DeadlineExceeded +} + +func TestCTPolicyMeasurements(t *testing.T) { + _, ssa, ra, _, cleanup := initAuthorities(t) + defer cleanup() + + ra.ctpolicy = ctpolicy.New(&timeoutPub{}, loglist.List{ + "OperA": { + "LogA1": {Url: "UrlA1", Key: "KeyA1"}, + }, + "OperB": { + "LogB1": {Url: "UrlB1", Key: "KeyB1"}, + }, + }, nil, nil, 0, log, metrics.NoopRegisterer) + + // Create valid authorizations for not-example.com and www.not-example.com + exp := ra.clk.Now().Add(365 * 24 * time.Hour) + authzIDA := createFinalizedAuthorization(t, ssa, "not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + authzIDB := createFinalizedAuthorization(t, ssa, "www.not-example.com", exp, core.ChallengeTypeHTTP01, ra.clk.Now()) + + order, err := ra.SA.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: []string{"not-example.com", "www.not-example.com"}, + V2Authorizations: []int64{authzIDA, authzIDB}, + }, + }) + test.AssertNotError(t, err, "error generating test order") + + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.Public(), + SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: []string{"not-example.com", "www.not-example.com"}, + }, testKey) + test.AssertNotError(t, err, "error generating test CSR") + + _, err = ra.FinalizeOrder(context.Background(), &rapb.FinalizeOrderRequest{ + Order: order, + Csr: csr, + }) + test.AssertError(t, err, "FinalizeOrder should have failed when SCTs timed out") + test.AssertContains(t, err.Error(), "getting SCTs") + test.AssertMetricWithLabelsEquals(t, ra.ctpolicyResults, prometheus.Labels{"result": "failure"}, 1) +} + +func TestWildcardOverlap(t *testing.T) { + err := wildcardOverlap([]string{ + "*.example.com", + "*.example.net", + }) + if err != nil { + t.Errorf("Got error %q, expected none", err) + } + err = wildcardOverlap([]string{ + "*.example.com", + "*.example.net", + "www.example.com", + }) + if err == nil { + t.Errorf("Got no error, expected one") + } + test.AssertErrorIs(t, err, berrors.Malformed) + + err = wildcardOverlap([]string{ + "*.foo.example.com", + "*.example.net", + "www.example.com", + }) + if err != nil { + t.Errorf("Got error %q, expected none", err) + } +} + +// mockCAFailPrecert is a mock CA that always returns an error from `IssuePrecertificate` +type mockCAFailPrecert struct { + mocks.MockCA + err error +} + +func (ca *mockCAFailPrecert) IssuePrecertificate( + context.Context, + *capb.IssueCertificateRequest, + ...grpc.CallOption) (*capb.IssuePrecertificateResponse, error) { + return nil, ca.err +} + +// mockCAFailCertForPrecert is a mock CA that always returns an error from +// `IssueCertificateForPrecertificate` +type mockCAFailCertForPrecert struct { + mocks.MockCA + err error +} + +// IssuePrecertificate needs to be mocked for mockCAFailCertForPrecert's `IssueCertificateForPrecertificate` to get called. +func (ca *mockCAFailCertForPrecert) IssuePrecertificate( + context.Context, + *capb.IssueCertificateRequest, + ...grpc.CallOption) (*capb.IssuePrecertificateResponse, error) { + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + tmpl := &ctx509.Certificate{ + SerialNumber: big.NewInt(1), + ExtraExtensions: []ctpkix.Extension{ + { + Id: ctx509.OIDExtensionCTPoison, + Critical: true, + Value: ctasn1.NullBytes, + }, + }, + } + precert, err := ctx509.CreateCertificate(rand.Reader, tmpl, tmpl, k.Public(), k) + if err != nil { + return nil, err + } + return &capb.IssuePrecertificateResponse{ + DER: precert, + }, nil +} + +func (ca *mockCAFailCertForPrecert) IssueCertificateForPrecertificate( + context.Context, + *capb.IssueCertificateForPrecertificateRequest, + ...grpc.CallOption) (*corepb.Certificate, error) { + return &corepb.Certificate{}, ca.err +} + +// TestIssueCertificateInnerErrs tests that errors from the CA caught during +// `ra.issueCertificateInner` are propagated correctly, with the part of the +// issuance process that failed prefixed on the error message. +func TestIssueCertificateInnerErrs(t *testing.T) { + _, sa, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.orderLifetime = 24 * time.Hour + exp := ra.clk.Now().Add(24 * time.Hour) + + // Make some valid authorizations for some names + names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"} + var authzIDs []int64 + for _, name := range names { + authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, ra.clk.Now())) + } + + // Create a pending order for all of the names + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: names, + V2Authorizations: authzIDs, + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs") + + // Generate a CSR covering the order names with a random RSA key + testKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "error generating test key") + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.PublicKey, + SignatureAlgorithm: x509.SHA256WithRSA, + Subject: pkix.Name{CommonName: "not-example.com"}, + DNSNames: names, + }, testKey) + test.AssertNotError(t, err, "Could not create test order CSR") + + csrOb, err := x509.ParseCertificateRequest(csr) + test.AssertNotError(t, err, "Error pasring generated CSR") + + testCases := []struct { + Name string + Mock capb.CertificateAuthorityClient + ExpectedErr error + ExpectedProb *berrors.BoulderError + }{ + { + Name: "vanilla error during IssuePrecertificate", + Mock: &mockCAFailPrecert{ + err: fmt.Errorf("bad bad not good"), + }, + ExpectedErr: fmt.Errorf("issuing precertificate: bad bad not good"), + }, + { + Name: "malformed problem during IssuePrecertificate", + Mock: &mockCAFailPrecert{ + err: berrors.MalformedError("detected 1x whack attack"), + }, + ExpectedProb: &berrors.BoulderError{ + Detail: "issuing precertificate: detected 1x whack attack", + Type: berrors.Malformed, + }, + }, + { + Name: "vanilla error during IssueCertificateForPrecertificate", + Mock: &mockCAFailCertForPrecert{ + err: fmt.Errorf("aaaaaaaaaaaaaaaaaaaa!!"), + }, + ExpectedErr: fmt.Errorf("issuing certificate for precertificate: aaaaaaaaaaaaaaaaaaaa!!"), + }, + { + Name: "malformed problem during IssueCertificateForPrecertificate", + Mock: &mockCAFailCertForPrecert{ + err: berrors.MalformedError("provided DER is DERanged"), + }, + ExpectedProb: &berrors.BoulderError{ + Detail: "issuing certificate for precertificate: provided DER is DERanged", + Type: berrors.Malformed, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // Mock the CA + ra.CA = tc.Mock + // Attempt issuance + _, _, err = ra.issueCertificateInner(ctx, csrOb, order.CertificateProfileName, accountID(Registration.Id), orderID(order.Id)) + // We expect all of the testcases to fail because all use mocked CAs that deliberately error + test.AssertError(t, err, "issueCertificateInner with failing mock CA did not fail") + // If there is an expected `error` then match the error message + if tc.ExpectedErr != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedErr.Error()) + } else if tc.ExpectedProb != nil { + // If there is an expected `berrors.BoulderError` then we expect the + // `issueCertificateInner` error to be a `berrors.BoulderError` + var berr *berrors.BoulderError + test.AssertErrorWraps(t, err, &berr) + // Match the expected berror Type and Detail to the observed + test.AssertErrorIs(t, berr, tc.ExpectedProb.Type) + test.AssertEquals(t, berr.Detail, tc.ExpectedProb.Detail) + } + }) + } +} + +type MockCARecordingProfile struct { + inner *mocks.MockCA + profileName string + profileHash []byte +} + +func (ca *MockCARecordingProfile) IssuePrecertificate(ctx context.Context, req *capb.IssueCertificateRequest, _ ...grpc.CallOption) (*capb.IssuePrecertificateResponse, error) { + ca.profileName = req.CertProfileName + return ca.inner.IssuePrecertificate(ctx, req) +} + +func (ca *MockCARecordingProfile) IssueCertificateForPrecertificate(ctx context.Context, req *capb.IssueCertificateForPrecertificateRequest, _ ...grpc.CallOption) (*corepb.Certificate, error) { + ca.profileHash = req.CertProfileHash + return ca.inner.IssueCertificateForPrecertificate(ctx, req) +} + +type mockSAWithFinalize struct { + sapb.StorageAuthorityClient +} + +func (sa *mockSAWithFinalize) FinalizeOrder(ctx context.Context, req *sapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func TestIssueCertificateInnerWithProfile(t *testing.T) { + _, _, ra, fc, cleanup := initAuthorities(t) + defer cleanup() + + // Generate a reasonable-looking CSR and cert to pass the matchesCSR check. + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "generating test key") + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{DNSNames: []string{"example.com"}}, testKey) + test.AssertNotError(t, err, "creating test csr") + csr, err := x509.ParseCertificateRequest(csrDER) + test.AssertNotError(t, err, "parsing test csr") + certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + }, &x509.Certificate{}, testKey.Public(), testKey) + test.AssertNotError(t, err, "creating test cert") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + // Use a mock CA that will record the profile name and profile hash included + // in the RA's request messages. Populate it with the cert generated above. + mockCA := MockCARecordingProfile{inner: &mocks.MockCA{PEM: certPEM}} + ra.CA = &mockCA + + ra.SA = &mockSAWithFinalize{} + + // Call issueCertificateInner with the CSR generated above and the profile + // name "default", which will cause the mockCA to return a specific hash. + _, cpId, err := ra.issueCertificateInner(context.Background(), csr, "default", 1, 1) + test.AssertNotError(t, err, "issuing cert with profile name") + test.AssertEquals(t, mockCA.profileName, cpId.name) + test.AssertByteEquals(t, mockCA.profileHash, cpId.hash) +} + +func TestIssueCertificateOuter(t *testing.T) { + _, sa, ra, fc, cleanup := initAuthorities(t) + defer cleanup() + + ra.orderLifetime = 24 * time.Hour + exp := ra.clk.Now().Add(24 * time.Hour) + + // Make some valid authorizations for some names + names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"} + var authzIDs []int64 + for _, name := range names { + authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, ra.clk.Now())) + } + + // Create a pending order for all of the names + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Expires: timestamppb.New(exp), + Names: names, + V2Authorizations: authzIDs, + CertificateProfileName: "philsProfile", + }, + }) + test.AssertNotError(t, err, "Could not add test order with finalized authz IDs") + + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "generating test key") + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{DNSNames: []string{"example.com"}}, testKey) + test.AssertNotError(t, err, "creating test csr") + csr, err := x509.ParseCertificateRequest(csrDER) + test.AssertNotError(t, err, "parsing test csr") + certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"example.com"}, + NotBefore: fc.Now(), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + }, &x509.Certificate{}, testKey.Public(), testKey) + test.AssertNotError(t, err, "creating test cert") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + // Use a mock CA that will record the profile name and profile hash included + // in the RA's request messages. Populate it with the cert generated above. + mockCA := MockCARecordingProfile{inner: &mocks.MockCA{PEM: certPEM}} + ra.CA = &mockCA + + ra.SA = &mockSAWithFinalize{} + + _, err = ra.issueCertificateOuter(context.Background(), order, csr, certificateRequestEvent{}) + test.AssertNotError(t, err, "Could not issue certificate") + test.AssertMetricWithLabelsEquals(t, ra.newCertCounter, prometheus.Labels{"profileName": mockCA.profileName, "profileHash": fmt.Sprintf("%x", mockCA.profileHash)}, 1) +} + +func TestNewOrderMaxNames(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.maxNames = 2 + _, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: 1, + Names: []string{ + "a", + "b", + "c", + }, + }) + test.AssertError(t, err, "NewOrder didn't fail with too many names in request") + test.AssertEquals(t, err.Error(), "Order cannot contain more than 2 DNS names") + test.AssertErrorIs(t, err, berrors.Malformed) +} + +// CSR generated by Go: +// * Random public key +// * CN = not-example.com +// * DNSNames = not-example.com, www.not-example.com +var CSRPEM = []byte(` +-----BEGIN CERTIFICATE REQUEST----- +MIICrjCCAZYCAQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMTD25vdC1leGFtcGxl +LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKT1B7UsonZuLOp7 +qq2pw+COo0I9ZheuhN9ltu1+bAMWBYUb8KFPNGGp8Ygt6YCLjlnWOche7Fjb5lPj +hV6U2BkEt85mdaGTDg6mU3qjk2/cnZeAvJWW5ewYOBGxN/g/KHgdYZ+uhHH/PbGt +Wktcv5bRJ9Dxbjxsy7l8SLQ6fd/MF/3z6sBJzIHkcDupDOFdPN/Z0KOw7BOPHAbg +ghLJTmiESA1Ljxb8848bENlCz8pVizIu2Ilr4xBPtA5oUfO0FJKbT1T66JZoqwy/ +drfrlHA7F6c8kYlAmwiOfWHzlWCkE1YuZPJrZQrt4tJ70rrPxV1qEGJDumzgcEbU +/aYYiBsCAwEAAaBCMEAGCSqGSIb3DQEJDjEzMDEwLwYDVR0RBCgwJoIPbm90LWV4 +YW1wbGUuY29tghN3d3cubm90LWV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IB +AQBuFo5SHqN1lWmM6rKaOBXFezAdzZyGb9x8+5Zq/eh9pSxpn0MTOmq/u+sDHxsC +ywcshUO3P9//9u4ALtNn/jsJmSrElsTvG3SH5owl9muNEiOgf+6/rY/X8Zcnv/e0 +Ar9r73BcCkjoAOFbr7xiLLYu5EaBQjSj6/m4ujwJTWS2SqobK5VfdpzmDp4wT3eB +V4FPLxyxxOLuWLzcBkDdLw/zh922HtR5fqk155Y4pj3WS9NnI/NMHmclrlfY/2P4 +dJrBVM+qVbPTzM19QplMkiy7FxpDx6toUXDYM4KdKKV0+yX/zw/V0/Gb7K7yIjVB +wqjllqgMjN4nvHjiDXFx/kPY +-----END CERTIFICATE REQUEST----- +`) + +var eeCertPEM = []byte(` +-----BEGIN CERTIFICATE----- +MIIEfTCCAmWgAwIBAgISCr9BRk0C9OOGVke6CAa8F+AXMA0GCSqGSIb3DQEBCwUA +MDExCzAJBgNVBAYTAlVTMRAwDgYDVQQKDAdUZXN0IENBMRAwDgYDVQQDDAdUZXN0 +IENBMB4XDTE2MDMyMDE4MTEwMFoXDTE2MDMyMDE5MTEwMFowHjEcMBoGA1UEAxMT +d3d3Lm5vdC1leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKT1B7UsonZuLOp7qq2pw+COo0I9ZheuhN9ltu1+bAMWBYUb8KFPNGGp8Ygt +6YCLjlnWOche7Fjb5lPjhV6U2BkEt85mdaGTDg6mU3qjk2/cnZeAvJWW5ewYOBGx +N/g/KHgdYZ+uhHH/PbGtWktcv5bRJ9Dxbjxsy7l8SLQ6fd/MF/3z6sBJzIHkcDup +DOFdPN/Z0KOw7BOPHAbgghLJTmiESA1Ljxb8848bENlCz8pVizIu2Ilr4xBPtA5o +UfO0FJKbT1T66JZoqwy/drfrlHA7F6c8kYlAmwiOfWHzlWCkE1YuZPJrZQrt4tJ7 +0rrPxV1qEGJDumzgcEbU/aYYiBsCAwEAAaOBoTCBnjAdBgNVHSUEFjAUBggrBgEF +BQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUIEr9ryJ0aJuD +CwBsCp7Eun8Hx4AwHwYDVR0jBBgwFoAUmiamd/N/8knrCb1QlhwB4WXCqaswLwYD +VR0RBCgwJoIPbm90LWV4YW1wbGUuY29tghN3d3cubm90LWV4YW1wbGUuY29tMA0G +CSqGSIb3DQEBCwUAA4ICAQBpGLrCt38Z+knbuE1ALEB3hqUQCAm1OPDW6HR+v2nO +f2ERxTwL9Cad++3vONxgB68+6KQeIf5ph48OGnS5DgO13mb2cxLlmM2IJpkbSFtW +VeRNFt/WxRJafpbKw2hgQNJ/sxEAsCyA+kVeh1oCxGQyPO7IIXtw5FecWfIiNNwM +mVM17uchtvsM5BRePvet9xZxrKOFnn6TQRs8vC4e59Y8h52On+L2Q/ytAa7j3+fb +7OYCe+yWypGeosekamZTMBjHFV3RRxsGdRATSuZkv1uewyUnEPmsy5Ow4doSYZKW +QmKjti+vv1YhAhFxPArob0SG3YOiFuKzZ9rSOhUtzSg01ml/kRyOiC7rfO7NRzHq +idhPUhu2QBmdJTLLOBQLvKDNDOHqDYwKdIHJ7pup2y0Fvm4T96q5bnrSdmz/QAlB +XVw08HWMcjeOeHYiHST3yxYfQivTNm2PlKfUACb7vcrQ6pYhOnVdYgJZm6gkV4Xd +K1HKja36snIevv/gSgsE7bGcBYLVCvf16o3IRt9K8CpDoSsWn0iAVcwUP2CyPLm4 +QsqA1afjTUPKQTAgDKRecDPhrT1+FjtBwdpXetpRiBK0UE5exfnI4nszZ9+BYG1l +xGUhoOJp0T++nz6R3TX7Rwk7KmG6xX3vWr/MFu5A3c8fvkqj987Vti5BeBezCXfs +rA== +-----END CERTIFICATE----- +`) + +// mockSARevocation is a fake which includes all of the SA methods called in the +// course of a revocation. Its behavior can be customized by providing sets of +// issued (known) certs, already-revoked certs, and already-blocked keys. It +// also updates the sets of revoked certs and blocked keys when certain methods +// are called, to allow for more complex test logic. +type mockSARevocation struct { + sapb.StorageAuthorityClient + + known map[string]*x509.Certificate + revoked map[string]*corepb.CertificateStatus + blocked []*sapb.AddBlockedKeyRequest +} + +func newMockSARevocation(known *x509.Certificate) *mockSARevocation { + return &mockSARevocation{ + known: map[string]*x509.Certificate{core.SerialToString(known.SerialNumber): known}, + revoked: make(map[string]*corepb.CertificateStatus), + blocked: make([]*sapb.AddBlockedKeyRequest, 0), + } +} + +func (msar *mockSARevocation) reset() { + msar.revoked = make(map[string]*corepb.CertificateStatus) + msar.blocked = make([]*sapb.AddBlockedKeyRequest, 0) +} + +func (msar *mockSARevocation) AddBlockedKey(_ context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + msar.blocked = append(msar.blocked, req) + return &emptypb.Empty{}, nil +} + +func (msar *mockSARevocation) GetSerialMetadata(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.SerialMetadata, error) { + if cert, present := msar.known[req.Serial]; present { + return &sapb.SerialMetadata{ + Serial: req.Serial, + RegistrationID: 1, + Created: timestamppb.New(cert.NotBefore), + Expires: timestamppb.New(cert.NotAfter), + }, nil + } + return nil, berrors.UnknownSerialError() +} + +func (msar *mockSARevocation) GetLintPrecertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + if cert, present := msar.known[req.Serial]; present { + return &corepb.Certificate{Der: cert.Raw}, nil + } + return nil, berrors.UnknownSerialError() +} + +func (msar *mockSARevocation) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + if status, present := msar.revoked[req.Serial]; present { + return status, nil + } + if cert, present := msar.known[req.Serial]; present { + return &corepb.CertificateStatus{ + Serial: core.SerialToString(cert.SerialNumber), + IssuerID: int64(issuance.IssuerNameID(cert)), + }, nil + } + return nil, berrors.UnknownSerialError() +} + +func (msar *mockSARevocation) RevokeCertificate(_ context.Context, req *sapb.RevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + if _, present := msar.revoked[req.Serial]; present { + return nil, berrors.AlreadyRevokedError("already revoked") + } + cert, present := msar.known[req.Serial] + if !present { + return nil, berrors.UnknownSerialError() + } + msar.revoked[req.Serial] = &corepb.CertificateStatus{ + Serial: req.Serial, + IssuerID: int64(issuance.IssuerNameID(cert)), + Status: string(core.OCSPStatusRevoked), + RevokedReason: req.Reason, + } + return &emptypb.Empty{}, nil +} + +func (msar *mockSARevocation) UpdateRevokedCertificate(_ context.Context, req *sapb.RevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + status, present := msar.revoked[req.Serial] + if !present { + return nil, errors.New("not already revoked") + } + if req.Reason != ocsp.KeyCompromise { + return nil, errors.New("cannot re-revoke except for keyCompromise") + } + if present && status.RevokedReason == ocsp.KeyCompromise { + return nil, berrors.AlreadyRevokedError("already revoked for keyCompromise") + } + msar.revoked[req.Serial].RevokedReason = req.Reason + return &emptypb.Empty{}, nil +} + +type mockOCSPA struct { + mocks.MockCA +} + +func (mcao *mockOCSPA) GenerateOCSP(context.Context, *capb.GenerateOCSPRequest, ...grpc.CallOption) (*capb.OCSPResponse, error) { + return &capb.OCSPResponse{Response: []byte{1, 2, 3}}, nil +} + +type mockPurger struct{} + +func (mp *mockPurger) Purge(context.Context, *akamaipb.PurgeRequest, ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// mockSAGenerateOCSP is a mock SA that always returns a good OCSP response, with a constant NotAfter. +type mockSAGenerateOCSP struct { + sapb.StorageAuthorityClient + expiration time.Time +} + +func (msgo *mockSAGenerateOCSP) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + return &corepb.CertificateStatus{ + Serial: req.Serial, + Status: "good", + NotAfter: timestamppb.New(msgo.expiration.UTC()), + }, nil +} + +func TestGenerateOCSP(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.SA = &mockSAGenerateOCSP{expiration: clk.Now().Add(time.Hour)} + + req := &rapb.GenerateOCSPRequest{ + Serial: core.SerialToString(big.NewInt(1)), + } + + resp, err := ra.GenerateOCSP(context.Background(), req) + test.AssertNotError(t, err, "generating OCSP") + test.AssertByteEquals(t, resp.Response, []byte{1, 2, 3}) + + ra.SA = &mockSAGenerateOCSP{expiration: clk.Now().Add(-time.Hour)} + _, err = ra.GenerateOCSP(context.Background(), req) + if !errors.Is(err, berrors.NotFound) { + t.Errorf("expected NotFound error, got %s", err) + } +} + +// mockSALongExpiredSerial is a mock SA that treats every serial as if it expired a long time ago. +// Specifically, it returns NotFound to GetCertificateStatus (simulating the serial having been +// removed from the certificateStatus table), but returns success to GetSerialMetadata (simulating +// a serial number staying in the `serials` table indefinitely). +type mockSALongExpiredSerial struct { + sapb.StorageAuthorityClient +} + +func (msgo *mockSALongExpiredSerial) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + return nil, berrors.NotFoundError("not found") +} + +func (msgo *mockSALongExpiredSerial) GetSerialMetadata(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.SerialMetadata, error) { + return &sapb.SerialMetadata{ + Serial: req.Serial, + }, nil +} + +func TestGenerateOCSPLongExpiredSerial(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.SA = &mockSALongExpiredSerial{} + + req := &rapb.GenerateOCSPRequest{ + Serial: core.SerialToString(big.NewInt(1)), + } + + _, err := ra.GenerateOCSP(context.Background(), req) + test.AssertError(t, err, "generating OCSP") + if !errors.Is(err, berrors.NotFound) { + t.Errorf("expected NotFound error, got %#v", err) + } +} + +// mockSAUnknownSerial is a mock SA that always returns NotFound to certificate status and serial lookups. +// It emulates an SA that has never issued a certificate. +type mockSAUnknownSerial struct { + mockSALongExpiredSerial +} + +func (msgo *mockSAUnknownSerial) GetSerialMetadata(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.SerialMetadata, error) { + return nil, berrors.NotFoundError("not found") +} + +func TestGenerateOCSPUnknownSerial(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.SA = &mockSAUnknownSerial{} + + req := &rapb.GenerateOCSPRequest{ + Serial: core.SerialToString(big.NewInt(1)), + } + + _, err := ra.GenerateOCSP(context.Background(), req) + test.AssertError(t, err, "generating OCSP") + if !errors.Is(err, berrors.UnknownSerial) { + t.Errorf("expected UnknownSerial error, got %#v", err) + } +} + +func TestRevokeCertByApplicant_Subscriber(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.purger = &mockPurger{} + + // Use the same self-signed cert as both issuer and issuee for revocation. + _, cert := test.ThrowAwayCert(t, clk) + cert.IsCA = true + ic, err := issuance.NewCertificate(cert) + test.AssertNotError(t, err, "failed to create issuer cert") + ra.issuersByNameID = map[issuance.NameID]*issuance.Certificate{ + ic.NameID(): ic, + } + ra.SA = newMockSARevocation(cert) + + // Revoking without a regID should fail. + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 0, + }) + test.AssertError(t, err, "should have failed with no RegID") + test.AssertContains(t, err.Error(), "incomplete") + + // Revoking for a disallowed reason should fail. + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.CertificateHold, + RegID: 1, + }) + test.AssertError(t, err, "should have failed with bad reasonCode") + test.AssertContains(t, err.Error(), "disallowed revocation reason") + + // Revoking with the correct regID should succeed. + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 1, + }) + test.AssertNotError(t, err, "should have succeeded") + + // Revoking an already-revoked serial should fail. + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 1, + }) + test.AssertError(t, err, "should have failed with bad reasonCode") + test.AssertContains(t, err.Error(), "already revoked") +} + +// mockSARevocationWithAuthzs embeds a mockSARevocation and so inherits all its +// methods, but also adds GetValidAuthorizations2 so that it can pretend to +// either be authorized or not for all of the names in the to-be-revoked cert. +type mockSARevocationWithAuthzs struct { + *mockSARevocation + authorized bool +} + +func (msa *mockSARevocationWithAuthzs) GetValidAuthorizations2(ctx context.Context, req *sapb.GetValidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + authzs := &sapb.Authorizations{} + + if !msa.authorized { + return authzs, nil + } + + for _, name := range req.Domains { + authzs.Authz = append(authzs.Authz, &sapb.Authorizations_MapElement{ + Domain: name, + Authz: &corepb.Authorization{ + Identifier: name, + }, + }) + } + + return authzs, nil +} + +func TestRevokeCertByApplicant_Controller(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.purger = &mockPurger{} + + // Use the same self-signed cert as both issuer and issuee for revocation. + _, cert := test.ThrowAwayCert(t, clk) + cert.IsCA = true + ic, err := issuance.NewCertificate(cert) + test.AssertNotError(t, err, "failed to create issuer cert") + ra.issuersByNameID = map[issuance.NameID]*issuance.Certificate{ + ic.NameID(): ic, + } + mockSA := newMockSARevocation(cert) + + // Revoking when the account doesn't have valid authzs for the name should fail. + // We use RegID 2 here and below because the mockSARevocation believes regID 1 + // is the original issuer. + ra.SA = &mockSARevocationWithAuthzs{mockSA, false} + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 2, + }) + test.AssertError(t, err, "should have failed with wrong RegID") + test.AssertContains(t, err.Error(), "requester does not control all names") + + // Revoking when the account does have valid authzs for the name should succeed, + // but override the revocation reason to cessationOfOperation. + ra.SA = &mockSARevocationWithAuthzs{mockSA, true} + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 2, + }) + test.AssertNotError(t, err, "should have succeeded") + test.AssertEquals(t, mockSA.revoked[core.SerialToString(cert.SerialNumber)].RevokedReason, int64(ocsp.CessationOfOperation)) +} + +func TestRevokeCertByKey(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.purger = &mockPurger{} + + // Use the same self-signed cert as both issuer and issuee for revocation. + _, cert := test.ThrowAwayCert(t, clk) + digest, err := core.KeyDigest(cert.PublicKey) + test.AssertNotError(t, err, "core.KeyDigest failed") + cert.IsCA = true + ic, err := issuance.NewCertificate(cert) + test.AssertNotError(t, err, "failed to create issuer cert") + ra.issuersByNameID = map[issuance.NameID]*issuance.Certificate{ + ic.NameID(): ic, + } + mockSA := newMockSARevocation(cert) + ra.SA = mockSA + + // Revoking should work, but override the requested reason and block the key. + _, err = ra.RevokeCertByKey(context.Background(), &rapb.RevokeCertByKeyRequest{ + Cert: cert.Raw, + }) + test.AssertNotError(t, err, "should have succeeded") + test.AssertEquals(t, len(mockSA.blocked), 1) + test.Assert(t, bytes.Equal(digest[:], mockSA.blocked[0].KeyHash), "key hash mismatch") + test.AssertEquals(t, mockSA.blocked[0].Source, "API") + test.AssertEquals(t, len(mockSA.blocked[0].Comment), 0) + test.AssertEquals(t, mockSA.revoked[core.SerialToString(cert.SerialNumber)].RevokedReason, int64(ocsp.KeyCompromise)) + + // Re-revoking should fail, because it is already revoked for keyCompromise. + _, err = ra.RevokeCertByKey(context.Background(), &rapb.RevokeCertByKeyRequest{ + Cert: cert.Raw, + }) + test.AssertError(t, err, "should have failed") + + // Reset and have the Subscriber revoke for a different reason. + // Then re-revoking using the key should work. + mockSA.revoked = make(map[string]*corepb.CertificateStatus) + _, err = ra.RevokeCertByApplicant(context.Background(), &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: ocsp.Unspecified, + RegID: 1, + }) + test.AssertNotError(t, err, "should have succeeded") + _, err = ra.RevokeCertByKey(context.Background(), &rapb.RevokeCertByKeyRequest{ + Cert: cert.Raw, + }) + test.AssertNotError(t, err, "should have succeeded") +} + +func TestAdministrativelyRevokeCertificate(t *testing.T) { + _, _, ra, clk, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.OCSP = &mockOCSPA{} + ra.purger = &mockPurger{} + + // Use the same self-signed cert as both issuer and issuee for revocation. + serial, cert := test.ThrowAwayCert(t, clk) + digest, err := core.KeyDigest(cert.PublicKey) + test.AssertNotError(t, err, "core.KeyDigest failed") + cert.IsCA = true + ic, err := issuance.NewCertificate(cert) + test.AssertNotError(t, err, "failed to create issuer cert") + ra.issuersByNameID = map[issuance.NameID]*issuance.Certificate{ + ic.NameID(): ic, + } + mockSA := newMockSARevocation(cert) + ra.SA = mockSA + + // Revoking with an empty request should fail immediately. + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{}) + test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed for nil request object") + + // Revoking with no serial should fail immediately. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Code: ocsp.Unspecified, + AdminName: "root", + }) + test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed with no cert or serial") + + // Revoking without an admin name should fail immediately. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.Unspecified, + AdminName: "", + }) + test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed with empty string for `AdminName`") + + // Revoking for a forbidden reason should fail immediately. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.CertificateHold, + AdminName: "root", + }) + test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed with forbidden revocation reason") + + // Revoking a cert for an unspecified reason should work but not block the key. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.Unspecified, + AdminName: "root", + }) + test.AssertNotError(t, err, "AdministrativelyRevokeCertificate failed") + test.AssertEquals(t, len(mockSA.blocked), 0) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "unspecified"}, 1) + + // Revoking a serial for an unspecified reason should work but not block the key. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.Unspecified, + AdminName: "root", + }) + test.AssertNotError(t, err, "AdministrativelyRevokeCertificate failed") + test.AssertEquals(t, len(mockSA.blocked), 0) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "unspecified"}, 2) + + // Duplicate administrative revocation of a serial for an unspecified reason + // should succeed because the akamai cache purge succeeds. + // Note that we *don't* call reset() here, so it recognizes the duplicate. + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.Unspecified, + AdminName: "root", + }) + test.AssertNotError(t, err, "AdministrativelyRevokeCertificate failed") + test.AssertEquals(t, len(mockSA.blocked), 0) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "unspecified"}, 2) + + // Duplicate administrative revocation of a serial for a *malformed* cert for + // an unspecified reason should fail because we can't attempt an akamai cache + // purge so the underlying AlreadyRevoked error gets propagated upwards. + // Note that we *don't* call reset() here, so it recognizes the duplicate. + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.Unspecified, + AdminName: "root", + Malformed: true, + }) + test.AssertError(t, err, "Should be revoked") + test.AssertContains(t, err.Error(), "already revoked") + test.AssertEquals(t, len(mockSA.blocked), 0) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "unspecified"}, 2) + + // Revoking a cert for key compromise with skipBlockKey set should work but + // not block the key. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.KeyCompromise, + AdminName: "root", + SkipBlockKey: true, + }) + test.AssertNotError(t, err, "AdministrativelyRevokeCertificate failed") + test.AssertEquals(t, len(mockSA.blocked), 0) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "keyCompromise"}, 1) + + // Revoking a cert for key compromise should work and block the key. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: serial, + Code: ocsp.KeyCompromise, + AdminName: "root", + }) + test.AssertNotError(t, err, "AdministrativelyRevokeCertificate failed") + test.AssertEquals(t, len(mockSA.blocked), 1) + test.Assert(t, bytes.Equal(digest[:], mockSA.blocked[0].KeyHash), "key hash mismatch") + test.AssertEquals(t, mockSA.blocked[0].Source, "admin-revoker") + test.AssertEquals(t, mockSA.blocked[0].Comment, "revoked by root") + test.AssertEquals(t, mockSA.blocked[0].Added.AsTime(), clk.Now()) + test.AssertMetricWithLabelsEquals( + t, ra.revocationReasonCounter, prometheus.Labels{"reason": "keyCompromise"}, 2) + + // Revoking a malformed cert for key compromise should fail because we don't + // have the pubkey to block. + mockSA.reset() + _, err = ra.AdministrativelyRevokeCertificate(context.Background(), &rapb.AdministrativelyRevokeCertificateRequest{ + Serial: core.SerialToString(cert.SerialNumber), + Code: ocsp.KeyCompromise, + AdminName: "root", + Malformed: true, + }) + test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed with just serial for keyCompromise") +} + +func TestNewOrderRateLimitingExempt(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + ra.orderLifetime = 5 * 24 * time.Hour + + // Set up a rate limit policy that allows 1 order every 5 minutes. + rateLimitDuration := 5 * time.Minute + ra.rlPolicies = &dummyRateLimitConfig{ + NewOrdersPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: rateLimitDuration}, + }, + } + + exampleOrderOne := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"first.example.com", "second.example.com"}, + } + exampleOrderTwo := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"first.example.com", "third.example.com"}, + } + + // Create an order immediately. + _, err := ra.NewOrder(ctx, exampleOrderOne) + test.AssertNotError(t, err, "orderOne should have succeeded") + + // Create another order immediately. This should fail. + _, err = ra.NewOrder(ctx, exampleOrderTwo) + test.AssertError(t, err, "orderTwo should have failed") + + // Exempt orderTwo from rate limiting. + exampleOrderTwo.LimitsExempt = true + _, err = ra.NewOrder(ctx, exampleOrderTwo) + test.AssertNotError(t, err, "orderTwo should have succeeded") +} + +func TestNewOrderFailedAuthzRateLimitingExempt(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + exampleOrder := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"example.com"}, + } + + // Create an order, and thus a pending authz, for "example.com". + ctx := context.Background() + order, err := ra.NewOrder(ctx, exampleOrder) + test.AssertNotError(t, err, "adding an initial order for regA") + test.AssertNotNil(t, order.Id, "initial order had a nil ID") + test.AssertEquals(t, numAuthorizations(order), 1) + + // Mock SA that has a failed authorization for "example.com". + ra.SA = &mockInvalidPlusValidAuthzAuthority{ + mockSAWithAuthzs{authzs: map[string]*core.Authorization{}}, + "example.com", + } + + // Set up a rate limit policy that allows 1 order every 24 hours. + ra.rlPolicies = &dummyRateLimitConfig{ + InvalidAuthorizationsPerAccountPolicy: ratelimit.RateLimitPolicy{ + Threshold: 1, + Window: config.Duration{Duration: 24 * time.Hour}, + }, + } + + // Requesting a new order for "example.com" should fail due to too many + // failed authorizations. + _, err = ra.NewOrder(ctx, exampleOrder) + test.AssertError(t, err, "expected error for domain with too many failures") + + // Exempt the order from rate limiting. + exampleOrder.LimitsExempt = true + _, err = ra.NewOrder(ctx, exampleOrder) + test.AssertNotError(t, err, "limit exempt order should have succeeded") +} + +// An authority that returns an error from NewOrderAndAuthzs if the +// "ReplacesSerial" field of the request is empty. +type mockNewOrderMustBeReplacementAuthority struct { + mockSAWithAuthzs +} + +func (sa *mockNewOrderMustBeReplacementAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb.NewOrderAndAuthzsRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + if req.NewOrder.ReplacesSerial == "" { + return nil, status.Error(codes.InvalidArgument, "NewOrder is not a replacement") + } + return &corepb.Order{ + Id: 1, + RegistrationID: req.NewOrder.RegistrationID, + Expires: req.NewOrder.Expires, + Status: string(core.StatusPending), + Created: timestamppb.New(time.Now()), + Names: req.NewOrder.Names, + }, nil +} + +func TestNewOrderReplacesSerialCarriesThroughToSA(t *testing.T) { + _, _, ra, _, cleanUp := initAuthorities(t) + defer cleanUp() + + exampleOrder := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + Names: []string{"example.com"}, + ReplacesSerial: "1234", + } + + // Mock SA that returns an error from NewOrderAndAuthzs if the + // "ReplacesSerial" field of the request is empty. + ra.SA = &mockNewOrderMustBeReplacementAuthority{mockSAWithAuthzs{}} + + _, err := ra.NewOrder(ctx, exampleOrder) + test.AssertNotError(t, err, "order with ReplacesSerial should have succeeded") +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits.go b/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits.go new file mode 100644 index 000000000..812b723b2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits.go @@ -0,0 +1,237 @@ +package ratelimit + +import ( + "strconv" + "time" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/strictyaml" +) + +const ( + // CertificatesPerName is the name of the CertificatesPerName rate limit + // when referenced in metric labels. + CertificatesPerName = "certificates_per_domain" + + // RegistrationsPerIP is the name of the RegistrationsPerIP rate limit when + // referenced in metric labels. + RegistrationsPerIP = "registrations_per_ip" + + // RegistrationsPerIPRange is the name of the RegistrationsPerIPRange rate + // limit when referenced in metric labels. + RegistrationsPerIPRange = "registrations_per_ipv6_range" + + // PendingAuthorizationsPerAccount is the name of the + // PendingAuthorizationsPerAccount rate limit when referenced in metric + // labels. + PendingAuthorizationsPerAccount = "pending_authorizations_per_account" + + // InvalidAuthorizationsPerAccount is the name of the + // InvalidAuthorizationsPerAccount rate limit when referenced in metric + // labels. + InvalidAuthorizationsPerAccount = "failed_authorizations_per_account" + + // CertificatesPerFQDNSet is the name of the CertificatesPerFQDNSet rate + // limit when referenced in metric labels. + CertificatesPerFQDNSet = "certificates_per_fqdn_set" + + // CertificatesPerFQDNSetFast is the name of the CertificatesPerFQDNSetFast + // rate limit when referenced in metric labels. + CertificatesPerFQDNSetFast = "certificates_per_fqdn_set_fast" + + // NewOrdersPerAccount is the name of the NewOrdersPerAccount rate limit + // when referenced in metric labels. + NewOrdersPerAccount = "new_orders_per_account" +) + +// Limits is defined to allow mock implementations be provided during unit +// testing +type Limits interface { + CertificatesPerName() RateLimitPolicy + RegistrationsPerIP() RateLimitPolicy + RegistrationsPerIPRange() RateLimitPolicy + PendingAuthorizationsPerAccount() RateLimitPolicy + InvalidAuthorizationsPerAccount() RateLimitPolicy + CertificatesPerFQDNSet() RateLimitPolicy + CertificatesPerFQDNSetFast() RateLimitPolicy + NewOrdersPerAccount() RateLimitPolicy + LoadPolicies(contents []byte) error +} + +// limitsImpl is an unexported implementation of the Limits interface. It acts +// as a container for a rateLimitConfig. +type limitsImpl struct { + rlPolicy *rateLimitConfig +} + +func (r *limitsImpl) CertificatesPerName() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.CertificatesPerName +} + +func (r *limitsImpl) RegistrationsPerIP() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.RegistrationsPerIP +} + +func (r *limitsImpl) RegistrationsPerIPRange() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.RegistrationsPerIPRange +} + +func (r *limitsImpl) PendingAuthorizationsPerAccount() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.PendingAuthorizationsPerAccount +} + +func (r *limitsImpl) InvalidAuthorizationsPerAccount() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.InvalidAuthorizationsPerAccount +} + +func (r *limitsImpl) CertificatesPerFQDNSet() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.CertificatesPerFQDNSet +} + +func (r *limitsImpl) CertificatesPerFQDNSetFast() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.CertificatesPerFQDNSetFast +} + +func (r *limitsImpl) NewOrdersPerAccount() RateLimitPolicy { + if r.rlPolicy == nil { + return RateLimitPolicy{} + } + return r.rlPolicy.NewOrdersPerAccount +} + +// LoadPolicies loads various rate limiting policies from a byte array of +// YAML configuration. +func (r *limitsImpl) LoadPolicies(contents []byte) error { + var newPolicy rateLimitConfig + err := strictyaml.Unmarshal(contents, &newPolicy) + if err != nil { + return err + } + r.rlPolicy = &newPolicy + return nil +} + +func New() Limits { + return &limitsImpl{} +} + +// rateLimitConfig contains all application layer rate limiting policies. It is +// unexported and clients are expected to use the exported container struct +type rateLimitConfig struct { + // Number of certificates that can be extant containing any given name. + // These are counted by "base domain" aka eTLD+1, so any entries in the + // overrides section must be an eTLD+1 according to the publicsuffix package. + CertificatesPerName RateLimitPolicy `yaml:"certificatesPerName"` + // Number of registrations that can be created per IP. + // Note: Since this is checked before a registration is created, setting a + // RegistrationOverride on it has no effect. + RegistrationsPerIP RateLimitPolicy `yaml:"registrationsPerIP"` + // Number of registrations that can be created per fuzzy IP range. Unlike + // RegistrationsPerIP this will apply to a /48 for IPv6 addresses to help curb + // abuse from easily obtained IPv6 ranges. + // Note: Like RegistrationsPerIP, setting a RegistrationOverride has no + // effect here. + RegistrationsPerIPRange RateLimitPolicy `yaml:"registrationsPerIPRange"` + // Number of pending authorizations that can exist per account. Overrides by + // key are not applied, but overrides by registration are. + PendingAuthorizationsPerAccount RateLimitPolicy `yaml:"pendingAuthorizationsPerAccount"` + // Number of invalid authorizations that can be failed per account within the + // given window. Overrides by key are not applied, but overrides by registration are. + // Note that this limit is actually "per account, per hostname," but that + // is too long for the variable name. + InvalidAuthorizationsPerAccount RateLimitPolicy `yaml:"invalidAuthorizationsPerAccount"` + // Number of new orders that can be created per account within the given + // window. Overrides by key are not applied, but overrides by registration are. + NewOrdersPerAccount RateLimitPolicy `yaml:"newOrdersPerAccount"` + // Number of certificates that can be extant containing a specific set + // of DNS names. + CertificatesPerFQDNSet RateLimitPolicy `yaml:"certificatesPerFQDNSet"` + // Same as above, but intended to both trigger and reset faster (i.e. a + // lower threshold and smaller window), so that clients don't have to wait + // a long time after a small burst of accidental duplicate issuance. + CertificatesPerFQDNSetFast RateLimitPolicy `yaml:"certificatesPerFQDNSetFast"` +} + +// RateLimitPolicy describes a general limiting policy +type RateLimitPolicy struct { + // How long to count items for + Window config.Duration `yaml:"window"` + // The max number of items that can be present before triggering the rate + // limit. Zero means "no limit." + Threshold int64 `yaml:"threshold"` + // A per-key override setting different limits than the default (higher or lower). + // The key is defined on a per-limit basis and should match the key it counts on. + // For instance, a rate limit on the number of certificates per name uses name as + // a key, while a rate limit on the number of registrations per IP subnet would + // use subnet as a key. Note that a zero entry in the overrides map does not + // mean "no limit," it means a limit of zero. An entry of -1 means + // "no limit", only for the pending authorizations rate limit. + Overrides map[string]int64 `yaml:"overrides"` + // A per-registration override setting. This can be used, e.g. if there are + // hosting providers that we would like to grant a higher rate of issuance + // than the default. If both key-based and registration-based overrides are + // available, whichever is larger takes priority. Note that a zero entry in + // the overrides map does not mean "no limit", it means a limit of zero. + RegistrationOverrides map[int64]int64 `yaml:"registrationOverrides"` +} + +// Enabled returns true iff the RateLimitPolicy is enabled. +func (rlp *RateLimitPolicy) Enabled() bool { + return rlp.Threshold != 0 +} + +// GetThreshold returns the threshold for this rate limit and the override +// Id/Key if that threshold is the result of an override for the default limit, +// empty-string otherwise. The threshold returned takes into account any +// overrides for `key` or `regID`. If both `key` and `regID` have an override +// the largest of the two will be used. +func (rlp *RateLimitPolicy) GetThreshold(key string, regID int64) (int64, string) { + regOverride, regOverrideExists := rlp.RegistrationOverrides[regID] + keyOverride, keyOverrideExists := rlp.Overrides[key] + + if regOverrideExists && !keyOverrideExists { + // If there is a regOverride and no keyOverride use the regOverride + return regOverride, strconv.FormatInt(regID, 10) + } else if !regOverrideExists && keyOverrideExists { + // If there is a keyOverride and no regOverride use the keyOverride + return keyOverride, key + } else if regOverrideExists && keyOverrideExists { + // If there is both a regOverride and a keyOverride use whichever is larger. + if regOverride > keyOverride { + return regOverride, strconv.FormatInt(regID, 10) + } else { + return keyOverride, key + } + } + + // Otherwise there was no regOverride and no keyOverride, use the base + // Threshold + return rlp.Threshold, "" +} + +// WindowBegin returns the time that a RateLimitPolicy's window begins, given a +// particular end time (typically the current time). +func (rlp *RateLimitPolicy) WindowBegin(windowEnd time.Time) time.Time { + return windowEnd.Add(-1 * rlp.Window.Duration) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits_test.go b/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits_test.go new file mode 100644 index 000000000..d264e1428 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimit/rate-limits_test.go @@ -0,0 +1,187 @@ +package ratelimit + +import ( + "os" + "testing" + "time" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/test" +) + +func TestEnabled(t *testing.T) { + policy := RateLimitPolicy{ + Threshold: 10, + } + if !policy.Enabled() { + t.Errorf("Policy should have been enabled.") + } +} + +func TestNotEnabled(t *testing.T) { + policy := RateLimitPolicy{ + Threshold: 0, + } + if policy.Enabled() { + t.Errorf("Policy should not have been enabled.") + } +} + +func TestGetThreshold(t *testing.T) { + policy := RateLimitPolicy{ + Threshold: 1, + Overrides: map[string]int64{ + "key": 2, + "baz": 99, + }, + RegistrationOverrides: map[int64]int64{ + 101: 3, + }, + } + + testCases := []struct { + Name string + Key string + RegID int64 + Expected int64 + }{ + + { + Name: "No key or reg overrides", + Key: "foo", + RegID: 11, + Expected: 1, + }, + { + Name: "Key override, no reg override", + Key: "key", + RegID: 11, + Expected: 2, + }, + { + Name: "No key override, reg override", + Key: "foo", + RegID: 101, + Expected: 3, + }, + { + Name: "Key override, larger reg override", + Key: "foo", + RegID: 101, + Expected: 3, + }, + { + Name: "Key override, smaller reg override", + Key: "baz", + RegID: 101, + Expected: 99, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + threshold, _ := policy.GetThreshold(tc.Key, tc.RegID) + test.AssertEquals(t, + threshold, + tc.Expected) + }) + } +} + +func TestWindowBegin(t *testing.T) { + policy := RateLimitPolicy{ + Window: config.Duration{Duration: 24 * time.Hour}, + } + now := time.Date(2015, 9, 22, 0, 0, 0, 0, time.UTC) + expected := time.Date(2015, 9, 21, 0, 0, 0, 0, time.UTC) + actual := policy.WindowBegin(now) + if actual != expected { + t.Errorf("Incorrect WindowBegin: %s, expected %s", actual, expected) + } +} + +func TestLoadPolicies(t *testing.T) { + policy := New() + + policyContent, readErr := os.ReadFile("../test/rate-limit-policies.yml") + test.AssertNotError(t, readErr, "Failed to load rate-limit-policies.yml") + + // Test that loading a good policy from YAML doesn't error + err := policy.LoadPolicies(policyContent) + test.AssertNotError(t, err, "Failed to parse rate-limit-policies.yml") + + // Test that the CertificatesPerName section parsed correctly + certsPerName := policy.CertificatesPerName() + test.AssertEquals(t, certsPerName.Threshold, int64(2)) + test.AssertDeepEquals(t, certsPerName.Overrides, map[string]int64{ + "ratelimit.me": 1, + "lim.it": 0, + "le.wtf": 10000, + "le1.wtf": 10000, + "le2.wtf": 10000, + "le3.wtf": 10000, + "nginx.wtf": 10000, + "good-caa-reserved.com": 10000, + "bad-caa-reserved.com": 10000, + "ecdsa.le.wtf": 10000, + "must-staple.le.wtf": 10000, + }) + test.AssertDeepEquals(t, certsPerName.RegistrationOverrides, map[int64]int64{ + 101: 1000, + }) + + // Test that the RegistrationsPerIP section parsed correctly + regsPerIP := policy.RegistrationsPerIP() + test.AssertEquals(t, regsPerIP.Threshold, int64(10000)) + test.AssertDeepEquals(t, regsPerIP.Overrides, map[string]int64{ + "127.0.0.1": 1000000, + }) + test.AssertEquals(t, len(regsPerIP.RegistrationOverrides), 0) + + // Test that the PendingAuthorizationsPerAccount section parsed correctly + pendingAuthsPerAcct := policy.PendingAuthorizationsPerAccount() + test.AssertEquals(t, pendingAuthsPerAcct.Threshold, int64(150)) + test.AssertEquals(t, len(pendingAuthsPerAcct.Overrides), 0) + test.AssertEquals(t, len(pendingAuthsPerAcct.RegistrationOverrides), 0) + + // Test that the CertificatesPerFQDN section parsed correctly + certsPerFQDN := policy.CertificatesPerFQDNSet() + test.AssertEquals(t, certsPerFQDN.Threshold, int64(6)) + test.AssertDeepEquals(t, certsPerFQDN.Overrides, map[string]int64{ + "le.wtf": 10000, + "le1.wtf": 10000, + "le2.wtf": 10000, + "le3.wtf": 10000, + "le.wtf,le1.wtf": 10000, + "good-caa-reserved.com": 10000, + "nginx.wtf": 10000, + "ecdsa.le.wtf": 10000, + "must-staple.le.wtf": 10000, + }) + test.AssertEquals(t, len(certsPerFQDN.RegistrationOverrides), 0) + certsPerFQDNFast := policy.CertificatesPerFQDNSetFast() + test.AssertEquals(t, certsPerFQDNFast.Threshold, int64(2)) + test.AssertDeepEquals(t, certsPerFQDNFast.Overrides, map[string]int64{ + "le.wtf": 100, + }) + test.AssertEquals(t, len(certsPerFQDNFast.RegistrationOverrides), 0) + + // Test that loading invalid YAML generates an error + err = policy.LoadPolicies([]byte("err")) + test.AssertError(t, err, "Failed to generate error loading invalid yaml policy file") + // Re-check a field of policy to make sure a LoadPolicies error doesn't + // corrupt the existing policies + test.AssertDeepEquals(t, policy.RegistrationsPerIP().Overrides, map[string]int64{ + "127.0.0.1": 1000000, + }) + + // Test that the RateLimitConfig accessors do not panic when there has been no + // `LoadPolicy` call, and instead return empty RateLimitPolicy objects with default + // values. + emptyPolicy := New() + test.AssertEquals(t, emptyPolicy.CertificatesPerName().Threshold, int64(0)) + test.AssertEquals(t, emptyPolicy.RegistrationsPerIP().Threshold, int64(0)) + test.AssertEquals(t, emptyPolicy.RegistrationsPerIP().Threshold, int64(0)) + test.AssertEquals(t, emptyPolicy.PendingAuthorizationsPerAccount().Threshold, int64(0)) + test.AssertEquals(t, emptyPolicy.CertificatesPerFQDNSet().Threshold, int64(0)) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/README.md b/third-party/github.com/letsencrypt/boulder/ratelimits/README.md new file mode 100644 index 000000000..adf8afc06 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/README.md @@ -0,0 +1,199 @@ +# Configuring and Storing Key-Value Rate Limits + +## Rate Limit Structure + +All rate limits use a token-bucket model. The metaphor is that each limit is +represented by a bucket which holds tokens. Each request removes some number of +tokens from the bucket, or is denied if there aren't enough tokens to remove. +Over time, new tokens are added to the bucket at a steady rate, until the bucket +is full. The _burst_ parameter of a rate limit indicates the maximum capacity of +a bucket: how many tokens can it hold before new ones stop being added. +Therefore, this also indicates how many requests can be made in a single burst +before a full bucket is completely emptied. The _count_ and _period_ parameters +indicate the rate at which new tokens are added to a bucket: every period, count +tokens will be added. Therefore, these also indicate the steady-state rate at +which a client which has exhausted its quota can make requests: one token every +(period / count) duration. + +## Default Limit Settings + +Each key directly corresponds to a `Name` enumeration as detailed in `//ratelimits/names.go`. +The `Name` enum is used to identify the particular limit. The parameters of a +default limit are the values that will be used for all buckets that do not have +an explicit override (see below). + +```yaml +NewRegistrationsPerIPAddress: + burst: 20 + count: 20 + period: 1s +NewOrdersPerAccount: + burst: 300 + count: 300 + period: 180m +``` + +## Override Limit Settings + +Each entry in the override list is a map, where the key is a limit name, +corresponding to the `Name` enum of the limit, and the value is a set of +overridden parameters. These parameters are applicable to a specific list of IDs +included in each entry. It's important that the formatting of these IDs matches +the ID format associated with their respective limit's `Name`. For more details on +the relationship of ID format to limit `Name`s, please refer to the documentation +of each `Name` in the `//ratelimits/names.go` file or the [ratelimits package +documentation](https://pkg.go.dev/github.com/letsencrypt/boulder/ratelimits#Name). + +```yaml +- NewRegistrationsPerIPAddress: + burst: 20 + count: 40 + period: 1s + ids: + - 10.0.0.2 + - 10.0.0.5 +- NewOrdersPerAccount: + burst: 300 + count: 600 + period: 180m + ids: + - 12345678 + - 87654321 +``` + +The above example overrides the default limits for specific subscribers. In both +cases the count of requests per period are doubled, but the burst capacity is +explicitly configured to match the default rate limit. + +### Id Formats in Limit Override Settings + +Id formats vary based on the `Name` enumeration. Below are examples for each +format: + +#### ipAddress + +A valid IPv4 or IPv6 address. + +Examples: + - `10.0.0.1` + - `2001:0db8:0000:0000:0000:ff00:0042:8329` + +#### ipv6RangeCIDR + +A valid IPv6 range in CIDR notation with a /48 mask. A /48 range is typically +assigned to a single subscriber. + +Example: `2001:0db8:0000::/48` + +#### regId + +An ACME account registration ID. + +Example: `12345678` + +#### domain + +A valid eTLD+1 domain name. + +Example: `example.com` + +#### fqdnSet + +A comma-separated list of domain names. + +Example: `example.com,example.org` + +## Bucket Key Definitions + +A bucket key is used to lookup the bucket for a given limit and +subscriber. Bucket keys are formatted similarly to the overrides but with a +slight difference: the limit Names do not carry the string form of each limit. +Instead, they apply the `Name` enum equivalent for every limit. + +So, instead of: + +``` +NewOrdersPerAccount:12345678 +``` + +The corresponding bucket key for regId 12345678 would look like this: + +``` +6:12345678 +``` + +When loaded from a file, the keys for the default/override limits undergo the +same interning process as the aforementioned subscriber bucket keys. This +eliminates the need for redundant conversions when fetching each +default/override limit. + +## How Limits are Applied + +Although rate limit buckets are configured in terms of tokens, we do not +actually keep track of the number of tokens in each bucket. Instead, we track +the Theoretical Arrival Time (TAT) at which the bucket will be full again. If +the TAT is in the past, the bucket is full. If the TAT is in the future, some +number of tokens have been spent and the bucket is slowly refilling. If the TAT +is far enough in the future (specifically, more than `burst * (period / count)`) +in the future), then the bucket is completely empty and requests will be denied. + +Additional terminology: + + - **burst offset** is the duration of time it takes for a bucket to go from + empty to full (`burst * (period / count)`). + - **emission interval** is the interval at which tokens are added to a bucket + (`period / count`). This is also the steady-state rate at which requests can + be made without being denied even once the burst has been exhausted. + - **cost** is the number of tokens removed from a bucket for a single request. + - **cost increment** is the duration of time the TAT is advanced to account + for the cost of the request (`cost * emission interval`). + +For the purposes of this example, subscribers originating from a specific IPv4 +address are allowed 20 requests to the newFoo endpoint per second, with a +maximum burst of 20 requests at any point-in-time, or: + +```yaml +- NewFoosPerIPAddress: + burst: 20 + count: 20 + period: 1s + ids: + - 172.23.45.22 +``` + +A subscriber calls the newFoo endpoint for the first time with an IP address of +172.23.45.22. Here's what happens: + +1. The subscriber's IP address is used to generate a bucket key in the form of + 'NewFoosPerIPAddress:172.23.45.22'. + +2. The request is approved and the 'NewFoosPerIPAddress:172.23.45.22' bucket is + initialized with 19 tokens, as 1 token has been removed to account for the + cost of the current request. To accomplish this, the initial TAT is set to + the current time plus the _cost increment_ (which is 1/20th of a second if we + are limiting to 20 requests per second). + +3. Bucket 'NewFoosPerIPAddress:172.23.45.22': + - will reset to full in 50ms (1/20th of a second), + - will allow another newFoo request immediately, + - will allow between 1 and 19 more requests in the next 50ms, + - will reject the 20th request made in the next 50ms, + - and will allow 1 request every 50ms, indefinitely. + +The subscriber makes another request 5ms later: + +4. The TAT at bucket key 'NewFoosPerIPAddress:172.23.45.22' is compared against + the current time and the _burst offset_. The current time is greater than the + TAT minus the cost increment. Therefore, the request is approved. + +5. The TAT at bucket key 'NewFoosPerIPAddress:172.23.45.22' is advanced by the + cost increment to account for the cost of the request. + +The subscriber makes a total of 18 requests over the next 44ms: + +6. The current time is less than the TAT at bucket key + 'NewFoosPerIPAddress:172.23.45.22' minus the burst offset, thus the request + is rejected. + +This mechanism allows for bursts of traffic but also ensures that the average +rate of requests stays within the prescribed limits over time. diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/bucket.go b/third-party/github.com/letsencrypt/boulder/ratelimits/bucket.go new file mode 100644 index 000000000..ba555c2db --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/bucket.go @@ -0,0 +1,414 @@ +package ratelimits + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/letsencrypt/boulder/core" +) + +// ErrInvalidCost indicates that the cost specified was < 0. +var ErrInvalidCost = fmt.Errorf("invalid cost, must be >= 0") + +// ErrInvalidCostOverLimit indicates that the cost specified was > limit.Burst. +var ErrInvalidCostOverLimit = fmt.Errorf("invalid cost, must be <= limit.Burst") + +// newIPAddressBucketKey validates and returns a bucketKey for limits that use +// the 'enum:ipAddress' bucket key format. +func newIPAddressBucketKey(name Name, ip net.IP) (string, error) { //nolint: unparam + id := ip.String() + err := validateIdForName(name, id) + if err != nil { + return "", err + } + return joinWithColon(name.EnumString(), id), nil +} + +// newIPv6RangeCIDRBucketKey validates and returns a bucketKey for limits that +// use the 'enum:ipv6RangeCIDR' bucket key format. +func newIPv6RangeCIDRBucketKey(name Name, ip net.IP) (string, error) { + if ip.To4() != nil { + return "", fmt.Errorf("invalid IPv6 address, %q must be an IPv6 address", ip.String()) + } + ipMask := net.CIDRMask(48, 128) + ipNet := &net.IPNet{IP: ip.Mask(ipMask), Mask: ipMask} + id := ipNet.String() + err := validateIdForName(name, id) + if err != nil { + return "", err + } + return joinWithColon(name.EnumString(), id), nil +} + +// newRegIdBucketKey validates and returns a bucketKey for limits that use the +// 'enum:regId' bucket key format. +func newRegIdBucketKey(name Name, regId int64) (string, error) { + id := strconv.FormatInt(regId, 10) + err := validateIdForName(name, id) + if err != nil { + return "", err + } + return joinWithColon(name.EnumString(), id), nil +} + +// newDomainBucketKey validates and returns a bucketKey for limits that use the +// 'enum:domain' bucket key format. +func newDomainBucketKey(name Name, orderName string) (string, error) { + err := validateIdForName(name, orderName) + if err != nil { + return "", err + } + return joinWithColon(name.EnumString(), orderName), nil +} + +// newRegIdDomainBucketKey validates and returns a bucketKey for limits that use +// the 'enum:regId:domain' bucket key format. +func newRegIdDomainBucketKey(name Name, regId int64, orderName string) (string, error) { + regIdStr := strconv.FormatInt(regId, 10) + err := validateIdForName(name, joinWithColon(regIdStr, orderName)) + if err != nil { + return "", err + } + return joinWithColon(name.EnumString(), regIdStr, orderName), nil +} + +// newFQDNSetBucketKey validates and returns a bucketKey for limits that use the +// 'enum:fqdnSet' bucket key format. +func newFQDNSetBucketKey(name Name, orderNames []string) (string, error) { //nolint: unparam + err := validateIdForName(name, strings.Join(orderNames, ",")) + if err != nil { + return "", err + } + id := fmt.Sprintf("%x", core.HashNames(orderNames)) + return joinWithColon(name.EnumString(), id), nil +} + +// Transaction represents a single rate limit operation. It includes a +// bucketKey, which combines the specific rate limit enum with a unique +// identifier to form the key where the state of the "bucket" can be referenced +// or stored by the Limiter, the rate limit being enforced, a cost which MUST be +// >= 0, and check/spend fields, which indicate how the Transaction should be +// processed. The following are acceptable combinations of check/spend: +// - check-and-spend: when check and spend are both true, the cost will be +// checked against the bucket's capacity and spent/refunded, when possible. +// - check-only: when only check is true, the cost will be checked against the +// bucket's capacity, but will never be spent/refunded. +// - spend-only: when only spend is true, spending is best-effort. Regardless +// of the bucket's capacity, the transaction will be considered "allowed". +// - allow-only: when neither check nor spend are true, the transaction will +// be considered "allowed" regardless of the bucket's capacity. This is +// useful for limits that are disabled. +type Transaction struct { + bucketKey string + limit limit + cost int64 + check bool + spend bool +} + +func (txn Transaction) checkOnly() bool { + return txn.check && !txn.spend +} + +func (txn Transaction) spendOnly() bool { + return txn.spend && !txn.check +} + +func (txn Transaction) allowOnly() bool { + return !txn.check && !txn.spend +} + +func validateTransaction(txn Transaction) (Transaction, error) { + if txn.cost < 0 { + return Transaction{}, ErrInvalidCost + } + if txn.cost > txn.limit.Burst { + return Transaction{}, ErrInvalidCostOverLimit + } + return txn, nil +} + +func newTransaction(limit limit, bucketKey string, cost int64) (Transaction, error) { + return validateTransaction(Transaction{ + bucketKey: bucketKey, + limit: limit, + cost: cost, + check: true, + spend: true, + }) +} + +func newCheckOnlyTransaction(limit limit, bucketKey string, cost int64) (Transaction, error) { + return validateTransaction(Transaction{ + bucketKey: bucketKey, + limit: limit, + cost: cost, + check: true, + }) +} + +func newSpendOnlyTransaction(limit limit, bucketKey string, cost int64) (Transaction, error) { + return validateTransaction(Transaction{ + bucketKey: bucketKey, + limit: limit, + cost: cost, + spend: true, + }) +} + +func newAllowOnlyTransaction() (Transaction, error) { + // Zero values are sufficient. + return validateTransaction(Transaction{}) +} + +// TransactionBuilder is used to build Transactions for various rate limits. +// Each rate limit has a corresponding method that returns a Transaction for +// that limit. Call NewTransactionBuilder to create a new *TransactionBuilder. +type TransactionBuilder struct { + *limitRegistry +} + +// NewTransactionBuilder returns a new *TransactionBuilder. The provided +// defaults and overrides paths are expected to be paths to YAML files that +// contain the default and override limits, respectively. Overrides is optional, +// defaults is required. +func NewTransactionBuilder(defaults, overrides string) (*TransactionBuilder, error) { + registry, err := newLimitRegistry(defaults, overrides) + if err != nil { + return nil, err + } + return &TransactionBuilder{registry}, nil +} + +// RegistrationsPerIPAddressTransaction returns a Transaction for the +// NewRegistrationsPerIPAddress limit for the provided IP address. +func (builder *TransactionBuilder) RegistrationsPerIPAddressTransaction(ip net.IP) (Transaction, error) { + bucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, ip) + if err != nil { + return Transaction{}, err + } + limit, err := builder.getLimit(NewRegistrationsPerIPAddress, bucketKey) + if err != nil { + if errors.Is(err, errLimitDisabled) { + return newAllowOnlyTransaction() + } + return Transaction{}, err + } + return newTransaction(limit, bucketKey, 1) +} + +// RegistrationsPerIPv6RangeTransaction returns a Transaction for the +// NewRegistrationsPerIPv6Range limit for the /48 IPv6 range which contains the +// provided IPv6 address. +func (builder *TransactionBuilder) RegistrationsPerIPv6RangeTransaction(ip net.IP) (Transaction, error) { + bucketKey, err := newIPv6RangeCIDRBucketKey(NewRegistrationsPerIPv6Range, ip) + if err != nil { + return Transaction{}, err + } + limit, err := builder.getLimit(NewRegistrationsPerIPv6Range, bucketKey) + if err != nil { + if errors.Is(err, errLimitDisabled) { + return newAllowOnlyTransaction() + } + return Transaction{}, err + } + return newTransaction(limit, bucketKey, 1) +} + +// OrdersPerAccountTransaction returns a Transaction for the NewOrdersPerAccount +// limit for the provided ACME registration Id. +func (builder *TransactionBuilder) OrdersPerAccountTransaction(regId int64) (Transaction, error) { + bucketKey, err := newRegIdBucketKey(NewOrdersPerAccount, regId) + if err != nil { + return Transaction{}, err + } + limit, err := builder.getLimit(NewOrdersPerAccount, bucketKey) + if err != nil { + if errors.Is(err, errLimitDisabled) { + return newAllowOnlyTransaction() + } + return Transaction{}, err + } + return newTransaction(limit, bucketKey, 1) +} + +// FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions returns a slice +// of Transactions for the provided order domain names. An error is returned if +// any of the order domain names are invalid. This method should be used for +// checking capacity, before allowing more authorizations to be created. +// +// Precondition: orderDomains must all pass policy.WellFormedDomainNames. +// Precondition: len(orderDomains) < maxNames. +func (builder *TransactionBuilder) FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(regId int64, orderDomains []string, maxNames int) ([]Transaction, error) { + if len(orderDomains) > maxNames { + return nil, fmt.Errorf("order contains more than %d DNS names", maxNames) + } + + // FailedAuthorizationsPerDomainPerAccount limit uses the 'enum:regId' + // bucket key format for overrides. + perAccountBucketKey, err := newRegIdBucketKey(FailedAuthorizationsPerDomainPerAccount, regId) + if err != nil { + return nil, err + } + limit, err := builder.getLimit(FailedAuthorizationsPerDomainPerAccount, perAccountBucketKey) + if err != nil && !errors.Is(err, errLimitDisabled) { + return nil, err + } + + var txns []Transaction + for _, name := range DomainsForRateLimiting(orderDomains) { + // FailedAuthorizationsPerDomainPerAccount limit uses the + // 'enum:regId:domain' bucket key format for transactions. + perDomainPerAccountBucketKey, err := newRegIdDomainBucketKey(FailedAuthorizationsPerDomainPerAccount, regId, name) + if err != nil { + return nil, err + } + + // Add a check-only transaction for each per domain per account bucket. + // The cost is 0, as we are only checking that the account and domain + // pair aren't already over the limit. + txn, err := newCheckOnlyTransaction(limit, perDomainPerAccountBucketKey, 0) + if err != nil { + return nil, err + } + txns = append(txns, txn) + } + return txns, nil +} + +// FailedAuthorizationsPerDomainPerAccountSpendOnlyTransaction returns a spend- +// only Transaction for the provided order domain name. An error is returned if +// the order domain name is invalid. This method should be used for spending +// capacity, as a result of a failed authorization. +func (builder *TransactionBuilder) FailedAuthorizationsPerDomainPerAccountSpendOnlyTransaction(regId int64, orderDomain string) (Transaction, error) { + // FailedAuthorizationsPerDomainPerAccount limit uses the 'enum:regId' + // bucket key format for overrides. + perAccountBucketKey, err := newRegIdBucketKey(FailedAuthorizationsPerDomainPerAccount, regId) + if err != nil { + return Transaction{}, err + } + limit, err := builder.getLimit(FailedAuthorizationsPerDomainPerAccount, perAccountBucketKey) + if err != nil && !errors.Is(err, errLimitDisabled) { + return Transaction{}, err + } + + // FailedAuthorizationsPerDomainPerAccount limit uses the + // 'enum:regId:domain' bucket key format for transactions. + perDomainPerAccountBucketKey, err := newRegIdDomainBucketKey(FailedAuthorizationsPerDomainPerAccount, regId, orderDomain) + if err != nil { + return Transaction{}, err + } + txn, err := newSpendOnlyTransaction(limit, perDomainPerAccountBucketKey, 1) + if err != nil { + return Transaction{}, err + } + + return txn, nil +} + +// CertificatesPerDomainTransactions returns a slice of Transactions for the +// provided order domain names. An error is returned if any of the order domain +// names are invalid. When a CertificatesPerDomainPerAccount override is +// configured, two types of Transactions are returned: +// - A spend-only Transaction for each per domain bucket. Spend-only transactions +// will not be denied if the bucket lacks the capacity to satisfy the cost. +// - A check-and-spend Transaction for each per account per domain bucket. Check- +// and-spend transactions will be denied if the bucket lacks the capacity to +// satisfy the cost. +// +// When a CertificatesPerDomainPerAccount override is not configured, a check- +// and-spend Transaction is returned for each per domain bucket. +// +// Precondition: orderDomains must all pass policy.WellFormedDomainNames. +// Precondition: len(orderDomains) < maxNames. +func (builder *TransactionBuilder) CertificatesPerDomainTransactions(regId int64, orderDomains []string, maxNames int) ([]Transaction, error) { + if len(orderDomains) > maxNames { + return nil, fmt.Errorf("order contains more than %d DNS names", maxNames) + } + + perAccountLimitBucketKey, err := newRegIdBucketKey(CertificatesPerDomainPerAccount, regId) + if err != nil { + return nil, err + } + perAccountLimit, err := builder.getLimit(CertificatesPerDomainPerAccount, perAccountLimitBucketKey) + if err != nil && !errors.Is(err, errLimitDisabled) { + return nil, err + } + + var txns []Transaction + for _, name := range DomainsForRateLimiting(orderDomains) { + perDomainBucketKey, err := newDomainBucketKey(CertificatesPerDomain, name) + if err != nil { + return nil, err + } + if perAccountLimit.isOverride() { + // An override is configured for the CertificatesPerDomainPerAccount + // limit. + perAccountPerDomainKey, err := newRegIdDomainBucketKey(CertificatesPerDomainPerAccount, regId, name) + if err != nil { + return nil, err + } + // Add a check-and-spend transaction for each per account per domain + // bucket. + txn, err := newTransaction(perAccountLimit, perAccountPerDomainKey, 1) + if err != nil { + return nil, err + } + txns = append(txns, txn) + + perDomainLimit, err := builder.getLimit(CertificatesPerDomain, perDomainBucketKey) + if errors.Is(err, errLimitDisabled) { + // Skip disabled limit. + continue + } + if err != nil { + return nil, err + } + + // Add a spend-only transaction for each per domain bucket. + txn, err = newSpendOnlyTransaction(perDomainLimit, perDomainBucketKey, 1) + if err != nil { + return nil, err + } + txns = append(txns, txn) + } else { + // Use the per domain bucket key when no per account per domain override + // is configured. + perDomainLimit, err := builder.getLimit(CertificatesPerDomain, perDomainBucketKey) + if errors.Is(err, errLimitDisabled) { + // Skip disabled limit. + continue + } + if err != nil { + return nil, err + } + // Add a check-and-spend transaction for each per domain bucket. + txn, err := newTransaction(perDomainLimit, perDomainBucketKey, 1) + if err != nil { + return nil, err + } + txns = append(txns, txn) + } + } + return txns, nil +} + +// CertificatesPerFQDNSetTransaction returns a Transaction for the provided +// order domain names. +func (builder *TransactionBuilder) CertificatesPerFQDNSetTransaction(orderNames []string) (Transaction, error) { + bucketKey, err := newFQDNSetBucketKey(CertificatesPerFQDNSet, orderNames) + if err != nil { + return Transaction{}, err + } + limit, err := builder.getLimit(CertificatesPerFQDNSet, bucketKey) + if err != nil { + if errors.Is(err, errLimitDisabled) { + return newAllowOnlyTransaction() + } + return Transaction{}, err + } + return newTransaction(limit, bucketKey, 1) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/bucket_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/bucket_test.go new file mode 100644 index 000000000..575577caf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/bucket_test.go @@ -0,0 +1,16 @@ +package ratelimits + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestNewTransactionBuilder_WithBadLimitsPath(t *testing.T) { + t.Parallel() + _, err := NewTransactionBuilder("testdata/does-not-exist.yml", "") + test.AssertError(t, err, "should error") + + _, err = NewTransactionBuilder("testdata/defaults.yml", "testdata/does-not-exist.yml") + test.AssertError(t, err, "should error") +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/gcra.go b/third-party/github.com/letsencrypt/boulder/ratelimits/gcra.go new file mode 100644 index 000000000..a712dfb98 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/gcra.go @@ -0,0 +1,110 @@ +package ratelimits + +import ( + "time" + + "github.com/jmhodges/clock" +) + +// maybeSpend uses the GCRA algorithm to decide whether to allow a request. It +// returns a Decision struct with the result of the decision and the updated +// TAT. The cost must be 0 or greater and <= the burst capacity of the limit. +func maybeSpend(clk clock.Clock, rl limit, tat time.Time, cost int64) *Decision { + if cost < 0 || cost > rl.Burst { + // The condition above is the union of the conditions checked in Check + // and Spend methods of Limiter. If this panic is reached, it means that + // the caller has introduced a bug. + panic("invalid cost for maybeSpend") + } + nowUnix := clk.Now().UnixNano() + tatUnix := tat.UnixNano() + + // If the TAT is in the future, use it as the starting point for the + // calculation. Otherwise, use the current time. This is to prevent the + // bucket from being filled with capacity from the past. + if nowUnix > tatUnix { + tatUnix = nowUnix + } + + // Compute the cost increment. + costIncrement := rl.emissionInterval * cost + + // Deduct the cost to find the new TAT and residual capacity. + newTAT := tatUnix + costIncrement + difference := nowUnix - (newTAT - rl.burstOffset) + + if difference < 0 { + // Too little capacity to satisfy the cost, deny the request. + residual := (nowUnix - (tatUnix - rl.burstOffset)) / rl.emissionInterval + return &Decision{ + Allowed: false, + Remaining: residual, + RetryIn: -time.Duration(difference), + ResetIn: time.Duration(tatUnix - nowUnix), + newTAT: time.Unix(0, tatUnix).UTC(), + } + } + + // There is enough capacity to satisfy the cost, allow the request. + var retryIn time.Duration + residual := difference / rl.emissionInterval + if difference < costIncrement { + retryIn = time.Duration(costIncrement - difference) + } + return &Decision{ + Allowed: true, + Remaining: residual, + RetryIn: retryIn, + ResetIn: time.Duration(newTAT - nowUnix), + newTAT: time.Unix(0, newTAT).UTC(), + } +} + +// maybeRefund uses the Generic Cell Rate Algorithm (GCRA) to attempt to refund +// the cost of a request which was previously spent. The refund cost must be 0 +// or greater. A cost will only be refunded up to the burst capacity of the +// limit. A partial refund is still considered successful. +func maybeRefund(clk clock.Clock, rl limit, tat time.Time, cost int64) *Decision { + if cost < 0 || cost > rl.Burst { + // The condition above is checked in the Refund method of Limiter. If + // this panic is reached, it means that the caller has introduced a bug. + panic("invalid cost for maybeRefund") + } + nowUnix := clk.Now().UnixNano() + tatUnix := tat.UnixNano() + + // The TAT must be in the future to refund capacity. + if nowUnix > tatUnix { + // The TAT is in the past, therefore the bucket is full. + return &Decision{ + Allowed: false, + Remaining: rl.Burst, + RetryIn: time.Duration(0), + ResetIn: time.Duration(0), + newTAT: tat, + } + } + + // Compute the refund increment. + refundIncrement := rl.emissionInterval * cost + + // Subtract the refund increment from the TAT to find the new TAT. + newTAT := tatUnix - refundIncrement + + // Ensure the new TAT is not earlier than now. + if newTAT < nowUnix { + newTAT = nowUnix + } + + // Calculate the new capacity. + difference := nowUnix - (newTAT - rl.burstOffset) + residual := difference / rl.emissionInterval + + return &Decision{ + Allowed: (newTAT != tatUnix), + Remaining: residual, + RetryIn: time.Duration(0), + ResetIn: time.Duration(newTAT - nowUnix), + newTAT: time.Unix(0, newTAT).UTC(), + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/gcra_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/gcra_test.go new file mode 100644 index 000000000..c1ebcf53c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/gcra_test.go @@ -0,0 +1,225 @@ +package ratelimits + +import ( + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/test" +) + +func TestDecide(t *testing.T) { + clk := clock.NewFake() + limit := limit{Burst: 10, Count: 1, Period: config.Duration{Duration: time.Second}} + limit.precompute() + + // Begin by using 1 of our 10 requests. + d := maybeSpend(clk, limit, clk.Now(), 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(9)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Immediately use another 9 of our remaining requests. + d = maybeSpend(clk, limit, d.newTAT, 9) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + // We should have to wait 1 second before we can use another request but we + // used 9 so we should have to wait 9 seconds to make an identical request. + test.AssertEquals(t, d.RetryIn, time.Second*9) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Our new TAT should be 10 seconds (limit.Burst) in the future. + test.AssertEquals(t, d.newTAT, clk.Now().Add(time.Second*10)) + + // Let's try using just 1 more request without waiting. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.RetryIn, time.Second) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Let's try being exactly as patient as we're told to be. + clk.Add(d.RetryIn) + d = maybeSpend(clk, limit, d.newTAT, 0) + test.AssertEquals(t, d.Remaining, int64(1)) + + // We are 1 second in the future, we should have 1 new request. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.RetryIn, time.Second) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Let's try waiting (10 seconds) for our whole bucket to refill. + clk.Add(d.ResetIn) + + // We should have 10 new requests. If we use 1 we should have 9 remaining. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(9)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Wait just shy of how long we're told to wait for refilling. + clk.Add(d.ResetIn - time.Millisecond) + + // We should still have 9 remaining because we're still 1ms shy of the + // refill time. + d = maybeSpend(clk, limit, d.newTAT, 0) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(9)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond) + + // Spending 0 simply informed us that we still have 9 remaining, let's see + // what we have after waiting 20 hours. + clk.Add(20 * time.Hour) + + // C'mon, big money, no whammies, no whammies, STOP! + d = maybeSpend(clk, limit, d.newTAT, 0) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Turns out that the most we can accrue is 10 (limit.Burst). Let's empty + // this bucket out so we can try something else. + d = maybeSpend(clk, limit, d.newTAT, 10) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + // We should have to wait 1 second before we can use another request but we + // used 10 so we should have to wait 10 seconds to make an identical + // request. + test.AssertEquals(t, d.RetryIn, time.Second*10) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // If you spend 0 while you have 0 you should get 0. + d = maybeSpend(clk, limit, d.newTAT, 0) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // We don't play by the rules, we spend 1 when we have 0. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.RetryIn, time.Second) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Okay, maybe we should play by the rules if we want to get anywhere. + clk.Add(d.RetryIn) + + // Our patience pays off, we should have 1 new request. Let's use it. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.RetryIn, time.Second) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Refill from empty to 5. + clk.Add(d.ResetIn / 2) + + // Attempt to spend 7 when we only have 5. We should be denied but the + // decision should reflect a retry of 2 seconds, the time it would take to + // refill from 5 to 7. + d = maybeSpend(clk, limit, d.newTAT, 7) + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(5)) + test.AssertEquals(t, d.RetryIn, time.Second*2) + test.AssertEquals(t, d.ResetIn, time.Second*5) +} + +func TestMaybeRefund(t *testing.T) { + clk := clock.NewFake() + limit := limit{Burst: 10, Count: 1, Period: config.Duration{Duration: time.Second}} + limit.precompute() + + // Begin by using 1 of our 10 requests. + d := maybeSpend(clk, limit, clk.Now(), 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(9)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Refund back to 10. + d = maybeRefund(clk, limit, d.newTAT, 1) + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Refund 0, we should still have 10. + d = maybeRefund(clk, limit, d.newTAT, 0) + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Spend 1 more of our 10 requests. + d = maybeSpend(clk, limit, d.newTAT, 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(9)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Wait for our bucket to refill. + clk.Add(d.ResetIn) + + // Attempt to refund from 10 to 11. + d = maybeRefund(clk, limit, d.newTAT, 1) + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Spend 10 all 10 of our requests. + d = maybeSpend(clk, limit, d.newTAT, 10) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + // We should have to wait 1 second before we can use another request but we + // used 10 so we should have to wait 10 seconds to make an identical + // request. + test.AssertEquals(t, d.RetryIn, time.Second*10) + test.AssertEquals(t, d.ResetIn, time.Second*10) + + // Attempt a refund of 10. + d = maybeRefund(clk, limit, d.newTAT, 10) + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Wait 11 seconds to catching up to TAT. + clk.Add(11 * time.Second) + + // Attempt to refund to 11, then ensure it's still 10. + d = maybeRefund(clk, limit, d.newTAT, 1) + test.Assert(t, !d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Spend 5 of our 10 requests, then refund 1. + d = maybeSpend(clk, limit, d.newTAT, 5) + d = maybeRefund(clk, limit, d.newTAT, 1) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(6)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + + // Wait, a 2.5 seconds to refill to 8.5 requests. + clk.Add(time.Millisecond * 2500) + + // Ensure we have 8.5 requests. + d = maybeSpend(clk, limit, d.newTAT, 0) + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(8)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + // Check that ResetIn represents the fractional earned request. + test.AssertEquals(t, d.ResetIn, time.Millisecond*1500) + + // Refund 2 requests, we should only have 10, not 10.5. + d = maybeRefund(clk, limit, d.newTAT, 2) + test.AssertEquals(t, d.Remaining, int64(10)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/limit.go b/third-party/github.com/letsencrypt/boulder/ratelimits/limit.go new file mode 100644 index 000000000..df2cd268c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/limit.go @@ -0,0 +1,265 @@ +package ratelimits + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/strictyaml" +) + +// errLimitDisabled indicates that the limit name specified is valid but is not +// currently configured. +var errLimitDisabled = errors.New("limit disabled") + +type limit struct { + // Burst specifies maximum concurrent allowed requests at any given time. It + // must be greater than zero. + Burst int64 + + // Count is the number of requests allowed per period. It must be greater + // than zero. + Count int64 + + // Period is the duration of time in which the count (of requests) is + // allowed. It must be greater than zero. + Period config.Duration + + // name is the name of the limit. It must be one of the Name enums defined + // in this package. + name Name + + // emissionInterval is the interval, in nanoseconds, at which tokens are + // added to a bucket (period / count). This is also the steady-state rate at + // which requests can be made without being denied even once the burst has + // been exhausted. This is precomputed to avoid doing the same calculation + // on every request. + emissionInterval int64 + + // burstOffset is the duration of time, in nanoseconds, it takes for a + // bucket to go from empty to full (burst * (period / count)). This is + // precomputed to avoid doing the same calculation on every request. + burstOffset int64 + + // overrideKey is the key used to look up this limit in the overrides map. + overrideKey string +} + +// isOverride returns true if the limit is an override. +func (l *limit) isOverride() bool { + return l.overrideKey != "" +} + +// precompute calculates the emissionInterval and burstOffset for the limit. +func (l *limit) precompute() { + l.emissionInterval = l.Period.Nanoseconds() / l.Count + l.burstOffset = l.emissionInterval * l.Burst +} + +func validateLimit(l limit) error { + if l.Burst <= 0 { + return fmt.Errorf("invalid burst '%d', must be > 0", l.Burst) + } + if l.Count <= 0 { + return fmt.Errorf("invalid count '%d', must be > 0", l.Count) + } + if l.Period.Duration <= 0 { + return fmt.Errorf("invalid period '%s', must be > 0", l.Period) + } + return nil +} + +type limits map[string]limit + +// loadDefaults marshals the defaults YAML file at path into a map of limits. +func loadDefaults(path string) (limits, error) { + lm := make(limits) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + err = strictyaml.Unmarshal(data, &lm) + if err != nil { + return nil, err + } + return lm, nil +} + +type overrideYAML struct { + limit `yaml:",inline"` + // Ids is a list of ids that this override applies to. + Ids []struct { + Id string `yaml:"id"` + // Comment is an optional field that can be used to provide additional + // context for the override. + Comment string `yaml:"comment,omitempty"` + } `yaml:"ids"` +} + +type overridesYAML []map[string]overrideYAML + +// loadOverrides marshals the YAML file at path into a map of overrides. +func loadOverrides(path string) (overridesYAML, error) { + ov := overridesYAML{} + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + err = strictyaml.Unmarshal(data, &ov) + if err != nil { + return nil, err + } + return ov, nil +} + +// parseOverrideNameId is broken out for ease of testing. +func parseOverrideNameId(key string) (Name, string, error) { + if !strings.Contains(key, ":") { + // Avoids a potential panic in strings.SplitN below. + return Unknown, "", fmt.Errorf("invalid override %q, must be formatted 'name:id'", key) + } + nameAndId := strings.SplitN(key, ":", 2) + nameStr := nameAndId[0] + if nameStr == "" { + return Unknown, "", fmt.Errorf("empty name in override %q, must be formatted 'name:id'", key) + } + + name, ok := stringToName[nameStr] + if !ok { + return Unknown, "", fmt.Errorf("unrecognized name %q in override limit %q, must be one of %v", nameStr, key, limitNames) + } + id := nameAndId[1] + if id == "" { + return Unknown, "", fmt.Errorf("empty id in override %q, must be formatted 'name:id'", key) + } + return name, id, nil +} + +// loadAndParseOverrideLimits loads override limits from YAML. The YAML file +// must be formatted as a list of maps, where each map has a single key +// representing the limit name and a value that is a map containing the limit +// fields and an additional 'ids' field that is a list of ids that this override +// applies to. +func loadAndParseOverrideLimits(path string) (limits, error) { + fromFile, err := loadOverrides(path) + if err != nil { + return nil, err + } + parsed := make(limits) + + for _, ov := range fromFile { + for k, v := range ov { + err = validateLimit(v.limit) + if err != nil { + return nil, fmt.Errorf("validating override limit %q: %w", k, err) + } + name, ok := stringToName[k] + if !ok { + return nil, fmt.Errorf("unrecognized name %q in override limit, must be one of %v", k, limitNames) + } + v.limit.name = name + + for _, entry := range v.Ids { + limit := v.limit + id := entry.Id + err = validateIdForName(name, id) + if err != nil { + return nil, fmt.Errorf( + "validating name %s and id %q for override limit %q: %w", name, id, k, err) + } + limit.overrideKey = joinWithColon(name.EnumString(), id) + if name == CertificatesPerFQDNSet { + // FQDNSet hashes are not a nice thing to ask for in a + // config file, so we allow the user to specify a + // comma-separated list of FQDNs and compute the hash here. + id = fmt.Sprintf("%x", core.HashNames(strings.Split(id, ","))) + } + limit.precompute() + parsed[joinWithColon(name.EnumString(), id)] = limit + } + } + } + return parsed, nil +} + +// loadAndParseDefaultLimits loads default limits from YAML, validates them, and +// parses them into a map of limits keyed by 'Name'. +func loadAndParseDefaultLimits(path string) (limits, error) { + fromFile, err := loadDefaults(path) + if err != nil { + return nil, err + } + parsed := make(limits, len(fromFile)) + + for k, v := range fromFile { + err := validateLimit(v) + if err != nil { + return nil, fmt.Errorf("parsing default limit %q: %w", k, err) + } + name, ok := stringToName[k] + if !ok { + return nil, fmt.Errorf("unrecognized name %q in default limit, must be one of %v", k, limitNames) + } + v.name = name + v.precompute() + parsed[name.EnumString()] = v + } + return parsed, nil +} + +type limitRegistry struct { + // defaults stores default limits by 'name'. + defaults limits + + // overrides stores override limits by 'name:id'. + overrides limits +} + +func newLimitRegistry(defaults, overrides string) (*limitRegistry, error) { + var err error + registry := &limitRegistry{} + registry.defaults, err = loadAndParseDefaultLimits(defaults) + if err != nil { + return nil, err + } + + if overrides == "" { + // No overrides specified, initialize an empty map. + registry.overrides = make(limits) + return registry, nil + } + + registry.overrides, err = loadAndParseOverrideLimits(overrides) + if err != nil { + return nil, err + } + + return registry, nil +} + +// getLimit returns the limit for the specified by name and bucketKey, name is +// required, bucketKey is optional. If bucketkey is empty, the default for the +// limit specified by name is returned. If no default limit exists for the +// specified name, errLimitDisabled is returned. +func (l *limitRegistry) getLimit(name Name, bucketKey string) (limit, error) { + if !name.isValid() { + // This should never happen. Callers should only be specifying the limit + // Name enums defined in this package. + return limit{}, fmt.Errorf("specified name enum %q, is invalid", name) + } + if bucketKey != "" { + // Check for override. + ol, ok := l.overrides[bucketKey] + if ok { + return ol, nil + } + } + dl, ok := l.defaults[name.EnumString()] + if ok { + return dl, nil + } + return limit{}, errLimitDisabled +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/limit_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/limit_test.go new file mode 100644 index 000000000..a783e8ce6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/limit_test.go @@ -0,0 +1,198 @@ +package ratelimits + +import ( + "os" + "testing" + "time" + + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/test" +) + +func TestParseOverrideNameId(t *testing.T) { + // 'enum:ipv4' + // Valid IPv4 address. + name, id, err := parseOverrideNameId(NewRegistrationsPerIPAddress.String() + ":10.0.0.1") + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, name, NewRegistrationsPerIPAddress) + test.AssertEquals(t, id, "10.0.0.1") + + // 'enum:ipv6range' + // Valid IPv6 address range. + name, id, err = parseOverrideNameId(NewRegistrationsPerIPv6Range.String() + ":2001:0db8:0000::/48") + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, name, NewRegistrationsPerIPv6Range) + test.AssertEquals(t, id, "2001:0db8:0000::/48") + + // Missing colon (this should never happen but we should avoid panicking). + _, _, err = parseOverrideNameId(NewRegistrationsPerIPAddress.String() + "10.0.0.1") + test.AssertError(t, err, "missing colon") + + // Empty string. + _, _, err = parseOverrideNameId("") + test.AssertError(t, err, "empty string") + + // Only a colon. + _, _, err = parseOverrideNameId(NewRegistrationsPerIPAddress.String() + ":") + test.AssertError(t, err, "only a colon") + + // Invalid enum. + _, _, err = parseOverrideNameId("lol:noexist") + test.AssertError(t, err, "invalid enum") +} + +func TestValidateLimit(t *testing.T) { + err := validateLimit(limit{Burst: 1, Count: 1, Period: config.Duration{Duration: time.Second}}) + test.AssertNotError(t, err, "valid limit") + + // All of the following are invalid. + for _, l := range []limit{ + {Burst: 0, Count: 1, Period: config.Duration{Duration: time.Second}}, + {Burst: 1, Count: 0, Period: config.Duration{Duration: time.Second}}, + {Burst: 1, Count: 1, Period: config.Duration{Duration: 0}}, + } { + err = validateLimit(l) + test.AssertError(t, err, "limit should be invalid") + } +} + +func TestLoadAndParseOverrideLimits(t *testing.T) { + // Load a single valid override limit with Id formatted as 'enum:RegId'. + l, err := loadAndParseOverrideLimits("testdata/working_override.yml") + test.AssertNotError(t, err, "valid single override limit") + expectKey := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "10.0.0.2") + test.AssertEquals(t, l[expectKey].Burst, int64(40)) + test.AssertEquals(t, l[expectKey].Count, int64(40)) + test.AssertEquals(t, l[expectKey].Period.Duration, time.Second) + + // Load single valid override limit with a 'domain' Id. + l, err = loadAndParseOverrideLimits("testdata/working_override_regid_domain.yml") + test.AssertNotError(t, err, "valid single override limit with Id of regId:domain") + expectKey = joinWithColon(CertificatesPerDomain.EnumString(), "example.com") + test.AssertEquals(t, l[expectKey].Burst, int64(40)) + test.AssertEquals(t, l[expectKey].Count, int64(40)) + test.AssertEquals(t, l[expectKey].Period.Duration, time.Second) + + // Load multiple valid override limits with 'regId' Ids. + l, err = loadAndParseOverrideLimits("testdata/working_overrides.yml") + test.AssertNotError(t, err, "multiple valid override limits") + expectKey1 := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "10.0.0.2") + test.AssertEquals(t, l[expectKey1].Burst, int64(40)) + test.AssertEquals(t, l[expectKey1].Count, int64(40)) + test.AssertEquals(t, l[expectKey1].Period.Duration, time.Second) + expectKey2 := joinWithColon(NewRegistrationsPerIPv6Range.EnumString(), "2001:0db8:0000::/48") + test.AssertEquals(t, l[expectKey2].Burst, int64(50)) + test.AssertEquals(t, l[expectKey2].Count, int64(50)) + test.AssertEquals(t, l[expectKey2].Period.Duration, time.Second*2) + + // Load multiple valid override limits with 'fqdnSet' Ids, as follows: + // - CertificatesPerFQDNSet:example.com + // - CertificatesPerFQDNSet:example.com,example.net + // - CertificatesPerFQDNSet:example.com,example.net,example.org + firstEntryKey, err := newFQDNSetBucketKey(CertificatesPerFQDNSet, []string{"example.com"}) + test.AssertNotError(t, err, "valid fqdnSet with one domain should not fail") + secondEntryKey, err := newFQDNSetBucketKey(CertificatesPerFQDNSet, []string{"example.com", "example.net"}) + test.AssertNotError(t, err, "valid fqdnSet with two domains should not fail") + thirdEntryKey, err := newFQDNSetBucketKey(CertificatesPerFQDNSet, []string{"example.com", "example.net", "example.org"}) + test.AssertNotError(t, err, "valid fqdnSet with three domains should not fail") + l, err = loadAndParseOverrideLimits("testdata/working_overrides_regid_fqdnset.yml") + test.AssertNotError(t, err, "multiple valid override limits with 'fqdnSet' Ids") + test.AssertEquals(t, l[firstEntryKey].Burst, int64(40)) + test.AssertEquals(t, l[firstEntryKey].Count, int64(40)) + test.AssertEquals(t, l[firstEntryKey].Period.Duration, time.Second) + test.AssertEquals(t, l[secondEntryKey].Burst, int64(50)) + test.AssertEquals(t, l[secondEntryKey].Count, int64(50)) + test.AssertEquals(t, l[secondEntryKey].Period.Duration, time.Second*2) + test.AssertEquals(t, l[thirdEntryKey].Burst, int64(60)) + test.AssertEquals(t, l[thirdEntryKey].Count, int64(60)) + test.AssertEquals(t, l[thirdEntryKey].Period.Duration, time.Second*3) + + // Path is empty string. + _, err = loadAndParseOverrideLimits("") + test.AssertError(t, err, "path is empty string") + test.Assert(t, os.IsNotExist(err), "path is empty string") + + // Path to file which does not exist. + _, err = loadAndParseOverrideLimits("testdata/file_does_not_exist.yml") + test.AssertError(t, err, "a file that does not exist ") + test.Assert(t, os.IsNotExist(err), "test file should not exist") + + // Burst cannot be 0. + _, err = loadAndParseOverrideLimits("testdata/busted_override_burst_0.yml") + test.AssertError(t, err, "single override limit with burst=0") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Id cannot be empty. + _, err = loadAndParseOverrideLimits("testdata/busted_override_empty_id.yml") + test.AssertError(t, err, "single override limit with empty id") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Name cannot be empty. + _, err = loadAndParseOverrideLimits("testdata/busted_override_empty_name.yml") + test.AssertError(t, err, "single override limit with empty name") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Name must be a string representation of a valid Name enumeration. + _, err = loadAndParseOverrideLimits("testdata/busted_override_invalid_name.yml") + test.AssertError(t, err, "single override limit with invalid name") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Multiple entries, second entry has a bad name. + _, err = loadAndParseOverrideLimits("testdata/busted_overrides_second_entry_bad_name.yml") + test.AssertError(t, err, "multiple override limits, second entry is bad") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Multiple entries, third entry has id of "lol", instead of an IPv4 address. + _, err = loadAndParseOverrideLimits("testdata/busted_overrides_third_entry_bad_id.yml") + test.AssertError(t, err, "multiple override limits, third entry has bad Id value") + test.Assert(t, !os.IsNotExist(err), "test file should exist") +} + +func TestLoadAndParseDefaultLimits(t *testing.T) { + // Load a single valid default limit. + l, err := loadAndParseDefaultLimits("testdata/working_default.yml") + test.AssertNotError(t, err, "valid single default limit") + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20)) + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20)) + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second) + + // Load multiple valid default limits. + l, err = loadAndParseDefaultLimits("testdata/working_defaults.yml") + test.AssertNotError(t, err, "multiple valid default limits") + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20)) + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20)) + test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second) + test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Burst, int64(30)) + test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Count, int64(30)) + test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Period.Duration, time.Second*2) + + // Path is empty string. + _, err = loadAndParseDefaultLimits("") + test.AssertError(t, err, "path is empty string") + test.Assert(t, os.IsNotExist(err), "path is empty string") + + // Path to file which does not exist. + _, err = loadAndParseDefaultLimits("testdata/file_does_not_exist.yml") + test.AssertError(t, err, "a file that does not exist") + test.Assert(t, os.IsNotExist(err), "test file should not exist") + + // Burst cannot be 0. + _, err = loadAndParseDefaultLimits("testdata/busted_default_burst_0.yml") + test.AssertError(t, err, "single default limit with burst=0") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Name cannot be empty. + _, err = loadAndParseDefaultLimits("testdata/busted_default_empty_name.yml") + test.AssertError(t, err, "single default limit with empty name") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Name must be a string representation of a valid Name enumeration. + _, err = loadAndParseDefaultLimits("testdata/busted_default_invalid_name.yml") + test.AssertError(t, err, "single default limit with invalid name") + test.Assert(t, !os.IsNotExist(err), "test file should exist") + + // Multiple entries, second entry has a bad name. + _, err = loadAndParseDefaultLimits("testdata/busted_defaults_second_entry_bad_name.yml") + test.AssertError(t, err, "multiple default limits, one is bad") + test.Assert(t, !os.IsNotExist(err), "test file should exist") +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/limiter.go b/third-party/github.com/letsencrypt/boulder/ratelimits/limiter.go new file mode 100644 index 000000000..557a83304 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/limiter.go @@ -0,0 +1,308 @@ +package ratelimits + +import ( + "context" + "errors" + "fmt" + "math" + "slices" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + // Allowed is used for rate limit metrics, it's the value of the 'decision' + // label when a request was allowed. + Allowed = "allowed" + + // Denied is used for rate limit metrics, it's the value of the 'decision' + // label when a request was denied. + Denied = "denied" +) + +// allowedDecision is an "allowed" *Decision that should be returned when a +// checked limit is found to be disabled. +var allowedDecision = &Decision{Allowed: true, Remaining: math.MaxInt64} + +// Limiter provides a high-level interface for rate limiting requests by +// utilizing a leaky bucket-style approach. +type Limiter struct { + // source is used to store buckets. It must be safe for concurrent use. + source source + clk clock.Clock + + spendLatency *prometheus.HistogramVec + overrideUsageGauge *prometheus.GaugeVec +} + +// NewLimiter returns a new *Limiter. The provided source must be safe for +// concurrent use. +func NewLimiter(clk clock.Clock, source source, stats prometheus.Registerer) (*Limiter, error) { + limiter := &Limiter{source: source, clk: clk} + limiter.spendLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "ratelimits_spend_latency", + Help: fmt.Sprintf("Latency of ratelimit checks labeled by limit=[name] and decision=[%s|%s], in seconds", Allowed, Denied), + // Exponential buckets ranging from 0.0005s to 3s. + Buckets: prometheus.ExponentialBuckets(0.0005, 3, 8), + }, []string{"limit", "decision"}) + stats.MustRegister(limiter.spendLatency) + + limiter.overrideUsageGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ratelimits_override_usage", + Help: "Proportion of override limit used, by limit name and bucket key.", + }, []string{"limit", "bucket_key"}) + stats.MustRegister(limiter.overrideUsageGauge) + + return limiter, nil +} + +type Decision struct { + // Allowed is true if the bucket possessed enough capacity to allow the + // request given the cost. + Allowed bool + + // Remaining is the number of requests the client is allowed to make before + // they're rate limited. + Remaining int64 + + // RetryIn is the duration the client MUST wait before they're allowed to + // make a request. + RetryIn time.Duration + + // ResetIn is the duration the bucket will take to refill to its maximum + // capacity, assuming no further requests are made. + ResetIn time.Duration + + // newTAT indicates the time at which the bucket will be full. It is the + // theoretical arrival time (TAT) of next request. It must be no more than + // (burst * (period / count)) in the future at any single point in time. + newTAT time.Time +} + +// Check DOES NOT deduct the cost of the request from the provided bucket's +// capacity. The returned *Decision indicates whether the capacity exists to +// satisfy the cost and represents the hypothetical state of the bucket IF the +// cost WERE to be deducted. If no bucket exists it will NOT be created. No +// state is persisted to the underlying datastore. +func (l *Limiter) Check(ctx context.Context, txn Transaction) (*Decision, error) { + if txn.allowOnly() { + return allowedDecision, nil + } + // Remove cancellation from the request context so that transactions are not + // interrupted by a client disconnect. + ctx = context.WithoutCancel(ctx) + tat, err := l.source.Get(ctx, txn.bucketKey) + if err != nil { + if !errors.Is(err, ErrBucketNotFound) { + return nil, err + } + // First request from this client. No need to initialize the bucket + // because this is a check, not a spend. A TAT of "now" is equivalent to + // a full bucket. + return maybeSpend(l.clk, txn.limit, l.clk.Now(), txn.cost), nil + } + return maybeSpend(l.clk, txn.limit, tat, txn.cost), nil +} + +// Spend attempts to deduct the cost from the provided bucket's capacity. The +// returned *Decision indicates whether the capacity existed to satisfy the cost +// and represents the current state of the bucket. If no bucket exists it WILL +// be created WITH the cost factored into its initial state. The new bucket +// state is persisted to the underlying datastore, if applicable, before +// returning. +func (l *Limiter) Spend(ctx context.Context, txn Transaction) (*Decision, error) { + return l.BatchSpend(ctx, []Transaction{txn}) +} + +func prepareBatch(txns []Transaction) ([]Transaction, []string, error) { + var bucketKeys []string + var transactions []Transaction + for _, txn := range txns { + if txn.allowOnly() { + // Ignore allow-only transactions. + continue + } + if slices.Contains(bucketKeys, txn.bucketKey) { + return nil, nil, fmt.Errorf("found duplicate bucket %q in batch", txn.bucketKey) + } + bucketKeys = append(bucketKeys, txn.bucketKey) + transactions = append(transactions, txn) + } + return transactions, bucketKeys, nil +} + +type batchDecision struct { + *Decision +} + +func newBatchDecision() *batchDecision { + return &batchDecision{ + Decision: &Decision{ + Allowed: true, + Remaining: math.MaxInt64, + }, + } +} + +func (d *batchDecision) merge(in *Decision) { + d.Allowed = d.Allowed && in.Allowed + d.Remaining = min(d.Remaining, in.Remaining) + d.RetryIn = max(d.RetryIn, in.RetryIn) + d.ResetIn = max(d.ResetIn, in.ResetIn) + if in.newTAT.After(d.newTAT) { + d.newTAT = in.newTAT + } +} + +// BatchSpend attempts to deduct the costs from the provided buckets' +// capacities. If applicable, new bucket states are persisted to the underlying +// datastore before returning. Non-existent buckets will be initialized WITH the +// cost factored into the initial state. The following rules are applied to +// merge the Decisions for each Transaction into a single batch Decision: +// - Allowed is true if all Transactions where check is true were allowed, +// - RetryIn and ResetIn are the largest values of each across all Decisions, +// - Remaining is the smallest value of each across all Decisions, and +// - Decisions resulting from spend-only Transactions are never merged. +func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision, error) { + batch, bucketKeys, err := prepareBatch(txns) + if err != nil { + return nil, err + } + if len(batch) == 0 { + // All Transactions were allow-only. + return allowedDecision, nil + } + + // Remove cancellation from the request context so that transactions are not + // interrupted by a client disconnect. + ctx = context.WithoutCancel(ctx) + tats, err := l.source.BatchGet(ctx, bucketKeys) + if err != nil { + return nil, err + } + + start := l.clk.Now() + batchDecision := newBatchDecision() + newTATs := make(map[string]time.Time) + + for _, txn := range batch { + tat, exists := tats[txn.bucketKey] + if !exists { + // First request from this client. + tat = l.clk.Now() + } + + d := maybeSpend(l.clk, txn.limit, tat, txn.cost) + + if txn.limit.isOverride() { + utilization := float64(txn.limit.Burst-d.Remaining) / float64(txn.limit.Burst) + l.overrideUsageGauge.WithLabelValues(txn.limit.name.String(), txn.limit.overrideKey).Set(utilization) + } + + if d.Allowed && (tat != d.newTAT) && txn.spend { + // New bucket state should be persisted. + newTATs[txn.bucketKey] = d.newTAT + } + + if !txn.spendOnly() { + batchDecision.merge(d) + } + } + + if batchDecision.Allowed { + err = l.source.BatchSet(ctx, newTATs) + if err != nil { + return nil, err + } + l.spendLatency.WithLabelValues("batch", Allowed).Observe(l.clk.Since(start).Seconds()) + } else { + l.spendLatency.WithLabelValues("batch", Denied).Observe(l.clk.Since(start).Seconds()) + } + return batchDecision.Decision, nil +} + +// Refund attempts to refund all of the cost to the capacity of the specified +// bucket. The returned *Decision indicates whether the refund was successful +// and represents the current state of the bucket. The new bucket state is +// persisted to the underlying datastore, if applicable, before returning. If no +// bucket exists it will NOT be created. Spend-only Transactions are assumed to +// be refundable. Check-only Transactions are never refunded. +// +// Note: The amount refunded cannot cause the bucket to exceed its maximum +// capacity. Partial refunds are allowed and are considered successful. For +// instance, if a bucket has a maximum capacity of 10 and currently has 5 +// requests remaining, a refund request of 7 will result in the bucket reaching +// its maximum capacity of 10, not 12. +func (l *Limiter) Refund(ctx context.Context, txn Transaction) (*Decision, error) { + return l.BatchRefund(ctx, []Transaction{txn}) +} + +// BatchRefund attempts to refund all or some of the costs to the provided +// buckets' capacities. Non-existent buckets will NOT be initialized. The new +// bucket state is persisted to the underlying datastore, if applicable, before +// returning. Spend-only Transactions are assumed to be refundable. Check-only +// Transactions are never refunded. The following rules are applied to merge the +// Decisions for each Transaction into a single batch Decision: +// - Allowed is true if all Transactions where check is true were allowed, +// - RetryIn and ResetIn are the largest values of each across all Decisions, +// - Remaining is the smallest value of each across all Decisions, and +// - Decisions resulting from spend-only Transactions are never merged. +func (l *Limiter) BatchRefund(ctx context.Context, txns []Transaction) (*Decision, error) { + batch, bucketKeys, err := prepareBatch(txns) + if err != nil { + return nil, err + } + if len(batch) == 0 { + // All Transactions were allow-only. + return allowedDecision, nil + } + + // Remove cancellation from the request context so that transactions are not + // interrupted by a client disconnect. + ctx = context.WithoutCancel(ctx) + tats, err := l.source.BatchGet(ctx, bucketKeys) + if err != nil { + return nil, err + } + + batchDecision := newBatchDecision() + newTATs := make(map[string]time.Time) + + for _, txn := range batch { + tat, exists := tats[txn.bucketKey] + if !exists { + // Ignore non-existent bucket. + continue + } + + var cost int64 + if !txn.checkOnly() { + cost = txn.cost + } + d := maybeRefund(l.clk, txn.limit, tat, cost) + batchDecision.merge(d) + if d.Allowed && tat != d.newTAT { + // New bucket state should be persisted. + newTATs[txn.bucketKey] = d.newTAT + } + } + + if len(newTATs) > 0 { + err = l.source.BatchSet(ctx, newTATs) + if err != nil { + return nil, err + } + } + return batchDecision.Decision, nil +} + +// Reset resets the specified bucket to its maximum capacity. The new bucket +// state is persisted to the underlying datastore before returning. +func (l *Limiter) Reset(ctx context.Context, bucketKey string) error { + // Remove cancellation from the request context so that transactions are not + // interrupted by a client disconnect. + ctx = context.WithoutCancel(ctx) + return l.source.Delete(ctx, bucketKey) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/limiter_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/limiter_test.go new file mode 100644 index 000000000..efec45432 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/limiter_test.go @@ -0,0 +1,459 @@ +package ratelimits + +import ( + "context" + "math/rand" + "net" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +// tenZeroZeroTwo is overridden in 'testdata/working_override.yml' to have +// higher burst and count values. +const tenZeroZeroTwo = "10.0.0.2" + +// newTestLimiter constructs a new limiter. +func newTestLimiter(t *testing.T, s source, clk clock.FakeClock) *Limiter { + l, err := NewLimiter(clk, s, metrics.NoopRegisterer) + test.AssertNotError(t, err, "should not error") + return l +} + +// newTestTransactionBuilder constructs a new *TransactionBuilder with the +// following configuration: +// - 'NewRegistrationsPerIPAddress' burst: 20 count: 20 period: 1s +// - 'NewRegistrationsPerIPAddress:10.0.0.2' burst: 40 count: 40 period: 1s +func newTestTransactionBuilder(t *testing.T) *TransactionBuilder { + c, err := NewTransactionBuilder("testdata/working_default.yml", "testdata/working_override.yml") + test.AssertNotError(t, err, "should not error") + return c +} + +func setup(t *testing.T) (context.Context, map[string]*Limiter, *TransactionBuilder, clock.FakeClock, string) { + testCtx := context.Background() + clk := clock.NewFake() + + // Generate a random IP address to avoid collisions during and between test + // runs. + randIP := make(net.IP, 4) + for i := range 4 { + randIP[i] = byte(rand.Intn(256)) + } + + // Construct a limiter for each source. + return testCtx, map[string]*Limiter{ + "inmem": newInmemTestLimiter(t, clk), + "redis": newRedisTestLimiter(t, clk), + }, newTestTransactionBuilder(t), clk, randIP.String() +} + +func TestLimiter_CheckWithLimitOverrides(t *testing.T) { + t.Parallel() + testCtx, limiters, txnBuilder, clk, testIP := setup(t) + for name, l := range limiters { + t.Run(name, func(t *testing.T) { + // Verify our overrideUsageGauge is being set correctly. 0.0 == 0% + // of the bucket has been consumed. + test.AssertMetricWithLabelsEquals(t, l.overrideUsageGauge, prometheus.Labels{ + "limit": NewRegistrationsPerIPAddress.String(), + "bucket_key": joinWithColon(NewRegistrationsPerIPAddress.EnumString(), tenZeroZeroTwo)}, 0) + + overriddenBucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, net.ParseIP(tenZeroZeroTwo)) + test.AssertNotError(t, err, "should not error") + overriddenLimit, err := txnBuilder.getLimit(NewRegistrationsPerIPAddress, overriddenBucketKey) + test.AssertNotError(t, err, "should not error") + + // Attempt to spend all 40 requests, this should succeed. + overriddenTxn40, err := newTransaction(overriddenLimit, overriddenBucketKey, 40) + test.AssertNotError(t, err, "txn should be valid") + d, err := l.Spend(testCtx, overriddenTxn40) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + + // Attempting to spend 1 more, this should fail. + overriddenTxn1, err := newTransaction(overriddenLimit, overriddenBucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Spend(testCtx, overriddenTxn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Verify our overrideUsageGauge is being set correctly. 1.0 == 100% + // of the bucket has been consumed. + test.AssertMetricWithLabelsEquals(t, l.overrideUsageGauge, prometheus.Labels{ + "limit_name": NewRegistrationsPerIPAddress.String(), + "bucket_key": joinWithColon(NewRegistrationsPerIPAddress.EnumString(), tenZeroZeroTwo)}, 1.0) + + // Verify our RetryIn is correct. 1 second == 1000 milliseconds and + // 1000/40 = 25 milliseconds per request. + test.AssertEquals(t, d.RetryIn, time.Millisecond*25) + + // Wait 50 milliseconds and try again. + clk.Add(d.RetryIn) + + // We should be allowed to spend 1 more request. + d, err = l.Spend(testCtx, overriddenTxn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Wait 1 second for a full bucket reset. + clk.Add(d.ResetIn) + + // Quickly spend 40 requests in a row. + for i := range 40 { + d, err = l.Spend(testCtx, overriddenTxn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(39-i)) + } + + // Attempting to spend 1 more, this should fail. + d, err = l.Spend(testCtx, overriddenTxn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Wait 1 second for a full bucket reset. + clk.Add(d.ResetIn) + + testIP := net.ParseIP(testIP) + normalBucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, testIP) + test.AssertNotError(t, err, "should not error") + normalLimit, err := txnBuilder.getLimit(NewRegistrationsPerIPAddress, normalBucketKey) + test.AssertNotError(t, err, "should not error") + + // Spend the same bucket but in a batch with bucket subject to + // default limits. This should succeed, but the decision should + // reflect that of the default bucket. + defaultTxn1, err := newTransaction(normalLimit, normalBucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.BatchSpend(testCtx, []Transaction{overriddenTxn1, defaultTxn1}) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Refund quota to both buckets. This should succeed, but the + // decision should reflect that of the default bucket. + d, err = l.BatchRefund(testCtx, []Transaction{overriddenTxn1, defaultTxn1}) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(20)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Once more. + d, err = l.BatchSpend(testCtx, []Transaction{overriddenTxn1, defaultTxn1}) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Reset between tests. + err = l.Reset(testCtx, overriddenBucketKey) + test.AssertNotError(t, err, "should not error") + err = l.Reset(testCtx, normalBucketKey) + test.AssertNotError(t, err, "should not error") + + // Spend the same bucket but in a batch with a Transaction that is + // check-only. This should succeed, but the decision should reflect + // that of the default bucket. + defaultCheckOnlyTxn1, err := newCheckOnlyTransaction(normalLimit, normalBucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.BatchSpend(testCtx, []Transaction{overriddenTxn1, defaultCheckOnlyTxn1}) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(19)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Check the remaining quota of the overridden bucket. + overriddenCheckOnlyTxn0, err := newCheckOnlyTransaction(overriddenLimit, overriddenBucketKey, 0) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Check(testCtx, overriddenCheckOnlyTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(39)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*25) + + // Check the remaining quota of the default bucket. + defaultTxn0, err := newTransaction(normalLimit, normalBucketKey, 0) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Check(testCtx, defaultTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(20)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + + // Spend the same bucket but in a batch with a Transaction that is + // spend-only. This should succeed, but the decision should reflect + // that of the overridden bucket. + defaultSpendOnlyTxn1, err := newSpendOnlyTransaction(normalLimit, normalBucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.BatchSpend(testCtx, []Transaction{overriddenTxn1, defaultSpendOnlyTxn1}) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(38)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Check the remaining quota of the overridden bucket. + d, err = l.Check(testCtx, overriddenCheckOnlyTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(38)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Check the remaining quota of the default bucket. + d, err = l.Check(testCtx, defaultTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(19)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Once more, but in now the spend-only Transaction will attempt to + // spend 20 requests. The spend-only Transaction should fail, but + // the decision should reflect that of the overridden bucket. + defaultSpendOnlyTxn20, err := newSpendOnlyTransaction(normalLimit, normalBucketKey, 20) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.BatchSpend(testCtx, []Transaction{overriddenTxn1, defaultSpendOnlyTxn20}) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(37)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*75) + + // Check the remaining quota of the overridden bucket. + d, err = l.Check(testCtx, overriddenCheckOnlyTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(37)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*75) + + // Check the remaining quota of the default bucket. + d, err = l.Check(testCtx, defaultTxn0) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(19)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + + // Reset between tests. + err = l.Reset(testCtx, overriddenBucketKey) + test.AssertNotError(t, err, "should not error") + }) + } +} + +func TestLimiter_InitializationViaCheckAndSpend(t *testing.T) { + t.Parallel() + testCtx, limiters, txnBuilder, _, testIP := setup(t) + for name, l := range limiters { + t.Run(name, func(t *testing.T) { + bucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, net.ParseIP(testIP)) + test.AssertNotError(t, err, "should not error") + limit, err := txnBuilder.getLimit(NewRegistrationsPerIPAddress, bucketKey) + test.AssertNotError(t, err, "should not error") + + // Check on an empty bucket should return the theoretical next state + // of that bucket if the cost were spent. + txn1, err := newTransaction(limit, bucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err := l.Check(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19)) + // Verify our ResetIn timing is correct. 1 second == 1000 + // milliseconds and 1000/20 = 50 milliseconds per request. + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + + // However, that cost should not be spent yet, a 0 cost check should + // tell us that we actually have 20 remaining. + txn0, err := newTransaction(limit, bucketKey, 0) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Check(testCtx, txn0) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(20)) + test.AssertEquals(t, d.ResetIn, time.Duration(0)) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + + // Reset our bucket. + err = l.Reset(testCtx, bucketKey) + test.AssertNotError(t, err, "should not error") + + // Similar to above, but we'll use Spend() to actually initialize + // the bucket. Spend should return the same result as Check. + d, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19)) + // Verify our ResetIn timing is correct. 1 second == 1000 + // milliseconds and 1000/20 = 50 milliseconds per request. + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + + // However, that cost should not be spent yet, a 0 cost check should + // tell us that we actually have 19 remaining. + d, err = l.Check(testCtx, txn0) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19)) + // Verify our ResetIn is correct. 1 second == 1000 milliseconds and + // 1000/20 = 50 milliseconds per request. + test.AssertEquals(t, d.ResetIn, time.Millisecond*50) + test.AssertEquals(t, d.RetryIn, time.Duration(0)) + }) + } +} + +func TestLimiter_DefaultLimits(t *testing.T) { + t.Parallel() + testCtx, limiters, txnBuilder, clk, testIP := setup(t) + for name, l := range limiters { + t.Run(name, func(t *testing.T) { + bucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, net.ParseIP(testIP)) + test.AssertNotError(t, err, "should not error") + limit, err := txnBuilder.getLimit(NewRegistrationsPerIPAddress, bucketKey) + test.AssertNotError(t, err, "should not error") + + // Attempt to spend all 20 requests, this should succeed. + txn20, err := newTransaction(limit, bucketKey, 20) + test.AssertNotError(t, err, "txn should be valid") + d, err := l.Spend(testCtx, txn20) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Attempting to spend 1 more, this should fail. + txn1, err := newTransaction(limit, bucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Verify our ResetIn is correct. 1 second == 1000 milliseconds and + // 1000/20 = 50 milliseconds per request. + test.AssertEquals(t, d.RetryIn, time.Millisecond*50) + + // Wait 50 milliseconds and try again. + clk.Add(d.RetryIn) + + // We should be allowed to spend 1 more request. + d, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Wait 1 second for a full bucket reset. + clk.Add(d.ResetIn) + + // Quickly spend 20 requests in a row. + for i := range 20 { + d, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(19-i)) + } + + // Attempting to spend 1 more, this should fail. + d, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + }) + } +} + +func TestLimiter_RefundAndReset(t *testing.T) { + t.Parallel() + testCtx, limiters, txnBuilder, clk, testIP := setup(t) + for name, l := range limiters { + t.Run(name, func(t *testing.T) { + bucketKey, err := newIPAddressBucketKey(NewRegistrationsPerIPAddress, net.ParseIP(testIP)) + test.AssertNotError(t, err, "should not error") + limit, err := txnBuilder.getLimit(NewRegistrationsPerIPAddress, bucketKey) + test.AssertNotError(t, err, "should not error") + + // Attempt to spend all 20 requests, this should succeed. + txn20, err := newTransaction(limit, bucketKey, 20) + test.AssertNotError(t, err, "txn should be valid") + d, err := l.Spend(testCtx, txn20) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Refund 10 requests. + txn10, err := newTransaction(limit, bucketKey, 10) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Refund(testCtx, txn10) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, d.Remaining, int64(10)) + + // Spend 10 requests, this should succeed. + d, err = l.Spend(testCtx, txn10) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + err = l.Reset(testCtx, bucketKey) + test.AssertNotError(t, err, "should not error") + + // Attempt to spend 20 more requests, this should succeed. + d, err = l.Spend(testCtx, txn20) + test.AssertNotError(t, err, "should not error") + test.Assert(t, d.Allowed, "should be allowed") + test.AssertEquals(t, d.Remaining, int64(0)) + test.AssertEquals(t, d.ResetIn, time.Second) + + // Reset to full. + clk.Add(d.ResetIn) + + // Refund 1 requests above our limit, this should fail. + txn1, err := newTransaction(limit, bucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + d, err = l.Refund(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + test.Assert(t, !d.Allowed, "should not be allowed") + test.AssertEquals(t, d.Remaining, int64(20)) + + // Spend so we can refund. + _, err = l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + + // Refund a spendOnly Transaction, which should succeed. + spendOnlyTxn1, err := newSpendOnlyTransaction(limit, bucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + _, err = l.Refund(testCtx, spendOnlyTxn1) + test.AssertNotError(t, err, "should not error") + + // Spend so we can refund. + expectedDecision, err := l.Spend(testCtx, txn1) + test.AssertNotError(t, err, "should not error") + + // Refund a checkOnly Transaction, which shouldn't error but should + // return the same TAT as the previous spend. + checkOnlyTxn1, err := newCheckOnlyTransaction(limit, bucketKey, 1) + test.AssertNotError(t, err, "txn should be valid") + newDecision, err := l.Refund(testCtx, checkOnlyTxn1) + test.AssertNotError(t, err, "should not error") + test.AssertEquals(t, newDecision.newTAT, expectedDecision.newTAT) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/names.go b/third-party/github.com/letsencrypt/boulder/ratelimits/names.go new file mode 100644 index 000000000..fdfd8e81e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/names.go @@ -0,0 +1,258 @@ +package ratelimits + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/letsencrypt/boulder/policy" +) + +// Name is an enumeration of all rate limit names. It is used to intern rate +// limit names as strings and to provide a type-safe way to refer to rate +// limits. +// +// IMPORTANT: If you add a new limit Name, you MUST add: +// - it to the nameToString mapping, +// - an entry for it in the validateIdForName(), and +// - provide the appropriate constructors in bucket.go. +type Name int + +const ( + // Unknown is the zero value of Name and is used to indicate an unknown + // limit name. + Unknown Name = iota + + // NewRegistrationsPerIPAddress uses bucket key 'enum:ipAddress'. + NewRegistrationsPerIPAddress + + // NewRegistrationsPerIPv6Range uses bucket key 'enum:ipv6rangeCIDR'. The + // address range must be a /48. RFC 3177, which was published in 2001, + // advised operators to allocate a /48 block of IPv6 addresses for most end + // sites. RFC 6177, which was published in 2011 and obsoletes RFC 3177, + // advises allocating a smaller /56 block. We've chosen to use the larger + // /48 block for our IPv6 rate limiting. See: + // 1. https://tools.ietf.org/html/rfc3177#section-3 + // 2. https://datatracker.ietf.org/doc/html/rfc6177#section-2 + NewRegistrationsPerIPv6Range + + // NewOrdersPerAccount uses bucket key 'enum:regId'. + NewOrdersPerAccount + + // FailedAuthorizationsPerDomainPerAccount uses two different bucket keys + // depending on the context: + // - When referenced in an overrides file: uses bucket key 'enum:regId', + // where regId is the ACME registration Id of the account. + // - When referenced in a transaction: uses bucket key 'enum:regId:domain', + // where regId is the ACME registration Id of the account and domain is a + // domain name in the certificate. + FailedAuthorizationsPerDomainPerAccount + + // CertificatesPerDomain uses bucket key 'enum:domain', where domain is a + // domain name in the certificate. + CertificatesPerDomain + + // CertificatesPerDomainPerAccount is only used for per-account overrides to + // the CertificatesPerDomain rate limit. If this limit is referenced in the + // default limits file, it will be ignored. It uses two different bucket + // keys depending on the context: + // - When referenced in an overrides file: uses bucket key 'enum:regId', + // where regId is the ACME registration Id of the account. + // - When referenced in a transaction: uses bucket key 'enum:regId:domain', + // where regId is the ACME registration Id of the account and domain is a + // domain name in the certificate. + // + // When overrides to the CertificatesPerDomainPerAccount are configured for a + // subscriber, the cost: + // - MUST be consumed from each CertificatesPerDomainPerAccount bucket and + // - SHOULD be consumed from each CertificatesPerDomain bucket, if possible. + CertificatesPerDomainPerAccount + + // CertificatesPerFQDNSet uses bucket key 'enum:fqdnSet', where fqdnSet is a + // hashed set of unique eTLD+1 domain names in the certificate. + // + // Note: When this is referenced in an overrides file, the fqdnSet MUST be + // passed as a comma-separated list of domain names. + CertificatesPerFQDNSet +) + +// isValid returns true if the Name is a valid rate limit name. +func (n Name) isValid() bool { + return n > Unknown && n < Name(len(nameToString)) +} + +// String returns the string representation of the Name. It allows Name to +// satisfy the fmt.Stringer interface. +func (n Name) String() string { + if !n.isValid() { + return nameToString[Unknown] + } + return nameToString[n] +} + +// EnumString returns the string representation of the Name enumeration. +func (n Name) EnumString() string { + if !n.isValid() { + return nameToString[Unknown] + } + return strconv.Itoa(int(n)) +} + +// nameToString is a map of Name values to string names. +var nameToString = map[Name]string{ + Unknown: "Unknown", + NewRegistrationsPerIPAddress: "NewRegistrationsPerIPAddress", + NewRegistrationsPerIPv6Range: "NewRegistrationsPerIPv6Range", + NewOrdersPerAccount: "NewOrdersPerAccount", + FailedAuthorizationsPerDomainPerAccount: "FailedAuthorizationsPerDomainPerAccount", + CertificatesPerDomain: "CertificatesPerDomain", + CertificatesPerDomainPerAccount: "CertificatesPerDomainPerAccount", + CertificatesPerFQDNSet: "CertificatesPerFQDNSet", +} + +// validIPAddress validates that the provided string is a valid IP address. +func validIPAddress(id string) error { + ip := net.ParseIP(id) + if ip == nil { + return fmt.Errorf("invalid IP address, %q must be an IP address", id) + } + return nil +} + +// validIPv6RangeCIDR validates that the provided string is formatted is an IPv6 +// CIDR range with a /48 mask. +func validIPv6RangeCIDR(id string) error { + _, ipNet, err := net.ParseCIDR(id) + if err != nil { + return fmt.Errorf( + "invalid CIDR, %q must be an IPv6 CIDR range", id) + } + ones, _ := ipNet.Mask.Size() + if ones != 48 { + // This also catches the case where the range is an IPv4 CIDR, since an + // IPv4 CIDR can't have a /48 subnet mask - the maximum is /32. + return fmt.Errorf( + "invalid CIDR, %q must be /48", id) + } + return nil +} + +// validateRegId validates that the provided string is a valid ACME regId. +func validateRegId(id string) error { + _, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return fmt.Errorf("invalid regId, %q must be an ACME registration Id", id) + } + return nil +} + +// validateDomain validates that the provided string is formatted 'domain', +// where domain is a domain name. +func validateDomain(id string) error { + err := policy.ValidDomain(id) + if err != nil { + return fmt.Errorf("invalid domain, %q must be formatted 'domain': %w", id, err) + } + return nil +} + +// validateRegIdDomain validates that the provided string is formatted +// 'regId:domain', where regId is an ACME registration Id and domain is a domain +// name. +func validateRegIdDomain(id string) error { + regIdDomain := strings.Split(id, ":") + if len(regIdDomain) != 2 { + return fmt.Errorf( + "invalid regId:domain, %q must be formatted 'regId:domain'", id) + } + err := validateRegId(regIdDomain[0]) + if err != nil { + return fmt.Errorf( + "invalid regId, %q must be formatted 'regId:domain'", id) + } + err = policy.ValidDomain(regIdDomain[1]) + if err != nil { + return fmt.Errorf( + "invalid domain, %q must be formatted 'regId:domain': %w", id, err) + } + return nil +} + +// validateFQDNSet validates that the provided string is formatted 'fqdnSet', +// where fqdnSet is a comma-separated list of domain names. +func validateFQDNSet(id string) error { + domains := strings.Split(id, ",") + if len(domains) == 0 { + return fmt.Errorf( + "invalid fqdnSet, %q must be formatted 'fqdnSet'", id) + } + return policy.WellFormedDomainNames(domains) +} + +func validateIdForName(name Name, id string) error { + switch name { + case NewRegistrationsPerIPAddress: + // 'enum:ipaddress' + return validIPAddress(id) + + case NewRegistrationsPerIPv6Range: + // 'enum:ipv6rangeCIDR' + return validIPv6RangeCIDR(id) + + case NewOrdersPerAccount: + // 'enum:regId' + return validateRegId(id) + + case FailedAuthorizationsPerDomainPerAccount: + if strings.Contains(id, ":") { + // 'enum:regId:domain' for transaction + return validateRegIdDomain(id) + } else { + // 'enum:regId' for overrides + return validateRegId(id) + } + + case CertificatesPerDomainPerAccount: + if strings.Contains(id, ":") { + // 'enum:regId:domain' for transaction + return validateRegIdDomain(id) + } else { + // 'enum:regId' for overrides + return validateRegId(id) + } + + case CertificatesPerDomain: + // 'enum:domain' + return validateDomain(id) + + case CertificatesPerFQDNSet: + // 'enum:fqdnSet' + return validateFQDNSet(id) + + case Unknown: + fallthrough + + default: + // This should never happen. + return fmt.Errorf("unknown limit enum %q", name) + } +} + +// stringToName is a map of string names to Name values. +var stringToName = func() map[string]Name { + m := make(map[string]Name, len(nameToString)) + for k, v := range nameToString { + m[v] = k + } + return m +}() + +// limitNames is a slice of all rate limit names. +var limitNames = func() []string { + names := make([]string, len(nameToString)) + for _, v := range nameToString { + names = append(names, v) + } + return names +}() diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/names_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/names_test.go new file mode 100644 index 000000000..a12b069e2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/names_test.go @@ -0,0 +1,207 @@ +package ratelimits + +import ( + "fmt" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestNameIsValid(t *testing.T) { + t.Parallel() + type args struct { + name Name + } + tests := []struct { + name string + args args + want bool + }{ + {name: "Unknown", args: args{name: Unknown}, want: false}, + {name: "9001", args: args{name: 9001}, want: false}, + {name: "NewRegistrationsPerIPAddress", args: args{name: NewRegistrationsPerIPAddress}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.args.name.isValid() + test.AssertEquals(t, tt.want, got) + }) + } +} + +func TestValidateIdForName(t *testing.T) { + t.Parallel() + + testCases := []struct { + limit Name + desc string + id string + err string + }{ + { + limit: NewRegistrationsPerIPAddress, + desc: "valid IPv4 address", + id: "10.0.0.1", + }, + { + limit: NewRegistrationsPerIPAddress, + desc: "valid IPv6 address", + id: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + }, + { + limit: NewRegistrationsPerIPAddress, + desc: "empty string", + id: "", + err: "must be an IP address", + }, + { + limit: NewRegistrationsPerIPAddress, + desc: "one space", + id: " ", + err: "must be an IP address", + }, + { + limit: NewRegistrationsPerIPAddress, + desc: "invalid IPv4 address", + id: "10.0.0.9000", + err: "must be an IP address", + }, + { + limit: NewRegistrationsPerIPAddress, + desc: "invalid IPv6 address", + id: "2001:0db8:85a3:0000:0000:8a2e:0370:7334:9000", + err: "must be an IP address", + }, + { + limit: NewRegistrationsPerIPv6Range, + desc: "valid IPv6 address range", + id: "2001:0db8:0000::/48", + }, + { + limit: NewRegistrationsPerIPv6Range, + desc: "invalid IPv6 CIDR range", + id: "2001:0db8:0000::/128", + err: "must be /48", + }, + { + limit: NewRegistrationsPerIPv6Range, + desc: "invalid IPv6 CIDR", + id: "2001:0db8:0000::/48/48", + err: "must be an IPv6 CIDR range", + }, + { + limit: NewRegistrationsPerIPv6Range, + desc: "IPv4 CIDR when we expect IPv6 CIDR range", + id: "10.0.0.0/16", + err: "must be /48", + }, + { + limit: NewOrdersPerAccount, + desc: "valid regId", + id: "1234567890", + }, + { + limit: NewOrdersPerAccount, + desc: "invalid regId", + id: "lol", + err: "must be an ACME registration Id", + }, + { + limit: FailedAuthorizationsPerDomainPerAccount, + desc: "transaction: valid regId and domain", + id: "12345:example.com", + }, + { + limit: FailedAuthorizationsPerDomainPerAccount, + desc: "transaction: invalid regId", + id: "12ea5:example.com", + err: "invalid regId", + }, + { + limit: FailedAuthorizationsPerDomainPerAccount, + desc: "transaction: invalid domain", + id: "12345:examplecom", + err: "name needs at least one dot", + }, + { + limit: FailedAuthorizationsPerDomainPerAccount, + desc: "override: valid regId", + id: "12345", + }, + { + limit: FailedAuthorizationsPerDomainPerAccount, + desc: "override: invalid regId", + id: "12ea5", + err: "invalid regId", + }, + { + limit: CertificatesPerDomainPerAccount, + desc: "transaction: valid regId and domain", + id: "12345:example.com", + }, + { + limit: CertificatesPerDomainPerAccount, + desc: "transaction: invalid regId", + id: "12ea5:example.com", + err: "invalid regId", + }, + { + limit: CertificatesPerDomainPerAccount, + desc: "transaction: invalid domain", + id: "12345:examplecom", + err: "name needs at least one dot", + }, + { + limit: CertificatesPerDomainPerAccount, + desc: "override: valid regId", + id: "12345", + }, + { + limit: CertificatesPerDomainPerAccount, + desc: "override: invalid regId", + id: "12ea5", + err: "invalid regId", + }, + { + limit: CertificatesPerDomain, + desc: "valid domain", + id: "example.com", + }, + { + limit: CertificatesPerDomain, + desc: "malformed domain", + id: "example:.com", + err: "name contains an invalid character", + }, + { + limit: CertificatesPerDomain, + desc: "empty domain", + id: "", + err: "name is empty", + }, + { + limit: CertificatesPerFQDNSet, + desc: "valid fqdnSet containing a single domain", + id: "example.com", + }, + { + limit: CertificatesPerFQDNSet, + desc: "valid fqdnSet containing multiple domains", + id: "example.com,example.org", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("%s/%s", tc.limit, tc.desc), func(t *testing.T) { + t.Parallel() + err := validateIdForName(tc.limit, tc.id) + if tc.err != "" { + test.AssertError(t, err, "should have failed") + test.AssertContains(t, err.Error(), tc.err) + } else { + test.AssertNotError(t, err, "should have succeeded") + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/source.go b/third-party/github.com/letsencrypt/boulder/ratelimits/source.go new file mode 100644 index 000000000..77f43b739 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/source.go @@ -0,0 +1,97 @@ +package ratelimits + +import ( + "context" + "fmt" + "sync" + "time" +) + +// ErrBucketNotFound indicates that the bucket was not found. +var ErrBucketNotFound = fmt.Errorf("bucket not found") + +// source is an interface for creating and modifying TATs. +type source interface { + // BatchSet stores the TATs at the specified bucketKeys (formatted as + // 'name:id'). Implementations MUST ensure non-blocking operations by + // either: + // a) applying a deadline or timeout to the context WITHIN the method, or + // b) guaranteeing the operation will not block indefinitely (e.g. via + // the underlying storage client implementation). + BatchSet(ctx context.Context, bucketKeys map[string]time.Time) error + + // Get retrieves the TAT associated with the specified bucketKey (formatted + // as 'name:id'). Implementations MUST ensure non-blocking operations by + // either: + // a) applying a deadline or timeout to the context WITHIN the method, or + // b) guaranteeing the operation will not block indefinitely (e.g. via + // the underlying storage client implementation). + Get(ctx context.Context, bucketKey string) (time.Time, error) + + // BatchGet retrieves the TATs associated with the specified bucketKeys + // (formatted as 'name:id'). Implementations MUST ensure non-blocking + // operations by either: + // a) applying a deadline or timeout to the context WITHIN the method, or + // b) guaranteeing the operation will not block indefinitely (e.g. via + // the underlying storage client implementation). + BatchGet(ctx context.Context, bucketKeys []string) (map[string]time.Time, error) + + // Delete removes the TAT associated with the specified bucketKey (formatted + // as 'name:id'). Implementations MUST ensure non-blocking operations by + // either: + // a) applying a deadline or timeout to the context WITHIN the method, or + // b) guaranteeing the operation will not block indefinitely (e.g. via + // the underlying storage client implementation). + Delete(ctx context.Context, bucketKey string) error +} + +// inmem is an in-memory implementation of the source interface used for +// testing. +type inmem struct { + sync.RWMutex + m map[string]time.Time +} + +func newInmem() *inmem { + return &inmem{m: make(map[string]time.Time)} +} + +func (in *inmem) BatchSet(_ context.Context, bucketKeys map[string]time.Time) error { + in.Lock() + defer in.Unlock() + for k, v := range bucketKeys { + in.m[k] = v + } + return nil +} + +func (in *inmem) Get(_ context.Context, bucketKey string) (time.Time, error) { + in.RLock() + defer in.RUnlock() + tat, ok := in.m[bucketKey] + if !ok { + return time.Time{}, ErrBucketNotFound + } + return tat, nil +} + +func (in *inmem) BatchGet(_ context.Context, bucketKeys []string) (map[string]time.Time, error) { + in.RLock() + defer in.RUnlock() + tats := make(map[string]time.Time, len(bucketKeys)) + for _, k := range bucketKeys { + tat, ok := in.m[k] + if !ok { + tats[k] = time.Time{} + } + tats[k] = tat + } + return tats, nil +} + +func (in *inmem) Delete(_ context.Context, bucketKey string) error { + in.Lock() + defer in.Unlock() + delete(in.m, bucketKey) + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis.go b/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis.go new file mode 100644 index 000000000..2c807c9d4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis.go @@ -0,0 +1,179 @@ +package ratelimits + +import ( + "context" + "errors" + "net" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" +) + +// Compile-time check that RedisSource implements the source interface. +var _ source = (*RedisSource)(nil) + +// RedisSource is a ratelimits source backed by sharded Redis. +type RedisSource struct { + client *redis.Ring + clk clock.Clock + latency *prometheus.HistogramVec +} + +// NewRedisSource returns a new Redis backed source using the provided +// *redis.Ring client. +func NewRedisSource(client *redis.Ring, clk clock.Clock, stats prometheus.Registerer) *RedisSource { + latency := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ratelimits_latency", + Help: "Histogram of Redis call latencies labeled by call=[set|get|delete|ping] and result=[success|error]", + // Exponential buckets ranging from 0.0005s to 3s. + Buckets: prometheus.ExponentialBucketsRange(0.0005, 3, 8), + }, + []string{"call", "result"}, + ) + stats.MustRegister(latency) + + return &RedisSource{ + client: client, + clk: clk, + latency: latency, + } +} + +// resultForError returns a string representing the result of the operation +// based on the provided error. +func resultForError(err error) string { + if errors.Is(redis.Nil, err) { + // Bucket key does not exist. + return "notFound" + } else if errors.Is(err, context.DeadlineExceeded) { + // Client read or write deadline exceeded. + return "deadlineExceeded" + } else if errors.Is(err, context.Canceled) { + // Caller canceled the operation. + return "canceled" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + // Dialer timed out connecting to Redis. + return "timeout" + } + var redisErr redis.Error + if errors.Is(err, redisErr) { + // An internal error was returned by the Redis server. + return "redisError" + } + return "failed" +} + +// BatchSet stores TATs at the specified bucketKeys using a pipelined Redis +// Transaction in order to reduce the number of round-trips to each Redis shard. +// An error is returned if the operation failed and nil otherwise. +func (r *RedisSource) BatchSet(ctx context.Context, buckets map[string]time.Time) error { + start := r.clk.Now() + + pipeline := r.client.Pipeline() + for bucketKey, tat := range buckets { + pipeline.Set(ctx, bucketKey, tat.UTC().UnixNano(), 0) + } + _, err := pipeline.Exec(ctx) + if err != nil { + r.latency.With(prometheus.Labels{"call": "batchset", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + return err + } + + r.latency.With(prometheus.Labels{"call": "batchset", "result": "success"}).Observe(time.Since(start).Seconds()) + return nil +} + +// Get retrieves the TAT at the specified bucketKey. An error is returned if the +// operation failed and nil otherwise. If the bucketKey does not exist, +// ErrBucketNotFound is returned. +func (r *RedisSource) Get(ctx context.Context, bucketKey string) (time.Time, error) { + start := r.clk.Now() + + tatNano, err := r.client.Get(ctx, bucketKey).Int64() + if err != nil { + if errors.Is(err, redis.Nil) { + // Bucket key does not exist. + r.latency.With(prometheus.Labels{"call": "get", "result": "notFound"}).Observe(time.Since(start).Seconds()) + return time.Time{}, ErrBucketNotFound + } + r.latency.With(prometheus.Labels{"call": "get", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + return time.Time{}, err + } + + r.latency.With(prometheus.Labels{"call": "get", "result": "success"}).Observe(time.Since(start).Seconds()) + return time.Unix(0, tatNano).UTC(), nil +} + +// BatchGet retrieves the TATs at the specified bucketKeys using a pipelined +// Redis Transaction in order to reduce the number of round-trips to each Redis +// shard. An error is returned if the operation failed and nil otherwise. If a +// bucketKey does not exist, it WILL NOT be included in the returned map. +func (r *RedisSource) BatchGet(ctx context.Context, bucketKeys []string) (map[string]time.Time, error) { + start := r.clk.Now() + + pipeline := r.client.Pipeline() + for _, bucketKey := range bucketKeys { + pipeline.Get(ctx, bucketKey) + } + results, err := pipeline.Exec(ctx) + if err != nil { + r.latency.With(prometheus.Labels{"call": "batchget", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + if !errors.Is(err, redis.Nil) { + return nil, err + } + } + + tats := make(map[string]time.Time, len(bucketKeys)) + for i, result := range results { + tatNano, err := result.(*redis.StringCmd).Int64() + if err != nil { + if errors.Is(err, redis.Nil) { + // Bucket key does not exist. + continue + } + r.latency.With(prometheus.Labels{"call": "batchget", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + return nil, err + } + tats[bucketKeys[i]] = time.Unix(0, tatNano).UTC() + } + + r.latency.With(prometheus.Labels{"call": "batchget", "result": "success"}).Observe(time.Since(start).Seconds()) + return tats, nil +} + +// Delete deletes the TAT at the specified bucketKey ('name:id'). It returns an +// error if the operation failed and nil otherwise. A nil return value does not +// indicate that the bucketKey existed. +func (r *RedisSource) Delete(ctx context.Context, bucketKey string) error { + start := r.clk.Now() + + err := r.client.Del(ctx, bucketKey).Err() + if err != nil { + r.latency.With(prometheus.Labels{"call": "delete", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + return err + } + + r.latency.With(prometheus.Labels{"call": "delete", "result": "success"}).Observe(time.Since(start).Seconds()) + return nil +} + +// Ping checks that each shard of the *redis.Ring is reachable using the PING +// command. It returns an error if any shard is unreachable and nil otherwise. +func (r *RedisSource) Ping(ctx context.Context) error { + start := r.clk.Now() + + err := r.client.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error { + return shard.Ping(ctx).Err() + }) + if err != nil { + r.latency.With(prometheus.Labels{"call": "ping", "result": resultForError(err)}).Observe(time.Since(start).Seconds()) + return err + } + r.latency.With(prometheus.Labels{"call": "ping", "result": "success"}).Observe(time.Since(start).Seconds()) + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis_test.go new file mode 100644 index 000000000..11ed27158 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/source_redis_test.go @@ -0,0 +1,105 @@ +package ratelimits + +import ( + "context" + "testing" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" + + "github.com/jmhodges/clock" + "github.com/redis/go-redis/v9" +) + +func newTestRedisSource(clk clock.FakeClock, addrs map[string]string) *RedisSource { + CACertFile := "../test/certs/ipki/minica.pem" + CertFile := "../test/certs/ipki/localhost/cert.pem" + KeyFile := "../test/certs/ipki/localhost/key.pem" + tlsConfig := cmd.TLSConfig{ + CACertFile: CACertFile, + CertFile: CertFile, + KeyFile: KeyFile, + } + tlsConfig2, err := tlsConfig.Load(metrics.NoopRegisterer) + if err != nil { + panic(err) + } + + client := redis.NewRing(&redis.RingOptions{ + Addrs: addrs, + Username: "unittest-rw", + Password: "824968fa490f4ecec1e52d5e34916bdb60d45f8d", + TLSConfig: tlsConfig2, + }) + return NewRedisSource(client, clk, metrics.NoopRegisterer) +} + +func newRedisTestLimiter(t *testing.T, clk clock.FakeClock) *Limiter { + return newTestLimiter(t, newTestRedisSource(clk, map[string]string{ + "shard1": "10.33.33.4:4218", + "shard2": "10.33.33.5:4218", + }), clk) +} + +func TestRedisSource_Ping(t *testing.T) { + clk := clock.NewFake() + workingSource := newTestRedisSource(clk, map[string]string{ + "shard1": "10.33.33.4:4218", + "shard2": "10.33.33.5:4218", + }) + + err := workingSource.Ping(context.Background()) + test.AssertNotError(t, err, "Ping should not error") + + missingFirstShardSource := newTestRedisSource(clk, map[string]string{ + "shard1": "10.33.33.4:1337", + "shard2": "10.33.33.5:4218", + }) + + err = missingFirstShardSource.Ping(context.Background()) + test.AssertError(t, err, "Ping should not error") + + missingSecondShardSource := newTestRedisSource(clk, map[string]string{ + "shard1": "10.33.33.4:4218", + "shard2": "10.33.33.5:1337", + }) + + err = missingSecondShardSource.Ping(context.Background()) + test.AssertError(t, err, "Ping should not error") +} + +func TestRedisSource_BatchSetAndGet(t *testing.T) { + clk := clock.NewFake() + s := newTestRedisSource(clk, map[string]string{ + "shard1": "10.33.33.4:4218", + "shard2": "10.33.33.5:4218", + }) + + now := clk.Now() + val1 := now.Add(time.Second) + val2 := now.Add(time.Second * 2) + val3 := now.Add(time.Second * 3) + + set := map[string]time.Time{ + "test1": val1, + "test2": val2, + "test3": val3, + } + + err := s.BatchSet(context.Background(), set) + test.AssertNotError(t, err, "BatchSet() should not error") + + got, err := s.BatchGet(context.Background(), []string{"test1", "test2", "test3"}) + test.AssertNotError(t, err, "BatchGet() should not error") + + for k, v := range set { + test.Assert(t, got[k].Equal(v), "BatchGet() should return the values set by BatchSet()") + } + + // Test that BatchGet() returns a zero time for a key that does not exist. + got, err = s.BatchGet(context.Background(), []string{"test1", "test4", "test3"}) + test.AssertNotError(t, err, "BatchGet() should not error when a key isn't found") + test.Assert(t, got["test4"].IsZero(), "BatchGet() should return a zero time for a key that does not exist") +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/source_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/source_test.go new file mode 100644 index 000000000..a4f55ba87 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/source_test.go @@ -0,0 +1,11 @@ +package ratelimits + +import ( + "testing" + + "github.com/jmhodges/clock" +) + +func newInmemTestLimiter(t *testing.T, clk clock.FakeClock) *Limiter { + return newTestLimiter(t, newInmem(), clk) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_burst_0.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_burst_0.yml new file mode 100644 index 000000000..26a2466ad --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_burst_0.yml @@ -0,0 +1,4 @@ +NewRegistrationsPerIPAddress: + burst: 0 + count: 20 + period: 1s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_empty_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_empty_name.yml new file mode 100644 index 000000000..981c58536 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_empty_name.yml @@ -0,0 +1,4 @@ +"": + burst: 20 + count: 20 + period: 1s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_invalid_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_invalid_name.yml new file mode 100644 index 000000000..bf41b326d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_default_invalid_name.yml @@ -0,0 +1,4 @@ +UsageRequestsPerIPv10Address: + burst: 20 + count: 20 + period: 1s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_defaults_second_entry_bad_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_defaults_second_entry_bad_name.yml new file mode 100644 index 000000000..cc276a869 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_defaults_second_entry_bad_name.yml @@ -0,0 +1,8 @@ +NewRegistrationsPerIPAddress: + burst: 20 + count: 20 + period: 1s +UsageRequestsPerIPv10Address: + burst: 20 + count: 20 + period: 1s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_burst_0.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_burst_0.yml new file mode 100644 index 000000000..9c74e16ac --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_burst_0.yml @@ -0,0 +1,7 @@ +- NewRegistrationsPerIPAddress: + burst: 0 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_id.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_id.yml new file mode 100644 index 000000000..2db8c8de5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_id.yml @@ -0,0 +1,5 @@ +- UsageRequestsPerIPv10Address: + burst: 40 + count: 40 + period: 1s + ids: [] diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_name.yml new file mode 100644 index 000000000..27825eee5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_empty_name.yml @@ -0,0 +1,7 @@ +- "": + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_invalid_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_invalid_name.yml new file mode 100644 index 000000000..6160de758 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_override_invalid_name.yml @@ -0,0 +1,7 @@ +- UsageRequestsPerIPv10Address: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_second_entry_bad_name.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_second_entry_bad_name.yml new file mode 100644 index 000000000..147ab5b1a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_second_entry_bad_name.yml @@ -0,0 +1,14 @@ +- NewRegistrationsPerIPAddress: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo +- UsageRequestsPerIPv10Address: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.5 + comment: Bar diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_third_entry_bad_id.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_third_entry_bad_id.yml new file mode 100644 index 000000000..e46b8d690 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/busted_overrides_third_entry_bad_id.yml @@ -0,0 +1,11 @@ +- NewRegistrationsPerIPAddress: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.5 + comment: Foo + - id: 10.0.0.2 + comment: Bar + - id: lol + comment: Baz diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_default.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_default.yml new file mode 100644 index 000000000..1c0c63bce --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_default.yml @@ -0,0 +1,4 @@ +NewRegistrationsPerIPAddress: + burst: 20 + count: 20 + period: 1s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_defaults.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_defaults.yml new file mode 100644 index 000000000..be5988b7a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_defaults.yml @@ -0,0 +1,8 @@ +NewRegistrationsPerIPAddress: + burst: 20 + count: 20 + period: 1s +NewRegistrationsPerIPv6Range: + burst: 30 + count: 30 + period: 2s diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override.yml new file mode 100644 index 000000000..bd5dc80fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override.yml @@ -0,0 +1,7 @@ +- NewRegistrationsPerIPAddress: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override_regid_domain.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override_regid_domain.yml new file mode 100644 index 000000000..81ac3a561 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_override_regid_domain.yml @@ -0,0 +1,7 @@ +- CertificatesPerDomain: + burst: 40 + count: 40 + period: 1s + ids: + - id: example.com + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides.yml new file mode 100644 index 000000000..584676e87 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides.yml @@ -0,0 +1,24 @@ +- NewRegistrationsPerIPAddress: + burst: 40 + count: 40 + period: 1s + ids: + - id: 10.0.0.2 + comment: Foo +- NewRegistrationsPerIPv6Range: + burst: 50 + count: 50 + period: 2s + ids: + - id: 2001:0db8:0000::/48 + comment: Foo +- FailedAuthorizationsPerDomainPerAccount: + burst: 60 + count: 60 + period: 3s + ids: + - id: 1234 + comment: Foo + - id: 5678 + comment: Foo + diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides_regid_fqdnset.yml b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides_regid_fqdnset.yml new file mode 100644 index 000000000..60e337fb1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/testdata/working_overrides_regid_fqdnset.yml @@ -0,0 +1,21 @@ +- CertificatesPerFQDNSet: + burst: 40 + count: 40 + period: 1s + ids: + - id: example.com + comment: Foo +- CertificatesPerFQDNSet: + burst: 50 + count: 50 + period: 2s + ids: + - id: "example.com,example.net" + comment: Foo +- CertificatesPerFQDNSet: + burst: 60 + count: 60 + period: 3s + ids: + - id: "example.com,example.net,example.org" + comment: Foo diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/utilities.go b/third-party/github.com/letsencrypt/boulder/ratelimits/utilities.go new file mode 100644 index 000000000..dd5a1167e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/utilities.go @@ -0,0 +1,33 @@ +package ratelimits + +import ( + "strings" + + "github.com/letsencrypt/boulder/core" + "github.com/weppos/publicsuffix-go/publicsuffix" +) + +// joinWithColon joins the provided args with a colon. +func joinWithColon(args ...string) string { + return strings.Join(args, ":") +} + +// DomainsForRateLimiting transforms a list of FQDNs into a list of eTLD+1's +// for the purpose of rate limiting. It also de-duplicates the output +// domains. Exact public suffix matches are included. +func DomainsForRateLimiting(names []string) []string { + var domains []string + for _, name := range names { + domain, err := publicsuffix.Domain(name) + if err != nil { + // The only possible errors are: + // (1) publicsuffix.Domain is giving garbage values + // (2) the public suffix is the domain itself + // We assume 2 and include the original name in the result. + domains = append(domains, name) + } else { + domains = append(domains, domain) + } + } + return core.UniqueLowerNames(domains) +} diff --git a/third-party/github.com/letsencrypt/boulder/ratelimits/utilities_test.go b/third-party/github.com/letsencrypt/boulder/ratelimits/utilities_test.go new file mode 100644 index 000000000..9c68d3a6e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/ratelimits/utilities_test.go @@ -0,0 +1,27 @@ +package ratelimits + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestDomainsForRateLimiting(t *testing.T) { + domains := DomainsForRateLimiting([]string{}) + test.AssertEquals(t, len(domains), 0) + + domains = DomainsForRateLimiting([]string{"www.example.com", "example.com"}) + test.AssertDeepEquals(t, domains, []string{"example.com"}) + + domains = DomainsForRateLimiting([]string{"www.example.com", "example.com", "www.example.co.uk"}) + test.AssertDeepEquals(t, domains, []string{"example.co.uk", "example.com"}) + + domains = DomainsForRateLimiting([]string{"www.example.com", "example.com", "www.example.co.uk", "co.uk"}) + test.AssertDeepEquals(t, domains, []string{"co.uk", "example.co.uk", "example.com"}) + + domains = DomainsForRateLimiting([]string{"foo.bar.baz.www.example.com", "baz.example.com"}) + test.AssertDeepEquals(t, domains, []string{"example.com"}) + + domains = DomainsForRateLimiting([]string{"github.io", "foo.github.io", "bar.github.io"}) + test.AssertDeepEquals(t, domains, []string{"bar.github.io", "foo.github.io", "github.io"}) +} diff --git a/third-party/github.com/letsencrypt/boulder/redis/config.go b/third-party/github.com/letsencrypt/boulder/redis/config.go new file mode 100644 index 000000000..997969373 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/redis/config.go @@ -0,0 +1,181 @@ +package redis + +import ( + "fmt" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + blog "github.com/letsencrypt/boulder/log" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" +) + +// Config contains the configuration needed to act as a Redis client. +type Config struct { + // TLS contains the configuration to speak TLS with Redis. + TLS cmd.TLSConfig + + // Username used to authenticate to each Redis instance. + Username string `validate:"required"` + + // PasswordFile is the path to a file holding the password used to + // authenticate to each Redis instance. + cmd.PasswordConfig + + // ShardAddrs is a map of shard names to IP address:port pairs. The go-redis + // `Ring` client will shard reads and writes across the provided Redis + // Servers based on a consistent hashing algorithm. + ShardAddrs map[string]string `validate:"omitempty,required_without=Lookups,min=1,dive,hostname_port"` + + // Lookups each entry contains a service and domain name that will be used + // to construct a SRV DNS query to lookup Redis backends. For example: if + // the resource record is 'foo.service.consul', then the 'Service' is 'foo' + // and the 'Domain' is 'service.consul'. The expected dNSName to be + // authenticated in the server certificate would be 'foo.service.consul'. + Lookups []cmd.ServiceDomain `validate:"omitempty,required_without=ShardAddrs,min=1,dive"` + + // LookupFrequency is the frequency of periodic SRV lookups. Defaults to 30 + // seconds. + LookupFrequency config.Duration `validate:"-"` + + // LookupDNSAuthority can only be specified with Lookups. It's a single + // : of the DNS server to be used for resolution + // of Redis backends. If the address contains a hostname it will be resolved + // using system DNS. If the address contains a port, the client will use it + // directly, otherwise port 53 is used. If this field is left unspecified + // the system DNS will be used for resolution. + LookupDNSAuthority string `validate:"excluded_without=Lookups,omitempty,ip|hostname|hostname_port"` + + // Enables read-only commands on replicas. + ReadOnly bool + // Allows routing read-only commands to the closest primary or replica. + // It automatically enables ReadOnly. + RouteByLatency bool + // Allows routing read-only commands to a random primary or replica. + // It automatically enables ReadOnly. + RouteRandomly bool + + // PoolFIFO uses FIFO mode for each node connection pool GET/PUT (default LIFO). + PoolFIFO bool + + // Maximum number of retries before giving up. + // Default is to not retry failed commands. + MaxRetries int `validate:"min=0"` + // Minimum backoff between each retry. + // Default is 8 milliseconds; -1 disables backoff. + MinRetryBackoff config.Duration `validate:"-"` + // Maximum backoff between each retry. + // Default is 512 milliseconds; -1 disables backoff. + MaxRetryBackoff config.Duration `validate:"-"` + + // Dial timeout for establishing new connections. + // Default is 5 seconds. + DialTimeout config.Duration `validate:"-"` + // Timeout for socket reads. If reached, commands will fail + // with a timeout instead of blocking. Use value -1 for no timeout and 0 for default. + // Default is 3 seconds. + ReadTimeout config.Duration `validate:"-"` + // Timeout for socket writes. If reached, commands will fail + // with a timeout instead of blocking. + // Default is ReadTimeout. + WriteTimeout config.Duration `validate:"-"` + + // Maximum number of socket connections. + // Default is 5 connections per every CPU as reported by runtime.NumCPU. + // If this is set to an explicit value, that's not multiplied by NumCPU. + // PoolSize applies per cluster node and not for the whole cluster. + // https://pkg.go.dev/github.com/go-redis/redis#ClusterOptions + PoolSize int `validate:"min=0"` + // Minimum number of idle connections which is useful when establishing + // new connection is slow. + MinIdleConns int `validate:"min=0"` + // Connection age at which client retires (closes) the connection. + // Default is to not close aged connections. + MaxConnAge config.Duration `validate:"-"` + // Amount of time client waits for connection if all connections + // are busy before returning an error. + // Default is ReadTimeout + 1 second. + PoolTimeout config.Duration `validate:"-"` + // Amount of time after which client closes idle connections. + // Should be less than server's timeout. + // Default is 5 minutes. -1 disables idle timeout check. + IdleTimeout config.Duration `validate:"-"` + // Frequency of idle checks made by idle connections reaper. + // Default is 1 minute. -1 disables idle connections reaper, + // but idle connections are still discarded by the client + // if IdleTimeout is set. + // Deprecated: This field has been deprecated and will be removed. + IdleCheckFrequency config.Duration `validate:"-"` +} + +// Ring is a wrapper around the go-redis/v9 Ring client that adds support for +// (optional) periodic SRV lookups. +type Ring struct { + *redis.Ring + lookup *lookup +} + +// NewRingFromConfig returns a new *redis.Ring client. If periodic SRV lookups +// are supplied, a goroutine will be started to periodically perform lookups. +// Callers should defer a call to StopLookups() to ensure that this goroutine is +// gracefully shutdown. +func NewRingFromConfig(c Config, stats prometheus.Registerer, log blog.Logger) (*Ring, error) { + password, err := c.Pass() + if err != nil { + return nil, fmt.Errorf("loading password: %w", err) + } + + tlsConfig, err := c.TLS.Load(stats) + if err != nil { + return nil, fmt.Errorf("loading TLS config: %w", err) + } + + inner := redis.NewRing(&redis.RingOptions{ + Addrs: c.ShardAddrs, + Username: c.Username, + Password: password, + TLSConfig: tlsConfig, + + MaxRetries: c.MaxRetries, + MinRetryBackoff: c.MinRetryBackoff.Duration, + MaxRetryBackoff: c.MaxRetryBackoff.Duration, + DialTimeout: c.DialTimeout.Duration, + ReadTimeout: c.ReadTimeout.Duration, + WriteTimeout: c.WriteTimeout.Duration, + + PoolSize: c.PoolSize, + MinIdleConns: c.MinIdleConns, + ConnMaxLifetime: c.MaxConnAge.Duration, + PoolTimeout: c.PoolTimeout.Duration, + ConnMaxIdleTime: c.IdleTimeout.Duration, + }) + if len(c.ShardAddrs) > 0 { + // Client was statically configured with a list of shards. + MustRegisterClientMetricsCollector(inner, stats, c.ShardAddrs, c.Username) + } + + var lookup *lookup + if len(c.Lookups) != 0 { + lookup, err = newLookup(c.Lookups, c.LookupDNSAuthority, c.LookupFrequency.Duration, inner, log, stats) + if err != nil { + return nil, err + } + lookup.start() + } + + return &Ring{ + Ring: inner, + lookup: lookup, + }, nil +} + +// StopLookups stops the goroutine responsible for keeping the shards of the +// inner *redis.Ring up-to-date. It is a no-op if the Ring was not constructed +// with periodic lookups or if the lookups have already been stopped. +func (r *Ring) StopLookups() { + if r == nil || r.lookup == nil { + // No-op. + return + } + r.lookup.stop() +} diff --git a/third-party/github.com/letsencrypt/boulder/redis/lookup.go b/third-party/github.com/letsencrypt/boulder/redis/lookup.go new file mode 100644 index 000000000..f66ed7450 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/redis/lookup.go @@ -0,0 +1,218 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" + "github.com/prometheus/client_golang/prometheus" + + "github.com/redis/go-redis/v9" +) + +var ErrNoShardsResolved = errors.New("0 shards were resolved") + +// lookup wraps a Redis ring client by reference and keeps the Redis ring shards +// up to date via periodic SRV lookups. +type lookup struct { + // srvLookups is a list of SRV records to be looked up. + srvLookups []cmd.ServiceDomain + + // updateFrequency is the frequency of periodic SRV lookups. Defaults to 30 + // seconds. + updateFrequency time.Duration + + // updateTimeout is the timeout for each SRV lookup. Defaults to 90% of the + // update frequency. + updateTimeout time.Duration + + // dnsAuthority is the single : of the DNS + // server to be used for SRV lookups. If the address contains a hostname it + // will be resolved via the system DNS. If the port is left unspecified it + // will default to '53'. If this field is left unspecified the system DNS + // will be used for resolution. + dnsAuthority string + + // stop is a context.CancelFunc that can be used to stop the goroutine + // responsible for performing periodic SRV lookups. + stop context.CancelFunc + + resolver *net.Resolver + ring *redis.Ring + logger blog.Logger + stats prometheus.Registerer +} + +// newLookup constructs and returns a new lookup instance. An initial SRV lookup +// is performed to populate the Redis ring shards. If this lookup fails or +// otherwise results in an empty set of resolved shards, an error is returned. +func newLookup(srvLookups []cmd.ServiceDomain, dnsAuthority string, frequency time.Duration, ring *redis.Ring, logger blog.Logger, stats prometheus.Registerer) (*lookup, error) { + updateFrequency := frequency + if updateFrequency <= 0 { + // Set default frequency. + updateFrequency = 30 * time.Second + } + // Set default timeout to 90% of the update frequency. + updateTimeout := updateFrequency - updateFrequency/10 + + lookup := &lookup{ + srvLookups: srvLookups, + ring: ring, + logger: logger, + stats: stats, + updateFrequency: updateFrequency, + updateTimeout: updateTimeout, + dnsAuthority: dnsAuthority, + } + + if dnsAuthority == "" { + // Use the system DNS resolver. + lookup.resolver = net.DefaultResolver + } else { + // Setup a custom DNS resolver. + host, port, err := net.SplitHostPort(dnsAuthority) + if err != nil { + // Assume only hostname or IPv4 address was specified. + host = dnsAuthority + port = "53" + } + lookup.dnsAuthority = net.JoinHostPort(host, port) + lookup.resolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + // The custom resolver closes over the lookup.dnsAuthority field + // so it can be swapped out in testing. + return net.Dial(network, lookup.dnsAuthority) + }, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) + defer cancel() + tempErr, nonTempErr := lookup.updateNow(ctx) + if tempErr != nil { + // Log and discard temporary errors, as they're likely to be transient + // (e.g. network connectivity issues). + logger.Warningf("resolving ring shards: %s", tempErr) + } + if nonTempErr != nil && errors.Is(nonTempErr, ErrNoShardsResolved) { + // Non-temporary errors are always logged inside of updateNow(), so we + // only need return the error here if it's ErrNoShardsResolved. + return nil, nonTempErr + } + + return lookup, nil +} + +// updateNow resolves and updates the Redis ring shards accordingly. If all +// lookups fail or otherwise result in an empty set of resolved shards, the +// Redis ring is left unmodified and any errors are returned. If at least one +// lookup succeeds, the Redis ring is updated, and all errors are discarded. +// Non-temporary DNS errors are always logged as they occur, as they're likely +// to be indicative of a misconfiguration. +func (look *lookup) updateNow(ctx context.Context) (tempError, nonTempError error) { + var tempErrs []error + handleDNSError := func(err error, srv cmd.ServiceDomain) { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsTimeout || dnsErr.IsTemporary) { + tempErrs = append(tempErrs, err) + return + } + // Log non-temporary DNS errors as they occur, as they're likely to be + // indicative of misconfiguration. + look.logger.Errf("resolving service _%s._tcp.%s: %s", srv.Service, srv.Domain, err) + } + + nextAddrs := make(map[string]string) + for _, srv := range look.srvLookups { + _, targets, err := look.resolver.LookupSRV(ctx, srv.Service, "tcp", srv.Domain) + if err != nil { + handleDNSError(err, srv) + // Skip to the next SRV lookup. + continue + } + if len(targets) <= 0 { + tempErrs = append(tempErrs, fmt.Errorf("0 targets resolved for service \"_%s._tcp.%s\"", srv.Service, srv.Domain)) + // Skip to the next SRV lookup. + continue + } + + for _, target := range targets { + host := strings.TrimRight(target.Target, ".") + if look.dnsAuthority != "" { + // Lookup A/AAAA records for the SRV target using the custom DNS + // authority. + hostAddrs, err := look.resolver.LookupHost(ctx, host) + if err != nil { + handleDNSError(err, srv) + // Skip to the next A/AAAA lookup. + continue + } + if len(hostAddrs) <= 0 { + tempErrs = append(tempErrs, fmt.Errorf("0 addrs resolved for target %q of service \"_%s._tcp.%s\"", host, srv.Service, srv.Domain)) + // Skip to the next A/AAAA lookup. + continue + } + // Use the first resolved IP address. + host = hostAddrs[0] + } + addr := fmt.Sprintf("%s:%d", host, target.Port) + nextAddrs[addr] = addr + } + } + + // Only return errors if we failed to resolve any shards. + if len(nextAddrs) <= 0 { + return errors.Join(tempErrs...), ErrNoShardsResolved + } + + // Some shards were resolved, update the Redis ring and discard all errors. + look.ring.SetAddrs(nextAddrs) + + // Update the Redis client metrics. + MustRegisterClientMetricsCollector(look.ring, look.stats, nextAddrs, look.ring.Options().Username) + + return nil, nil +} + +// start starts a goroutine that keeps the Redis ring shards up-to-date by +// periodically performing SRV lookups. +func (look *lookup) start() { + var lookupCtx context.Context + lookupCtx, look.stop = context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(look.updateFrequency) + defer ticker.Stop() + for { + // Check for context cancellation before we do any work. + if lookupCtx.Err() != nil { + return + } + + timeoutCtx, cancel := context.WithTimeout(lookupCtx, look.updateTimeout) + tempErrs, nonTempErrs := look.updateNow(timeoutCtx) + cancel() + if tempErrs != nil { + look.logger.Warningf("resolving ring shards, temporary errors: %s", tempErrs) + continue + } + if nonTempErrs != nil { + look.logger.Errf("resolving ring shards, non-temporary errors: %s", nonTempErrs) + continue + } + + select { + case <-ticker.C: + continue + + case <-lookupCtx.Done(): + return + } + } + }() +} diff --git a/third-party/github.com/letsencrypt/boulder/redis/lookup_test.go b/third-party/github.com/letsencrypt/boulder/redis/lookup_test.go new file mode 100644 index 000000000..818278ec1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/redis/lookup_test.go @@ -0,0 +1,253 @@ +package redis + +import ( + "context" + "testing" + "time" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" + + "github.com/redis/go-redis/v9" +) + +func newTestRedisRing() *redis.Ring { + CACertFile := "../test/certs/ipki/minica.pem" + CertFile := "../test/certs/ipki/localhost/cert.pem" + KeyFile := "../test/certs/ipki/localhost/key.pem" + tlsConfig := cmd.TLSConfig{ + CACertFile: CACertFile, + CertFile: CertFile, + KeyFile: KeyFile, + } + tlsConfig2, err := tlsConfig.Load(metrics.NoopRegisterer) + if err != nil { + panic(err) + } + + client := redis.NewRing(&redis.RingOptions{ + Username: "unittest-rw", + Password: "824968fa490f4ecec1e52d5e34916bdb60d45f8d", + TLSConfig: tlsConfig2, + }) + return client +} + +func TestNewLookup(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + _, err := newLookup([]cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") +} + +func TestStart(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + lookup, err := newLookup([]cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") + + lookup.start() + lookup.stop() +} + +func TestNewLookupWithOneFailingSRV(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + _, err := newLookup([]cmd.ServiceDomain{ + { + Service: "doesnotexist", + Domain: "service.consuls", + }, + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") +} + +func TestNewLookupWithAllFailingSRV(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + _, err := newLookup([]cmd.ServiceDomain{ + { + Service: "doesnotexist", + Domain: "service.consuls", + }, + { + Service: "doesnotexist2", + Domain: "service.consuls", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertError(t, err, "Expected newLookup construction to fail") +} + +func TestUpdateNowWithAllFailingSRV(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + lookup, err := newLookup([]cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") + + lookup.srvLookups = []cmd.ServiceDomain{ + { + Service: "doesnotexist1", + Domain: "service.consul", + }, + { + Service: "doesnotexist2", + Domain: "service.consul", + }, + } + + testCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tempErr, nonTempErr := lookup.updateNow(testCtx) + test.AssertNotError(t, tempErr, "Expected no temporary errors") + test.AssertError(t, nonTempErr, "Expected non-temporary errors to have occurred") +} + +func TestUpdateNowWithAllFailingSRVs(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + lookup, err := newLookup([]cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") + + // Replace the dnsAuthority with a non-existent DNS server, this will cause + // a timeout error, which is technically a temporary error, but will + // eventually result in a non-temporary error when no shards are resolved. + lookup.dnsAuthority = "consuls.services.consuls:53" + + testCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + tempErr, nonTempErr := lookup.updateNow(testCtx) + test.AssertError(t, tempErr, "Expected temporary errors") + test.AssertError(t, nonTempErr, "Expected a non-temporary error") + test.AssertErrorIs(t, nonTempErr, ErrNoShardsResolved) +} + +func TestUpdateNowWithOneFailingSRV(t *testing.T) { + t.Parallel() + + logger := blog.NewMock() + ring := newTestRedisRing() + + lookup, err := newLookup([]cmd.ServiceDomain{ + { + Service: "doesnotexist", + Domain: "service.consuls", + }, + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + "consul.service.consul", + 250*time.Millisecond, + ring, + logger, + metrics.NoopRegisterer, + ) + test.AssertNotError(t, err, "Expected newLookup construction to succeed") + + // The Consul service entry for 'redisratelimits' is configured to return + // two SRV targets. We should only have two shards in the ring. + test.Assert(t, ring.Len() == 2, "Expected 2 shards in the ring") + + testCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Ensure we can reach both shards using the PING command. + err = ring.ForEachShard(testCtx, func(ctx context.Context, shard *redis.Client) error { + return shard.Ping(ctx).Err() + }) + test.AssertNotError(t, err, "Expected PING to succeed for both shards") + + // Drop both Shards from the ring. + ring.SetAddrs(map[string]string{}) + test.Assert(t, ring.Len() == 0, "Expected 0 shards in the ring") + + // Force a lookup to occur. + tempErr, nonTempErr := lookup.updateNow(testCtx) + test.AssertNotError(t, tempErr, "Expected no temporary errors") + test.AssertNotError(t, nonTempErr, "Expected no non-temporary errors") + + // The ring should now have two shards again. + test.Assert(t, ring.Len() == 2, "Expected 2 shards in the ring") +} diff --git a/third-party/github.com/letsencrypt/boulder/redis/metrics.go b/third-party/github.com/letsencrypt/boulder/redis/metrics.go new file mode 100644 index 000000000..1a7c04878 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/redis/metrics.go @@ -0,0 +1,103 @@ +package redis + +import ( + "errors" + "slices" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" +) + +// An interface satisfied by *redis.ClusterClient and also by a mock in our tests. +type poolStatGetter interface { + PoolStats() *redis.PoolStats +} + +var _ poolStatGetter = (*redis.ClusterClient)(nil) + +type metricsCollector struct { + statGetter poolStatGetter + + // Stats accessible from the go-redis connector: + // https://pkg.go.dev/github.com/go-redis/redis@v6.15.9+incompatible/internal/pool#Stats + lookups *prometheus.Desc + totalConns *prometheus.Desc + idleConns *prometheus.Desc + staleConns *prometheus.Desc +} + +// Describe is implemented with DescribeByCollect. That's possible because the +// Collect method will always return the same metrics with the same descriptors. +func (dbc metricsCollector) Describe(ch chan<- *prometheus.Desc) { + prometheus.DescribeByCollect(dbc, ch) +} + +// Collect first triggers the Redis ClusterClient's PoolStats function. +// Then it creates constant metrics for each Stats value on the fly based +// on the returned data. +// +// Note that Collect could be called concurrently, so we depend on PoolStats() +// to be concurrency-safe. +func (dbc metricsCollector) Collect(ch chan<- prometheus.Metric) { + writeGauge := func(stat *prometheus.Desc, val uint32, labelValues ...string) { + ch <- prometheus.MustNewConstMetric(stat, prometheus.GaugeValue, float64(val), labelValues...) + } + + stats := dbc.statGetter.PoolStats() + writeGauge(dbc.lookups, stats.Hits, "hit") + writeGauge(dbc.lookups, stats.Misses, "miss") + writeGauge(dbc.lookups, stats.Timeouts, "timeout") + writeGauge(dbc.totalConns, stats.TotalConns) + writeGauge(dbc.idleConns, stats.IdleConns) + writeGauge(dbc.staleConns, stats.StaleConns) +} + +// newClientMetricsCollector is broken out for testing purposes. +func newClientMetricsCollector(statGetter poolStatGetter, labels prometheus.Labels) metricsCollector { + return metricsCollector{ + statGetter: statGetter, + lookups: prometheus.NewDesc( + "redis_connection_pool_lookups", + "Number of lookups for a connection in the pool, labeled by hit/miss", + []string{"result"}, labels), + totalConns: prometheus.NewDesc( + "redis_connection_pool_total_conns", + "Number of total connections in the pool.", + nil, labels), + idleConns: prometheus.NewDesc( + "redis_connection_pool_idle_conns", + "Number of idle connections in the pool.", + nil, labels), + staleConns: prometheus.NewDesc( + "redis_connection_pool_stale_conns", + "Number of stale connections removed from the pool.", + nil, labels), + } +} + +// MustRegisterClientMetricsCollector registers a metrics collector for the +// given Redis client with the provided prometheus.Registerer. The collector +// will report metrics labelled by the provided addresses and username. If the +// collector is already registered, this function is a no-op. +func MustRegisterClientMetricsCollector(client poolStatGetter, stats prometheus.Registerer, addrs map[string]string, user string) { + var labelAddrs []string + for addr := range addrs { + labelAddrs = append(labelAddrs, addr) + } + // Keep the list of addresses sorted for consistency. + slices.Sort(labelAddrs) + labels := prometheus.Labels{ + "addresses": strings.Join(labelAddrs, ", "), + "user": user, + } + err := stats.Register(newClientMetricsCollector(client, labels)) + if err != nil { + are := prometheus.AlreadyRegisteredError{} + if errors.As(err, &are) { + // The collector is already registered using the same labels. + return + } + panic(err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/redis/metrics_test.go b/third-party/github.com/letsencrypt/boulder/redis/metrics_test.go new file mode 100644 index 000000000..9da3bb613 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/redis/metrics_test.go @@ -0,0 +1,76 @@ +package redis + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" + + "github.com/letsencrypt/boulder/metrics" +) + +type mockPoolStatGetter struct{} + +var _ poolStatGetter = mockPoolStatGetter{} + +func (mockPoolStatGetter) PoolStats() *redis.PoolStats { + return &redis.PoolStats{ + Hits: 13, + Misses: 7, + Timeouts: 4, + TotalConns: 1000, + IdleConns: 500, + StaleConns: 10, + } +} + +func TestMetrics(t *testing.T) { + mets := newClientMetricsCollector(mockPoolStatGetter{}, + prometheus.Labels{ + "foo": "bar", + }) + // Check that it has the correct type to satisfy MustRegister + metrics.NoopRegisterer.MustRegister(mets) + + expectedMetrics := 6 + outChan := make(chan prometheus.Metric, expectedMetrics) + mets.Collect(outChan) + + results := make(map[string]bool) + for range expectedMetrics { + metric := <-outChan + results[metric.Desc().String()] = true + } + + expected := strings.Split( + `Desc{fqName: "redis_connection_pool_lookups", help: "Number of lookups for a connection in the pool, labeled by hit/miss", constLabels: {foo="bar"}, variableLabels: [{result }]} +Desc{fqName: "redis_connection_pool_lookups", help: "Number of lookups for a connection in the pool, labeled by hit/miss", constLabels: {foo="bar"}, variableLabels: [{result }]} +Desc{fqName: "redis_connection_pool_lookups", help: "Number of lookups for a connection in the pool, labeled by hit/miss", constLabels: {foo="bar"}, variableLabels: [{result }]} +Desc{fqName: "redis_connection_pool_total_conns", help: "Number of total connections in the pool.", constLabels: {foo="bar"}, variableLabels: []} +Desc{fqName: "redis_connection_pool_idle_conns", help: "Number of idle connections in the pool.", constLabels: {foo="bar"}, variableLabels: []} +Desc{fqName: "redis_connection_pool_stale_conns", help: "Number of stale connections removed from the pool.", constLabels: {foo="bar"}, variableLabels: []}`, + "\n") + + for _, e := range expected { + if !results[e] { + t.Errorf("expected metrics to contain %q, but they didn't", e) + } + } + + if len(results) > len(expected) { + t.Errorf("expected metrics to contain %d entries, but they contained %d", + len(expected), len(results)) + } +} + +func TestMustRegisterClientMetricsCollector(t *testing.T) { + client := mockPoolStatGetter{} + stats := prometheus.NewRegistry() + // First registration should succeed. + MustRegisterClientMetricsCollector(client, stats, map[string]string{"foo": "bar"}, "baz") + // Duplicate registration should succeed. + MustRegisterClientMetricsCollector(client, stats, map[string]string{"foo": "bar"}, "baz") + // Registration with different label values should succeed. + MustRegisterClientMetricsCollector(client, stats, map[string]string{"f00": "b4r"}, "b4z") +} diff --git a/third-party/github.com/letsencrypt/boulder/revocation/reasons.go b/third-party/github.com/letsencrypt/boulder/revocation/reasons.go new file mode 100644 index 000000000..50f556be0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/revocation/reasons.go @@ -0,0 +1,72 @@ +package revocation + +import ( + "fmt" + "sort" + "strings" + + "golang.org/x/crypto/ocsp" +) + +// Reason is used to specify a certificate revocation reason +type Reason int + +// ReasonToString provides a map from reason code to string +var ReasonToString = map[Reason]string{ + ocsp.Unspecified: "unspecified", + ocsp.KeyCompromise: "keyCompromise", + ocsp.CACompromise: "cACompromise", + ocsp.AffiliationChanged: "affiliationChanged", + ocsp.Superseded: "superseded", + ocsp.CessationOfOperation: "cessationOfOperation", + ocsp.CertificateHold: "certificateHold", + // 7 is unused + ocsp.RemoveFromCRL: "removeFromCRL", + ocsp.PrivilegeWithdrawn: "privilegeWithdrawn", + ocsp.AACompromise: "aAcompromise", +} + +// UserAllowedReasons contains the subset of Reasons which users are +// allowed to use +var UserAllowedReasons = map[Reason]struct{}{ + ocsp.Unspecified: {}, + ocsp.KeyCompromise: {}, + ocsp.Superseded: {}, + ocsp.CessationOfOperation: {}, +} + +// AdminAllowedReasons contains the subset of Reasons which admins are allowed +// to use. Reasons not found here will soon be forbidden from appearing in CRLs +// or OCSP responses by root programs. +var AdminAllowedReasons = map[Reason]struct{}{ + ocsp.Unspecified: {}, + ocsp.KeyCompromise: {}, + ocsp.Superseded: {}, + ocsp.CessationOfOperation: {}, + ocsp.PrivilegeWithdrawn: {}, +} + +// UserAllowedReasonsMessage contains a string describing a list of user allowed +// revocation reasons. This is useful when a revocation is rejected because it +// is not a valid user supplied reason and the allowed values must be +// communicated. This variable is populated during package initialization. +var UserAllowedReasonsMessage = "" + +func init() { + // Build a slice of ints from the allowed reason codes. + // We want a slice because iterating `UserAllowedReasons` will change order + // and make the message unpredictable and cumbersome for unit testing. + // We use []ints instead of []Reason to use `sort.Ints` without fuss. + var allowed []int + for reason := range UserAllowedReasons { + allowed = append(allowed, int(reason)) + } + sort.Ints(allowed) + + var reasonStrings []string + for _, reason := range allowed { + reasonStrings = append(reasonStrings, fmt.Sprintf("%s (%d)", + ReasonToString[Reason(reason)], reason)) + } + UserAllowedReasonsMessage = strings.Join(reasonStrings, ", ") +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/config/issuers_test.go b/third-party/github.com/letsencrypt/boulder/rocsp/config/issuers_test.go new file mode 100644 index 000000000..2a277fed3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/rocsp/config/issuers_test.go @@ -0,0 +1,105 @@ +package rocsp_config + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/letsencrypt/boulder/test" + "golang.org/x/crypto/ocsp" +) + +func TestLoadIssuers(t *testing.T) { + input := map[string]int{ + "../../test/hierarchy/int-e1.cert.pem": 23, + "../../test/hierarchy/int-r3.cert.pem": 99, + } + output, err := LoadIssuers(input) + if err != nil { + t.Fatal(err) + } + + var e1 *ShortIDIssuer + var r3 *ShortIDIssuer + + for i, v := range output { + if strings.Contains(v.Certificate.Subject.String(), "E1") { + e1 = &output[i] + } + if strings.Contains(v.Certificate.Subject.String(), "R3") { + r3 = &output[i] + } + } + + test.AssertEquals(t, e1.Subject.String(), "CN=(TEST) Elegant Elephant E1,O=Boulder Test,C=XX") + test.AssertEquals(t, r3.Subject.String(), "CN=(TEST) Radical Rhino R3,O=Boulder Test,C=XX") + test.AssertEquals(t, e1.shortID, uint8(23)) + test.AssertEquals(t, r3.shortID, uint8(99)) +} + +func TestFindIssuerByName(t *testing.T) { + input := map[string]int{ + "../../test/hierarchy/int-e1.cert.pem": 23, + "../../test/hierarchy/int-r3.cert.pem": 99, + } + issuers, err := LoadIssuers(input) + if err != nil { + t.Fatal(err) + } + + elephant, err := hex.DecodeString("3049310b300906035504061302585831153013060355040a130c426f756c6465722054657374312330210603550403131a28544553542920456c6567616e7420456c657068616e74204531") + if err != nil { + t.Fatal(err) + } + rhino, err := hex.DecodeString("3046310b300906035504061302585831153013060355040a130c426f756c64657220546573743120301e06035504031317285445535429205261646963616c205268696e6f205233") + if err != nil { + t.Fatal(err) + } + + ocspResp := &ocsp.Response{ + RawResponderName: elephant, + } + + issuer, err := FindIssuerByName(ocspResp, issuers) + if err != nil { + t.Fatalf("couldn't find issuer: %s", err) + } + + test.AssertEquals(t, issuer.shortID, uint8(23)) + + ocspResp = &ocsp.Response{ + RawResponderName: rhino, + } + + issuer, err = FindIssuerByName(ocspResp, issuers) + if err != nil { + t.Fatalf("couldn't find issuer: %s", err) + } + + test.AssertEquals(t, issuer.shortID, uint8(99)) +} + +func TestFindIssuerByID(t *testing.T) { + input := map[string]int{ + "../../test/hierarchy/int-e1.cert.pem": 23, + "../../test/hierarchy/int-r3.cert.pem": 99, + } + issuers, err := LoadIssuers(input) + if err != nil { + t.Fatal(err) + } + + // an IssuerNameID + issuer, err := FindIssuerByID(66283756913588288, issuers) + if err != nil { + t.Fatalf("couldn't find issuer: %s", err) + } + test.AssertEquals(t, issuer.shortID, uint8(23)) + + // an IssuerNameID + issuer, err = FindIssuerByID(58923463773186183, issuers) + if err != nil { + t.Fatalf("couldn't find issuer: %s", err) + } + test.AssertEquals(t, issuer.shortID, uint8(99)) +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/config/rocsp_config.go b/third-party/github.com/letsencrypt/boulder/rocsp/config/rocsp_config.go new file mode 100644 index 000000000..c5416a499 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/rocsp/config/rocsp_config.go @@ -0,0 +1,252 @@ +package rocsp_config + +import ( + "bytes" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "strings" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/issuance" + bredis "github.com/letsencrypt/boulder/redis" + "github.com/letsencrypt/boulder/rocsp" +) + +// RedisConfig contains the configuration needed to act as a Redis client. +// +// TODO(#7081): Deprecate this in favor of bredis.Config once we can support SRV +// lookups in rocsp. +type RedisConfig struct { + // PasswordFile is a file containing the password for the Redis user. + cmd.PasswordConfig + // TLS contains the configuration to speak TLS with Redis. + TLS cmd.TLSConfig + // Username is a Redis username. + Username string `validate:"required"` + // ShardAddrs is a map of shard names to IP address:port pairs. The go-redis + // `Ring` client will shard reads and writes across the provided Redis + // Servers based on a consistent hashing algorithm. + ShardAddrs map[string]string `validate:"min=1,dive,hostname_port"` + // Timeout is a per-request timeout applied to all Redis requests. + Timeout config.Duration `validate:"-"` + + // Enables read-only commands on replicas. + ReadOnly bool + // Allows routing read-only commands to the closest primary or replica. + // It automatically enables ReadOnly. + RouteByLatency bool + // Allows routing read-only commands to a random primary or replica. + // It automatically enables ReadOnly. + RouteRandomly bool + + // PoolFIFO uses FIFO mode for each node connection pool GET/PUT (default LIFO). + PoolFIFO bool + + // Maximum number of retries before giving up. + // Default is to not retry failed commands. + MaxRetries int `validate:"min=0"` + // Minimum backoff between each retry. + // Default is 8 milliseconds; -1 disables backoff. + MinRetryBackoff config.Duration `validate:"-"` + // Maximum backoff between each retry. + // Default is 512 milliseconds; -1 disables backoff. + MaxRetryBackoff config.Duration `validate:"-"` + + // Dial timeout for establishing new connections. + // Default is 5 seconds. + DialTimeout config.Duration `validate:"-"` + // Timeout for socket reads. If reached, commands will fail + // with a timeout instead of blocking. Use value -1 for no timeout and 0 for default. + // Default is 3 seconds. + ReadTimeout config.Duration `validate:"-"` + // Timeout for socket writes. If reached, commands will fail + // with a timeout instead of blocking. + // Default is ReadTimeout. + WriteTimeout config.Duration `validate:"-"` + + // Maximum number of socket connections. + // Default is 5 connections per every CPU as reported by runtime.NumCPU. + // If this is set to an explicit value, that's not multiplied by NumCPU. + // PoolSize applies per cluster node and not for the whole cluster. + // https://pkg.go.dev/github.com/go-redis/redis#ClusterOptions + PoolSize int `validate:"min=0"` + // Minimum number of idle connections which is useful when establishing + // new connection is slow. + MinIdleConns int `validate:"min=0"` + // Connection age at which client retires (closes) the connection. + // Default is to not close aged connections. + MaxConnAge config.Duration `validate:"-"` + // Amount of time client waits for connection if all connections + // are busy before returning an error. + // Default is ReadTimeout + 1 second. + PoolTimeout config.Duration `validate:"-"` + // Amount of time after which client closes idle connections. + // Should be less than server's timeout. + // Default is 5 minutes. -1 disables idle timeout check. + IdleTimeout config.Duration `validate:"-"` + // Frequency of idle checks made by idle connections reaper. + // Default is 1 minute. -1 disables idle connections reaper, + // but idle connections are still discarded by the client + // if IdleTimeout is set. + // Deprecated: This field has been deprecated and will be removed. + IdleCheckFrequency config.Duration `validate:"-"` +} + +// MakeClient produces a read-write ROCSP client from a config. +func MakeClient(c *RedisConfig, clk clock.Clock, stats prometheus.Registerer) (*rocsp.RWClient, error) { + password, err := c.PasswordConfig.Pass() + if err != nil { + return nil, fmt.Errorf("loading password: %w", err) + } + + tlsConfig, err := c.TLS.Load(stats) + if err != nil { + return nil, fmt.Errorf("loading TLS config: %w", err) + } + + rdb := redis.NewRing(&redis.RingOptions{ + Addrs: c.ShardAddrs, + Username: c.Username, + Password: password, + TLSConfig: tlsConfig, + + MaxRetries: c.MaxRetries, + MinRetryBackoff: c.MinRetryBackoff.Duration, + MaxRetryBackoff: c.MaxRetryBackoff.Duration, + DialTimeout: c.DialTimeout.Duration, + ReadTimeout: c.ReadTimeout.Duration, + WriteTimeout: c.WriteTimeout.Duration, + + PoolSize: c.PoolSize, + MinIdleConns: c.MinIdleConns, + ConnMaxLifetime: c.MaxConnAge.Duration, + PoolTimeout: c.PoolTimeout.Duration, + ConnMaxIdleTime: c.IdleTimeout.Duration, + }) + return rocsp.NewWritingClient(rdb, c.Timeout.Duration, clk, stats), nil +} + +// MakeReadClient produces a read-only ROCSP client from a config. +func MakeReadClient(c *RedisConfig, clk clock.Clock, stats prometheus.Registerer) (*rocsp.ROClient, error) { + if len(c.ShardAddrs) == 0 { + return nil, errors.New("redis config's 'shardAddrs' field was empty") + } + + password, err := c.PasswordConfig.Pass() + if err != nil { + return nil, fmt.Errorf("loading password: %w", err) + } + + tlsConfig, err := c.TLS.Load(stats) + if err != nil { + return nil, fmt.Errorf("loading TLS config: %w", err) + } + + rdb := redis.NewRing(&redis.RingOptions{ + Addrs: c.ShardAddrs, + Username: c.Username, + Password: password, + TLSConfig: tlsConfig, + + PoolFIFO: c.PoolFIFO, + + MaxRetries: c.MaxRetries, + MinRetryBackoff: c.MinRetryBackoff.Duration, + MaxRetryBackoff: c.MaxRetryBackoff.Duration, + DialTimeout: c.DialTimeout.Duration, + ReadTimeout: c.ReadTimeout.Duration, + + PoolSize: c.PoolSize, + MinIdleConns: c.MinIdleConns, + ConnMaxLifetime: c.MaxConnAge.Duration, + PoolTimeout: c.PoolTimeout.Duration, + ConnMaxIdleTime: c.IdleTimeout.Duration, + }) + bredis.MustRegisterClientMetricsCollector(rdb, stats, rdb.Options().Addrs, rdb.Options().Username) + return rocsp.NewReadingClient(rdb, c.Timeout.Duration, clk, stats), nil +} + +// A ShortIDIssuer combines an issuance.Certificate with some fields necessary +// to process OCSP responses: the subject name and the shortID. +type ShortIDIssuer struct { + *issuance.Certificate + subject pkix.RDNSequence + shortID byte +} + +// LoadIssuers takes a map where the keys are filenames and the values are the +// corresponding short issuer ID. It loads issuer certificates from the given +// files and produces a []ShortIDIssuer. +func LoadIssuers(input map[string]int) ([]ShortIDIssuer, error) { + var issuers []ShortIDIssuer + for issuerFile, shortID := range input { + if shortID > 255 || shortID < 0 { + return nil, fmt.Errorf("invalid shortID %d (must be byte)", shortID) + } + cert, err := issuance.LoadCertificate(issuerFile) + if err != nil { + return nil, fmt.Errorf("reading issuer: %w", err) + } + var subject pkix.RDNSequence + _, err = asn1.Unmarshal(cert.Certificate.RawSubject, &subject) + if err != nil { + return nil, fmt.Errorf("parsing issuer.RawSubject: %w", err) + } + shortID := byte(shortID) + for _, issuer := range issuers { + if issuer.shortID == shortID { + return nil, fmt.Errorf("duplicate shortID '%d' in (for %q and %q) in config file", shortID, issuer.subject, subject) + } + if !issuer.IsCA { + return nil, fmt.Errorf("certificate for %q is not a CA certificate", subject) + } + } + issuers = append(issuers, ShortIDIssuer{ + Certificate: cert, + subject: subject, + shortID: shortID, + }) + } + return issuers, nil +} + +// ShortID returns the short ID of an issuer. The short ID is a single byte that +// is unique for that issuer. +func (si *ShortIDIssuer) ShortID() byte { + return si.shortID +} + +// FindIssuerByID returns the issuer that matches the given IssuerNameID. +func FindIssuerByID(longID int64, issuers []ShortIDIssuer) (*ShortIDIssuer, error) { + for _, iss := range issuers { + if iss.NameID() == issuance.NameID(longID) { + return &iss, nil + } + } + return nil, fmt.Errorf("no issuer found for an ID in certificateStatus: %d", longID) +} + +// FindIssuerByName returns the issuer with a Subject matching the *ocsp.Response. +func FindIssuerByName(resp *ocsp.Response, issuers []ShortIDIssuer) (*ShortIDIssuer, error) { + var responder pkix.RDNSequence + _, err := asn1.Unmarshal(resp.RawResponderName, &responder) + if err != nil { + return nil, fmt.Errorf("parsing resp.RawResponderName: %w", err) + } + var responders strings.Builder + for _, issuer := range issuers { + fmt.Fprintf(&responders, "%s\n", issuer.subject) + if bytes.Equal(issuer.RawSubject, resp.RawResponderName) { + return &issuer, nil + } + } + return nil, fmt.Errorf("no issuer found matching OCSP response for %s. Available issuers:\n%s\n", responder, responders.String()) +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/mocks.go b/third-party/github.com/letsencrypt/boulder/rocsp/mocks.go new file mode 100644 index 000000000..2f11264ff --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/rocsp/mocks.go @@ -0,0 +1,31 @@ +package rocsp + +import ( + "context" + "fmt" + + "golang.org/x/crypto/ocsp" +) + +// MockWriteClient is a mock +type MockWriteClient struct { + StoreResponseReturnError error +} + +// StoreResponse mocks a rocsp.StoreResponse method and returns nil or an +// error depending on the desired state. +func (r MockWriteClient) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + return r.StoreResponseReturnError +} + +// NewMockWriteSucceedClient returns a mock MockWriteClient with a +// StoreResponse method that will always succeed. +func NewMockWriteSucceedClient() MockWriteClient { + return MockWriteClient{nil} +} + +// NewMockWriteFailClient returns a mock MockWriteClient with a +// StoreResponse method that will always fail. +func NewMockWriteFailClient() MockWriteClient { + return MockWriteClient{StoreResponseReturnError: fmt.Errorf("could not store response")} +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/rocsp.go b/third-party/github.com/letsencrypt/boulder/rocsp/rocsp.go new file mode 100644 index 000000000..8b25af01f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/rocsp/rocsp.go @@ -0,0 +1,180 @@ +package rocsp + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/letsencrypt/boulder/core" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/ocsp" +) + +var ErrRedisNotFound = errors.New("redis key not found") + +// ROClient represents a read-only Redis client. +type ROClient struct { + rdb *redis.Ring + timeout time.Duration + clk clock.Clock + getLatency *prometheus.HistogramVec +} + +// NewReadingClient creates a read-only client. The timeout applies to all +// requests, though a shorter timeout can be applied on a per-request basis +// using context.Context. rdb must be non-nil. +func NewReadingClient(rdb *redis.Ring, timeout time.Duration, clk clock.Clock, stats prometheus.Registerer) *ROClient { + getLatency := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rocsp_get_latency", + Help: "Histogram of latencies of rocsp.GetResponse calls with result", + // 8 buckets, ranging from 0.5ms to 2s + Buckets: prometheus.ExponentialBucketsRange(0.0005, 2, 8), + }, + []string{"result"}, + ) + stats.MustRegister(getLatency) + + return &ROClient{ + rdb: rdb, + timeout: timeout, + clk: clk, + getLatency: getLatency, + } +} + +// Ping checks that each shard of the *redis.Ring is reachable using the PING +// command. It returns an error if any shard is unreachable and nil otherwise. +func (c *ROClient) Ping(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + err := c.rdb.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error { + return shard.Ping(ctx).Err() + }) + if err != nil { + return err + } + return nil +} + +// RWClient represents a Redis client that can both read and write. +type RWClient struct { + *ROClient + storeResponseLatency *prometheus.HistogramVec +} + +// NewWritingClient creates a RWClient. +func NewWritingClient(rdb *redis.Ring, timeout time.Duration, clk clock.Clock, stats prometheus.Registerer) *RWClient { + storeResponseLatency := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rocsp_store_response_latency", + Help: "Histogram of latencies of rocsp.StoreResponse calls with result labels", + }, + []string{"result"}, + ) + stats.MustRegister(storeResponseLatency) + return &RWClient{NewReadingClient(rdb, timeout, clk, stats), storeResponseLatency} +} + +// StoreResponse parses the given bytes as an OCSP response, and stores it +// into Redis. The expiration time (ttl) of the Redis key is set to OCSP +// response `NextUpdate`. +func (c *RWClient) StoreResponse(ctx context.Context, resp *ocsp.Response) error { + start := c.clk.Now() + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + serial := core.SerialToString(resp.SerialNumber) + + // Set the ttl duration to the response `NextUpdate - now()` + ttl := time.Until(resp.NextUpdate) + + err := c.rdb.Set(ctx, serial, resp.Raw, ttl).Err() + if err != nil { + state := "failed" + if errors.Is(err, context.DeadlineExceeded) { + state = "deadlineExceeded" + } else if errors.Is(err, context.Canceled) { + state = "canceled" + } + c.storeResponseLatency.With(prometheus.Labels{"result": state}).Observe(time.Since(start).Seconds()) + return fmt.Errorf("setting response: %w", err) + } + + c.storeResponseLatency.With(prometheus.Labels{"result": "success"}).Observe(time.Since(start).Seconds()) + return nil +} + +// GetResponse fetches a response for the given serial number. +// Returns error if the OCSP response fails to parse. +func (c *ROClient) GetResponse(ctx context.Context, serial string) ([]byte, error) { + start := c.clk.Now() + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + resp, err := c.rdb.Get(ctx, serial).Result() + if err != nil { + // go-redis `Get` returns redis.Nil error when key does not exist. In + // that case return a `ErrRedisNotFound` error. + if errors.Is(err, redis.Nil) { + c.getLatency.With(prometheus.Labels{"result": "notFound"}).Observe(time.Since(start).Seconds()) + return nil, ErrRedisNotFound + } + + state := "failed" + if errors.Is(err, context.DeadlineExceeded) { + state = "deadlineExceeded" + } else if errors.Is(err, context.Canceled) { + state = "canceled" + } + c.getLatency.With(prometheus.Labels{"result": state}).Observe(time.Since(start).Seconds()) + return nil, fmt.Errorf("getting response: %w", err) + } + + c.getLatency.With(prometheus.Labels{"result": "success"}).Observe(time.Since(start).Seconds()) + return []byte(resp), nil +} + +// ScanResponsesResult represents a single OCSP response entry in redis. +// `Serial` is the stringified serial number of the response. `Body` is the +// DER bytes of the response. If this object represents an error, `Err` will +// be non-nil and the other entries will have their zero values. +type ScanResponsesResult struct { + Serial string + Body []byte + Err error +} + +// ScanResponses scans Redis for all OCSP responses where the serial number matches the provided pattern. +// It returns immediately and emits results and errors on `<-chan ScanResponsesResult`. It closes the +// channel when it is done or hits an error. +func (c *ROClient) ScanResponses(ctx context.Context, serialPattern string) <-chan ScanResponsesResult { + pattern := fmt.Sprintf("r{%s}", serialPattern) + results := make(chan ScanResponsesResult) + go func() { + defer close(results) + err := c.rdb.ForEachShard(ctx, func(ctx context.Context, rdb *redis.Client) error { + iter := rdb.Scan(ctx, 0, pattern, 0).Iterator() + for iter.Next(ctx) { + key := iter.Val() + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + results <- ScanResponsesResult{Err: fmt.Errorf("getting response: %w", err)} + continue + } + results <- ScanResponsesResult{Serial: key, Body: []byte(val)} + } + return iter.Err() + }) + if err != nil { + results <- ScanResponsesResult{Err: err} + return + } + }() + return results +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/rocsp_test.go b/third-party/github.com/letsencrypt/boulder/rocsp/rocsp_test.go new file mode 100644 index 000000000..51bbc903d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/rocsp/rocsp_test.go @@ -0,0 +1,72 @@ +package rocsp + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/metrics" +) + +func makeClient() (*RWClient, clock.Clock) { + CACertFile := "../test/certs/ipki/minica.pem" + CertFile := "../test/certs/ipki/localhost/cert.pem" + KeyFile := "../test/certs/ipki/localhost/key.pem" + tlsConfig := cmd.TLSConfig{ + CACertFile: CACertFile, + CertFile: CertFile, + KeyFile: KeyFile, + } + tlsConfig2, err := tlsConfig.Load(metrics.NoopRegisterer) + if err != nil { + panic(err) + } + + rdb := redis.NewRing(&redis.RingOptions{ + Addrs: map[string]string{ + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218", + }, + Username: "unittest-rw", + Password: "824968fa490f4ecec1e52d5e34916bdb60d45f8d", + TLSConfig: tlsConfig2, + }) + clk := clock.NewFake() + return NewWritingClient(rdb, 5*time.Second, clk, metrics.NoopRegisterer), clk +} + +func TestSetAndGet(t *testing.T) { + client, _ := makeClient() + fmt.Println(client.Ping(context.Background())) + + respBytes, err := os.ReadFile("testdata/ocsp.response") + if err != nil { + t.Fatal(err) + } + + response, err := ocsp.ParseResponse(respBytes, nil) + if err != nil { + t.Fatal(err) + } + err = client.StoreResponse(context.Background(), response) + if err != nil { + t.Fatalf("storing response: %s", err) + } + + serial := "ffaa13f9c34be80b8e2532b83afe063b59a6" + resp2, err := client.GetResponse(context.Background(), serial) + if err != nil { + t.Fatalf("getting response: %s", err) + } + if !bytes.Equal(resp2, respBytes) { + t.Errorf("response written and response retrieved were not equal") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/rocsp/testdata/ocsp.response b/third-party/github.com/letsencrypt/boulder/rocsp/testdata/ocsp.response new file mode 100644 index 000000000..c52cbbc1e Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/rocsp/testdata/ocsp.response differ diff --git a/third-party/github.com/letsencrypt/boulder/sa/database.go b/third-party/github.com/letsencrypt/boulder/sa/database.go new file mode 100644 index 000000000..ba3b73003 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/database.go @@ -0,0 +1,298 @@ +package sa + +import ( + "database/sql" + "fmt" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/borp" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + boulderDB "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + blog "github.com/letsencrypt/boulder/log" +) + +// DbSettings contains settings for the database/sql driver. The zero +// value of each field means use the default setting from database/sql. +// ConnMaxIdleTime and ConnMaxLifetime should be set lower than their +// mariab counterparts interactive_timeout and wait_timeout. +type DbSettings struct { + // MaxOpenConns sets the maximum number of open connections to the + // database. If MaxIdleConns is greater than 0 and MaxOpenConns is + // less than MaxIdleConns, then MaxIdleConns will be reduced to + // match the new MaxOpenConns limit. If n < 0, then there is no + // limit on the number of open connections. + MaxOpenConns int + + // MaxIdleConns sets the maximum number of connections in the idle + // connection pool. If MaxOpenConns is greater than 0 but less than + // MaxIdleConns, then MaxIdleConns will be reduced to match the + // MaxOpenConns limit. If n < 0, no idle connections are retained. + MaxIdleConns int + + // ConnMaxLifetime sets the maximum amount of time a connection may + // be reused. Expired connections may be closed lazily before reuse. + // If d < 0, connections are not closed due to a connection's age. + ConnMaxLifetime time.Duration + + // ConnMaxIdleTime sets the maximum amount of time a connection may + // be idle. Expired connections may be closed lazily before reuse. + // If d < 0, connections are not closed due to a connection's idle + // time. + ConnMaxIdleTime time.Duration +} + +// InitWrappedDb constructs a wrapped borp mapping object with the provided +// settings. If scope is non-nil, Prometheus metrics will be exported. If logger +// is non-nil, SQL debug-level logging will be enabled. The only required parameter +// is config. +func InitWrappedDb(config cmd.DBConfig, scope prometheus.Registerer, logger blog.Logger) (*boulderDB.WrappedMap, error) { + url, err := config.URL() + if err != nil { + return nil, fmt.Errorf("failed to load DBConnect URL: %s", err) + } + + settings := DbSettings{ + MaxOpenConns: config.MaxOpenConns, + MaxIdleConns: config.MaxIdleConns, + ConnMaxLifetime: config.ConnMaxLifetime.Duration, + ConnMaxIdleTime: config.ConnMaxIdleTime.Duration, + } + + mysqlConfig, err := mysql.ParseDSN(url) + if err != nil { + return nil, err + } + + dbMap, err := newDbMapFromMySQLConfig(mysqlConfig, settings, scope, logger) + if err != nil { + return nil, err + } + + return dbMap, nil +} + +// DBMapForTest creates a wrapped root borp mapping object. Create one of these for +// each database schema you wish to map. Each DbMap contains a list of mapped +// tables. It automatically maps the tables for the primary parts of Boulder +// around the Storage Authority. +func DBMapForTest(dbConnect string) (*boulderDB.WrappedMap, error) { + return DBMapForTestWithLog(dbConnect, nil) +} + +// DBMapForTestWithLog does the same as DBMapForTest but also routes the debug logs +// from the database driver to the given log (usually a `blog.NewMock`). +func DBMapForTestWithLog(dbConnect string, log blog.Logger) (*boulderDB.WrappedMap, error) { + var err error + var config *mysql.Config + + config, err = mysql.ParseDSN(dbConnect) + if err != nil { + return nil, err + } + + return newDbMapFromMySQLConfig(config, DbSettings{}, nil, log) +} + +// sqlOpen is used in the tests to check that the arguments are properly +// transformed +var sqlOpen = func(dbType, connectStr string) (*sql.DB, error) { + return sql.Open(dbType, connectStr) +} + +// setMaxOpenConns is also used so that we can replace it for testing. +var setMaxOpenConns = func(db *sql.DB, maxOpenConns int) { + if maxOpenConns != 0 { + db.SetMaxOpenConns(maxOpenConns) + } +} + +// setMaxIdleConns is also used so that we can replace it for testing. +var setMaxIdleConns = func(db *sql.DB, maxIdleConns int) { + if maxIdleConns != 0 { + db.SetMaxIdleConns(maxIdleConns) + } +} + +// setConnMaxLifetime is also used so that we can replace it for testing. +var setConnMaxLifetime = func(db *sql.DB, connMaxLifetime time.Duration) { + if connMaxLifetime != 0 { + db.SetConnMaxLifetime(connMaxLifetime) + } +} + +// setConnMaxIdleTime is also used so that we can replace it for testing. +var setConnMaxIdleTime = func(db *sql.DB, connMaxIdleTime time.Duration) { + if connMaxIdleTime != 0 { + db.SetConnMaxIdleTime(connMaxIdleTime) + } +} + +// newDbMapFromMySQLConfig opens a database connection given the provided *mysql.Config, plus some Boulder-specific +// required and default settings, plus some additional config in the sa.DbSettings object. The sa.DbSettings object +// is usually provided from JSON config. +// +// This function also: +// - pings the database (and errors if it's unreachable) +// - wraps the connection in a borp.DbMap so we can use the handy Get/Insert methods borp provides +// - wraps that in a db.WrappedMap to get more useful error messages +// +// If logger is non-nil, it will receive debug log messages from borp. +// If scope is non-nil, it will be used to register Prometheus metrics. +func newDbMapFromMySQLConfig(config *mysql.Config, settings DbSettings, scope prometheus.Registerer, logger blog.Logger) (*boulderDB.WrappedMap, error) { + err := adjustMySQLConfig(config) + if err != nil { + return nil, err + } + + db, err := sqlOpen("mysql", config.FormatDSN()) + if err != nil { + return nil, err + } + if err = db.Ping(); err != nil { + return nil, err + } + setMaxOpenConns(db, settings.MaxOpenConns) + setMaxIdleConns(db, settings.MaxIdleConns) + setConnMaxLifetime(db, settings.ConnMaxLifetime) + setConnMaxIdleTime(db, settings.ConnMaxIdleTime) + + if scope != nil { + err = initDBMetrics(db, scope, settings, config.Addr, config.User) + if err != nil { + return nil, fmt.Errorf("while initializing metrics: %w", err) + } + } + + dialect := borp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"} + dbmap := &borp.DbMap{Db: db, Dialect: dialect, TypeConverter: BoulderTypeConverter{}} + + if logger != nil { + dbmap.TraceOn("SQL: ", &SQLLogger{logger}) + } + + initTables(dbmap) + return boulderDB.NewWrappedMap(dbmap), nil +} + +// adjustMySQLConfig sets certain flags that we want on every connection. +func adjustMySQLConfig(conf *mysql.Config) error { + // Required to turn DATETIME fields into time.Time + conf.ParseTime = true + + // Required to make UPDATE return the number of rows matched, + // instead of the number of rows changed by the UPDATE. + conf.ClientFoundRows = true + + if conf.Params == nil { + conf.Params = make(map[string]string) + } + + // If a given parameter is not already set in conf.Params from the DSN, set it. + setDefault := func(name, value string) { + _, ok := conf.Params[name] + if !ok { + conf.Params[name] = value + } + } + + // If a given parameter has the value "0", delete it from conf.Params. + omitZero := func(name string) { + if conf.Params[name] == "0" { + delete(conf.Params, name) + } + } + + // Ensures that MySQL/MariaDB warnings are treated as errors. This + // avoids a number of nasty edge conditions we could wander into. + // Common things this discovers includes places where data being sent + // had a different type than what is in the schema, strings being + // truncated, writing null to a NOT NULL column, and so on. See + // . + setDefault("sql_mode", "'STRICT_ALL_TABLES'") + + // If a read timeout is set, we set max_statement_time to 95% of that, and + // long_query_time to 80% of that. That way we get logs of queries that are + // close to timing out but not yet doing so, and our queries get stopped by + // max_statement_time before timing out the read. This generates clearer + // errors, and avoids unnecessary reconnects. + // To override these values, set them in the DSN, e.g. + // `?max_statement_time=2`. A zero value in the DSN means these won't be + // sent on new connections. + if conf.ReadTimeout != 0 { + // In MariaDB, max_statement_time and long_query_time are both seconds, + // but can have up to microsecond granularity. + // Note: in MySQL (which we don't use), max_statement_time is millis. + readTimeout := conf.ReadTimeout.Seconds() + setDefault("max_statement_time", fmt.Sprintf("%.6f", readTimeout*0.95)) + setDefault("long_query_time", fmt.Sprintf("%.6f", readTimeout*0.80)) + } + + omitZero("max_statement_time") + omitZero("long_query_time") + + // Finally, perform validation over all variables set by the DSN and via Boulder. + for k, v := range conf.Params { + err := checkMariaDBSystemVariables(k, v) + if err != nil { + return err + } + } + + return nil +} + +// SQLLogger adapts the Boulder Logger to a format borp can use. +type SQLLogger struct { + blog.Logger +} + +// Printf adapts the Logger to borp's interface +func (log *SQLLogger) Printf(format string, v ...interface{}) { + log.Debugf(format, v...) +} + +// initTables constructs the table map for the ORM. +// NOTE: For tables with an auto-increment primary key (SetKeys(true, ...)), +// it is very important to declare them as a such here. It produces a side +// effect in Insert() where the inserted object has its id field set to the +// autoincremented value that resulted from the insert. See +// https://godoc.org/github.com/coopernurse/borp#DbMap.Insert +func initTables(dbMap *borp.DbMap) { + regTable := dbMap.AddTableWithName(regModel{}, "registrations").SetKeys(true, "ID") + + regTable.SetVersionCol("LockCol") + regTable.ColMap("Key").SetNotNull(true) + regTable.ColMap("KeySHA256").SetNotNull(true).SetUnique(true) + dbMap.AddTableWithName(issuedNameModel{}, "issuedNames").SetKeys(true, "ID") + dbMap.AddTableWithName(core.Certificate{}, "certificates").SetKeys(true, "ID") + dbMap.AddTableWithName(core.CertificateStatus{}, "certificateStatus").SetKeys(true, "ID") + dbMap.AddTableWithName(core.FQDNSet{}, "fqdnSets").SetKeys(true, "ID") + if features.Get().MultipleCertificateProfiles { + dbMap.AddTableWithName(orderModelv2{}, "orders").SetKeys(true, "ID") + } else { + dbMap.AddTableWithName(orderModelv1{}, "orders").SetKeys(true, "ID") + } + dbMap.AddTableWithName(orderToAuthzModel{}, "orderToAuthz").SetKeys(false, "OrderID", "AuthzID") + dbMap.AddTableWithName(orderFQDNSet{}, "orderFqdnSets").SetKeys(true, "ID") + dbMap.AddTableWithName(authzModel{}, "authz2").SetKeys(true, "ID") + dbMap.AddTableWithName(orderToAuthzModel{}, "orderToAuthz2").SetKeys(false, "OrderID", "AuthzID") + dbMap.AddTableWithName(recordedSerialModel{}, "serials").SetKeys(true, "ID") + dbMap.AddTableWithName(precertificateModel{}, "precertificates").SetKeys(true, "ID") + dbMap.AddTableWithName(keyHashModel{}, "keyHashToSerial").SetKeys(true, "ID") + dbMap.AddTableWithName(incidentModel{}, "incidents").SetKeys(true, "ID") + dbMap.AddTable(incidentSerialModel{}) + dbMap.AddTableWithName(crlShardModel{}, "crlShards").SetKeys(true, "ID") + dbMap.AddTableWithName(revokedCertModel{}, "revokedCertificates").SetKeys(true, "ID") + dbMap.AddTableWithName(replacementOrderModel{}, "replacementOrders").SetKeys(true, "ID") + dbMap.AddTableWithName(pausedModel{}, "paused") + + // Read-only maps used for selecting subsets of columns. + dbMap.AddTableWithName(CertStatusMetadata{}, "certificateStatus") + dbMap.AddTableWithName(crlEntryModel{}, "certificateStatus") +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/database_test.go b/third-party/github.com/letsencrypt/boulder/sa/database_test.go new file mode 100644 index 000000000..1585c6d89 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/database_test.go @@ -0,0 +1,229 @@ +package sa + +import ( + "context" + "database/sql" + "errors" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +func TestInvalidDSN(t *testing.T) { + _, err := DBMapForTest("invalid") + test.AssertError(t, err, "DB connect string missing the slash separating the database name") + + DSN := "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?readTimeout=800ms&writeTimeout=800ms&stringVarThatDoesntExist=%27whoopsidaisies" + _, err = DBMapForTest(DSN) + test.AssertError(t, err, "Variable does not exist in curated system var list, but didn't return an error and should have") + + DSN = "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?readTimeout=800ms&writeTimeout=800ms&concurrent_insert=2" + _, err = DBMapForTest(DSN) + test.AssertError(t, err, "Variable is unable to be set in the SESSION scope, but was declared") + + DSN = "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?readTimeout=800ms&writeTimeout=800ms&optimizer_switch=incorrect-quoted-string" + _, err = DBMapForTest(DSN) + test.AssertError(t, err, "Variable declared with incorrect quoting") + + DSN = "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?readTimeout=800ms&writeTimeout=800ms&concurrent_insert=%272%27" + _, err = DBMapForTest(DSN) + test.AssertError(t, err, "Integer enum declared, but should not have been quoted") +} + +var errExpected = errors.New("expected") + +func TestDbSettings(t *testing.T) { + // TODO(#5248): Add a full db.mockWrappedMap to sa/database tests + oldSetMaxOpenConns := setMaxOpenConns + oldSetMaxIdleConns := setMaxIdleConns + oldSetConnMaxLifetime := setConnMaxLifetime + oldSetConnMaxIdleTime := setConnMaxIdleTime + defer func() { + setMaxOpenConns = oldSetMaxOpenConns + setMaxIdleConns = oldSetMaxIdleConns + setConnMaxLifetime = oldSetConnMaxLifetime + setConnMaxIdleTime = oldSetConnMaxIdleTime + }() + + maxOpenConns := -1 + maxIdleConns := -1 + connMaxLifetime := time.Second * 1 + connMaxIdleTime := time.Second * 1 + + setMaxOpenConns = func(db *sql.DB, m int) { + maxOpenConns = m + oldSetMaxOpenConns(db, maxOpenConns) + } + setMaxIdleConns = func(db *sql.DB, m int) { + maxIdleConns = m + oldSetMaxIdleConns(db, maxIdleConns) + } + setConnMaxLifetime = func(db *sql.DB, c time.Duration) { + connMaxLifetime = c + oldSetConnMaxLifetime(db, connMaxLifetime) + } + setConnMaxIdleTime = func(db *sql.DB, c time.Duration) { + connMaxIdleTime = c + oldSetConnMaxIdleTime(db, connMaxIdleTime) + } + dsnFile := path.Join(t.TempDir(), "dbconnect") + err := os.WriteFile(dsnFile, + []byte("sa@tcp(boulder-proxysql:6033)/boulder_sa_integration"), + os.ModeAppend) + test.AssertNotError(t, err, "writing dbconnect file") + + config := cmd.DBConfig{ + DBConnectFile: dsnFile, + MaxOpenConns: 100, + MaxIdleConns: 100, + ConnMaxLifetime: config.Duration{Duration: 100 * time.Second}, + ConnMaxIdleTime: config.Duration{Duration: 100 * time.Second}, + } + _, err = InitWrappedDb(config, nil, nil) + if err != nil { + t.Errorf("connecting to DB: %s", err) + } + if maxOpenConns != 100 { + t.Errorf("maxOpenConns was not set: expected 100, got %d", maxOpenConns) + } + if maxIdleConns != 100 { + t.Errorf("maxIdleConns was not set: expected 100, got %d", maxIdleConns) + } + if connMaxLifetime != 100*time.Second { + t.Errorf("connMaxLifetime was not set: expected 100s, got %s", connMaxLifetime) + } + if connMaxIdleTime != 100*time.Second { + t.Errorf("connMaxIdleTime was not set: expected 100s, got %s", connMaxIdleTime) + } +} + +// TODO: Change this to test `newDbMapFromMySQLConfig` instead? +func TestNewDbMap(t *testing.T) { + const mysqlConnectURL = "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?readTimeout=800ms&writeTimeout=800ms" + const expected = "policy:password@tcp(boulder-proxysql:6033)/boulder_policy_integration?clientFoundRows=true&parseTime=true&readTimeout=800ms&writeTimeout=800ms&long_query_time=0.640000&max_statement_time=0.760000&sql_mode=%27STRICT_ALL_TABLES%27" + oldSQLOpen := sqlOpen + defer func() { + sqlOpen = oldSQLOpen + }() + sqlOpen = func(dbType, connectString string) (*sql.DB, error) { + if connectString != expected { + t.Errorf("incorrect connection string mangling, want %#v, got %#v", expected, connectString) + } + return nil, errExpected + } + + dbMap, err := DBMapForTest(mysqlConnectURL) + if err != errExpected { + t.Errorf("got incorrect error. Got %v, expected %v", err, errExpected) + } + if dbMap != nil { + t.Errorf("expected nil, got %v", dbMap) + } + +} + +func TestStrictness(t *testing.T) { + dbMap, err := DBMapForTest(vars.DBConnSA) + if err != nil { + t.Fatal(err) + } + _, err = dbMap.ExecContext(ctx, `insert into orderToAuthz2 set + orderID=999999999999999999999999999, + authzID=999999999999999999999999999;`) + if err == nil { + t.Fatal("Expected error when providing out of range value, got none.") + } + if !strings.Contains(err.Error(), "Out of range value for column") { + t.Fatalf("Got wrong type of error: %s", err) + } +} + +func TestTimeouts(t *testing.T) { + dbMap, err := DBMapForTest(vars.DBConnSA + "?max_statement_time=1") + if err != nil { + t.Fatal("Error setting up DB:", err) + } + // SLEEP is defined to return 1 if it was interrupted, but we want to actually + // get an error to simulate what would happen with a slow query. So we wrap + // the SLEEP in a subselect. + _, err = dbMap.ExecContext(ctx, `SELECT 1 FROM (SELECT SLEEP(5)) as subselect;`) + if err == nil { + t.Fatal("Expected error when running slow query, got none.") + } + + // We expect to get: + // Error 1969: Query execution was interrupted (max_statement_time exceeded) + // https://mariadb.com/kb/en/mariadb/mariadb-error-codes/ + if !strings.Contains(err.Error(), "Error 1969") { + t.Fatalf("Got wrong type of error: %s", err) + } +} + +// TestAutoIncrementSchema tests that all of the tables in the boulder_* +// databases that have auto_increment columns use BIGINT for the data type. Our +// data is too big for INT. +func TestAutoIncrementSchema(t *testing.T) { + dbMap, err := DBMapForTest(vars.DBInfoSchemaRoot) + test.AssertNotError(t, err, "unexpected err making NewDbMap") + + var count int64 + err = dbMap.SelectOne( + context.Background(), + &count, + `SELECT COUNT(*) FROM columns WHERE + table_schema LIKE 'boulder%' AND + extra LIKE '%auto_increment%' AND + data_type != "bigint"`) + test.AssertNotError(t, err, "unexpected err querying columns") + test.AssertEquals(t, count, int64(0)) +} + +func TestAdjustMySQLConfig(t *testing.T) { + conf := &mysql.Config{} + err := adjustMySQLConfig(conf) + test.AssertNotError(t, err, "unexpected err setting server variables") + test.AssertDeepEquals(t, conf.Params, map[string]string{ + "sql_mode": "'STRICT_ALL_TABLES'", + }) + + conf = &mysql.Config{ReadTimeout: 100 * time.Second} + err = adjustMySQLConfig(conf) + test.AssertNotError(t, err, "unexpected err setting server variables") + test.AssertDeepEquals(t, conf.Params, map[string]string{ + "sql_mode": "'STRICT_ALL_TABLES'", + "max_statement_time": "95.000000", + "long_query_time": "80.000000", + }) + + conf = &mysql.Config{ + ReadTimeout: 100 * time.Second, + Params: map[string]string{ + "max_statement_time": "0", + }, + } + err = adjustMySQLConfig(conf) + test.AssertNotError(t, err, "unexpected err setting server variables") + test.AssertDeepEquals(t, conf.Params, map[string]string{ + "sql_mode": "'STRICT_ALL_TABLES'", + "long_query_time": "80.000000", + }) + + conf = &mysql.Config{ + Params: map[string]string{ + "max_statement_time": "0", + }, + } + err = adjustMySQLConfig(conf) + test.AssertNotError(t, err, "unexpected err setting server variables") + test.AssertDeepEquals(t, conf.Params, map[string]string{ + "sql_mode": "'STRICT_ALL_TABLES'", + }) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000001_DropCertStatusSubscriberApproved.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000001_DropCertStatusSubscriberApproved.sql new file mode 100644 index 000000000..f1dfadabb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000001_DropCertStatusSubscriberApproved.sql @@ -0,0 +1,10 @@ + +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +ALTER TABLE `certificateStatus` DROP COLUMN `subscriberApproved`; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +ALTER TABLE `certificateStatus` ADD COLUMN `subscriberApproved` TINYINT(1) DEFAULT 0; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000002_DropCertStatusLockCol.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000002_DropCertStatusLockCol.sql new file mode 100644 index 000000000..f634cac25 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000002_DropCertStatusLockCol.sql @@ -0,0 +1,10 @@ + +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +ALTER TABLE `certificateStatus` DROP COLUMN `LockCol`; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +ALTER TABLE `certificateStatus` ADD COLUMN `LockCol` BIGINT(20) DEFAULT 0; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000003_OrderToAuthzID.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000003_OrderToAuthzID.sql new file mode 100644 index 000000000..2a2ab06cc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230419000003_OrderToAuthzID.sql @@ -0,0 +1,27 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +DROP TABLE orderToAuthz2; +CREATE TABLE `orderToAuthz2` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `orderID` bigint(20) UNSIGNED NOT NULL, + `authzID` bigint(20) UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + KEY `orderID_idx` (`orderID`), + KEY `authzID_idx` (`authzID`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE (`id`) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE orderToAuthz2; +CREATE TABLE `orderToAuthz2` ( + `orderID` bigint(20) NOT NULL, + `authzID` bigint(20) NOT NULL, + PRIMARY KEY (`orderID`,`authzID`), + KEY `authzID` (`authzID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE COLUMNS(orderID, authzID) +(PARTITION p_start VALUES LESS THAN (MAXVALUE, MAXVALUE)); diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230919000000_RevokedCertificates.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230919000000_RevokedCertificates.sql new file mode 100644 index 000000000..fe86aa71b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20230919000000_RevokedCertificates.sql @@ -0,0 +1,21 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE `revokedCertificates` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `issuerID` bigint(20) NOT NULL, + `serial` varchar(255) NOT NULL, + `notAfterHour` datetime NOT NULL, + `shardIdx` bigint(20) NOT NULL, + `revokedDate` datetime NOT NULL, + `revokedReason` int(11) NOT NULL, + PRIMARY KEY (`id`), + KEY `issuerID_shardIdx_notAfterHour_idx` (`issuerID`, `shardIdx`, `notAfterHour`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE `revokedCertificates`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240119000000_ReplacementOrders.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240119000000_ReplacementOrders.sql new file mode 100644 index 000000000..c2bc65f9c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240119000000_ReplacementOrders.sql @@ -0,0 +1,20 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE `replacementOrders` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `serial` varchar(255) NOT NULL, + `orderID` bigint(20) NOT NULL, + `orderExpires` datetime NOT NULL, + `replaced` boolean DEFAULT false, + PRIMARY KEY (`id`), + KEY `serial_idx` (`serial`), + KEY `orderID_idx` (`orderID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE `replacementOrders`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240304000000_CertificateProfiles.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240304000000_CertificateProfiles.sql new file mode 100644 index 000000000..583a106d6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240304000000_CertificateProfiles.sql @@ -0,0 +1,9 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +ALTER TABLE `orders` ADD COLUMN `certificateProfileName` varchar(32) DEFAULT NULL; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +ALTER TABLE `orders` DROP COLUMN `certificateProfileName`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240503000000_RemoveRequestedNames.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240503000000_RemoveRequestedNames.sql new file mode 100644 index 000000000..1837923dd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240503000000_RemoveRequestedNames.sql @@ -0,0 +1,18 @@ +-- +migrate Up + +DROP TABLE requestedNames; + +-- +migrate Down + +DROP TABLE requestedNames; + +CREATE TABLE `requestedNames` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `orderID` bigint(20) NOT NULL, + `reversedName` varchar(253) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`id`), + KEY `orderID_idx` (`orderID`), + KEY `reversedName_idx` (`reversedName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240514000000_Paused.sql b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240514000000_Paused.sql new file mode 100644 index 000000000..e59c693eb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-next/boulder_sa/20240514000000_Paused.sql @@ -0,0 +1,20 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- This table has no auto-incrementing primary key because we don't plan to +-- partition it. This table expected to be < 800K rows initially and grow at a +-- rate of ~18% per year. + +CREATE TABLE `paused` ( + `registrationID` bigint(20) NOT NULL, + `identifierType` tinyint(4) NOT NULL, + `identifierValue` varchar(255) NOT NULL, + `pausedAt` datetime NOT NULL, + `unpausedAt` datetime DEFAULT NULL, + PRIMARY KEY (`registrationID`, `identifierType`, `identifierValue`) +); + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE `paused`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-users/boulder_sa.sql b/third-party/github.com/letsencrypt/boulder/sa/db-users/boulder_sa.sql new file mode 100644 index 000000000..544f52620 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-users/boulder_sa.sql @@ -0,0 +1,93 @@ +-- this file is run by test/create_db.sh to create users for each +-- component with the appropriate permissions. + +-- These lines require MariaDB 10.1+ +CREATE USER IF NOT EXISTS 'policy'@'localhost'; +CREATE USER IF NOT EXISTS 'sa'@'localhost'; +CREATE USER IF NOT EXISTS 'sa_ro'@'localhost'; +CREATE USER IF NOT EXISTS 'ocsp_resp'@'localhost'; +CREATE USER IF NOT EXISTS 'revoker'@'localhost'; +CREATE USER IF NOT EXISTS 'importer'@'localhost'; +CREATE USER IF NOT EXISTS 'mailer'@'localhost'; +CREATE USER IF NOT EXISTS 'cert_checker'@'localhost'; +CREATE USER IF NOT EXISTS 'test_setup'@'localhost'; +CREATE USER IF NOT EXISTS 'badkeyrevoker'@'localhost'; +CREATE USER IF NOT EXISTS 'proxysql'@'localhost'; + +-- Storage Authority +GRANT SELECT,INSERT ON certificates TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON certificateStatus TO 'sa'@'localhost'; +GRANT SELECT,INSERT ON issuedNames TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON certificatesPerName TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON registrations TO 'sa'@'localhost'; +GRANT SELECT,INSERT on fqdnSets TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON orders TO 'sa'@'localhost'; +GRANT SELECT,INSERT,DELETE ON orderFqdnSets TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON authz2 TO 'sa'@'localhost'; +GRANT SELECT,INSERT ON orderToAuthz2 TO 'sa'@'localhost'; +GRANT INSERT,SELECT ON serials TO 'sa'@'localhost'; +GRANT SELECT,INSERT ON precertificates TO 'sa'@'localhost'; +GRANT SELECT,INSERT ON keyHashToSerial TO 'sa'@'localhost'; +GRANT SELECT,INSERT ON blockedKeys TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON newOrdersRL TO 'sa'@'localhost'; +GRANT SELECT ON incidents TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON crlShards TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON revokedCertificates TO 'sa'@'localhost'; +GRANT SELECT,INSERT,UPDATE ON replacementOrders TO 'sa'@'localhost'; +-- Tests need to be able to TRUNCATE this table, so DROP is necessary. +GRANT SELECT,INSERT,UPDATE,DROP ON paused TO 'sa'@'localhost'; + +GRANT SELECT ON certificates TO 'sa_ro'@'localhost'; +GRANT SELECT ON certificateStatus TO 'sa_ro'@'localhost'; +GRANT SELECT ON issuedNames TO 'sa_ro'@'localhost'; +GRANT SELECT ON certificatesPerName TO 'sa_ro'@'localhost'; +GRANT SELECT ON registrations TO 'sa_ro'@'localhost'; +GRANT SELECT on fqdnSets TO 'sa_ro'@'localhost'; +GRANT SELECT ON orders TO 'sa_ro'@'localhost'; +GRANT SELECT ON orderFqdnSets TO 'sa_ro'@'localhost'; +GRANT SELECT ON authz2 TO 'sa_ro'@'localhost'; +GRANT SELECT ON orderToAuthz2 TO 'sa_ro'@'localhost'; +GRANT SELECT ON serials TO 'sa_ro'@'localhost'; +GRANT SELECT ON precertificates TO 'sa_ro'@'localhost'; +GRANT SELECT ON keyHashToSerial TO 'sa_ro'@'localhost'; +GRANT SELECT ON blockedKeys TO 'sa_ro'@'localhost'; +GRANT SELECT ON newOrdersRL TO 'sa_ro'@'localhost'; +GRANT SELECT ON incidents TO 'sa_ro'@'localhost'; +GRANT SELECT ON crlShards TO 'sa_ro'@'localhost'; +GRANT SELECT ON revokedCertificates TO 'sa_ro'@'localhost'; +GRANT SELECT ON replacementOrders TO 'sa_ro'@'localhost'; +GRANT SELECT ON paused TO 'sa_ro'@'localhost'; + +-- OCSP Responder +GRANT SELECT ON certificateStatus TO 'ocsp_resp'@'localhost'; + +-- Revoker Tool +GRANT SELECT,UPDATE ON registrations TO 'revoker'@'localhost'; +GRANT SELECT ON certificates TO 'revoker'@'localhost'; +GRANT SELECT ON precertificates TO 'revoker'@'localhost'; +GRANT SELECT ON keyHashToSerial TO 'revoker'@'localhost'; +GRANT SELECT,UPDATE ON blockedKeys TO 'revoker'@'localhost'; + +-- Expiration mailer +GRANT SELECT ON certificates TO 'mailer'@'localhost'; +GRANT SELECT ON registrations TO 'mailer'@'localhost'; +GRANT SELECT,UPDATE ON certificateStatus TO 'mailer'@'localhost'; +GRANT SELECT ON fqdnSets TO 'mailer'@'localhost'; + +-- Cert checker +GRANT SELECT ON certificates TO 'cert_checker'@'localhost'; +GRANT SELECT ON authz2 TO 'cert_checker'@'localhost'; +GRANT SELECT ON precertificates TO 'cert_checker'@'localhost'; + +-- Bad Key Revoker +GRANT SELECT,UPDATE ON blockedKeys TO 'badkeyrevoker'@'localhost'; +GRANT SELECT ON keyHashToSerial TO 'badkeyrevoker'@'localhost'; +GRANT SELECT ON certificateStatus TO 'badkeyrevoker'@'localhost'; +GRANT SELECT ON precertificates TO 'badkeyrevoker'@'localhost'; +GRANT SELECT ON registrations TO 'badkeyrevoker'@'localhost'; + +-- ProxySQL -- +GRANT ALL PRIVILEGES ON monitor TO 'proxysql'@'localhost'; + +-- Test setup and teardown +GRANT ALL PRIVILEGES ON * to 'test_setup'@'localhost'; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db-users/incidents_sa.sql b/third-party/github.com/letsencrypt/boulder/sa/db-users/incidents_sa.sql new file mode 100644 index 000000000..5fa61fc84 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db-users/incidents_sa.sql @@ -0,0 +1,12 @@ +-- this file is run by test/create_db.sh to create users for each +-- component with the appropriate permissions. + +-- These lines require MariaDB 10.1+ +CREATE USER IF NOT EXISTS 'incidents_sa'@'localhost'; +CREATE USER IF NOT EXISTS 'test_setup'@'localhost'; + +-- Storage Authority +GRANT SELECT ON * TO 'incidents_sa'@'localhost'; + +-- Test setup and teardown +GRANT ALL PRIVILEGES ON * to 'test_setup'@'localhost'; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230419000000_CombinedSchema.sql b/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230419000000_CombinedSchema.sql new file mode 100644 index 000000000..34d6f151c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230419000000_CombinedSchema.sql @@ -0,0 +1,251 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE `authz2` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `identifierType` tinyint(4) NOT NULL, + `identifierValue` varchar(255) NOT NULL, + `registrationID` bigint(20) NOT NULL, + `status` tinyint(4) NOT NULL, + `expires` datetime NOT NULL, + `challenges` tinyint(4) NOT NULL, + `attempted` tinyint(4) DEFAULT NULL, + `attemptedAt` datetime DEFAULT NULL, + `token` binary(32) NOT NULL, + `validationError` mediumblob DEFAULT NULL, + `validationRecord` mediumblob DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `regID_expires_idx` (`registrationID`,`status`,`expires`), + KEY `regID_identifier_status_expires_idx` (`registrationID`,`identifierType`,`identifierValue`,`status`,`expires`), + KEY `expires_idx` (`expires`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `blockedKeys` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `keyHash` binary(32) NOT NULL, + `added` datetime NOT NULL, + `source` tinyint(4) NOT NULL, + `comment` varchar(255) DEFAULT NULL, + `revokedBy` bigint(20) DEFAULT 0, + `extantCertificatesChecked` tinyint(1) DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `keyHash` (`keyHash`), + KEY `extantCertificatesChecked_idx` (`extantCertificatesChecked`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `certificateStatus` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `serial` varchar(255) NOT NULL, + `subscriberApproved` tinyint(1) DEFAULT 0, + `status` varchar(255) NOT NULL, + `ocspLastUpdated` datetime NOT NULL, + `revokedDate` datetime NOT NULL, + `revokedReason` int(11) NOT NULL, + `lastExpirationNagSent` datetime NOT NULL, + `LockCol` bigint(20) DEFAULT 0, + `ocspResponse` blob DEFAULT NULL, + `notAfter` datetime DEFAULT NULL, + `isExpired` tinyint(1) DEFAULT 0, + `issuerID` bigint(20) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `serial` (`serial`), + KEY `isExpired_ocspLastUpdated_idx` (`isExpired`,`ocspLastUpdated`), + KEY `notAfter_idx` (`notAfter`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `certificates` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `registrationID` bigint(20) NOT NULL, + `serial` varchar(255) NOT NULL, + `digest` varchar(255) NOT NULL, + `der` mediumblob NOT NULL, + `issued` datetime NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `serial` (`serial`), + KEY `regId_certificates_idx` (`registrationID`) COMMENT 'Common lookup', + KEY `issued_idx` (`issued`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `certificatesPerName` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `eTLDPlusOne` varchar(255) NOT NULL, + `time` datetime NOT NULL, + `count` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `eTLDPlusOne_time_idx` (`eTLDPlusOne`,`time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `fqdnSets` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `setHash` binary(32) NOT NULL, + `serial` varchar(255) NOT NULL, + `issued` datetime NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `serial` (`serial`), + KEY `setHash_issued_idx` (`setHash`,`issued`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `incidents` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `serialTable` varchar(128) NOT NULL, + `url` varchar(1024) NOT NULL, + `renewBy` datetime NOT NULL, + `enabled` boolean DEFAULT false, + PRIMARY KEY (`id`) +) CHARSET=utf8mb4; + +CREATE TABLE `issuedNames` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `reversedName` varchar(640) CHARACTER SET ascii NOT NULL, + `notBefore` datetime NOT NULL, + `serial` varchar(255) NOT NULL, + `renewal` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `reversedName_notBefore_Idx` (`reversedName`,`notBefore`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `keyHashToSerial` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `keyHash` binary(32) NOT NULL, + `certNotAfter` datetime NOT NULL, + `certSerial` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_keyHash_certserial` (`keyHash`,`certSerial`), + KEY `keyHash_certNotAfter` (`keyHash`,`certNotAfter`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `newOrdersRL` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `regID` bigint(20) NOT NULL, + `time` datetime NOT NULL, + `count` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `regID_time_idx` (`regID`,`time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `orderFqdnSets` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `setHash` binary(32) NOT NULL, + `orderID` bigint(20) NOT NULL, + `registrationID` bigint(20) NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `setHash_expires_idx` (`setHash`,`expires`), + KEY `orderID_idx` (`orderID`), + KEY `orderFqdnSets_registrationID_registrations` (`registrationID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `orderToAuthz2` ( + `orderID` bigint(20) NOT NULL, + `authzID` bigint(20) NOT NULL, + PRIMARY KEY (`orderID`,`authzID`), + KEY `authzID` (`authzID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE COLUMNS(orderID, authzID) +(PARTITION p_start VALUES LESS THAN (MAXVALUE, MAXVALUE)); + +CREATE TABLE `orders` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `registrationID` bigint(20) NOT NULL, + `expires` datetime NOT NULL, + `error` mediumblob DEFAULT NULL, + `certificateSerial` varchar(255) DEFAULT NULL, + `beganProcessing` tinyint(1) NOT NULL DEFAULT 0, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `reg_status_expires` (`registrationID`,`expires`), + KEY `regID_created_idx` (`registrationID`,`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `precertificates` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `registrationID` bigint(20) NOT NULL, + `serial` varchar(255) NOT NULL, + `der` mediumblob NOT NULL, + `issued` datetime NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `serial` (`serial`), + KEY `regId_precertificates_idx` (`registrationID`), + KEY `issued_precertificates_idx` (`issued`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +CREATE TABLE `registrations` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `jwk` mediumblob NOT NULL, + `jwk_sha256` varchar(255) NOT NULL, + `contact` varchar(191) CHARACTER SET utf8mb4 NOT NULL, + `agreement` varchar(255) NOT NULL, + `LockCol` bigint(20) NOT NULL, + `initialIP` binary(16) NOT NULL DEFAULT '\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0', + `createdAt` datetime NOT NULL, + `status` varchar(255) NOT NULL DEFAULT 'valid', + PRIMARY KEY (`id`), + UNIQUE KEY `jwk_sha256` (`jwk_sha256`), + KEY `initialIP_createdAt` (`initialIP`,`createdAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `requestedNames` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `orderID` bigint(20) NOT NULL, + `reversedName` varchar(253) CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`id`), + KEY `orderID_idx` (`orderID`), + KEY `reversedName_idx` (`reversedName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + PARTITION BY RANGE(id) +(PARTITION p_start VALUES LESS THAN (MAXVALUE)); + +-- Tables below have foreign key constraints, so are created after all other tables. + +CREATE TABLE `serials` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `registrationID` bigint(20) NOT NULL, + `serial` varchar(255) NOT NULL, + `created` datetime NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `serial` (`serial`), + KEY `regId_serials_idx` (`registrationID`), + CONSTRAINT `regId_serials` FOREIGN KEY (`registrationID`) REFERENCES `registrations` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +-- First set of tables have foreign key constraints, so are dropped first. +DROP TABLE `serials`; + +DROP TABLE `authz2`; +DROP TABLE `blockedKeys`; +DROP TABLE `certificateStatus`; +DROP TABLE `certificatesPerName`; +DROP TABLE `certificates`; +DROP TABLE `fqdnSets`; +DROP TABLE `issuedNames`; +DROP TABLE `keyHashToSerial`; +DROP TABLE `newOrdersRL`; +DROP TABLE `orderFqdnSets`; +DROP TABLE `orderToAuthz2`; +DROP TABLE `orders`; +DROP TABLE `precertificates`; +DROP TABLE `registrations`; +DROP TABLE `requestedNames`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230519000000_CrlShards.sql b/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230519000000_CrlShards.sql new file mode 100644 index 000000000..6c0d0f9eb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db/boulder_sa/20230519000000_CrlShards.sql @@ -0,0 +1,18 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE `crlShards` ( + `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `issuerID` bigint(20) NOT NULL, + `idx` int UNSIGNED NOT NULL, + `thisUpdate` datetime, + `nextUpdate` datetime, + `leasedUntil` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `shardID` (`issuerID`, `idx`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE `crlShards`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/db/dbconfig.yml b/third-party/github.com/letsencrypt/boulder/sa/db/dbconfig.yml new file mode 100644 index 000000000..747ce0365 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db/dbconfig.yml @@ -0,0 +1,20 @@ +# https://github.com/rubenv/sql-migrate#readme +boulder_sa_test: + dialect: mysql + datasource: root@tcp(boulder-proxysql:6033)/boulder_sa_test?parseTime=true + dir: boulder_sa + +boulder_sa_integration: + dialect: mysql + datasource: root@tcp(boulder-proxysql:6033)/boulder_sa_integration?parseTime=true + dir: boulder_sa + +incidents_sa_test: + dialect: mysql + datasource: root@tcp(boulder-proxysql:6033)/incidents_sa_test?parseTime=true + dir: incidents_sa + +incidents_sa_integration: + dialect: mysql + datasource: root@tcp(boulder-proxysql:6033)/incidents_sa_integration?parseTime=true + dir: incidents_sa diff --git a/third-party/github.com/letsencrypt/boulder/sa/db/incidents_sa/20220328100000_Incidents.sql b/third-party/github.com/letsencrypt/boulder/sa/db/incidents_sa/20220328100000_Incidents.sql new file mode 100644 index 000000000..dec39f18e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/db/incidents_sa/20220328100000_Incidents.sql @@ -0,0 +1,28 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE `incident_foo` ( + `serial` varchar(255) NOT NULL, + `registrationID` bigint(20) unsigned NULL, + `orderID` bigint(20) unsigned NULL, + `lastNoticeSent` datetime NULL, + PRIMARY KEY (`serial`), + KEY `registrationID_idx` (`registrationID`), + KEY `orderID_idx` (`orderID`) +) CHARSET=utf8mb4; + +CREATE TABLE `incident_bar` ( + `serial` varchar(255) NOT NULL, + `registrationID` bigint(20) unsigned NULL, + `orderID` bigint(20) unsigned NULL, + `lastNoticeSent` datetime NULL, + PRIMARY KEY (`serial`), + KEY `registrationID_idx` (`registrationID`), + KEY `orderID_idx` (`orderID`) +) CHARSET=utf8mb4; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE `incident_foo`; +DROP TABLE `incident_bar`; diff --git a/third-party/github.com/letsencrypt/boulder/sa/ip_range_test.go b/third-party/github.com/letsencrypt/boulder/sa/ip_range_test.go new file mode 100644 index 000000000..a92fc7b92 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/ip_range_test.go @@ -0,0 +1,54 @@ +package sa + +import ( + "net" + "testing" +) + +func TestIncrementIP(t *testing.T) { + testCases := []struct { + ip string + index int + expected string + }{ + {"0.0.0.0", 128, "0.0.0.1"}, + {"0.0.0.255", 128, "0.0.1.0"}, + {"127.0.0.1", 128, "127.0.0.2"}, + {"1.2.3.4", 120, "1.2.4.4"}, + {"::1", 128, "::2"}, + {"2002:1001:4008::", 128, "2002:1001:4008::1"}, + {"2002:1001:4008::", 48, "2002:1001:4009::"}, + {"2002:1001:ffff::", 48, "2002:1002::"}, + {"ffff:ffff:ffff::", 48, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"}, + } + for _, tc := range testCases { + ip := net.ParseIP(tc.ip).To16() + actual := incrementIP(ip, tc.index) + expectedIP := net.ParseIP(tc.expected) + if !actual.Equal(expectedIP) { + t.Errorf("Expected incrementIP(%s, %d) to be %s, instead got %s", + tc.ip, tc.index, expectedIP, actual.String()) + } + } +} + +func TestIPRange(t *testing.T) { + testCases := []struct { + ip string + expectedBegin string + expectedEnd string + }{ + {"28.45.45.28", "28.45.45.28", "28.45.45.29"}, + {"2002:1001:4008::", "2002:1001:4008::", "2002:1001:4009::"}, + } + for _, tc := range testCases { + ip := net.ParseIP(tc.ip) + expectedBegin := net.ParseIP(tc.expectedBegin) + expectedEnd := net.ParseIP(tc.expectedEnd) + actualBegin, actualEnd := ipRange(ip) + if !expectedBegin.Equal(actualBegin) || !expectedEnd.Equal(actualEnd) { + t.Errorf("Expected ipRange(%s) to be (%s, %s), got (%s, %s)", + tc.ip, tc.expectedBegin, tc.expectedEnd, actualBegin, actualEnd) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/metrics.go b/third-party/github.com/letsencrypt/boulder/sa/metrics.go new file mode 100644 index 000000000..34b56203e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/metrics.go @@ -0,0 +1,130 @@ +package sa + +import ( + "database/sql" + + "github.com/prometheus/client_golang/prometheus" +) + +type dbMetricsCollector struct { + db *sql.DB + dbSettings DbSettings + + maxOpenConns *prometheus.Desc + maxIdleConns *prometheus.Desc + connMaxLifetime *prometheus.Desc + connMaxIdleTime *prometheus.Desc + openConns *prometheus.Desc + inUse *prometheus.Desc + idle *prometheus.Desc + waitCount *prometheus.Desc + waitDuration *prometheus.Desc + maxIdleClosed *prometheus.Desc + maxLifetimeClosed *prometheus.Desc +} + +// Describe is implemented with DescribeByCollect. That's possible because the +// Collect method will always return the same metrics with the same descriptors. +func (dbc dbMetricsCollector) Describe(ch chan<- *prometheus.Desc) { + prometheus.DescribeByCollect(dbc, ch) +} + +// Collect first triggers the dbMaps's sql.Db's Stats function. Then it +// creates constant metrics for each DBStats value on the fly based on the +// returned data. +// +// Note that Collect could be called concurrently, so we depend on +// Stats() to be concurrency-safe. +func (dbc dbMetricsCollector) Collect(ch chan<- prometheus.Metric) { + writeStat := func(stat *prometheus.Desc, typ prometheus.ValueType, val float64) { + ch <- prometheus.MustNewConstMetric(stat, typ, val) + } + writeCounter := func(stat *prometheus.Desc, val float64) { + writeStat(stat, prometheus.CounterValue, val) + } + writeGauge := func(stat *prometheus.Desc, val float64) { + writeStat(stat, prometheus.GaugeValue, val) + } + + // Translate the DBMap's db.DBStats counter values into Prometheus metrics. + dbMapStats := dbc.db.Stats() + writeGauge(dbc.maxOpenConns, float64(dbMapStats.MaxOpenConnections)) + writeGauge(dbc.maxIdleConns, float64(dbc.dbSettings.MaxIdleConns)) + writeGauge(dbc.connMaxLifetime, float64(dbc.dbSettings.ConnMaxLifetime)) + writeGauge(dbc.connMaxIdleTime, float64(dbc.dbSettings.ConnMaxIdleTime)) + writeGauge(dbc.openConns, float64(dbMapStats.OpenConnections)) + writeGauge(dbc.inUse, float64(dbMapStats.InUse)) + writeGauge(dbc.idle, float64(dbMapStats.Idle)) + writeCounter(dbc.waitCount, float64(dbMapStats.WaitCount)) + writeCounter(dbc.waitDuration, dbMapStats.WaitDuration.Seconds()) + writeCounter(dbc.maxIdleClosed, float64(dbMapStats.MaxIdleClosed)) + writeCounter(dbc.maxLifetimeClosed, float64(dbMapStats.MaxLifetimeClosed)) +} + +// initDBMetrics will register a Collector that translates the provided dbMap's +// stats and DbSettings into Prometheus metrics on the fly. The exported metrics +// all start with `db_`. The underlying data comes from sql.DBStats: +// https://pkg.go.dev/database/sql#DBStats +func initDBMetrics(db *sql.DB, stats prometheus.Registerer, dbSettings DbSettings, address string, user string) error { + // Create a dbMetricsCollector and register it + dbc := dbMetricsCollector{db: db, dbSettings: dbSettings} + + labels := prometheus.Labels{"address": address, "user": user} + + dbc.maxOpenConns = prometheus.NewDesc( + "db_max_open_connections", + "Maximum number of DB connections allowed.", + nil, labels) + + dbc.maxIdleConns = prometheus.NewDesc( + "db_max_idle_connections", + "Maximum number of idle DB connections allowed.", + nil, labels) + + dbc.connMaxLifetime = prometheus.NewDesc( + "db_connection_max_lifetime", + "Maximum lifetime of DB connections allowed.", + nil, labels) + + dbc.connMaxIdleTime = prometheus.NewDesc( + "db_connection_max_idle_time", + "Maximum lifetime of idle DB connections allowed.", + nil, labels) + + dbc.openConns = prometheus.NewDesc( + "db_open_connections", + "Number of established DB connections (in-use and idle).", + nil, labels) + + dbc.inUse = prometheus.NewDesc( + "db_inuse", + "Number of DB connections currently in use.", + nil, labels) + + dbc.idle = prometheus.NewDesc( + "db_idle", + "Number of idle DB connections.", + nil, labels) + + dbc.waitCount = prometheus.NewDesc( + "db_wait_count", + "Total number of DB connections waited for.", + nil, labels) + + dbc.waitDuration = prometheus.NewDesc( + "db_wait_duration_seconds", + "The total time blocked waiting for a new connection.", + nil, labels) + + dbc.maxIdleClosed = prometheus.NewDesc( + "db_max_idle_closed", + "Total number of connections closed due to SetMaxIdleConns.", + nil, labels) + + dbc.maxLifetimeClosed = prometheus.NewDesc( + "db_max_lifetime_closed", + "Total number of connections closed due to SetConnMaxLifetime.", + nil, labels) + + return stats.Register(dbc) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/migrations.sh b/third-party/github.com/letsencrypt/boulder/sa/migrations.sh new file mode 100644 index 000000000..f849934e0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/migrations.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash + +set -eu + +if type realpath >/dev/null 2>&1 ; then + cd "$(realpath -- $(dirname -- "$0"))" +fi + +# posix compliant escape sequence +esc=$'\033'"[" +res="${esc}0m" + +# +# Defaults +# +DB_NEXT_PATH="db-next" +DB_PATH="db" +OUTCOME="ERROR" +PROMOTE=() +RUN=() +DB="" + +# +# Print Functions +# +function print_outcome() { + if [ "${OUTCOME}" == OK ] + then + echo -e "${esc}0;32;1m${OUTCOME}${res}" + else + echo -e "${esc}0;31;1m${OUTCOME}${res}" + fi +} + +function print_usage_exit() { + echo "${USAGE}" + exit 0 +} + +# newline + bold magenta +function print_heading() { + echo + echo -e "${esc}0;34;1m${1}${res}" +} + +# bold cyan +function print_moving() { + local src=${1} + local dest=${2} + echo -e "moving: ${esc}0;36;1m${src}${res}" + echo -e "to: ${esc}0;32;1m${dest}${res}" +} + +# bold yellow +function print_unlinking() { + echo -e "unlinking: ${esc}0;33;1m${1}${res}" +} + +# bold magenta +function print_linking () { + local from=${1} + local to=${2} + echo -e "linking: ${esc}0;35;1m${from} ->${res}" + echo -e "to: ${esc}0;39;1m${to}${res}" +} + +function check_arg() { + if [ -z "${OPTARG}" ] + then + exit_msg "No arg for --${OPT} option, use: -h for help">&2 + fi +} + +function print_migrations() { + iter=1 + for file in "${migrations[@]}" + do + echo "${iter}) $(basename -- ${file})" + iter=$(expr "${iter}" + 1) + done +} + +function exit_msg() { + # complain to STDERR and exit with error + echo "${*}" >&2 + exit 2 +} + +# +# Utility Functions +# +function get_promotable_migrations() { + local migrations=() + local migpath="${DB_NEXT_PATH}/${1}" + for file in "${migpath}"/*.sql; do + [[ -f "${file}" && ! -L "${file}" ]] || continue + migrations+=("${file}") + done + if [[ "${migrations[@]}" ]]; then + echo "${migrations[@]}" + else + exit_msg "There are no promotable migrations at path: "\"${migpath}\""" + fi +} + +function get_demotable_migrations() { + local migrations=() + local migpath="${DB_NEXT_PATH}/${1}" + for file in "${migpath}"/*.sql; do + [[ -L "${file}" ]] || continue + migrations+=("${file}") + done + if [[ "${migrations[@]}" ]]; then + echo "${migrations[@]}" + else + exit_msg "There are no demotable migrations at path: "\"${migpath}\""" + fi +} + +# +# CLI Parser +# +USAGE="$(cat -- <<-EOM + +Usage: + + Boulder DB Migrations CLI + + Helper for listing, promoting, and demoting migration files + + ./$(basename "${0}") [OPTION]... + -b --db Name of the database, this is required (e.g. boulder_sa or incidents_sa) + -n, --list-next Lists migration files present in sa/db-next/ + -c, --list-current Lists migration files promoted from sa/db-next/ to sa/db/ + -p, --promote Select and promote a migration from sa/db-next/ to sa/db/ + -d, --demote Select and demote a migration from sa/db/ to sa/db-next/ + -h, --help Shows this help message + +EOM +)" + +while getopts nchpd-:b:-: OPT; do + if [ "$OPT" = - ]; then # long option: reformulate OPT and OPTARG + OPT="${OPTARG%%=*}" # extract long option name + OPTARG="${OPTARG#$OPT}" # extract long option argument (may be empty) + OPTARG="${OPTARG#=}" # if long option argument, remove assigning `=` + fi + case "${OPT}" in + b | db ) check_arg; DB="${OPTARG}" ;; + n | list-next ) RUN+=("list_next") ;; + c | list-current ) RUN+=("list_current") ;; + p | promote ) RUN+=("promote") ;; + d | demote ) RUN+=("demote") ;; + h | help ) print_usage_exit ;; + ??* ) exit_msg "Illegal option --${OPT}" ;; # bad long option + ? ) exit 2 ;; # bad short option (error reported via getopts) + esac +done +shift $((OPTIND-1)) # remove parsed opts and args from $@ list + +# On EXIT, trap and print outcome +trap "print_outcome" EXIT + +[ -z "${DB}" ] && exit_msg "You must specify a database with flag -b \"foo\" or --db=\"foo\"" + +STEP="list_next" +if [[ "${RUN[@]}" =~ "${STEP}" ]] ; then + print_heading "Next Migrations" + migrations=($(get_promotable_migrations "${DB}")) + print_migrations "${migrations[@]}" +fi + +STEP="list_current" +if [[ "${RUN[@]}" =~ "${STEP}" ]] ; then + print_heading "Current Migrations" + migrations=($(get_demotable_migrations "${DB}")) + print_migrations "${migrations[@]}" +fi + +STEP="promote" +if [[ "${RUN[@]}" =~ "${STEP}" ]] ; then + print_heading "Promote Migration" + migrations=($(get_promotable_migrations "${DB}")) + declare -a mig_index=() + declare -A mig_file=() + for i in "${!migrations[@]}"; do + mig_index["$i"]="${migrations[$i]%% *}" + mig_file["${mig_index[$i]}"]="${migrations[$i]#* }" + done + + promote="" + PS3='Which migration would you like to promote? (q to cancel): ' + + select opt in "${mig_index[@]}"; do + case "${opt}" in + "") echo "Invalid option or cancelled, exiting..." ; break ;; + *) mig_file_path="${mig_file[$opt]}" ; break ;; + esac + done + if [[ "${mig_file_path}" ]] + then + print_heading "Promoting Migration" + promote_mig_name="$(basename -- "${mig_file_path}")" + promoted_mig_file_path="${DB_PATH}/${DB}/${promote_mig_name}" + symlink_relpath="$(realpath --relative-to=${DB_NEXT_PATH}/${DB} ${promoted_mig_file_path})" + + print_moving "${mig_file_path}" "${promoted_mig_file_path}" + mv "${mig_file_path}" "${promoted_mig_file_path}" + + print_linking "${mig_file_path}" "${symlink_relpath}" + ln -s "${symlink_relpath}" "${DB_NEXT_PATH}/${DB}" + fi +fi + +STEP="demote" +if [[ "${RUN[@]}" =~ "${STEP}" ]] ; then + print_heading "Demote Migration" + migrations=($(get_demotable_migrations "${DB}")) + declare -a mig_index=() + declare -A mig_file=() + for i in "${!migrations[@]}"; do + mig_index["$i"]="${migrations[$i]%% *}" + mig_file["${mig_index[$i]}"]="${migrations[$i]#* }" + done + + demote_mig="" + PS3='Which migration would you like to demote? (q to cancel): ' + + select opt in "${mig_index[@]}"; do + case "${opt}" in + "") echo "Invalid option or cancelled, exiting..." ; break ;; + *) mig_link_path="${mig_file[$opt]}" ; break ;; + esac + done + if [[ "${mig_link_path}" ]] + then + print_heading "Demoting Migration" + demote_mig_name="$(basename -- "${mig_link_path}")" + demote_mig_from="${DB_PATH}/${DB}/${demote_mig_name}" + + print_unlinking "${mig_link_path}" + rm "${mig_link_path}" + print_moving "${demote_mig_from}" "${mig_link_path}" + mv "${demote_mig_from}" "${mig_link_path}" + fi +fi + +OUTCOME="OK" diff --git a/third-party/github.com/letsencrypt/boulder/sa/model.go b/third-party/github.com/letsencrypt/boulder/sa/model.go new file mode 100644 index 000000000..19b6f569d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/model.go @@ -0,0 +1,1362 @@ +package sa + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math" + "net" + "net/url" + "slices" + "strconv" + "time" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +// errBadJSON is an error type returned when a json.Unmarshal performed by the +// SA fails. It includes both the Unmarshal error and the original JSON data in +// its error message to make it easier to track down the bad JSON data. +type errBadJSON struct { + msg string + json []byte + err error +} + +// Error returns an error message that includes the json.Unmarshal error as well +// as the bad JSON data. +func (e errBadJSON) Error() string { + return fmt.Sprintf( + "%s: error unmarshaling JSON %q: %s", + e.msg, + string(e.json), + e.err) +} + +// badJSONError is a convenience function for constructing a errBadJSON instance +// with the provided args. +func badJSONError(msg string, jsonData []byte, err error) error { + return errBadJSON{ + msg: msg, + json: jsonData, + err: err, + } +} + +const regFields = "id, jwk, jwk_sha256, contact, agreement, initialIP, createdAt, LockCol, status" + +// ClearEmail removes the provided email address from one specified registration. If +// there are multiple email addresses present, it does not modify other ones. If the email +// address is not present, it does not modify the registration and will return a nil error. +func ClearEmail(ctx context.Context, dbMap db.DatabaseMap, regID int64, email string) error { + _, overallError := db.WithTransaction(ctx, dbMap, func(tx db.Executor) (interface{}, error) { + curr, err := selectRegistration(ctx, tx, "id", regID) + if err != nil { + return nil, err + } + + currPb, err := registrationModelToPb(curr) + if err != nil { + return nil, err + } + + // newContacts will be a copy of all emails in currPb.Contact _except_ the one to be removed + var newContacts []string + for _, contact := range currPb.Contact { + if contact != "mailto:"+email { + newContacts = append(newContacts, contact) + } + } + + if slices.Equal(currPb.Contact, newContacts) { + return nil, nil + } + + currPb.Contact = newContacts + newModel, err := registrationPbToModel(currPb) + if err != nil { + return nil, err + } + + return tx.Update(ctx, newModel) + }) + if overallError != nil { + return overallError + } + + return nil +} + +// selectRegistration selects all fields of one registration model +func selectRegistration(ctx context.Context, s db.OneSelector, whereCol string, args ...interface{}) (*regModel, error) { + if whereCol != "id" && whereCol != "jwk_sha256" { + return nil, fmt.Errorf("column name %q invalid for registrations table WHERE clause", whereCol) + } + + var model regModel + err := s.SelectOne( + ctx, + &model, + "SELECT "+regFields+" FROM registrations WHERE "+whereCol+" = ? LIMIT 1", + args..., + ) + return &model, err +} + +const certFields = "registrationID, serial, digest, der, issued, expires" + +// SelectCertificate selects all fields of one certificate object identified by +// a serial. If more than one row contains the same serial only the first is +// returned. +func SelectCertificate(ctx context.Context, s db.OneSelector, serial string) (core.Certificate, error) { + var model core.Certificate + err := s.SelectOne( + ctx, + &model, + "SELECT "+certFields+" FROM certificates WHERE serial = ? LIMIT 1", + serial, + ) + return model, err +} + +const precertFields = "registrationID, serial, der, issued, expires" + +// SelectPrecertificate selects all fields of one precertificate object +// identified by serial. +func SelectPrecertificate(ctx context.Context, s db.OneSelector, serial string) (core.Certificate, error) { + var model precertificateModel + err := s.SelectOne( + ctx, + &model, + "SELECT "+precertFields+" FROM precertificates WHERE serial = ? LIMIT 1", + serial) + return core.Certificate{ + RegistrationID: model.RegistrationID, + Serial: model.Serial, + DER: model.DER, + Issued: model.Issued, + Expires: model.Expires, + }, err +} + +type CertWithID struct { + ID int64 + core.Certificate +} + +// SelectCertificates selects all fields of multiple certificate objects +func SelectCertificates(ctx context.Context, s db.Selector, q string, args map[string]interface{}) ([]CertWithID, error) { + var models []CertWithID + _, err := s.Select( + ctx, + &models, + "SELECT id, "+certFields+" FROM certificates "+q, args) + return models, err +} + +// SelectPrecertificates selects all fields of multiple precertificate objects. +func SelectPrecertificates(ctx context.Context, s db.Selector, q string, args map[string]interface{}) ([]CertWithID, error) { + var models []CertWithID + _, err := s.Select( + ctx, + &models, + "SELECT id, "+precertFields+" FROM precertificates "+q, args) + return models, err +} + +type CertStatusMetadata struct { + ID int64 `db:"id"` + Serial string `db:"serial"` + Status core.OCSPStatus `db:"status"` + OCSPLastUpdated time.Time `db:"ocspLastUpdated"` + RevokedDate time.Time `db:"revokedDate"` + RevokedReason revocation.Reason `db:"revokedReason"` + LastExpirationNagSent time.Time `db:"lastExpirationNagSent"` + NotAfter time.Time `db:"notAfter"` + IsExpired bool `db:"isExpired"` + IssuerID int64 `db:"issuerID"` +} + +const certStatusFields = "id, serial, status, ocspLastUpdated, revokedDate, revokedReason, lastExpirationNagSent, notAfter, isExpired, issuerID" + +// SelectCertificateStatus selects all fields of one certificate status model +// identified by serial +func SelectCertificateStatus(ctx context.Context, s db.OneSelector, serial string) (core.CertificateStatus, error) { + var model core.CertificateStatus + err := s.SelectOne( + ctx, + &model, + "SELECT "+certStatusFields+" FROM certificateStatus WHERE serial = ? LIMIT 1", + serial, + ) + return model, err +} + +// RevocationStatusModel represents a small subset of the columns in the +// certificateStatus table, used to determine the authoritative revocation +// status of a certificate. +type RevocationStatusModel struct { + Status core.OCSPStatus `db:"status"` + RevokedDate time.Time `db:"revokedDate"` + RevokedReason revocation.Reason `db:"revokedReason"` +} + +// SelectRevocationStatus returns the authoritative revocation information for +// the certificate with the given serial. +func SelectRevocationStatus(ctx context.Context, s db.OneSelector, serial string) (*sapb.RevocationStatus, error) { + var model RevocationStatusModel + err := s.SelectOne( + ctx, + &model, + "SELECT status, revokedDate, revokedReason FROM certificateStatus WHERE serial = ? LIMIT 1", + serial, + ) + if err != nil { + return nil, err + } + + statusInt, ok := core.OCSPStatusToInt[model.Status] + if !ok { + return nil, fmt.Errorf("got unrecognized status %q", model.Status) + } + + return &sapb.RevocationStatus{ + Status: int64(statusInt), + RevokedDate: timestamppb.New(model.RevokedDate), + RevokedReason: int64(model.RevokedReason), + }, nil +} + +var mediumBlobSize = int(math.Pow(2, 24)) + +type issuedNameModel struct { + ID int64 `db:"id"` + ReversedName string `db:"reversedName"` + NotBefore time.Time `db:"notBefore"` + Serial string `db:"serial"` +} + +// regModel is the description of a core.Registration in the database before +type regModel struct { + ID int64 `db:"id"` + Key []byte `db:"jwk"` + KeySHA256 string `db:"jwk_sha256"` + Contact string `db:"contact"` + Agreement string `db:"agreement"` + // InitialIP is stored as sixteen binary bytes, regardless of whether it + // represents a v4 or v6 IP address. + InitialIP []byte `db:"initialIp"` + CreatedAt time.Time `db:"createdAt"` + LockCol int64 + Status string `db:"status"` +} + +func registrationPbToModel(reg *corepb.Registration) (*regModel, error) { + // Even though we don't need to convert from JSON to an in-memory JSONWebKey + // for the sake of the `Key` field, we do need to do the conversion in order + // to compute the SHA256 key digest. + var jwk jose.JSONWebKey + err := jwk.UnmarshalJSON(reg.Key) + if err != nil { + return nil, err + } + sha, err := core.KeyDigestB64(jwk.Key) + if err != nil { + return nil, err + } + + // We don't want to write literal JSON "null" strings into the database if the + // list of contact addresses is empty. Replace any possibly-`nil` slice with + // an empty JSON array. We don't need to check reg.ContactPresent, because + // we're going to write the whole object to the database anyway. + jsonContact := []byte("[]") + if len(reg.Contact) != 0 { + jsonContact, err = json.Marshal(reg.Contact) + if err != nil { + return nil, err + } + } + + // For some reason we use different serialization formats for InitialIP + // in database models and in protobufs, despite the fact that both formats + // are just []byte. + var initialIP net.IP + err = initialIP.UnmarshalText(reg.InitialIP) + if err != nil { + return nil, err + } + + var createdAt time.Time + if !core.IsAnyNilOrZero(reg.CreatedAt) { + createdAt = reg.CreatedAt.AsTime() + } + + return ®Model{ + ID: reg.Id, + Key: reg.Key, + KeySHA256: sha, + Contact: string(jsonContact), + Agreement: reg.Agreement, + InitialIP: []byte(initialIP.To16()), + CreatedAt: createdAt, + Status: reg.Status, + }, nil +} + +func registrationModelToPb(reg *regModel) (*corepb.Registration, error) { + if reg.ID == 0 || len(reg.Key) == 0 || len(reg.InitialIP) == 0 { + return nil, errors.New("incomplete Registration retrieved from DB") + } + + contact := []string{} + contactsPresent := false + if len(reg.Contact) > 0 { + err := json.Unmarshal([]byte(reg.Contact), &contact) + if err != nil { + return nil, err + } + if len(contact) > 0 { + contactsPresent = true + } + } + + // For some reason we use different serialization formats for InitialIP + // in database models and in protobufs, despite the fact that both formats + // are just []byte. + ipBytes, err := net.IP(reg.InitialIP).MarshalText() + if err != nil { + return nil, err + } + + return &corepb.Registration{ + Id: reg.ID, + Key: reg.Key, + Contact: contact, + ContactsPresent: contactsPresent, + Agreement: reg.Agreement, + InitialIP: ipBytes, + CreatedAt: timestamppb.New(reg.CreatedAt.UTC()), + Status: reg.Status, + }, nil +} + +type recordedSerialModel struct { + ID int64 + Serial string + RegistrationID int64 + Created time.Time + Expires time.Time +} + +type precertificateModel struct { + ID int64 + Serial string + RegistrationID int64 + DER []byte + Issued time.Time + Expires time.Time +} + +// TODO(#7324) orderModelv1 is deprecated, use orderModelv2 moving forward. +type orderModelv1 struct { + ID int64 + RegistrationID int64 + Expires time.Time + Created time.Time + Error []byte + CertificateSerial string + BeganProcessing bool +} + +type orderModelv2 struct { + ID int64 + RegistrationID int64 + Expires time.Time + Created time.Time + Error []byte + CertificateSerial string + BeganProcessing bool + CertificateProfileName string +} + +type orderToAuthzModel struct { + OrderID int64 + AuthzID int64 +} + +// TODO(#7324) orderToModelv1 is deprecated, use orderModelv2 moving forward. +func orderToModelv1(order *corepb.Order) (*orderModelv1, error) { + om := &orderModelv1{ + ID: order.Id, + RegistrationID: order.RegistrationID, + Expires: order.Expires.AsTime(), + Created: order.Created.AsTime(), + BeganProcessing: order.BeganProcessing, + CertificateSerial: order.CertificateSerial, + } + + if order.Error != nil { + errJSON, err := json.Marshal(order.Error) + if err != nil { + return nil, err + } + if len(errJSON) > mediumBlobSize { + return nil, fmt.Errorf("Error object is too large to store in the database") + } + om.Error = errJSON + } + return om, nil +} + +// TODO(#7324) modelToOrderv1 is deprecated, use orderModelv2 moving forward. +func modelToOrderv1(om *orderModelv1) (*corepb.Order, error) { + order := &corepb.Order{ + Id: om.ID, + RegistrationID: om.RegistrationID, + Expires: timestamppb.New(om.Expires), + Created: timestamppb.New(om.Created), + CertificateSerial: om.CertificateSerial, + BeganProcessing: om.BeganProcessing, + } + if len(om.Error) > 0 { + var problem corepb.ProblemDetails + err := json.Unmarshal(om.Error, &problem) + if err != nil { + return &corepb.Order{}, badJSONError( + "failed to unmarshal order model's error", + om.Error, + err) + } + order.Error = &problem + } + return order, nil +} + +func orderToModelv2(order *corepb.Order) (*orderModelv2, error) { + om := &orderModelv2{ + ID: order.Id, + RegistrationID: order.RegistrationID, + Expires: order.Expires.AsTime(), + Created: order.Created.AsTime(), + BeganProcessing: order.BeganProcessing, + CertificateSerial: order.CertificateSerial, + CertificateProfileName: order.CertificateProfileName, + } + + if order.Error != nil { + errJSON, err := json.Marshal(order.Error) + if err != nil { + return nil, err + } + if len(errJSON) > mediumBlobSize { + return nil, fmt.Errorf("Error object is too large to store in the database") + } + om.Error = errJSON + } + return om, nil +} + +func modelToOrderv2(om *orderModelv2) (*corepb.Order, error) { + order := &corepb.Order{ + Id: om.ID, + RegistrationID: om.RegistrationID, + Expires: timestamppb.New(om.Expires), + Created: timestamppb.New(om.Created), + CertificateSerial: om.CertificateSerial, + BeganProcessing: om.BeganProcessing, + CertificateProfileName: om.CertificateProfileName, + } + if len(om.Error) > 0 { + var problem corepb.ProblemDetails + err := json.Unmarshal(om.Error, &problem) + if err != nil { + return &corepb.Order{}, badJSONError( + "failed to unmarshal order model's error", + om.Error, + err) + } + order.Error = &problem + } + return order, nil +} + +var challTypeToUint = map[string]uint8{ + "http-01": 0, + "dns-01": 1, + "tls-alpn-01": 2, +} + +var uintToChallType = map[uint8]string{ + 0: "http-01", + 1: "dns-01", + 2: "tls-alpn-01", +} + +var identifierTypeToUint = map[string]uint8{ + "dns": 0, +} + +var uintToIdentifierType = map[uint8]string{ + 0: "dns", +} + +var statusToUint = map[core.AcmeStatus]uint8{ + core.StatusPending: 0, + core.StatusValid: 1, + core.StatusInvalid: 2, + core.StatusDeactivated: 3, + core.StatusRevoked: 4, +} + +var uintToStatus = map[uint8]core.AcmeStatus{ + 0: core.StatusPending, + 1: core.StatusValid, + 2: core.StatusInvalid, + 3: core.StatusDeactivated, + 4: core.StatusRevoked, +} + +func statusUint(status core.AcmeStatus) uint8 { + return statusToUint[status] +} + +// authzFields is used in a variety of places in sa.go, and modifications to +// it must be carried through to every use in sa.go +const authzFields = "id, identifierType, identifierValue, registrationID, status, expires, challenges, attempted, attemptedAt, token, validationError, validationRecord" + +type authzModel struct { + ID int64 `db:"id"` + IdentifierType uint8 `db:"identifierType"` + IdentifierValue string `db:"identifierValue"` + RegistrationID int64 `db:"registrationID"` + Status uint8 `db:"status"` + Expires time.Time `db:"expires"` + Challenges uint8 `db:"challenges"` + Attempted *uint8 `db:"attempted"` + AttemptedAt *time.Time `db:"attemptedAt"` + Token []byte `db:"token"` + ValidationError []byte `db:"validationError"` + ValidationRecord []byte `db:"validationRecord"` +} + +// rehydrateHostPort mutates a validation record. If the URL in the validation +// record cannot be parsed, an error will be returned. If the Hostname and Port +// fields already exist in the validation record, they will be retained. +// Otherwise, the Hostname and Port will be derived and set from the URL field +// of the validation record. +func rehydrateHostPort(vr *core.ValidationRecord) error { + if vr.URL == "" { + return fmt.Errorf("rehydrating validation record, URL field cannot be empty") + } + + parsedUrl, err := url.Parse(vr.URL) + if err != nil { + return fmt.Errorf("parsing validation record URL %q: %w", vr.URL, err) + } + + if vr.Hostname == "" { + hostname := parsedUrl.Hostname() + if hostname == "" { + return fmt.Errorf("hostname missing in URL %q", vr.URL) + } + vr.Hostname = hostname + } + + if vr.Port == "" { + // CABF BRs section 1.6.1: Authorized Ports: One of the following ports: 80 + // (http), 443 (https) + if parsedUrl.Port() == "" { + // If there is only a scheme, then we'll determine the appropriate port. + switch parsedUrl.Scheme { + case "https": + vr.Port = "443" + case "http": + vr.Port = "80" + default: + // This should never happen since the VA should have already + // checked the scheme. + return fmt.Errorf("unknown scheme %q in URL %q", parsedUrl.Scheme, vr.URL) + } + } else if parsedUrl.Port() == "80" || parsedUrl.Port() == "443" { + // If :80 or :443 were embedded in the URL field + // e.g. '"url":"https://example.com:443"' + vr.Port = parsedUrl.Port() + } else { + return fmt.Errorf("only ports 80/tcp and 443/tcp are allowed in URL %q", vr.URL) + } + } + + return nil +} + +// SelectAuthzsMatchingIssuance looks for a set of authzs that would have +// authorized a given issuance that is known to have occurred. The returned +// authzs will all belong to the given regID, will have potentially been valid +// at the time of issuance, and will have the appropriate identifier type and +// value. This may return multiple authzs for the same identifier type and value. +// +// This returns "potentially" valid authzs because a client may have set an +// authzs status to deactivated after issuance, so we return both valid and +// deactivated authzs. It also uses a small amount of leeway (1s) to account +// for possible clock skew. +// +// This function doesn't do anything special for authzs with an expiration in +// the past. If the stored authz has a valid status, it is returned with a +// valid status regardless of whether it is also expired. +func SelectAuthzsMatchingIssuance( + ctx context.Context, + s db.Selector, + regID int64, + issued time.Time, + dnsNames []string, +) ([]*corepb.Authorization, error) { + query := fmt.Sprintf(`SELECT %s FROM authz2 WHERE + registrationID = ? AND + status IN (?, ?) AND + expires >= ? AND + attemptedAt <= ? AND + identifierType = ? AND + identifierValue IN (%s)`, + authzFields, + db.QuestionMarks(len(dnsNames))) + var args []any + args = append(args, + regID, + statusToUint[core.StatusValid], + statusToUint[core.StatusDeactivated], + issued.Add(-1*time.Second), // leeway for clock skew + issued.Add(1*time.Second), // leeway for clock skew + identifierTypeToUint[string(identifier.DNS)], + ) + for _, name := range dnsNames { + args = append(args, name) + } + + var authzModels []authzModel + _, err := s.Select(ctx, &authzModels, query, args...) + if err != nil { + return nil, err + } + + var authzs []*corepb.Authorization + for _, model := range authzModels { + authz, err := modelToAuthzPB(model) + if err != nil { + return nil, err + } + authzs = append(authzs, authz) + + } + return authzs, err +} + +// hasMultipleNonPendingChallenges checks if a slice of challenges contains +// more than one non-pending challenge +func hasMultipleNonPendingChallenges(challenges []*corepb.Challenge) bool { + nonPending := false + for _, c := range challenges { + if c.Status == string(core.StatusValid) || c.Status == string(core.StatusInvalid) { + if !nonPending { + nonPending = true + } else { + return true + } + } + } + return false +} + +// authzPBToModel converts a protobuf authorization representation to the +// authzModel storage representation. +func authzPBToModel(authz *corepb.Authorization) (*authzModel, error) { + am := &authzModel{ + IdentifierValue: authz.Identifier, + RegistrationID: authz.RegistrationID, + Status: statusToUint[core.AcmeStatus(authz.Status)], + Expires: authz.Expires.AsTime(), + } + if authz.Id != "" { + // The v1 internal authorization objects use a string for the ID, the v2 + // storage format uses a integer ID. In order to maintain compatibility we + // convert the integer ID to a string. + id, err := strconv.Atoi(authz.Id) + if err != nil { + return nil, err + } + am.ID = int64(id) + } + if hasMultipleNonPendingChallenges(authz.Challenges) { + return nil, errors.New("multiple challenges are non-pending") + } + // In the v2 authorization style we don't store individual challenges with their own + // token, validation errors/records, etc. Instead we store a single token/error/record + // set, a bitmap of available challenge types, and a row indicating which challenge type + // was 'attempted'. + // + // Since we don't currently have the singular token/error/record set abstracted out to + // the core authorization type yet we need to extract these from the challenges array. + // We assume that the token in each challenge is the same and that if any of the challenges + // has a non-pending status that it should be considered the 'attempted' challenge and + // we extract the error/record set from that particular challenge. + var tokenStr string + for _, chall := range authz.Challenges { + // Set the challenge type bit in the bitmap + am.Challenges |= 1 << challTypeToUint[chall.Type] + tokenStr = chall.Token + // If the challenge status is not core.StatusPending we assume it was the 'attempted' + // challenge and extract the relevant fields we need. + if chall.Status == string(core.StatusValid) || chall.Status == string(core.StatusInvalid) { + attemptedType := challTypeToUint[chall.Type] + am.Attempted = &attemptedType + + // If validated Unix timestamp is zero then keep the core.Challenge Validated object nil. + var validated *time.Time + if !core.IsAnyNilOrZero(chall.Validated) { + val := chall.Validated.AsTime() + validated = &val + } + am.AttemptedAt = validated + + // Marshal corepb.ValidationRecords to core.ValidationRecords so that we + // can marshal them to JSON. + records := make([]core.ValidationRecord, len(chall.Validationrecords)) + for i, recordPB := range chall.Validationrecords { + if chall.Type == string(core.ChallengeTypeHTTP01) { + // Remove these fields because they can be rehydrated later + // on from the URL field. + recordPB.Hostname = "" + recordPB.Port = "" + } + var err error + records[i], err = grpc.PBToValidationRecord(recordPB) + if err != nil { + return nil, err + } + } + var err error + am.ValidationRecord, err = json.Marshal(records) + if err != nil { + return nil, err + } + // If there is a error associated with the challenge marshal it to JSON + // so that we can store it in the database. + if chall.Error != nil { + prob, err := grpc.PBToProblemDetails(chall.Error) + if err != nil { + return nil, err + } + am.ValidationError, err = json.Marshal(prob) + if err != nil { + return nil, err + } + } + } + token, err := base64.RawURLEncoding.DecodeString(tokenStr) + if err != nil { + return nil, err + } + am.Token = token + } + + return am, nil +} + +// populateAttemptedFields takes a challenge and populates it with the validation fields status, +// validation records, and error (the latter only if the validation failed) from an authzModel. +func populateAttemptedFields(am authzModel, challenge *corepb.Challenge) error { + if len(am.ValidationError) != 0 { + // If the error is non-empty the challenge must be invalid. + challenge.Status = string(core.StatusInvalid) + var prob probs.ProblemDetails + err := json.Unmarshal(am.ValidationError, &prob) + if err != nil { + return badJSONError( + "failed to unmarshal authz2 model's validation error", + am.ValidationError, + err) + } + challenge.Error, err = grpc.ProblemDetailsToPB(&prob) + if err != nil { + return err + } + } else { + // If the error is empty the challenge must be valid. + challenge.Status = string(core.StatusValid) + } + var records []core.ValidationRecord + err := json.Unmarshal(am.ValidationRecord, &records) + if err != nil { + return badJSONError( + "failed to unmarshal authz2 model's validation record", + am.ValidationRecord, + err) + } + challenge.Validationrecords = make([]*corepb.ValidationRecord, len(records)) + for i, r := range records { + // Fixes implicit memory aliasing in for loop so we can deference r + // later on for rehydrateHostPort. + r := r + if challenge.Type == string(core.ChallengeTypeHTTP01) { + err := rehydrateHostPort(&r) + if err != nil { + return err + } + } + challenge.Validationrecords[i], err = grpc.ValidationRecordToPB(r) + if err != nil { + return err + } + } + return nil +} + +func modelToAuthzPB(am authzModel) (*corepb.Authorization, error) { + pb := &corepb.Authorization{ + Id: fmt.Sprintf("%d", am.ID), + Status: string(uintToStatus[am.Status]), + Identifier: am.IdentifierValue, + RegistrationID: am.RegistrationID, + Expires: timestamppb.New(am.Expires), + } + // Populate authorization challenge array. We do this by iterating through + // the challenge type bitmap and creating a challenge of each type if its + // bit is set. Each of these challenges has the token from the authorization + // model and has its status set to core.StatusPending by default. If the + // challenge type is equal to that in the 'attempted' row we set the status + // to core.StatusValid or core.StatusInvalid depending on if there is anything + // in ValidationError and populate the ValidationRecord and ValidationError + // fields. + for pos := uint8(0); pos < 8; pos++ { + if (am.Challenges>>pos)&1 == 1 { + challType := uintToChallType[pos] + challenge := &corepb.Challenge{ + Type: challType, + Status: string(core.StatusPending), + Token: base64.RawURLEncoding.EncodeToString(am.Token), + } + // If the challenge type matches the attempted type it must be either + // valid or invalid and we need to populate extra fields. + // Also, once any challenge has been attempted, we consider the other + // challenges "gone" per https://tools.ietf.org/html/rfc8555#section-7.1.4 + if am.Attempted != nil { + if uintToChallType[*am.Attempted] == challType { + err := populateAttemptedFields(am, challenge) + if err != nil { + return nil, err + } + // Get the attemptedAt time and assign to the challenge validated time. + var validated *timestamppb.Timestamp + if am.AttemptedAt != nil { + validated = timestamppb.New(*am.AttemptedAt) + } + challenge.Validated = validated + pb.Challenges = append(pb.Challenges, challenge) + } + } else { + // When no challenge has been attempted yet, all challenges are still + // present. + pb.Challenges = append(pb.Challenges, challenge) + } + } + } + return pb, nil +} + +type keyHashModel struct { + ID int64 + KeyHash []byte + CertNotAfter time.Time + CertSerial string +} + +var stringToSourceInt = map[string]int{ + "API": 1, + "admin-revoker": 2, +} + +// incidentModel represents a row in the 'incidents' table. +type incidentModel struct { + ID int64 `db:"id"` + SerialTable string `db:"serialTable"` + URL string `db:"url"` + RenewBy time.Time `db:"renewBy"` + Enabled bool `db:"enabled"` +} + +func incidentModelToPB(i incidentModel) sapb.Incident { + return sapb.Incident{ + Id: i.ID, + SerialTable: i.SerialTable, + Url: i.URL, + RenewBy: timestamppb.New(i.RenewBy), + Enabled: i.Enabled, + } +} + +// incidentSerialModel represents a row in an 'incident_*' table. +type incidentSerialModel struct { + Serial string `db:"serial"` + RegistrationID *int64 `db:"registrationID"` + OrderID *int64 `db:"orderID"` + LastNoticeSent *time.Time `db:"lastNoticeSent"` +} + +// crlEntryModel has just the certificate status fields necessary to construct +// an entry in a CRL. +type crlEntryModel struct { + Serial string `db:"serial"` + Status core.OCSPStatus `db:"status"` + RevokedReason revocation.Reason `db:"revokedReason"` + RevokedDate time.Time `db:"revokedDate"` +} + +// orderFQDNSet contains the SHA256 hash of the lowercased, comma joined names +// from a new-order request, along with the corresponding orderID, the +// registration ID, and the order expiry. This is used to find +// existing orders for reuse. +type orderFQDNSet struct { + ID int64 + SetHash []byte + OrderID int64 + RegistrationID int64 + Expires time.Time +} + +func addFQDNSet(ctx context.Context, db db.Inserter, names []string, serial string, issued time.Time, expires time.Time) error { + return db.Insert(ctx, &core.FQDNSet{ + SetHash: core.HashNames(names), + Serial: serial, + Issued: issued, + Expires: expires, + }) +} + +// addOrderFQDNSet creates a new OrderFQDNSet row using the provided +// information. This function accepts a transaction so that the orderFqdnSet +// addition can take place within the order addition transaction. The caller is +// required to rollback the transaction if an error is returned. +func addOrderFQDNSet( + ctx context.Context, + db db.Inserter, + names []string, + orderID int64, + regID int64, + expires time.Time) error { + return db.Insert(ctx, &orderFQDNSet{ + SetHash: core.HashNames(names), + OrderID: orderID, + RegistrationID: regID, + Expires: expires, + }) +} + +// deleteOrderFQDNSet deletes a OrderFQDNSet row that matches the provided +// orderID. This function accepts a transaction so that the deletion can +// take place within the finalization transaction. The caller is required to +// rollback the transaction if an error is returned. +func deleteOrderFQDNSet( + ctx context.Context, + db db.Execer, + orderID int64) error { + + result, err := db.ExecContext(ctx, ` + DELETE FROM orderFqdnSets + WHERE orderID = ?`, + orderID) + if err != nil { + return err + } + rowsDeleted, err := result.RowsAffected() + if err != nil { + return err + } + // We always expect there to be an order FQDN set row for each + // pending/processing order that is being finalized. If there isn't one then + // something is amiss and should be raised as an internal server error + if rowsDeleted == 0 { + return berrors.InternalServerError("No orderFQDNSet exists to delete") + } + return nil +} + +func addIssuedNames(ctx context.Context, queryer db.Queryer, cert *x509.Certificate, isRenewal bool) error { + if len(cert.DNSNames) == 0 { + return berrors.InternalServerError("certificate has no DNSNames") + } + + multiInserter, err := db.NewMultiInserter("issuedNames", []string{"reversedName", "serial", "notBefore", "renewal"}, "") + if err != nil { + return err + } + for _, name := range cert.DNSNames { + err = multiInserter.Add([]interface{}{ + ReverseName(name), + core.SerialToString(cert.SerialNumber), + cert.NotBefore, + isRenewal, + }) + if err != nil { + return err + } + } + _, err = multiInserter.Insert(ctx, queryer) + return err +} + +func addKeyHash(ctx context.Context, db db.Inserter, cert *x509.Certificate) error { + if cert.RawSubjectPublicKeyInfo == nil { + return errors.New("certificate has a nil RawSubjectPublicKeyInfo") + } + h := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + khm := &keyHashModel{ + KeyHash: h[:], + CertNotAfter: cert.NotAfter, + CertSerial: core.SerialToString(cert.SerialNumber), + } + return db.Insert(ctx, khm) +} + +var blockedKeysColumns = "keyHash, added, source, comment" + +// statusForOrder examines the status of a provided order's authorizations to +// determine what the overall status of the order should be. In summary: +// - If the order has an error, the order is invalid +// - If any of the order's authorizations are in any state other than +// valid or pending, the order is invalid. +// - If any of the order's authorizations are pending, the order is pending. +// - If all of the order's authorizations are valid, and there is +// a certificate serial, the order is valid. +// - If all of the order's authorizations are valid, and we have began +// processing, but there is no certificate serial, the order is processing. +// - If all of the order's authorizations are valid, and we haven't begun +// processing, then the order is status ready. +// +// An error is returned for any other case. +func statusForOrder(order *corepb.Order, authzValidityInfo []authzValidity, now time.Time) (string, error) { + // Without any further work we know an order with an error is invalid + if order.Error != nil { + return string(core.StatusInvalid), nil + } + + // If the order is expired the status is invalid and we don't need to get + // order authorizations. Its important to exit early in this case because an + // order that references an expired authorization will be itself have been + // expired (because we match the order expiry to the associated authz expiries + // in ra.NewOrder), and expired authorizations may be purged from the DB. + // Because of this purging fetching the authz's for an expired order may + // return fewer authz objects than expected, triggering a 500 error response. + if order.Expires.AsTime().Before(now) { + return string(core.StatusInvalid), nil + } + + // If getAuthorizationStatuses returned a different number of authorization + // objects than the order's slice of authorization IDs something has gone + // wrong worth raising an internal error about. + if len(authzValidityInfo) != len(order.V2Authorizations) { + return "", berrors.InternalServerError( + "getAuthorizationStatuses returned the wrong number of authorization statuses "+ + "(%d vs expected %d) for order %d", + len(authzValidityInfo), len(order.V2Authorizations), order.Id) + } + + // Keep a count of the authorizations seen + pendingAuthzs := 0 + validAuthzs := 0 + otherAuthzs := 0 + expiredAuthzs := 0 + + // Loop over each of the order's authorization objects to examine the authz status + for _, info := range authzValidityInfo { + switch uintToStatus[info.Status] { + case core.StatusPending: + pendingAuthzs++ + case core.StatusValid: + validAuthzs++ + case core.StatusInvalid: + otherAuthzs++ + case core.StatusDeactivated: + otherAuthzs++ + case core.StatusRevoked: + otherAuthzs++ + default: + return "", berrors.InternalServerError( + "Order is in an invalid state. Authz has invalid status %d", + info.Status) + } + if info.Expires.Before(now) { + expiredAuthzs++ + } + } + + // An order is invalid if **any** of its authzs are invalid, deactivated, + // revoked, or expired, see https://tools.ietf.org/html/rfc8555#section-7.1.6 + if otherAuthzs > 0 || expiredAuthzs > 0 { + return string(core.StatusInvalid), nil + } + // An order is pending if **any** of its authzs are pending + if pendingAuthzs > 0 { + return string(core.StatusPending), nil + } + + // An order is fully authorized if it has valid authzs for each of the order + // names + fullyAuthorized := len(order.Names) == validAuthzs + + // If the order isn't fully authorized we've encountered an internal error: + // Above we checked for any invalid or pending authzs and should have returned + // early. Somehow we made it this far but also don't have the correct number + // of valid authzs. + if !fullyAuthorized { + return "", berrors.InternalServerError( + "Order has the incorrect number of valid authorizations & no pending, " + + "deactivated or invalid authorizations") + } + + // If the order is fully authorized and the certificate serial is set then the + // order is valid + if fullyAuthorized && order.CertificateSerial != "" { + return string(core.StatusValid), nil + } + + // If the order is fully authorized, and we have began processing it, then the + // order is processing. + if fullyAuthorized && order.BeganProcessing { + return string(core.StatusProcessing), nil + } + + if fullyAuthorized && !order.BeganProcessing { + return string(core.StatusReady), nil + } + + return "", berrors.InternalServerError( + "Order %d is in an invalid state. No state known for this order's "+ + "authorizations", order.Id) +} + +// authzValidity is a subset of authzModel +type authzValidity struct { + IdentifierType uint8 `db:"identifierType"` + IdentifierValue string `db:"identifierValue"` + Status uint8 `db:"status"` + Expires time.Time `db:"expires"` +} + +// getAuthorizationStatuses takes a sequence of authz IDs, and returns the +// status and expiration date of each of them. +func getAuthorizationStatuses(ctx context.Context, s db.Selector, ids []int64) ([]authzValidity, error) { + var params []interface{} + for _, id := range ids { + params = append(params, id) + } + var validities []authzValidity + _, err := s.Select( + ctx, + &validities, + fmt.Sprintf("SELECT identifierType, identifierValue, status, expires FROM authz2 WHERE id IN (%s)", + db.QuestionMarks(len(ids))), + params..., + ) + if err != nil { + return nil, err + } + + return validities, nil +} + +// authzForOrder retrieves the authorization IDs for an order. +func authzForOrder(ctx context.Context, s db.Selector, orderID int64) ([]int64, error) { + var v2IDs []int64 + _, err := s.Select( + ctx, + &v2IDs, + "SELECT authzID FROM orderToAuthz2 WHERE orderID = ?", + orderID, + ) + return v2IDs, err +} + +// crlShardModel represents one row in the crlShards table. The ThisUpdate and +// NextUpdate fields are pointers because they are NULL-able columns. +type crlShardModel struct { + ID int64 `db:"id"` + IssuerID int64 `db:"issuerID"` + Idx int `db:"idx"` + ThisUpdate *time.Time `db:"thisUpdate"` + NextUpdate *time.Time `db:"nextUpdate"` + LeasedUntil time.Time `db:"leasedUntil"` +} + +// revokedCertModel represents one row in the revokedCertificates table. It +// contains all of the information necessary to populate a CRL entry or OCSP +// response for the indicated certificate. +type revokedCertModel struct { + ID int64 `db:"id"` + IssuerID int64 `db:"issuerID"` + Serial string `db:"serial"` + NotAfterHour time.Time `db:"notAfterHour"` + ShardIdx int64 `db:"shardIdx"` + RevokedDate time.Time `db:"revokedDate"` + RevokedReason revocation.Reason `db:"revokedReason"` +} + +// replacementOrderModel represents one row in the replacementOrders table. It +// contains all of the information necessary to link a renewal order to the +// certificate it replaces. +type replacementOrderModel struct { + // ID is an auto-incrementing row ID. + ID int64 `db:"id"` + // Serial is the serial number of the replaced certificate. + Serial string `db:"serial"` + // OrderId is the ID of the replacement order + OrderID int64 `db:"orderID"` + // OrderExpiry is the expiry time of the new order. This is used to + // determine if we can accept a new replacement order for the same Serial. + OrderExpires time.Time `db:"orderExpires"` + // Replaced is a boolean indicating whether the certificate has been + // replaced, i.e. whether the new order has been finalized. Once this is + // true, no new replacement orders can be accepted for the same Serial. + Replaced bool `db:"replaced"` +} + +// addReplacementOrder inserts or updates the replacementOrders row matching the +// provided serial with the details provided. This function accepts a +// transaction so that the insert or update takes place within the new order +// transaction. +func addReplacementOrder(ctx context.Context, db db.SelectExecer, serial string, orderID int64, orderExpires time.Time) error { + var existingID []int64 + _, err := db.Select(ctx, &existingID, ` + SELECT id + FROM replacementOrders + WHERE serial = ? + LIMIT 1`, + serial, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("checking for existing replacement order: %w", err) + } + + if len(existingID) > 0 { + // Update existing replacementOrder row. + _, err = db.ExecContext(ctx, ` + UPDATE replacementOrders + SET orderID = ?, orderExpires = ? + WHERE id = ?`, + orderID, orderExpires, + existingID[0], + ) + if err != nil { + return fmt.Errorf("updating replacement order: %w", err) + } + } else { + // Insert new replacementOrder row. + _, err = db.ExecContext(ctx, ` + INSERT INTO replacementOrders (serial, orderID, orderExpires) + VALUES (?, ?, ?)`, + serial, orderID, orderExpires, + ) + if err != nil { + return fmt.Errorf("creating replacement order: %w", err) + } + } + return nil +} + +// setReplacementOrderFinalized sets the replaced flag for the replacementOrder +// row matching the provided orderID to true. This function accepts a +// transaction so that the update can take place within the finalization +// transaction. +func setReplacementOrderFinalized(ctx context.Context, db db.Execer, orderID int64) error { + _, err := db.ExecContext(ctx, ` + UPDATE replacementOrders + SET replaced = true + WHERE orderID = ? + LIMIT 1`, + orderID, + ) + if err != nil { + return err + } + return nil +} + +type identifierModel struct { + Type uint8 `db:"identifierType"` + Value string `db:"identifierValue"` +} + +func newIdentifierModelFromPB(pb *sapb.Identifier) (identifierModel, error) { + idType, ok := identifierTypeToUint[pb.Type] + if !ok { + return identifierModel{}, fmt.Errorf("unsupported identifier type %q", pb.Type) + } + + return identifierModel{ + Type: idType, + Value: pb.Value, + }, nil +} + +func newPBFromIdentifierModel(id identifierModel) (*sapb.Identifier, error) { + idType, ok := uintToIdentifierType[id.Type] + if !ok { + return nil, fmt.Errorf("unsupported identifier type %d", id.Type) + } + + return &sapb.Identifier{ + Type: idType, + Value: id.Value, + }, nil +} + +func newIdentifierModelsFromPB(pbs []*sapb.Identifier) ([]identifierModel, error) { + ids := make([]identifierModel, 0, len(pbs)) + for _, pb := range pbs { + id, err := newIdentifierModelFromPB(pb) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +func newPBFromIdentifierModels(ids []identifierModel) (*sapb.Identifiers, error) { + pbs := make([]*sapb.Identifier, 0, len(ids)) + for _, id := range ids { + pb, err := newPBFromIdentifierModel(id) + if err != nil { + return nil, err + } + pbs = append(pbs, pb) + } + return &sapb.Identifiers{Identifiers: pbs}, nil +} + +// pausedModel represents a row in the paused table. It contains the +// registrationID of the paused account, the time the (account, identifier) pair +// was paused, and the time the pair was unpaused. The UnpausedAt field is +// nullable because the pair may not have been unpaused yet. A pair is +// considered paused if there is a matching row in the paused table with a NULL +// UnpausedAt time. +type pausedModel struct { + identifierModel + RegistrationID int64 `db:"registrationID"` + PausedAt time.Time `db:"pausedAt"` + UnpausedAt *time.Time `db:"unpausedAt"` +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/model_test.go b/third-party/github.com/letsencrypt/boulder/sa/model_test.go new file mode 100644 index 000000000..23f4e3754 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/model_test.go @@ -0,0 +1,554 @@ +package sa + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "database/sql" + "encoding/base64" + "fmt" + "math/big" + "net" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/db" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test/vars" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/test" +) + +func TestRegistrationModelToPb(t *testing.T) { + badCases := []struct { + name string + input regModel + }{ + { + name: "No ID", + input: regModel{ID: 0, Key: []byte("foo"), InitialIP: []byte("foo")}, + }, + { + name: "No Key", + input: regModel{ID: 1, Key: nil, InitialIP: []byte("foo")}, + }, + { + name: "No IP", + input: regModel{ID: 1, Key: []byte("foo"), InitialIP: nil}, + }, + { + name: "Bad IP", + input: regModel{ID: 1, Key: []byte("foo"), InitialIP: []byte("foo")}, + }, + } + for _, tc := range badCases { + t.Run(tc.name, func(t *testing.T) { + _, err := registrationModelToPb(&tc.input) + test.AssertError(t, err, "Should fail") + }) + } + + _, err := registrationModelToPb(®Model{ + ID: 1, Key: []byte("foo"), InitialIP: net.ParseIP("1.2.3.4"), + }) + test.AssertNotError(t, err, "Should pass") +} + +func TestRegistrationPbToModel(t *testing.T) {} + +func TestAuthzModel(t *testing.T) { + clk := clock.New() + now := clk.Now() + expires := now.Add(24 * time.Hour) + authzPB := &corepb.Authorization{ + Id: "1", + Identifier: "example.com", + RegistrationID: 1, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Challenges: []*corepb.Challenge{ + { + Type: string(core.ChallengeTypeHTTP01), + Status: string(core.StatusValid), + Token: "MTIz", + Validated: timestamppb.New(now), + Validationrecords: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("1.2.3.4"), + Url: "https://example.com", + Hostname: "example.com", + Port: "443", + AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + }, + }, + }, + }, + } + + model, err := authzPBToModel(authzPB) + test.AssertNotError(t, err, "authzPBToModel failed") + + authzPBOut, err := modelToAuthzPB(*model) + test.AssertNotError(t, err, "modelToAuthzPB failed") + if authzPB.Challenges[0].Validationrecords[0].Hostname != "" { + test.Assert(t, false, fmt.Sprintf("dehydrated http-01 validation record expected hostname field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Hostname)) + } + if authzPB.Challenges[0].Validationrecords[0].Port != "" { + test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Port)) + } + // Shoving the Hostname and Port backinto the validation record should + // succeed because authzPB validation record will should match the retrieved + // model from the database with the rehydrated Hostname and Port. + authzPB.Challenges[0].Validationrecords[0].Hostname = "example.com" + authzPB.Challenges[0].Validationrecords[0].Port = "443" + test.AssertDeepEquals(t, authzPB.Challenges, authzPBOut.Challenges) + + now = clk.Now() + expires = now.Add(24 * time.Hour) + authzPB = &corepb.Authorization{ + Id: "1", + Identifier: "example.com", + RegistrationID: 1, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Challenges: []*corepb.Challenge{ + { + Type: string(core.ChallengeTypeHTTP01), + Status: string(core.StatusValid), + Token: "MTIz", + Validated: timestamppb.New(now), + Validationrecords: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("1.2.3.4"), + Url: "https://example.com", + Hostname: "example.com", + Port: "443", + AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + }, + }, + }, + }, + } + + validationErr := probs.Connection("weewoo") + + authzPB.Challenges[0].Status = string(core.StatusInvalid) + authzPB.Challenges[0].Error, err = grpc.ProblemDetailsToPB(validationErr) + test.AssertNotError(t, err, "grpc.ProblemDetailsToPB failed") + model, err = authzPBToModel(authzPB) + test.AssertNotError(t, err, "authzPBToModel failed") + + authzPBOut, err = modelToAuthzPB(*model) + test.AssertNotError(t, err, "modelToAuthzPB failed") + if authzPB.Challenges[0].Validationrecords[0].Hostname != "" { + test.Assert(t, false, fmt.Sprintf("dehydrated http-01 validation record expected hostname field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Hostname)) + } + if authzPB.Challenges[0].Validationrecords[0].Port != "" { + test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Port)) + } + // Shoving the Hostname and Port back into the validation record should + // succeed because authzPB validation record will should match the retrieved + // model from the database with the rehydrated Hostname and Port. + authzPB.Challenges[0].Validationrecords[0].Hostname = "example.com" + authzPB.Challenges[0].Validationrecords[0].Port = "443" + test.AssertDeepEquals(t, authzPB.Challenges, authzPBOut.Challenges) + + now = clk.Now() + expires = now.Add(24 * time.Hour) + authzPB = &corepb.Authorization{ + Id: "1", + Identifier: "example.com", + RegistrationID: 1, + Status: string(core.StatusInvalid), + Expires: timestamppb.New(expires), + Challenges: []*corepb.Challenge{ + { + Type: string(core.ChallengeTypeHTTP01), + Status: string(core.StatusInvalid), + Token: "MTIz", + Validationrecords: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("1.2.3.4"), + Url: "url", + AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + }, + }, + }, + { + Type: string(core.ChallengeTypeDNS01), + Status: string(core.StatusInvalid), + Token: "MTIz", + Validationrecords: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("1.2.3.4"), + Url: "url", + AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + }, + }, + }, + }, + } + _, err = authzPBToModel(authzPB) + test.AssertError(t, err, "authzPBToModel didn't fail with multiple non-pending challenges") + + // Test that the caller Hostname and Port rehydration returns the expected data in the expected fields. + now = clk.Now() + expires = now.Add(24 * time.Hour) + authzPB = &corepb.Authorization{ + Id: "1", + Identifier: "example.com", + RegistrationID: 1, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Challenges: []*corepb.Challenge{ + { + Type: string(core.ChallengeTypeHTTP01), + Status: string(core.StatusValid), + Token: "MTIz", + Validated: timestamppb.New(now), + Validationrecords: []*corepb.ValidationRecord{ + { + AddressUsed: []byte("1.2.3.4"), + Url: "https://example.com", + AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, + }, + }, + }, + }, + } + + model, err = authzPBToModel(authzPB) + test.AssertNotError(t, err, "authzPBToModel failed") + + authzPBOut, err = modelToAuthzPB(*model) + test.AssertNotError(t, err, "modelToAuthzPB failed") + if authzPBOut.Challenges[0].Validationrecords[0].Hostname != "example.com" { + test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected hostname example.com but found %v", authzPBOut.Challenges[0].Validationrecords[0].Hostname)) + } + if authzPBOut.Challenges[0].Validationrecords[0].Port != "443" { + test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port 443 but found %v", authzPBOut.Challenges[0].Validationrecords[0].Port)) + } +} + +// TestModelToOrderBADJSON tests that converting an order model with an invalid +// validation error JSON field to an Order produces the expected bad JSON error. +func TestModelToOrderBadJSON(t *testing.T) { + badJSON := []byte(`{`) + _, err := modelToOrderv2(&orderModelv2{ + Error: badJSON, + }) + test.AssertError(t, err, "expected error from modelToOrderv2") + var badJSONErr errBadJSON + test.AssertErrorWraps(t, err, &badJSONErr) + test.AssertEquals(t, string(badJSONErr.json), string(badJSON)) +} + +func TestOrderModelThereAndBackAgain(t *testing.T) { + clk := clock.New() + now := clk.Now() + order := &corepb.Order{ + Id: 0, + RegistrationID: 2016, + Expires: timestamppb.New(now.Add(24 * time.Hour)), + Created: timestamppb.New(now), + Error: nil, + CertificateSerial: "1", + BeganProcessing: true, + } + model1, err := orderToModelv1(order) + test.AssertNotError(t, err, "orderToModelv1 should not have errored") + returnOrder, err := modelToOrderv1(model1) + test.AssertNotError(t, err, "modelToOrderv1 should not have errored") + test.AssertDeepEquals(t, order, returnOrder) + + anotherOrder := &corepb.Order{ + Id: 1, + RegistrationID: 2024, + Expires: timestamppb.New(now.Add(24 * time.Hour)), + Created: timestamppb.New(now), + Error: nil, + CertificateSerial: "2", + BeganProcessing: true, + CertificateProfileName: "phljny", + } + model2, err := orderToModelv2(anotherOrder) + test.AssertNotError(t, err, "orderToModelv2 should not have errored") + returnOrder, err = modelToOrderv2(model2) + test.AssertNotError(t, err, "modelToOrderv2 should not have errored") + test.AssertDeepEquals(t, anotherOrder, returnOrder) +} + +// TestPopulateAttemptedFieldsBadJSON tests that populating a challenge from an +// authz2 model with an invalid validation error or an invalid validation record +// produces the expected bad JSON error. +func TestPopulateAttemptedFieldsBadJSON(t *testing.T) { + badJSON := []byte(`{`) + + testCases := []struct { + Name string + Model *authzModel + }{ + { + Name: "Bad validation error field", + Model: &authzModel{ + ValidationError: badJSON, + }, + }, + { + Name: "Bad validation record field", + Model: &authzModel{ + ValidationRecord: badJSON, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + err := populateAttemptedFields(*tc.Model, &corepb.Challenge{}) + test.AssertError(t, err, "expected error from populateAttemptedFields") + var badJSONErr errBadJSON + test.AssertErrorWraps(t, err, &badJSONErr) + test.AssertEquals(t, string(badJSONErr.json), string(badJSON)) + }) + } +} + +func TestCertificatesTableContainsDuplicateSerials(t *testing.T) { + ctx := context.Background() + + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + serialString := core.SerialToString(big.NewInt(1337)) + + // Insert a certificate with a serial of `1337`. + err := insertCertificate(ctx, sa.dbMap, fc, "1337.com", "leet", 1337, 1) + test.AssertNotError(t, err, "couldn't insert valid certificate") + + // This should return the certificate that we just inserted. + certA, err := SelectCertificate(ctx, sa.dbMap, serialString) + test.AssertNotError(t, err, "received an error for a valid query") + + // Insert a certificate with a serial of `1337` but for a different + // hostname. + err = insertCertificate(ctx, sa.dbMap, fc, "1337.net", "leet", 1337, 1) + test.AssertNotError(t, err, "couldn't insert valid certificate") + + // Despite a duplicate being present, this shouldn't error. + certB, err := SelectCertificate(ctx, sa.dbMap, serialString) + test.AssertNotError(t, err, "received an error for a valid query") + + // Ensure that `certA` and `certB` are the same. + test.AssertByteEquals(t, certA.DER, certB.DER) +} + +func insertCertificate(ctx context.Context, dbMap *db.WrappedMap, fc clock.FakeClock, hostname, cn string, serial, regID int64) error { + serialBigInt := big.NewInt(serial) + serialString := core.SerialToString(serialBigInt) + + template := x509.Certificate{ + Subject: pkix.Name{ + CommonName: cn, + }, + NotAfter: fc.Now().Add(30 * 24 * time.Hour), + DNSNames: []string{hostname}, + SerialNumber: serialBigInt, + } + + testKey := makeKey() + certDer, _ := x509.CreateCertificate(rand.Reader, &template, &template, &testKey.PublicKey, &testKey) + cert := &core.Certificate{ + RegistrationID: regID, + Serial: serialString, + Expires: template.NotAfter, + DER: certDer, + } + err := dbMap.Insert(ctx, cert) + if err != nil { + return err + } + return nil +} + +func bigIntFromB64(b64 string) *big.Int { + bytes, _ := base64.URLEncoding.DecodeString(b64) + x := big.NewInt(0) + x.SetBytes(bytes) + return x +} + +func makeKey() rsa.PrivateKey { + n := bigIntFromB64("n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw==") + e := int(bigIntFromB64("AQAB").Int64()) + d := bigIntFromB64("bWUC9B-EFRIo8kpGfh0ZuyGPvMNKvYWNtB_ikiH9k20eT-O1q_I78eiZkpXxXQ0UTEs2LsNRS-8uJbvQ-A1irkwMSMkK1J3XTGgdrhCku9gRldY7sNA_AKZGh-Q661_42rINLRCe8W-nZ34ui_qOfkLnK9QWDDqpaIsA-bMwWWSDFu2MUBYwkHTMEzLYGqOe04noqeq1hExBTHBOBdkMXiuFhUq1BU6l-DqEiWxqg82sXt2h-LMnT3046AOYJoRioz75tSUQfGCshWTBnP5uDjd18kKhyv07lhfSJdrPdM5Plyl21hsFf4L_mHCuoFau7gdsPfHPxxjVOcOpBrQzwQ==") + p := bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") + q := bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") + return rsa.PrivateKey{PublicKey: rsa.PublicKey{N: n, E: e}, D: d, Primes: []*big.Int{p, q}} +} + +func TestIncidentSerialModel(t *testing.T) { + ctx := context.Background() + + testIncidentsDbMap, err := DBMapForTest(vars.DBConnIncidentsFullPerms) + test.AssertNotError(t, err, "Couldn't create test dbMap") + defer test.ResetIncidentsTestDatabase(t) + + // Inserting and retrieving a row with only the serial populated should work. + _, err = testIncidentsDbMap.ExecContext(ctx, + "INSERT INTO incident_foo (serial) VALUES (?)", + "1337", + ) + test.AssertNotError(t, err, "inserting row with only serial") + + var res1 incidentSerialModel + err = testIncidentsDbMap.SelectOne( + ctx, + &res1, + "SELECT * FROM incident_foo WHERE serial = ?", + "1337", + ) + test.AssertNotError(t, err, "selecting row with only serial") + + test.AssertEquals(t, res1.Serial, "1337") + test.AssertBoxedNil(t, res1.RegistrationID, "registrationID should be NULL") + test.AssertBoxedNil(t, res1.OrderID, "orderID should be NULL") + test.AssertBoxedNil(t, res1.LastNoticeSent, "lastNoticeSent should be NULL") + + // Inserting and retrieving a row with all columns populated should work. + _, err = testIncidentsDbMap.ExecContext(ctx, + "INSERT INTO incident_foo (serial, registrationID, orderID, lastNoticeSent) VALUES (?, ?, ?, ?)", + "1338", + 1, + 2, + time.Date(2023, 06, 29, 16, 9, 00, 00, time.UTC), + ) + test.AssertNotError(t, err, "inserting row with only serial") + + var res2 incidentSerialModel + err = testIncidentsDbMap.SelectOne( + ctx, + &res2, + "SELECT * FROM incident_foo WHERE serial = ?", + "1338", + ) + test.AssertNotError(t, err, "selecting row with only serial") + + test.AssertEquals(t, res2.Serial, "1338") + test.AssertEquals(t, *res2.RegistrationID, int64(1)) + test.AssertEquals(t, *res2.OrderID, int64(2)) + test.AssertEquals(t, *res2.LastNoticeSent, time.Date(2023, 06, 29, 16, 9, 00, 00, time.UTC)) +} + +func TestAddReplacementOrder(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires replacementOrders database table") + } + + sa, _, cleanUp := initSA(t) + defer cleanUp() + + features.Set(features.Config{TrackReplacementCertificatesARI: true}) + defer features.Reset() + + oldCertSerial := "1234567890" + orderId := int64(1337) + orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + + // Add a replacement order which doesn't exist. + err := addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires) + test.AssertNotError(t, err, "addReplacementOrder failed") + + // Fetch the replacement order so we can ensure it was added. + var replacementRow replacementOrderModel + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &replacementRow, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + oldCertSerial, + ) + test.AssertNotError(t, err, "SELECT from replacementOrders failed") + test.AssertEquals(t, oldCertSerial, replacementRow.Serial) + test.AssertEquals(t, orderId, replacementRow.OrderID) + test.AssertEquals(t, orderExpires, replacementRow.OrderExpires) + + nextOrderId := int64(1338) + nextOrderExpires := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Second) + + // Add a replacement order which already exists. + err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, nextOrderId, nextOrderExpires) + test.AssertNotError(t, err, "addReplacementOrder failed") + + // Fetch the replacement order so we can ensure it was updated. + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &replacementRow, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + oldCertSerial, + ) + test.AssertNotError(t, err, "SELECT from replacementOrders failed") + test.AssertEquals(t, oldCertSerial, replacementRow.Serial) + test.AssertEquals(t, nextOrderId, replacementRow.OrderID) + test.AssertEquals(t, nextOrderExpires, replacementRow.OrderExpires) +} + +func TestSetReplacementOrderFinalized(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires replacementOrders database table") + } + + sa, _, cleanUp := initSA(t) + defer cleanUp() + + features.Set(features.Config{TrackReplacementCertificatesARI: true}) + defer features.Reset() + + oldCertSerial := "1234567890" + orderId := int64(1337) + orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + + // Mark a non-existent certificate as finalized/replaced. + err := setReplacementOrderFinalized(ctx, sa.dbMap, orderId) + test.AssertNotError(t, err, "setReplacementOrderFinalized failed") + + // Ensure no replacement order was added for some reason. + var replacementRow replacementOrderModel + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &replacementRow, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + oldCertSerial, + ) + test.AssertErrorIs(t, err, sql.ErrNoRows) + + // Add a replacement order. + err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires) + test.AssertNotError(t, err, "addReplacementOrder failed") + + // Mark the certificate as finalized/replaced. + err = setReplacementOrderFinalized(ctx, sa.dbMap, orderId) + test.AssertNotError(t, err, "setReplacementOrderFinalized failed") + + // Fetch the replacement order so we can ensure it was finalized. + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &replacementRow, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + oldCertSerial, + ) + test.AssertNotError(t, err, "SELECT from replacementOrders failed") + test.Assert(t, replacementRow.Replaced, "replacement order should be marked as finalized") +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/proto/sa.pb.go b/third-party/github.com/letsencrypt/boulder/sa/proto/sa.pb.go new file mode 100644 index 000000000..e938545de --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/proto/sa.pb.go @@ -0,0 +1,4750 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: sa.proto + +package proto + +import ( + proto "github.com/letsencrypt/boulder/core/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + emptypb "google.golang.org/protobuf/types/known/emptypb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type RegistrationID struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *RegistrationID) Reset() { + *x = RegistrationID{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RegistrationID) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegistrationID) ProtoMessage() {} + +func (x *RegistrationID) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegistrationID.ProtoReflect.Descriptor instead. +func (*RegistrationID) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{0} +} + +func (x *RegistrationID) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type JSONWebKey struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Jwk []byte `protobuf:"bytes,1,opt,name=jwk,proto3" json:"jwk,omitempty"` +} + +func (x *JSONWebKey) Reset() { + *x = JSONWebKey{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *JSONWebKey) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JSONWebKey) ProtoMessage() {} + +func (x *JSONWebKey) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JSONWebKey.ProtoReflect.Descriptor instead. +func (*JSONWebKey) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{1} +} + +func (x *JSONWebKey) GetJwk() []byte { + if x != nil { + return x.Jwk + } + return nil +} + +type AuthorizationID struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *AuthorizationID) Reset() { + *x = AuthorizationID{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthorizationID) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthorizationID) ProtoMessage() {} + +func (x *AuthorizationID) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthorizationID.ProtoReflect.Descriptor instead. +func (*AuthorizationID) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{2} +} + +func (x *AuthorizationID) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetPendingAuthorizationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 6 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + IdentifierType string `protobuf:"bytes,2,opt,name=identifierType,proto3" json:"identifierType,omitempty"` + IdentifierValue string `protobuf:"bytes,3,opt,name=identifierValue,proto3" json:"identifierValue,omitempty"` + ValidUntil *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=validUntil,proto3" json:"validUntil,omitempty"` // Result must be valid until at least this timestamp +} + +func (x *GetPendingAuthorizationRequest) Reset() { + *x = GetPendingAuthorizationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetPendingAuthorizationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPendingAuthorizationRequest) ProtoMessage() {} + +func (x *GetPendingAuthorizationRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPendingAuthorizationRequest.ProtoReflect.Descriptor instead. +func (*GetPendingAuthorizationRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{3} +} + +func (x *GetPendingAuthorizationRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *GetPendingAuthorizationRequest) GetIdentifierType() string { + if x != nil { + return x.IdentifierType + } + return "" +} + +func (x *GetPendingAuthorizationRequest) GetIdentifierValue() string { + if x != nil { + return x.IdentifierValue + } + return "" +} + +func (x *GetPendingAuthorizationRequest) GetValidUntil() *timestamppb.Timestamp { + if x != nil { + return x.ValidUntil + } + return nil +} + +type GetValidAuthorizationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 5 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Domains []string `protobuf:"bytes,2,rep,name=domains,proto3" json:"domains,omitempty"` + Now *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=now,proto3" json:"now,omitempty"` +} + +func (x *GetValidAuthorizationsRequest) Reset() { + *x = GetValidAuthorizationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetValidAuthorizationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetValidAuthorizationsRequest) ProtoMessage() {} + +func (x *GetValidAuthorizationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetValidAuthorizationsRequest.ProtoReflect.Descriptor instead. +func (*GetValidAuthorizationsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{4} +} + +func (x *GetValidAuthorizationsRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *GetValidAuthorizationsRequest) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *GetValidAuthorizationsRequest) GetNow() *timestamppb.Timestamp { + if x != nil { + return x.Now + } + return nil +} + +type ValidAuthorizations struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Valid []*ValidAuthorizations_MapElement `protobuf:"bytes,1,rep,name=valid,proto3" json:"valid,omitempty"` +} + +func (x *ValidAuthorizations) Reset() { + *x = ValidAuthorizations{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidAuthorizations) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidAuthorizations) ProtoMessage() {} + +func (x *ValidAuthorizations) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidAuthorizations.ProtoReflect.Descriptor instead. +func (*ValidAuthorizations) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{5} +} + +func (x *ValidAuthorizations) GetValid() []*ValidAuthorizations_MapElement { + if x != nil { + return x.Valid + } + return nil +} + +type Serial struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` +} + +func (x *Serial) Reset() { + *x = Serial{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Serial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Serial) ProtoMessage() {} + +func (x *Serial) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Serial.ProtoReflect.Descriptor instead. +func (*Serial) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{6} +} + +func (x *Serial) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type SerialMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 7 + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Created *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created,proto3" json:"created,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires,proto3" json:"expires,omitempty"` +} + +func (x *SerialMetadata) Reset() { + *x = SerialMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SerialMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SerialMetadata) ProtoMessage() {} + +func (x *SerialMetadata) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SerialMetadata.ProtoReflect.Descriptor instead. +func (*SerialMetadata) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{7} +} + +func (x *SerialMetadata) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SerialMetadata) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *SerialMetadata) GetCreated() *timestamppb.Timestamp { + if x != nil { + return x.Created + } + return nil +} + +func (x *SerialMetadata) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +type Range struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Earliest *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=earliest,proto3" json:"earliest,omitempty"` + Latest *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=latest,proto3" json:"latest,omitempty"` +} + +func (x *Range) Reset() { + *x = Range{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Range) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Range) ProtoMessage() {} + +func (x *Range) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Range.ProtoReflect.Descriptor instead. +func (*Range) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{8} +} + +func (x *Range) GetEarliest() *timestamppb.Timestamp { + if x != nil { + return x.Earliest + } + return nil +} + +func (x *Range) GetLatest() *timestamppb.Timestamp { + if x != nil { + return x.Latest + } + return nil +} + +type Count struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` +} + +func (x *Count) Reset() { + *x = Count{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Count) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Count) ProtoMessage() {} + +func (x *Count) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Count.ProtoReflect.Descriptor instead. +func (*Count) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{9} +} + +func (x *Count) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +type Timestamps struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Timestamps []*timestamppb.Timestamp `protobuf:"bytes,2,rep,name=timestamps,proto3" json:"timestamps,omitempty"` +} + +func (x *Timestamps) Reset() { + *x = Timestamps{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Timestamps) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Timestamps) ProtoMessage() {} + +func (x *Timestamps) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Timestamps.ProtoReflect.Descriptor instead. +func (*Timestamps) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{10} +} + +func (x *Timestamps) GetTimestamps() []*timestamppb.Timestamp { + if x != nil { + return x.Timestamps + } + return nil +} + +type CountCertificatesByNamesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Range *Range `protobuf:"bytes,1,opt,name=range,proto3" json:"range,omitempty"` + Names []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"` +} + +func (x *CountCertificatesByNamesRequest) Reset() { + *x = CountCertificatesByNamesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountCertificatesByNamesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountCertificatesByNamesRequest) ProtoMessage() {} + +func (x *CountCertificatesByNamesRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountCertificatesByNamesRequest.ProtoReflect.Descriptor instead. +func (*CountCertificatesByNamesRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{11} +} + +func (x *CountCertificatesByNamesRequest) GetRange() *Range { + if x != nil { + return x.Range + } + return nil +} + +func (x *CountCertificatesByNamesRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +type CountByNames struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Counts map[string]int64 `protobuf:"bytes,1,rep,name=counts,proto3" json:"counts,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + Earliest *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=earliest,proto3" json:"earliest,omitempty"` // Unix timestamp (nanoseconds) +} + +func (x *CountByNames) Reset() { + *x = CountByNames{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountByNames) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountByNames) ProtoMessage() {} + +func (x *CountByNames) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountByNames.ProtoReflect.Descriptor instead. +func (*CountByNames) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{12} +} + +func (x *CountByNames) GetCounts() map[string]int64 { + if x != nil { + return x.Counts + } + return nil +} + +func (x *CountByNames) GetEarliest() *timestamppb.Timestamp { + if x != nil { + return x.Earliest + } + return nil +} + +type CountRegistrationsByIPRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ip []byte `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"` + Range *Range `protobuf:"bytes,2,opt,name=range,proto3" json:"range,omitempty"` +} + +func (x *CountRegistrationsByIPRequest) Reset() { + *x = CountRegistrationsByIPRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountRegistrationsByIPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountRegistrationsByIPRequest) ProtoMessage() {} + +func (x *CountRegistrationsByIPRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountRegistrationsByIPRequest.ProtoReflect.Descriptor instead. +func (*CountRegistrationsByIPRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{13} +} + +func (x *CountRegistrationsByIPRequest) GetIp() []byte { + if x != nil { + return x.Ip + } + return nil +} + +func (x *CountRegistrationsByIPRequest) GetRange() *Range { + if x != nil { + return x.Range + } + return nil +} + +type CountInvalidAuthorizationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` + // Count authorizations that expire in this range. + Range *Range `protobuf:"bytes,3,opt,name=range,proto3" json:"range,omitempty"` +} + +func (x *CountInvalidAuthorizationsRequest) Reset() { + *x = CountInvalidAuthorizationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountInvalidAuthorizationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountInvalidAuthorizationsRequest) ProtoMessage() {} + +func (x *CountInvalidAuthorizationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountInvalidAuthorizationsRequest.ProtoReflect.Descriptor instead. +func (*CountInvalidAuthorizationsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{14} +} + +func (x *CountInvalidAuthorizationsRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *CountInvalidAuthorizationsRequest) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *CountInvalidAuthorizationsRequest) GetRange() *Range { + if x != nil { + return x.Range + } + return nil +} + +type CountOrdersRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AccountID int64 `protobuf:"varint,1,opt,name=accountID,proto3" json:"accountID,omitempty"` + Range *Range `protobuf:"bytes,2,opt,name=range,proto3" json:"range,omitempty"` +} + +func (x *CountOrdersRequest) Reset() { + *x = CountOrdersRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountOrdersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountOrdersRequest) ProtoMessage() {} + +func (x *CountOrdersRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountOrdersRequest.ProtoReflect.Descriptor instead. +func (*CountOrdersRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{15} +} + +func (x *CountOrdersRequest) GetAccountID() int64 { + if x != nil { + return x.AccountID + } + return 0 +} + +func (x *CountOrdersRequest) GetRange() *Range { + if x != nil { + return x.Range + } + return nil +} + +type CountFQDNSetsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Domains []string `protobuf:"bytes,2,rep,name=domains,proto3" json:"domains,omitempty"` + Window *durationpb.Duration `protobuf:"bytes,3,opt,name=window,proto3" json:"window,omitempty"` +} + +func (x *CountFQDNSetsRequest) Reset() { + *x = CountFQDNSetsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CountFQDNSetsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountFQDNSetsRequest) ProtoMessage() {} + +func (x *CountFQDNSetsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountFQDNSetsRequest.ProtoReflect.Descriptor instead. +func (*CountFQDNSetsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{16} +} + +func (x *CountFQDNSetsRequest) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *CountFQDNSetsRequest) GetWindow() *durationpb.Duration { + if x != nil { + return x.Window + } + return nil +} + +type FQDNSetExistsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Domains []string `protobuf:"bytes,1,rep,name=domains,proto3" json:"domains,omitempty"` +} + +func (x *FQDNSetExistsRequest) Reset() { + *x = FQDNSetExistsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FQDNSetExistsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FQDNSetExistsRequest) ProtoMessage() {} + +func (x *FQDNSetExistsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FQDNSetExistsRequest.ProtoReflect.Descriptor instead. +func (*FQDNSetExistsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{17} +} + +func (x *FQDNSetExistsRequest) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +type Exists struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` +} + +func (x *Exists) Reset() { + *x = Exists{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Exists) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Exists) ProtoMessage() {} + +func (x *Exists) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Exists.ProtoReflect.Descriptor instead. +func (*Exists) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{18} +} + +func (x *Exists) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +type AddSerialRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 7 + RegID int64 `protobuf:"varint,1,opt,name=regID,proto3" json:"regID,omitempty"` + Serial string `protobuf:"bytes,2,opt,name=serial,proto3" json:"serial,omitempty"` + Created *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created,proto3" json:"created,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires,proto3" json:"expires,omitempty"` +} + +func (x *AddSerialRequest) Reset() { + *x = AddSerialRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddSerialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddSerialRequest) ProtoMessage() {} + +func (x *AddSerialRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddSerialRequest.ProtoReflect.Descriptor instead. +func (*AddSerialRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{19} +} + +func (x *AddSerialRequest) GetRegID() int64 { + if x != nil { + return x.RegID + } + return 0 +} + +func (x *AddSerialRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *AddSerialRequest) GetCreated() *timestamppb.Timestamp { + if x != nil { + return x.Created + } + return nil +} + +func (x *AddSerialRequest) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +type AddCertificateRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 8 + Der []byte `protobuf:"bytes,1,opt,name=der,proto3" json:"der,omitempty"` + RegID int64 `protobuf:"varint,2,opt,name=regID,proto3" json:"regID,omitempty"` + Issued *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=issued,proto3" json:"issued,omitempty"` + IssuerNameID int64 `protobuf:"varint,5,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` // https://pkg.go.dev/github.com/letsencrypt/boulder/issuance#IssuerNameID + // If this is set to true, the certificateStatus.status column will be set to + // "wait", which will cause us to serve internalError responses with OCSP is + // queried. This allows us to meet the BRs requirement: + // + // If the OCSP responder receives a request for the status of a certificate + // serial number that is “unused”, then ... + // the responder MUST NOT respond with a “good” status for such requests. + // + // Paraphrasing, a certificate serial number is unused if neither a + // Certificate nor a Precertificate has been issued with it. So when we write + // a linting certificate to the precertificates table, we want to make sure + // we never give a "good" response for that serial until the precertificate + // is actually issued. + OcspNotReady bool `protobuf:"varint,6,opt,name=ocspNotReady,proto3" json:"ocspNotReady,omitempty"` +} + +func (x *AddCertificateRequest) Reset() { + *x = AddCertificateRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddCertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddCertificateRequest) ProtoMessage() {} + +func (x *AddCertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddCertificateRequest.ProtoReflect.Descriptor instead. +func (*AddCertificateRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{20} +} + +func (x *AddCertificateRequest) GetDer() []byte { + if x != nil { + return x.Der + } + return nil +} + +func (x *AddCertificateRequest) GetRegID() int64 { + if x != nil { + return x.RegID + } + return 0 +} + +func (x *AddCertificateRequest) GetIssued() *timestamppb.Timestamp { + if x != nil { + return x.Issued + } + return nil +} + +func (x *AddCertificateRequest) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *AddCertificateRequest) GetOcspNotReady() bool { + if x != nil { + return x.OcspNotReady + } + return false +} + +type OrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *OrderRequest) Reset() { + *x = OrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OrderRequest) ProtoMessage() {} + +func (x *OrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OrderRequest.ProtoReflect.Descriptor instead. +func (*OrderRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{21} +} + +func (x *OrderRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type NewOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 8 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expires,proto3" json:"expires,omitempty"` + Names []string `protobuf:"bytes,3,rep,name=names,proto3" json:"names,omitempty"` + V2Authorizations []int64 `protobuf:"varint,4,rep,packed,name=v2Authorizations,proto3" json:"v2Authorizations,omitempty"` + ReplacesSerial string `protobuf:"bytes,6,opt,name=replacesSerial,proto3" json:"replacesSerial,omitempty"` + CertificateProfileName string `protobuf:"bytes,7,opt,name=certificateProfileName,proto3" json:"certificateProfileName,omitempty"` +} + +func (x *NewOrderRequest) Reset() { + *x = NewOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NewOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NewOrderRequest) ProtoMessage() {} + +func (x *NewOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NewOrderRequest.ProtoReflect.Descriptor instead. +func (*NewOrderRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{22} +} + +func (x *NewOrderRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *NewOrderRequest) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +func (x *NewOrderRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +func (x *NewOrderRequest) GetV2Authorizations() []int64 { + if x != nil { + return x.V2Authorizations + } + return nil +} + +func (x *NewOrderRequest) GetReplacesSerial() string { + if x != nil { + return x.ReplacesSerial + } + return "" +} + +func (x *NewOrderRequest) GetCertificateProfileName() string { + if x != nil { + return x.CertificateProfileName + } + return "" +} + +type NewOrderAndAuthzsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + NewOrder *NewOrderRequest `protobuf:"bytes,1,opt,name=newOrder,proto3" json:"newOrder,omitempty"` + NewAuthzs []*proto.Authorization `protobuf:"bytes,2,rep,name=newAuthzs,proto3" json:"newAuthzs,omitempty"` +} + +func (x *NewOrderAndAuthzsRequest) Reset() { + *x = NewOrderAndAuthzsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *NewOrderAndAuthzsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NewOrderAndAuthzsRequest) ProtoMessage() {} + +func (x *NewOrderAndAuthzsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NewOrderAndAuthzsRequest.ProtoReflect.Descriptor instead. +func (*NewOrderAndAuthzsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{23} +} + +func (x *NewOrderAndAuthzsRequest) GetNewOrder() *NewOrderRequest { + if x != nil { + return x.NewOrder + } + return nil +} + +func (x *NewOrderAndAuthzsRequest) GetNewAuthzs() []*proto.Authorization { + if x != nil { + return x.NewAuthzs + } + return nil +} + +type SetOrderErrorRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Error *proto.ProblemDetails `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SetOrderErrorRequest) Reset() { + *x = SetOrderErrorRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SetOrderErrorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetOrderErrorRequest) ProtoMessage() {} + +func (x *SetOrderErrorRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetOrderErrorRequest.ProtoReflect.Descriptor instead. +func (*SetOrderErrorRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{24} +} + +func (x *SetOrderErrorRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *SetOrderErrorRequest) GetError() *proto.ProblemDetails { + if x != nil { + return x.Error + } + return nil +} + +type GetValidOrderAuthorizationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + AcctID int64 `protobuf:"varint,2,opt,name=acctID,proto3" json:"acctID,omitempty"` +} + +func (x *GetValidOrderAuthorizationsRequest) Reset() { + *x = GetValidOrderAuthorizationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetValidOrderAuthorizationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetValidOrderAuthorizationsRequest) ProtoMessage() {} + +func (x *GetValidOrderAuthorizationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetValidOrderAuthorizationsRequest.ProtoReflect.Descriptor instead. +func (*GetValidOrderAuthorizationsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{25} +} + +func (x *GetValidOrderAuthorizationsRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *GetValidOrderAuthorizationsRequest) GetAcctID() int64 { + if x != nil { + return x.AcctID + } + return 0 +} + +type GetOrderForNamesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AcctID int64 `protobuf:"varint,1,opt,name=acctID,proto3" json:"acctID,omitempty"` + Names []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"` +} + +func (x *GetOrderForNamesRequest) Reset() { + *x = GetOrderForNamesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOrderForNamesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOrderForNamesRequest) ProtoMessage() {} + +func (x *GetOrderForNamesRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOrderForNamesRequest.ProtoReflect.Descriptor instead. +func (*GetOrderForNamesRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{26} +} + +func (x *GetOrderForNamesRequest) GetAcctID() int64 { + if x != nil { + return x.AcctID + } + return 0 +} + +func (x *GetOrderForNamesRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +type FinalizeOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + CertificateSerial string `protobuf:"bytes,2,opt,name=certificateSerial,proto3" json:"certificateSerial,omitempty"` +} + +func (x *FinalizeOrderRequest) Reset() { + *x = FinalizeOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeOrderRequest) ProtoMessage() {} + +func (x *FinalizeOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeOrderRequest.ProtoReflect.Descriptor instead. +func (*FinalizeOrderRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{27} +} + +func (x *FinalizeOrderRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *FinalizeOrderRequest) GetCertificateSerial() string { + if x != nil { + return x.CertificateSerial + } + return "" +} + +type GetAuthorizationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 5 + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Domains []string `protobuf:"bytes,2,rep,name=domains,proto3" json:"domains,omitempty"` + Now *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=now,proto3" json:"now,omitempty"` +} + +func (x *GetAuthorizationsRequest) Reset() { + *x = GetAuthorizationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetAuthorizationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthorizationsRequest) ProtoMessage() {} + +func (x *GetAuthorizationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[28] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthorizationsRequest.ProtoReflect.Descriptor instead. +func (*GetAuthorizationsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{28} +} + +func (x *GetAuthorizationsRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *GetAuthorizationsRequest) GetDomains() []string { + if x != nil { + return x.Domains + } + return nil +} + +func (x *GetAuthorizationsRequest) GetNow() *timestamppb.Timestamp { + if x != nil { + return x.Now + } + return nil +} + +type Authorizations struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Authz []*Authorizations_MapElement `protobuf:"bytes,1,rep,name=authz,proto3" json:"authz,omitempty"` +} + +func (x *Authorizations) Reset() { + *x = Authorizations{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Authorizations) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Authorizations) ProtoMessage() {} + +func (x *Authorizations) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[29] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Authorizations.ProtoReflect.Descriptor instead. +func (*Authorizations) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{29} +} + +func (x *Authorizations) GetAuthz() []*Authorizations_MapElement { + if x != nil { + return x.Authz + } + return nil +} + +type AuthorizationIDs struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ids []string `protobuf:"bytes,1,rep,name=ids,proto3" json:"ids,omitempty"` +} + +func (x *AuthorizationIDs) Reset() { + *x = AuthorizationIDs{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthorizationIDs) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthorizationIDs) ProtoMessage() {} + +func (x *AuthorizationIDs) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[30] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthorizationIDs.ProtoReflect.Descriptor instead. +func (*AuthorizationIDs) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{30} +} + +func (x *AuthorizationIDs) GetIds() []string { + if x != nil { + return x.Ids + } + return nil +} + +type AuthorizationID2 struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *AuthorizationID2) Reset() { + *x = AuthorizationID2{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthorizationID2) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthorizationID2) ProtoMessage() {} + +func (x *AuthorizationID2) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[31] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthorizationID2.ProtoReflect.Descriptor instead. +func (*AuthorizationID2) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{31} +} + +func (x *AuthorizationID2) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type RevokeCertificateRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 10 + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + Reason int64 `protobuf:"varint,2,opt,name=reason,proto3" json:"reason,omitempty"` + Date *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=date,proto3" json:"date,omitempty"` + Backdate *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=backdate,proto3" json:"backdate,omitempty"` + Response []byte `protobuf:"bytes,4,opt,name=response,proto3" json:"response,omitempty"` + IssuerID int64 `protobuf:"varint,6,opt,name=issuerID,proto3" json:"issuerID,omitempty"` + ShardIdx int64 `protobuf:"varint,7,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` +} + +func (x *RevokeCertificateRequest) Reset() { + *x = RevokeCertificateRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RevokeCertificateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeCertificateRequest) ProtoMessage() {} + +func (x *RevokeCertificateRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[32] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeCertificateRequest.ProtoReflect.Descriptor instead. +func (*RevokeCertificateRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{32} +} + +func (x *RevokeCertificateRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *RevokeCertificateRequest) GetReason() int64 { + if x != nil { + return x.Reason + } + return 0 +} + +func (x *RevokeCertificateRequest) GetDate() *timestamppb.Timestamp { + if x != nil { + return x.Date + } + return nil +} + +func (x *RevokeCertificateRequest) GetBackdate() *timestamppb.Timestamp { + if x != nil { + return x.Backdate + } + return nil +} + +func (x *RevokeCertificateRequest) GetResponse() []byte { + if x != nil { + return x.Response + } + return nil +} + +func (x *RevokeCertificateRequest) GetIssuerID() int64 { + if x != nil { + return x.IssuerID + } + return 0 +} + +func (x *RevokeCertificateRequest) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +type FinalizeAuthorizationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 10 + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Expires *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires,proto3" json:"expires,omitempty"` + Attempted string `protobuf:"bytes,4,opt,name=attempted,proto3" json:"attempted,omitempty"` + ValidationRecords []*proto.ValidationRecord `protobuf:"bytes,5,rep,name=validationRecords,proto3" json:"validationRecords,omitempty"` + ValidationError *proto.ProblemDetails `protobuf:"bytes,6,opt,name=validationError,proto3" json:"validationError,omitempty"` + AttemptedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=attemptedAt,proto3" json:"attemptedAt,omitempty"` +} + +func (x *FinalizeAuthorizationRequest) Reset() { + *x = FinalizeAuthorizationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeAuthorizationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeAuthorizationRequest) ProtoMessage() {} + +func (x *FinalizeAuthorizationRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[33] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeAuthorizationRequest.ProtoReflect.Descriptor instead. +func (*FinalizeAuthorizationRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{33} +} + +func (x *FinalizeAuthorizationRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *FinalizeAuthorizationRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *FinalizeAuthorizationRequest) GetExpires() *timestamppb.Timestamp { + if x != nil { + return x.Expires + } + return nil +} + +func (x *FinalizeAuthorizationRequest) GetAttempted() string { + if x != nil { + return x.Attempted + } + return "" +} + +func (x *FinalizeAuthorizationRequest) GetValidationRecords() []*proto.ValidationRecord { + if x != nil { + return x.ValidationRecords + } + return nil +} + +func (x *FinalizeAuthorizationRequest) GetValidationError() *proto.ProblemDetails { + if x != nil { + return x.ValidationError + } + return nil +} + +func (x *FinalizeAuthorizationRequest) GetAttemptedAt() *timestamppb.Timestamp { + if x != nil { + return x.AttemptedAt + } + return nil +} + +type AddBlockedKeyRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 7 + KeyHash []byte `protobuf:"bytes,1,opt,name=keyHash,proto3" json:"keyHash,omitempty"` + Added *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=added,proto3" json:"added,omitempty"` + Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"` + Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"` + RevokedBy int64 `protobuf:"varint,5,opt,name=revokedBy,proto3" json:"revokedBy,omitempty"` +} + +func (x *AddBlockedKeyRequest) Reset() { + *x = AddBlockedKeyRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddBlockedKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddBlockedKeyRequest) ProtoMessage() {} + +func (x *AddBlockedKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[34] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddBlockedKeyRequest.ProtoReflect.Descriptor instead. +func (*AddBlockedKeyRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{34} +} + +func (x *AddBlockedKeyRequest) GetKeyHash() []byte { + if x != nil { + return x.KeyHash + } + return nil +} + +func (x *AddBlockedKeyRequest) GetAdded() *timestamppb.Timestamp { + if x != nil { + return x.Added + } + return nil +} + +func (x *AddBlockedKeyRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *AddBlockedKeyRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +func (x *AddBlockedKeyRequest) GetRevokedBy() int64 { + if x != nil { + return x.RevokedBy + } + return 0 +} + +type SPKIHash struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyHash []byte `protobuf:"bytes,1,opt,name=keyHash,proto3" json:"keyHash,omitempty"` +} + +func (x *SPKIHash) Reset() { + *x = SPKIHash{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SPKIHash) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SPKIHash) ProtoMessage() {} + +func (x *SPKIHash) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[35] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SPKIHash.ProtoReflect.Descriptor instead. +func (*SPKIHash) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{35} +} + +func (x *SPKIHash) GetKeyHash() []byte { + if x != nil { + return x.KeyHash + } + return nil +} + +type Incident struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 7 + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + SerialTable string `protobuf:"bytes,2,opt,name=serialTable,proto3" json:"serialTable,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + RenewBy *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=renewBy,proto3" json:"renewBy,omitempty"` + Enabled bool `protobuf:"varint,5,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *Incident) Reset() { + *x = Incident{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Incident) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Incident) ProtoMessage() {} + +func (x *Incident) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[36] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Incident.ProtoReflect.Descriptor instead. +func (*Incident) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{36} +} + +func (x *Incident) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Incident) GetSerialTable() string { + if x != nil { + return x.SerialTable + } + return "" +} + +func (x *Incident) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *Incident) GetRenewBy() *timestamppb.Timestamp { + if x != nil { + return x.RenewBy + } + return nil +} + +func (x *Incident) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type Incidents struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Incidents []*Incident `protobuf:"bytes,1,rep,name=incidents,proto3" json:"incidents,omitempty"` +} + +func (x *Incidents) Reset() { + *x = Incidents{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Incidents) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Incidents) ProtoMessage() {} + +func (x *Incidents) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[37] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Incidents.ProtoReflect.Descriptor instead. +func (*Incidents) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{37} +} + +func (x *Incidents) GetIncidents() []*Incident { + if x != nil { + return x.Incidents + } + return nil +} + +type SerialsForIncidentRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IncidentTable string `protobuf:"bytes,1,opt,name=incidentTable,proto3" json:"incidentTable,omitempty"` +} + +func (x *SerialsForIncidentRequest) Reset() { + *x = SerialsForIncidentRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SerialsForIncidentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SerialsForIncidentRequest) ProtoMessage() {} + +func (x *SerialsForIncidentRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[38] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SerialsForIncidentRequest.ProtoReflect.Descriptor instead. +func (*SerialsForIncidentRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{38} +} + +func (x *SerialsForIncidentRequest) GetIncidentTable() string { + if x != nil { + return x.IncidentTable + } + return "" +} + +type IncidentSerial struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 6 + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"` // May be 0 (NULL) + OrderID int64 `protobuf:"varint,3,opt,name=orderID,proto3" json:"orderID,omitempty"` // May be 0 (NULL) + LastNoticeSent *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=lastNoticeSent,proto3" json:"lastNoticeSent,omitempty"` +} + +func (x *IncidentSerial) Reset() { + *x = IncidentSerial{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IncidentSerial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IncidentSerial) ProtoMessage() {} + +func (x *IncidentSerial) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[39] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IncidentSerial.ProtoReflect.Descriptor instead. +func (*IncidentSerial) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{39} +} + +func (x *IncidentSerial) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *IncidentSerial) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *IncidentSerial) GetOrderID() int64 { + if x != nil { + return x.OrderID + } + return 0 +} + +func (x *IncidentSerial) GetLastNoticeSent() *timestamppb.Timestamp { + if x != nil { + return x.LastNoticeSent + } + return nil +} + +type GetRevokedCertsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Next unused field number: 9 + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + ExpiresAfter *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expiresAfter,proto3" json:"expiresAfter,omitempty"` // inclusive + ExpiresBefore *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expiresBefore,proto3" json:"expiresBefore,omitempty"` // exclusive + RevokedBefore *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=revokedBefore,proto3" json:"revokedBefore,omitempty"` + ShardIdx int64 `protobuf:"varint,5,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` // Must not be set until the revokedCertificates table has 90+ days of entries. +} + +func (x *GetRevokedCertsRequest) Reset() { + *x = GetRevokedCertsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetRevokedCertsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRevokedCertsRequest) ProtoMessage() {} + +func (x *GetRevokedCertsRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[40] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetRevokedCertsRequest.ProtoReflect.Descriptor instead. +func (*GetRevokedCertsRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{40} +} + +func (x *GetRevokedCertsRequest) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *GetRevokedCertsRequest) GetExpiresAfter() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAfter + } + return nil +} + +func (x *GetRevokedCertsRequest) GetExpiresBefore() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresBefore + } + return nil +} + +func (x *GetRevokedCertsRequest) GetRevokedBefore() *timestamppb.Timestamp { + if x != nil { + return x.RevokedBefore + } + return nil +} + +func (x *GetRevokedCertsRequest) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +type RevocationStatus struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Status int64 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + RevokedReason int64 `protobuf:"varint,2,opt,name=revokedReason,proto3" json:"revokedReason,omitempty"` + RevokedDate *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=revokedDate,proto3" json:"revokedDate,omitempty"` // Unix timestamp (nanoseconds) +} + +func (x *RevocationStatus) Reset() { + *x = RevocationStatus{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RevocationStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevocationStatus) ProtoMessage() {} + +func (x *RevocationStatus) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[41] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevocationStatus.ProtoReflect.Descriptor instead. +func (*RevocationStatus) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{41} +} + +func (x *RevocationStatus) GetStatus() int64 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *RevocationStatus) GetRevokedReason() int64 { + if x != nil { + return x.RevokedReason + } + return 0 +} + +func (x *RevocationStatus) GetRevokedDate() *timestamppb.Timestamp { + if x != nil { + return x.RevokedDate + } + return nil +} + +type LeaseCRLShardRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + MinShardIdx int64 `protobuf:"varint,2,opt,name=minShardIdx,proto3" json:"minShardIdx,omitempty"` + MaxShardIdx int64 `protobuf:"varint,3,opt,name=maxShardIdx,proto3" json:"maxShardIdx,omitempty"` + Until *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=until,proto3" json:"until,omitempty"` +} + +func (x *LeaseCRLShardRequest) Reset() { + *x = LeaseCRLShardRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LeaseCRLShardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LeaseCRLShardRequest) ProtoMessage() {} + +func (x *LeaseCRLShardRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[42] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LeaseCRLShardRequest.ProtoReflect.Descriptor instead. +func (*LeaseCRLShardRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{42} +} + +func (x *LeaseCRLShardRequest) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *LeaseCRLShardRequest) GetMinShardIdx() int64 { + if x != nil { + return x.MinShardIdx + } + return 0 +} + +func (x *LeaseCRLShardRequest) GetMaxShardIdx() int64 { + if x != nil { + return x.MaxShardIdx + } + return 0 +} + +func (x *LeaseCRLShardRequest) GetUntil() *timestamppb.Timestamp { + if x != nil { + return x.Until + } + return nil +} + +type LeaseCRLShardResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + ShardIdx int64 `protobuf:"varint,2,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` +} + +func (x *LeaseCRLShardResponse) Reset() { + *x = LeaseCRLShardResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LeaseCRLShardResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LeaseCRLShardResponse) ProtoMessage() {} + +func (x *LeaseCRLShardResponse) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[43] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LeaseCRLShardResponse.ProtoReflect.Descriptor instead. +func (*LeaseCRLShardResponse) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{43} +} + +func (x *LeaseCRLShardResponse) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *LeaseCRLShardResponse) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +type UpdateCRLShardRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IssuerNameID int64 `protobuf:"varint,1,opt,name=issuerNameID,proto3" json:"issuerNameID,omitempty"` + ShardIdx int64 `protobuf:"varint,2,opt,name=shardIdx,proto3" json:"shardIdx,omitempty"` + ThisUpdate *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=thisUpdate,proto3" json:"thisUpdate,omitempty"` + NextUpdate *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=nextUpdate,proto3" json:"nextUpdate,omitempty"` +} + +func (x *UpdateCRLShardRequest) Reset() { + *x = UpdateCRLShardRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateCRLShardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateCRLShardRequest) ProtoMessage() {} + +func (x *UpdateCRLShardRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[44] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateCRLShardRequest.ProtoReflect.Descriptor instead. +func (*UpdateCRLShardRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{44} +} + +func (x *UpdateCRLShardRequest) GetIssuerNameID() int64 { + if x != nil { + return x.IssuerNameID + } + return 0 +} + +func (x *UpdateCRLShardRequest) GetShardIdx() int64 { + if x != nil { + return x.ShardIdx + } + return 0 +} + +func (x *UpdateCRLShardRequest) GetThisUpdate() *timestamppb.Timestamp { + if x != nil { + return x.ThisUpdate + } + return nil +} + +func (x *UpdateCRLShardRequest) GetNextUpdate() *timestamppb.Timestamp { + if x != nil { + return x.NextUpdate + } + return nil +} + +type Identifier struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *Identifier) Reset() { + *x = Identifier{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Identifier) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Identifier) ProtoMessage() {} + +func (x *Identifier) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[45] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Identifier.ProtoReflect.Descriptor instead. +func (*Identifier) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{45} +} + +func (x *Identifier) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Identifier) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type Identifiers struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Identifiers []*Identifier `protobuf:"bytes,1,rep,name=identifiers,proto3" json:"identifiers,omitempty"` +} + +func (x *Identifiers) Reset() { + *x = Identifiers{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Identifiers) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Identifiers) ProtoMessage() {} + +func (x *Identifiers) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[46] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Identifiers.ProtoReflect.Descriptor instead. +func (*Identifiers) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{46} +} + +func (x *Identifiers) GetIdentifiers() []*Identifier { + if x != nil { + return x.Identifiers + } + return nil +} + +type PauseRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RegistrationID int64 `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"` + Identifiers []*Identifier `protobuf:"bytes,2,rep,name=identifiers,proto3" json:"identifiers,omitempty"` +} + +func (x *PauseRequest) Reset() { + *x = PauseRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PauseRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseRequest) ProtoMessage() {} + +func (x *PauseRequest) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[47] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseRequest.ProtoReflect.Descriptor instead. +func (*PauseRequest) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{47} +} + +func (x *PauseRequest) GetRegistrationID() int64 { + if x != nil { + return x.RegistrationID + } + return 0 +} + +func (x *PauseRequest) GetIdentifiers() []*Identifier { + if x != nil { + return x.Identifiers + } + return nil +} + +type PauseIdentifiersResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Paused int64 `protobuf:"varint,1,opt,name=paused,proto3" json:"paused,omitempty"` + Repaused int64 `protobuf:"varint,2,opt,name=repaused,proto3" json:"repaused,omitempty"` +} + +func (x *PauseIdentifiersResponse) Reset() { + *x = PauseIdentifiersResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PauseIdentifiersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseIdentifiersResponse) ProtoMessage() {} + +func (x *PauseIdentifiersResponse) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[48] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseIdentifiersResponse.ProtoReflect.Descriptor instead. +func (*PauseIdentifiersResponse) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{48} +} + +func (x *PauseIdentifiersResponse) GetPaused() int64 { + if x != nil { + return x.Paused + } + return 0 +} + +func (x *PauseIdentifiersResponse) GetRepaused() int64 { + if x != nil { + return x.Repaused + } + return 0 +} + +type ValidAuthorizations_MapElement struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Authz *proto.Authorization `protobuf:"bytes,2,opt,name=authz,proto3" json:"authz,omitempty"` +} + +func (x *ValidAuthorizations_MapElement) Reset() { + *x = ValidAuthorizations_MapElement{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidAuthorizations_MapElement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidAuthorizations_MapElement) ProtoMessage() {} + +func (x *ValidAuthorizations_MapElement) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[49] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidAuthorizations_MapElement.ProtoReflect.Descriptor instead. +func (*ValidAuthorizations_MapElement) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{5, 0} +} + +func (x *ValidAuthorizations_MapElement) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *ValidAuthorizations_MapElement) GetAuthz() *proto.Authorization { + if x != nil { + return x.Authz + } + return nil +} + +type Authorizations_MapElement struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Authz *proto.Authorization `protobuf:"bytes,2,opt,name=authz,proto3" json:"authz,omitempty"` +} + +func (x *Authorizations_MapElement) Reset() { + *x = Authorizations_MapElement{} + if protoimpl.UnsafeEnabled { + mi := &file_sa_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Authorizations_MapElement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Authorizations_MapElement) ProtoMessage() {} + +func (x *Authorizations_MapElement) ProtoReflect() protoreflect.Message { + mi := &file_sa_proto_msgTypes[51] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Authorizations_MapElement.ProtoReflect.Descriptor instead. +func (*Authorizations_MapElement) Descriptor() ([]byte, []int) { + return file_sa_proto_rawDescGZIP(), []int{29, 0} +} + +func (x *Authorizations_MapElement) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *Authorizations_MapElement) GetAuthz() *proto.Authorization { + if x != nil { + return x.Authz + } + return nil +} + +var File_sa_proto protoreflect.FileDescriptor + +var file_sa_proto_rawDesc = []byte{ + 0x0a, 0x08, 0x73, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x73, 0x61, 0x1a, 0x15, + 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x02, 0x69, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x4a, 0x53, 0x4f, 0x4e, 0x57, 0x65, 0x62, + 0x4b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6a, 0x77, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x03, 0x6a, 0x77, 0x6b, 0x22, 0x21, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0xdc, 0x01, 0x0a, 0x1e, 0x47, 0x65, 0x74, + 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x3a, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x55, 0x6e, + 0x74, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x55, 0x6e, 0x74, 0x69, + 0x6c, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x95, 0x01, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x44, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x2c, 0x0a, 0x03, 0x6e, + 0x6f, 0x77, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03, 0x6e, 0x6f, 0x77, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x22, + 0xa0, 0x01, 0x0a, 0x13, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x73, 0x61, 0x2e, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, + 0x4d, 0x61, 0x70, 0x45, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x1a, 0x4f, 0x0a, 0x0a, 0x4d, 0x61, 0x70, 0x45, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, + 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x29, 0x0a, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x22, 0x20, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x22, 0xc8, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, + 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x34, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x34, 0x0a, + 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x78, 0x70, 0x69, + 0x72, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, + 0x7f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x65, 0x61, 0x72, 0x6c, + 0x69, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x65, 0x61, 0x72, 0x6c, 0x69, 0x65, 0x73, 0x74, + 0x12, 0x32, 0x0a, 0x06, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x06, 0x6c, 0x61, + 0x74, 0x65, 0x73, 0x74, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, + 0x22, 0x1d, 0x0a, 0x05, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0x4e, 0x0a, 0x0a, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x12, 0x3a, 0x0a, + 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, + 0x58, 0x0a, 0x1f, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x73, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x72, 0x61, + 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0xb7, 0x01, 0x0a, 0x0c, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x34, 0x0a, 0x06, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x73, 0x61, 0x2e, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x2e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, + 0x12, 0x36, 0x0a, 0x08, 0x65, 0x61, 0x72, 0x6c, 0x69, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, + 0x65, 0x61, 0x72, 0x6c, 0x69, 0x65, 0x73, 0x74, 0x1a, 0x39, 0x0a, 0x0b, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x22, 0x50, 0x0a, 0x1d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x49, 0x50, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x02, 0x69, 0x70, 0x12, 0x1f, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, + 0x72, 0x61, 0x6e, 0x67, 0x65, 0x22, 0x88, 0x01, 0x0a, 0x21, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, + 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, + 0x22, 0x53, 0x0a, 0x12, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x49, 0x44, 0x12, 0x1f, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, + 0x72, 0x61, 0x6e, 0x67, 0x65, 0x22, 0x69, 0x0a, 0x14, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, + 0x44, 0x4e, 0x53, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, + 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x31, 0x0a, 0x06, 0x77, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x06, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, + 0x22, 0x30, 0x0a, 0x14, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x45, 0x78, 0x69, 0x73, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x22, 0x20, 0x0a, 0x06, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x16, 0x0a, 0x06, + 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x65, 0x78, + 0x69, 0x73, 0x74, 0x73, 0x22, 0xb8, 0x01, 0x0a, 0x10, 0x41, 0x64, 0x64, 0x53, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x67, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x65, 0x67, 0x49, 0x44, 0x12, + 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x34, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x34, 0x0a, + 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x78, 0x70, 0x69, + 0x72, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, + 0xc7, 0x01, 0x0a, 0x15, 0x41, 0x64, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x65, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x72, + 0x65, 0x67, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x65, 0x67, 0x49, + 0x44, 0x12, 0x32, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x06, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, + 0x61, 0x6d, 0x65, 0x49, 0x44, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, + 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x63, 0x73, + 0x70, 0x4e, 0x6f, 0x74, 0x52, 0x65, 0x61, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0c, 0x6f, 0x63, 0x73, 0x70, 0x4e, 0x6f, 0x74, 0x52, 0x65, 0x61, 0x64, 0x79, 0x4a, 0x04, 0x08, + 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x1e, 0x0a, 0x0c, 0x4f, 0x72, 0x64, + 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x22, 0x97, 0x02, 0x0a, 0x0f, 0x4e, 0x65, + 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, + 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x34, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x76, 0x32, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x03, 0x52, 0x10, 0x76, 0x32, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, + 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x53, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x16, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x4a, 0x04, 0x08, + 0x02, 0x10, 0x03, 0x22, 0x7e, 0x0a, 0x18, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x41, + 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x2f, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x61, 0x2e, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x12, 0x31, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6e, 0x65, 0x77, 0x41, 0x75, 0x74, + 0x68, 0x7a, 0x73, 0x22, 0x52, 0x0a, 0x14, 0x53, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x45, + 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2a, 0x0a, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x72, + 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x4c, 0x0a, 0x22, 0x47, 0x65, 0x74, 0x56, 0x61, + 0x6c, 0x69, 0x64, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, + 0x06, 0x61, 0x63, 0x63, 0x74, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, + 0x63, 0x63, 0x74, 0x49, 0x44, 0x22, 0x47, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x46, 0x6f, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x63, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x06, 0x61, 0x63, 0x63, 0x74, 0x49, 0x44, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x54, + 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2c, 0x0a, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x22, 0x90, 0x01, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x12, 0x2c, 0x0a, 0x03, 0x6e, 0x6f, 0x77, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03, 0x6e, 0x6f, + 0x77, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x22, 0x96, 0x01, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x05, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x73, 0x61, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x4d, 0x61, + 0x70, 0x45, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x1a, + 0x4f, 0x0a, 0x0a, 0x4d, 0x61, 0x70, 0x45, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, + 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x29, 0x0a, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x22, 0x24, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x22, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x32, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x22, 0x92, 0x02, 0x0a, 0x18, 0x52, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, + 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x65, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x04, 0x64, 0x61, 0x74, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x62, 0x61, 0x63, 0x6b, 0x64, + 0x61, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x62, 0x61, 0x63, 0x6b, 0x64, 0x61, 0x74, 0x65, 0x12, + 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x49, 0x44, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, + 0x73, 0x73, 0x75, 0x65, 0x72, 0x49, 0x44, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, + 0x49, 0x64, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, + 0x49, 0x64, 0x78, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, + 0xea, 0x02, 0x0a, 0x1c, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x34, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x69, + 0x72, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x12, 0x1c, + 0x0a, 0x09, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x65, 0x64, 0x12, 0x44, 0x0a, 0x11, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, + 0x11, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x45, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, + 0x72, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, + 0x73, 0x52, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x12, 0x3c, 0x0a, 0x0b, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0xb8, 0x01, 0x0a, + 0x14, 0x41, 0x64, 0x64, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x12, + 0x30, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05, 0x61, 0x64, 0x64, 0x65, + 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x42, 0x79, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x42, + 0x79, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0x24, 0x0a, 0x08, 0x53, 0x50, 0x4b, 0x49, 0x48, + 0x61, 0x73, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x6b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x48, 0x61, 0x73, 0x68, 0x22, 0xa4, 0x01, + 0x0a, 0x08, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x34, + 0x0a, 0x07, 0x72, 0x65, 0x6e, 0x65, 0x77, 0x42, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x72, 0x65, 0x6e, + 0x65, 0x77, 0x42, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x4a, 0x04, + 0x08, 0x04, 0x10, 0x05, 0x22, 0x37, 0x0a, 0x09, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, + 0x73, 0x12, 0x2a, 0x0a, 0x09, 0x69, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x52, 0x09, 0x69, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x41, 0x0a, + 0x19, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x46, 0x6f, 0x72, 0x49, 0x6e, 0x63, 0x69, 0x64, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x69, 0x6e, + 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x22, 0xb4, 0x01, 0x0a, 0x0e, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x72, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x44, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x44, 0x12, 0x42, 0x0a, + 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x53, 0x65, 0x6e, 0x74, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x53, 0x65, 0x6e, + 0x74, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0xae, 0x02, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x52, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, + 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, 0x3e, 0x0a, 0x0c, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, + 0x73, 0x41, 0x66, 0x74, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0c, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, + 0x73, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x0d, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, + 0x73, 0x42, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x65, 0x73, 0x42, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x12, 0x40, 0x0a, 0x0d, 0x72, 0x65, 0x76, 0x6f, + 0x6b, 0x65, 0x64, 0x42, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x72, 0x65, 0x76, + 0x6f, 0x6b, 0x65, 0x64, 0x42, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x68, + 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x68, + 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x4a, 0x04, 0x08, 0x03, + 0x10, 0x04, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x8e, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x76, + 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, + 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x72, 0x65, + 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0b, 0x72, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x44, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x72, 0x65, + 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x44, 0x61, 0x74, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x14, 0x4c, 0x65, + 0x61, 0x73, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, + 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x69, 0x6e, 0x53, 0x68, 0x61, + 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x69, 0x6e, + 0x53, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x53, + 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, + 0x61, 0x78, 0x53, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x12, 0x30, 0x0a, 0x05, 0x75, 0x6e, + 0x74, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05, 0x75, 0x6e, 0x74, 0x69, 0x6c, 0x22, 0x57, 0x0a, 0x15, + 0x4c, 0x65, 0x61, 0x73, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, + 0x61, 0x6d, 0x65, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, + 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x68, 0x61, + 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x68, 0x61, + 0x72, 0x64, 0x49, 0x64, 0x78, 0x22, 0xcf, 0x01, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x22, 0x0a, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x49, 0x44, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x4e, 0x61, 0x6d, + 0x65, 0x49, 0x44, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x78, 0x12, + 0x3a, 0x0a, 0x0a, 0x74, 0x68, 0x69, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x0a, 0x74, 0x68, 0x69, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x3a, 0x0a, 0x0a, 0x6e, + 0x65, 0x78, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x6e, 0x65, 0x78, + 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x36, 0x0a, 0x0a, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, + 0x3f, 0x0a, 0x0b, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x12, 0x30, + 0x0a, 0x0b, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, + 0x69, 0x65, 0x72, 0x52, 0x0b, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, + 0x22, 0x68, 0x0a, 0x0c, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x30, 0x0a, 0x0b, 0x69, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, + 0x73, 0x61, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0b, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x22, 0x4e, 0x0a, 0x18, 0x50, 0x61, + 0x75, 0x73, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x75, 0x73, 0x65, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x70, 0x61, 0x75, 0x73, 0x65, 0x64, 0x12, 0x1a, + 0x0a, 0x08, 0x72, 0x65, 0x70, 0x61, 0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x08, 0x72, 0x65, 0x70, 0x61, 0x75, 0x73, 0x65, 0x64, 0x32, 0xfa, 0x10, 0x0a, 0x18, 0x53, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x52, + 0x65, 0x61, 0x64, 0x4f, 0x6e, 0x6c, 0x79, 0x12, 0x53, 0x0a, 0x18, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x42, 0x79, 0x4e, 0x61, + 0x6d, 0x65, 0x73, 0x12, 0x23, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x43, 0x65, + 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x00, 0x12, 0x36, 0x0a, 0x0d, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x73, 0x12, 0x18, 0x2e, + 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x6e, 0x76, + 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x32, 0x12, 0x25, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x6e, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x32, 0x0a, 0x0b, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x12, 0x16, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, + 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x1b, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x09, + 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x16, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x42, 0x79, 0x49, 0x50, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x49, + 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x4d, 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x49, 0x50, 0x52, + 0x61, 0x6e, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x49, 0x50, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x0d, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x45, + 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x18, 0x2e, 0x73, 0x61, 0x2e, 0x46, 0x51, 0x44, 0x4e, 0x53, + 0x65, 0x74, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x00, 0x12, 0x48, 0x0a, + 0x1a, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x73, 0x46, 0x6f, 0x72, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x12, 0x18, 0x2e, 0x73, 0x61, + 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x73, 0x61, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x73, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x32, 0x12, 0x14, 0x2e, 0x73, + 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x44, 0x32, 0x1a, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x12, 0x47, 0x65, 0x74, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, + 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, + 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, + 0x6c, 0x1a, 0x11, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x4c, 0x69, 0x6e, + 0x74, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, + 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x11, 0x2e, 0x63, 0x6f, + 0x72, 0x65, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x22, 0x00, + 0x12, 0x3d, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x65, 0x72, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x00, 0x12, + 0x48, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x78, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x08, 0x47, 0x65, 0x74, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, + 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4f, 0x72, 0x64, + 0x65, 0x72, 0x46, 0x6f, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1b, 0x2e, 0x73, 0x61, 0x2e, + 0x47, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x46, 0x6f, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, + 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x65, 0x6e, + 0x64, 0x69, 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x32, 0x12, 0x22, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x3b, 0x0a, + 0x0f, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x3c, 0x0a, 0x14, 0x47, 0x65, + 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x79, 0x4b, + 0x65, 0x79, 0x12, 0x0e, 0x2e, 0x73, 0x61, 0x2e, 0x4a, 0x53, 0x4f, 0x4e, 0x57, 0x65, 0x62, 0x4b, + 0x65, 0x79, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x39, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x52, + 0x65, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x14, 0x2e, 0x73, 0x61, + 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x22, 0x00, 0x12, 0x41, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x64, 0x43, 0x65, 0x72, 0x74, 0x73, 0x12, 0x1a, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x52, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x52, 0x4c, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x22, 0x00, 0x30, 0x01, 0x12, 0x35, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x0a, 0x2e, 0x73, 0x61, + 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x00, 0x12, 0x39, 0x0a, + 0x13, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x42, 0x79, 0x41, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, + 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x2f, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x53, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x12, 0x0c, 0x2e, 0x73, 0x61, + 0x2e, 0x53, 0x50, 0x4b, 0x49, 0x48, 0x61, 0x73, 0x68, 0x1a, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x52, 0x0a, 0x17, 0x47, 0x65, 0x74, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x00, 0x12, 0x5c, 0x0a, + 0x1c, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x26, 0x2e, + 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x12, 0x49, + 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x72, 0x69, 0x61, + 0x6c, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x0d, 0x2e, + 0x73, 0x61, 0x2e, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x00, 0x12, 0x28, + 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x0c, 0x2e, 0x73, + 0x61, 0x2e, 0x53, 0x50, 0x4b, 0x49, 0x48, 0x61, 0x73, 0x68, 0x1a, 0x0a, 0x2e, 0x73, 0x61, 0x2e, + 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x00, 0x12, 0x32, 0x0a, 0x16, 0x52, 0x65, 0x70, 0x6c, + 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x45, 0x78, 0x69, 0x73, + 0x74, 0x73, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x0a, + 0x2e, 0x73, 0x61, 0x2e, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x12, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x46, 0x6f, 0x72, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x46, + 0x6f, 0x72, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x53, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3d, 0x0a, 0x16, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x50, 0x61, 0x75, + 0x73, 0x65, 0x64, 0x12, 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x50, + 0x61, 0x75, 0x73, 0x65, 0x64, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, + 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x0f, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x66, 0x69, 0x65, 0x72, 0x73, 0x22, 0x00, 0x32, 0xf7, 0x1b, 0x0a, 0x10, 0x53, 0x74, 0x6f, 0x72, + 0x61, 0x67, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x53, 0x0a, 0x18, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x73, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x23, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x42, + 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, + 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, + 0x00, 0x12, 0x36, 0x0a, 0x0d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, + 0x74, 0x73, 0x12, 0x18, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, + 0x4e, 0x53, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, + 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x1b, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x49, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x25, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x49, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x32, 0x0a, 0x0b, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x12, 0x16, 0x2e, 0x73, 0x61, + 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, + 0x12, 0x3e, 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, + 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x1a, 0x09, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, + 0x12, 0x48, 0x0a, 0x16, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x49, 0x50, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x42, 0x79, 0x49, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, + 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x4d, 0x0a, 0x1b, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x42, 0x79, 0x49, 0x50, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x42, 0x79, 0x49, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x73, + 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x0d, 0x46, 0x51, 0x44, + 0x4e, 0x53, 0x65, 0x74, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x18, 0x2e, 0x73, 0x61, 0x2e, + 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x22, 0x00, 0x12, 0x48, 0x0a, 0x1a, 0x46, 0x51, 0x44, 0x4e, 0x53, 0x65, 0x74, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x46, 0x6f, 0x72, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, + 0x12, 0x18, 0x2e, 0x73, 0x61, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x46, 0x51, 0x44, 0x4e, 0x53, + 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x73, 0x61, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x11, + 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x32, 0x12, 0x14, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x32, 0x1a, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x48, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x43, + 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x11, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x65, + 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x15, 0x47, + 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x1a, 0x11, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x43, 0x65, 0x72, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0a, 0x2e, + 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x78, 0x45, 0x78, + 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x00, 0x12, 0x2b, + 0x0a, 0x08, 0x47, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x10, 0x2e, 0x73, 0x61, 0x2e, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, + 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x10, 0x47, + 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x46, 0x6f, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, + 0x1b, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x46, 0x6f, 0x72, + 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, + 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x18, 0x47, + 0x65, 0x74, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x32, 0x12, 0x22, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, + 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x63, 0x6f, + 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, + 0x3c, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x12, 0x0e, 0x2e, 0x73, 0x61, 0x2e, 0x4a, 0x53, 0x4f, + 0x4e, 0x57, 0x65, 0x62, 0x4b, 0x65, 0x79, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x39, 0x0a, + 0x13, 0x47, 0x65, 0x74, 0x52, 0x65, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x1a, 0x14, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x00, 0x12, 0x41, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x52, + 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x73, 0x12, 0x1a, 0x2e, 0x73, 0x61, + 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, + 0x52, 0x4c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x22, 0x00, 0x30, 0x01, 0x12, 0x35, 0x0a, 0x11, 0x47, + 0x65, 0x74, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x12, 0x2e, 0x73, + 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x22, 0x00, 0x12, 0x39, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, + 0x42, 0x79, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x0a, 0x2e, + 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x2f, 0x0a, + 0x0f, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x42, 0x79, 0x4b, 0x65, 0x79, + 0x12, 0x0c, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x50, 0x4b, 0x49, 0x48, 0x61, 0x73, 0x68, 0x1a, 0x0a, + 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x52, + 0x0a, 0x17, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x12, 0x21, 0x2e, 0x73, 0x61, 0x2e, 0x47, + 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, + 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x22, 0x00, 0x12, 0x5c, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x32, 0x12, 0x26, 0x2e, 0x73, 0x61, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x00, + 0x12, 0x31, 0x0a, 0x12, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x6f, 0x72, + 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x1a, 0x0d, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, + 0x73, 0x22, 0x00, 0x12, 0x28, 0x0a, 0x0a, 0x4b, 0x65, 0x79, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, + 0x64, 0x12, 0x0c, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x50, 0x4b, 0x49, 0x48, 0x61, 0x73, 0x68, 0x1a, + 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x00, 0x12, 0x32, 0x0a, + 0x16, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x12, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x1a, 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x45, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, + 0x00, 0x12, 0x4b, 0x0a, 0x12, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x73, 0x46, 0x6f, 0x72, 0x49, + 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x73, 0x46, 0x6f, 0x72, 0x49, 0x6e, 0x63, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x49, 0x6e, 0x63, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3d, + 0x0a, 0x16, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x73, 0x50, 0x61, 0x75, 0x73, 0x65, 0x64, 0x12, 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x50, 0x61, + 0x75, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x73, 0x61, 0x2e, + 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x22, 0x00, 0x12, 0x3d, 0x0a, + 0x14, 0x47, 0x65, 0x74, 0x50, 0x61, 0x75, 0x73, 0x65, 0x64, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x66, 0x69, 0x65, 0x72, 0x73, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x0f, 0x2e, 0x73, 0x61, 0x2e, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0d, + 0x41, 0x64, 0x64, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x2e, + 0x73, 0x61, 0x2e, 0x41, 0x64, 0x64, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x4b, 0x65, 0x79, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, + 0x00, 0x12, 0x45, 0x0a, 0x0e, 0x41, 0x64, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x12, 0x19, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x65, 0x72, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x11, 0x41, 0x64, 0x64, 0x50, + 0x72, 0x65, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x19, 0x2e, + 0x73, 0x61, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x22, 0x00, 0x12, 0x41, 0x0a, 0x19, 0x53, 0x65, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, + 0x0a, 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x1a, 0x16, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x09, 0x41, 0x64, 0x64, 0x53, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x12, 0x14, 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x64, 0x64, 0x53, 0x65, 0x72, 0x69, 0x61, + 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x18, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x32, 0x12, 0x14, + 0x2e, 0x73, 0x61, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x44, 0x32, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x46, + 0x0a, 0x16, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x16, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x16, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, + 0x7a, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x32, + 0x12, 0x20, 0x2e, 0x73, 0x61, 0x2e, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0d, + 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x18, 0x2e, + 0x73, 0x61, 0x2e, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, + 0x00, 0x12, 0x40, 0x0a, 0x11, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x41, 0x6e, 0x64, + 0x41, 0x75, 0x74, 0x68, 0x7a, 0x73, 0x12, 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x4e, 0x65, 0x77, 0x4f, + 0x72, 0x64, 0x65, 0x72, 0x41, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, + 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, + 0x12, 0x4b, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, + 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x43, 0x0a, + 0x0d, 0x53, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x18, + 0x2e, 0x73, 0x61, 0x2e, 0x53, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x22, 0x00, 0x12, 0x40, 0x0a, 0x12, 0x53, 0x65, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x50, 0x72, + 0x6f, 0x63, 0x65, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72, + 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x52, 0x0a, 0x18, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x12, 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0d, + 0x4c, 0x65, 0x61, 0x73, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x12, 0x18, 0x2e, + 0x73, 0x61, 0x2e, 0x4c, 0x65, 0x61, 0x73, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x73, 0x61, 0x2e, 0x4c, 0x65, 0x61, + 0x73, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x52, + 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x12, 0x19, 0x2e, 0x73, 0x61, 0x2e, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x43, 0x52, 0x4c, 0x53, 0x68, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x44, 0x0a, 0x10, 0x50, + 0x61, 0x75, 0x73, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x12, + 0x10, 0x2e, 0x73, 0x61, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1c, 0x2e, 0x73, 0x61, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x49, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x3e, 0x0a, 0x0e, 0x55, 0x6e, 0x70, 0x61, 0x75, 0x73, 0x65, 0x41, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x12, 0x12, 0x2e, 0x73, 0x61, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, + 0x00, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, + 0x64, 0x65, 0x72, 0x2f, 0x73, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_sa_proto_rawDescOnce sync.Once + file_sa_proto_rawDescData = file_sa_proto_rawDesc +) + +func file_sa_proto_rawDescGZIP() []byte { + file_sa_proto_rawDescOnce.Do(func() { + file_sa_proto_rawDescData = protoimpl.X.CompressGZIP(file_sa_proto_rawDescData) + }) + return file_sa_proto_rawDescData +} + +var file_sa_proto_msgTypes = make([]protoimpl.MessageInfo, 52) +var file_sa_proto_goTypes = []interface{}{ + (*RegistrationID)(nil), // 0: sa.RegistrationID + (*JSONWebKey)(nil), // 1: sa.JSONWebKey + (*AuthorizationID)(nil), // 2: sa.AuthorizationID + (*GetPendingAuthorizationRequest)(nil), // 3: sa.GetPendingAuthorizationRequest + (*GetValidAuthorizationsRequest)(nil), // 4: sa.GetValidAuthorizationsRequest + (*ValidAuthorizations)(nil), // 5: sa.ValidAuthorizations + (*Serial)(nil), // 6: sa.Serial + (*SerialMetadata)(nil), // 7: sa.SerialMetadata + (*Range)(nil), // 8: sa.Range + (*Count)(nil), // 9: sa.Count + (*Timestamps)(nil), // 10: sa.Timestamps + (*CountCertificatesByNamesRequest)(nil), // 11: sa.CountCertificatesByNamesRequest + (*CountByNames)(nil), // 12: sa.CountByNames + (*CountRegistrationsByIPRequest)(nil), // 13: sa.CountRegistrationsByIPRequest + (*CountInvalidAuthorizationsRequest)(nil), // 14: sa.CountInvalidAuthorizationsRequest + (*CountOrdersRequest)(nil), // 15: sa.CountOrdersRequest + (*CountFQDNSetsRequest)(nil), // 16: sa.CountFQDNSetsRequest + (*FQDNSetExistsRequest)(nil), // 17: sa.FQDNSetExistsRequest + (*Exists)(nil), // 18: sa.Exists + (*AddSerialRequest)(nil), // 19: sa.AddSerialRequest + (*AddCertificateRequest)(nil), // 20: sa.AddCertificateRequest + (*OrderRequest)(nil), // 21: sa.OrderRequest + (*NewOrderRequest)(nil), // 22: sa.NewOrderRequest + (*NewOrderAndAuthzsRequest)(nil), // 23: sa.NewOrderAndAuthzsRequest + (*SetOrderErrorRequest)(nil), // 24: sa.SetOrderErrorRequest + (*GetValidOrderAuthorizationsRequest)(nil), // 25: sa.GetValidOrderAuthorizationsRequest + (*GetOrderForNamesRequest)(nil), // 26: sa.GetOrderForNamesRequest + (*FinalizeOrderRequest)(nil), // 27: sa.FinalizeOrderRequest + (*GetAuthorizationsRequest)(nil), // 28: sa.GetAuthorizationsRequest + (*Authorizations)(nil), // 29: sa.Authorizations + (*AuthorizationIDs)(nil), // 30: sa.AuthorizationIDs + (*AuthorizationID2)(nil), // 31: sa.AuthorizationID2 + (*RevokeCertificateRequest)(nil), // 32: sa.RevokeCertificateRequest + (*FinalizeAuthorizationRequest)(nil), // 33: sa.FinalizeAuthorizationRequest + (*AddBlockedKeyRequest)(nil), // 34: sa.AddBlockedKeyRequest + (*SPKIHash)(nil), // 35: sa.SPKIHash + (*Incident)(nil), // 36: sa.Incident + (*Incidents)(nil), // 37: sa.Incidents + (*SerialsForIncidentRequest)(nil), // 38: sa.SerialsForIncidentRequest + (*IncidentSerial)(nil), // 39: sa.IncidentSerial + (*GetRevokedCertsRequest)(nil), // 40: sa.GetRevokedCertsRequest + (*RevocationStatus)(nil), // 41: sa.RevocationStatus + (*LeaseCRLShardRequest)(nil), // 42: sa.LeaseCRLShardRequest + (*LeaseCRLShardResponse)(nil), // 43: sa.LeaseCRLShardResponse + (*UpdateCRLShardRequest)(nil), // 44: sa.UpdateCRLShardRequest + (*Identifier)(nil), // 45: sa.Identifier + (*Identifiers)(nil), // 46: sa.Identifiers + (*PauseRequest)(nil), // 47: sa.PauseRequest + (*PauseIdentifiersResponse)(nil), // 48: sa.PauseIdentifiersResponse + (*ValidAuthorizations_MapElement)(nil), // 49: sa.ValidAuthorizations.MapElement + nil, // 50: sa.CountByNames.CountsEntry + (*Authorizations_MapElement)(nil), // 51: sa.Authorizations.MapElement + (*timestamppb.Timestamp)(nil), // 52: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 53: google.protobuf.Duration + (*proto.Authorization)(nil), // 54: core.Authorization + (*proto.ProblemDetails)(nil), // 55: core.ProblemDetails + (*proto.ValidationRecord)(nil), // 56: core.ValidationRecord + (*emptypb.Empty)(nil), // 57: google.protobuf.Empty + (*proto.Registration)(nil), // 58: core.Registration + (*proto.Certificate)(nil), // 59: core.Certificate + (*proto.CertificateStatus)(nil), // 60: core.CertificateStatus + (*proto.Order)(nil), // 61: core.Order + (*proto.CRLEntry)(nil), // 62: core.CRLEntry +} +var file_sa_proto_depIdxs = []int32{ + 52, // 0: sa.GetPendingAuthorizationRequest.validUntil:type_name -> google.protobuf.Timestamp + 52, // 1: sa.GetValidAuthorizationsRequest.now:type_name -> google.protobuf.Timestamp + 49, // 2: sa.ValidAuthorizations.valid:type_name -> sa.ValidAuthorizations.MapElement + 52, // 3: sa.SerialMetadata.created:type_name -> google.protobuf.Timestamp + 52, // 4: sa.SerialMetadata.expires:type_name -> google.protobuf.Timestamp + 52, // 5: sa.Range.earliest:type_name -> google.protobuf.Timestamp + 52, // 6: sa.Range.latest:type_name -> google.protobuf.Timestamp + 52, // 7: sa.Timestamps.timestamps:type_name -> google.protobuf.Timestamp + 8, // 8: sa.CountCertificatesByNamesRequest.range:type_name -> sa.Range + 50, // 9: sa.CountByNames.counts:type_name -> sa.CountByNames.CountsEntry + 52, // 10: sa.CountByNames.earliest:type_name -> google.protobuf.Timestamp + 8, // 11: sa.CountRegistrationsByIPRequest.range:type_name -> sa.Range + 8, // 12: sa.CountInvalidAuthorizationsRequest.range:type_name -> sa.Range + 8, // 13: sa.CountOrdersRequest.range:type_name -> sa.Range + 53, // 14: sa.CountFQDNSetsRequest.window:type_name -> google.protobuf.Duration + 52, // 15: sa.AddSerialRequest.created:type_name -> google.protobuf.Timestamp + 52, // 16: sa.AddSerialRequest.expires:type_name -> google.protobuf.Timestamp + 52, // 17: sa.AddCertificateRequest.issued:type_name -> google.protobuf.Timestamp + 52, // 18: sa.NewOrderRequest.expires:type_name -> google.protobuf.Timestamp + 22, // 19: sa.NewOrderAndAuthzsRequest.newOrder:type_name -> sa.NewOrderRequest + 54, // 20: sa.NewOrderAndAuthzsRequest.newAuthzs:type_name -> core.Authorization + 55, // 21: sa.SetOrderErrorRequest.error:type_name -> core.ProblemDetails + 52, // 22: sa.GetAuthorizationsRequest.now:type_name -> google.protobuf.Timestamp + 51, // 23: sa.Authorizations.authz:type_name -> sa.Authorizations.MapElement + 52, // 24: sa.RevokeCertificateRequest.date:type_name -> google.protobuf.Timestamp + 52, // 25: sa.RevokeCertificateRequest.backdate:type_name -> google.protobuf.Timestamp + 52, // 26: sa.FinalizeAuthorizationRequest.expires:type_name -> google.protobuf.Timestamp + 56, // 27: sa.FinalizeAuthorizationRequest.validationRecords:type_name -> core.ValidationRecord + 55, // 28: sa.FinalizeAuthorizationRequest.validationError:type_name -> core.ProblemDetails + 52, // 29: sa.FinalizeAuthorizationRequest.attemptedAt:type_name -> google.protobuf.Timestamp + 52, // 30: sa.AddBlockedKeyRequest.added:type_name -> google.protobuf.Timestamp + 52, // 31: sa.Incident.renewBy:type_name -> google.protobuf.Timestamp + 36, // 32: sa.Incidents.incidents:type_name -> sa.Incident + 52, // 33: sa.IncidentSerial.lastNoticeSent:type_name -> google.protobuf.Timestamp + 52, // 34: sa.GetRevokedCertsRequest.expiresAfter:type_name -> google.protobuf.Timestamp + 52, // 35: sa.GetRevokedCertsRequest.expiresBefore:type_name -> google.protobuf.Timestamp + 52, // 36: sa.GetRevokedCertsRequest.revokedBefore:type_name -> google.protobuf.Timestamp + 52, // 37: sa.RevocationStatus.revokedDate:type_name -> google.protobuf.Timestamp + 52, // 38: sa.LeaseCRLShardRequest.until:type_name -> google.protobuf.Timestamp + 52, // 39: sa.UpdateCRLShardRequest.thisUpdate:type_name -> google.protobuf.Timestamp + 52, // 40: sa.UpdateCRLShardRequest.nextUpdate:type_name -> google.protobuf.Timestamp + 45, // 41: sa.Identifiers.identifiers:type_name -> sa.Identifier + 45, // 42: sa.PauseRequest.identifiers:type_name -> sa.Identifier + 54, // 43: sa.ValidAuthorizations.MapElement.authz:type_name -> core.Authorization + 54, // 44: sa.Authorizations.MapElement.authz:type_name -> core.Authorization + 11, // 45: sa.StorageAuthorityReadOnly.CountCertificatesByNames:input_type -> sa.CountCertificatesByNamesRequest + 16, // 46: sa.StorageAuthorityReadOnly.CountFQDNSets:input_type -> sa.CountFQDNSetsRequest + 14, // 47: sa.StorageAuthorityReadOnly.CountInvalidAuthorizations2:input_type -> sa.CountInvalidAuthorizationsRequest + 15, // 48: sa.StorageAuthorityReadOnly.CountOrders:input_type -> sa.CountOrdersRequest + 0, // 49: sa.StorageAuthorityReadOnly.CountPendingAuthorizations2:input_type -> sa.RegistrationID + 13, // 50: sa.StorageAuthorityReadOnly.CountRegistrationsByIP:input_type -> sa.CountRegistrationsByIPRequest + 13, // 51: sa.StorageAuthorityReadOnly.CountRegistrationsByIPRange:input_type -> sa.CountRegistrationsByIPRequest + 17, // 52: sa.StorageAuthorityReadOnly.FQDNSetExists:input_type -> sa.FQDNSetExistsRequest + 16, // 53: sa.StorageAuthorityReadOnly.FQDNSetTimestampsForWindow:input_type -> sa.CountFQDNSetsRequest + 31, // 54: sa.StorageAuthorityReadOnly.GetAuthorization2:input_type -> sa.AuthorizationID2 + 28, // 55: sa.StorageAuthorityReadOnly.GetAuthorizations2:input_type -> sa.GetAuthorizationsRequest + 6, // 56: sa.StorageAuthorityReadOnly.GetCertificate:input_type -> sa.Serial + 6, // 57: sa.StorageAuthorityReadOnly.GetLintPrecertificate:input_type -> sa.Serial + 6, // 58: sa.StorageAuthorityReadOnly.GetCertificateStatus:input_type -> sa.Serial + 57, // 59: sa.StorageAuthorityReadOnly.GetMaxExpiration:input_type -> google.protobuf.Empty + 21, // 60: sa.StorageAuthorityReadOnly.GetOrder:input_type -> sa.OrderRequest + 26, // 61: sa.StorageAuthorityReadOnly.GetOrderForNames:input_type -> sa.GetOrderForNamesRequest + 3, // 62: sa.StorageAuthorityReadOnly.GetPendingAuthorization2:input_type -> sa.GetPendingAuthorizationRequest + 0, // 63: sa.StorageAuthorityReadOnly.GetRegistration:input_type -> sa.RegistrationID + 1, // 64: sa.StorageAuthorityReadOnly.GetRegistrationByKey:input_type -> sa.JSONWebKey + 6, // 65: sa.StorageAuthorityReadOnly.GetRevocationStatus:input_type -> sa.Serial + 40, // 66: sa.StorageAuthorityReadOnly.GetRevokedCerts:input_type -> sa.GetRevokedCertsRequest + 6, // 67: sa.StorageAuthorityReadOnly.GetSerialMetadata:input_type -> sa.Serial + 0, // 68: sa.StorageAuthorityReadOnly.GetSerialsByAccount:input_type -> sa.RegistrationID + 35, // 69: sa.StorageAuthorityReadOnly.GetSerialsByKey:input_type -> sa.SPKIHash + 4, // 70: sa.StorageAuthorityReadOnly.GetValidAuthorizations2:input_type -> sa.GetValidAuthorizationsRequest + 25, // 71: sa.StorageAuthorityReadOnly.GetValidOrderAuthorizations2:input_type -> sa.GetValidOrderAuthorizationsRequest + 6, // 72: sa.StorageAuthorityReadOnly.IncidentsForSerial:input_type -> sa.Serial + 35, // 73: sa.StorageAuthorityReadOnly.KeyBlocked:input_type -> sa.SPKIHash + 6, // 74: sa.StorageAuthorityReadOnly.ReplacementOrderExists:input_type -> sa.Serial + 38, // 75: sa.StorageAuthorityReadOnly.SerialsForIncident:input_type -> sa.SerialsForIncidentRequest + 47, // 76: sa.StorageAuthorityReadOnly.CheckIdentifiersPaused:input_type -> sa.PauseRequest + 0, // 77: sa.StorageAuthorityReadOnly.GetPausedIdentifiers:input_type -> sa.RegistrationID + 11, // 78: sa.StorageAuthority.CountCertificatesByNames:input_type -> sa.CountCertificatesByNamesRequest + 16, // 79: sa.StorageAuthority.CountFQDNSets:input_type -> sa.CountFQDNSetsRequest + 14, // 80: sa.StorageAuthority.CountInvalidAuthorizations2:input_type -> sa.CountInvalidAuthorizationsRequest + 15, // 81: sa.StorageAuthority.CountOrders:input_type -> sa.CountOrdersRequest + 0, // 82: sa.StorageAuthority.CountPendingAuthorizations2:input_type -> sa.RegistrationID + 13, // 83: sa.StorageAuthority.CountRegistrationsByIP:input_type -> sa.CountRegistrationsByIPRequest + 13, // 84: sa.StorageAuthority.CountRegistrationsByIPRange:input_type -> sa.CountRegistrationsByIPRequest + 17, // 85: sa.StorageAuthority.FQDNSetExists:input_type -> sa.FQDNSetExistsRequest + 16, // 86: sa.StorageAuthority.FQDNSetTimestampsForWindow:input_type -> sa.CountFQDNSetsRequest + 31, // 87: sa.StorageAuthority.GetAuthorization2:input_type -> sa.AuthorizationID2 + 28, // 88: sa.StorageAuthority.GetAuthorizations2:input_type -> sa.GetAuthorizationsRequest + 6, // 89: sa.StorageAuthority.GetCertificate:input_type -> sa.Serial + 6, // 90: sa.StorageAuthority.GetLintPrecertificate:input_type -> sa.Serial + 6, // 91: sa.StorageAuthority.GetCertificateStatus:input_type -> sa.Serial + 57, // 92: sa.StorageAuthority.GetMaxExpiration:input_type -> google.protobuf.Empty + 21, // 93: sa.StorageAuthority.GetOrder:input_type -> sa.OrderRequest + 26, // 94: sa.StorageAuthority.GetOrderForNames:input_type -> sa.GetOrderForNamesRequest + 3, // 95: sa.StorageAuthority.GetPendingAuthorization2:input_type -> sa.GetPendingAuthorizationRequest + 0, // 96: sa.StorageAuthority.GetRegistration:input_type -> sa.RegistrationID + 1, // 97: sa.StorageAuthority.GetRegistrationByKey:input_type -> sa.JSONWebKey + 6, // 98: sa.StorageAuthority.GetRevocationStatus:input_type -> sa.Serial + 40, // 99: sa.StorageAuthority.GetRevokedCerts:input_type -> sa.GetRevokedCertsRequest + 6, // 100: sa.StorageAuthority.GetSerialMetadata:input_type -> sa.Serial + 0, // 101: sa.StorageAuthority.GetSerialsByAccount:input_type -> sa.RegistrationID + 35, // 102: sa.StorageAuthority.GetSerialsByKey:input_type -> sa.SPKIHash + 4, // 103: sa.StorageAuthority.GetValidAuthorizations2:input_type -> sa.GetValidAuthorizationsRequest + 25, // 104: sa.StorageAuthority.GetValidOrderAuthorizations2:input_type -> sa.GetValidOrderAuthorizationsRequest + 6, // 105: sa.StorageAuthority.IncidentsForSerial:input_type -> sa.Serial + 35, // 106: sa.StorageAuthority.KeyBlocked:input_type -> sa.SPKIHash + 6, // 107: sa.StorageAuthority.ReplacementOrderExists:input_type -> sa.Serial + 38, // 108: sa.StorageAuthority.SerialsForIncident:input_type -> sa.SerialsForIncidentRequest + 47, // 109: sa.StorageAuthority.CheckIdentifiersPaused:input_type -> sa.PauseRequest + 0, // 110: sa.StorageAuthority.GetPausedIdentifiers:input_type -> sa.RegistrationID + 34, // 111: sa.StorageAuthority.AddBlockedKey:input_type -> sa.AddBlockedKeyRequest + 20, // 112: sa.StorageAuthority.AddCertificate:input_type -> sa.AddCertificateRequest + 20, // 113: sa.StorageAuthority.AddPrecertificate:input_type -> sa.AddCertificateRequest + 6, // 114: sa.StorageAuthority.SetCertificateStatusReady:input_type -> sa.Serial + 19, // 115: sa.StorageAuthority.AddSerial:input_type -> sa.AddSerialRequest + 31, // 116: sa.StorageAuthority.DeactivateAuthorization2:input_type -> sa.AuthorizationID2 + 0, // 117: sa.StorageAuthority.DeactivateRegistration:input_type -> sa.RegistrationID + 33, // 118: sa.StorageAuthority.FinalizeAuthorization2:input_type -> sa.FinalizeAuthorizationRequest + 27, // 119: sa.StorageAuthority.FinalizeOrder:input_type -> sa.FinalizeOrderRequest + 23, // 120: sa.StorageAuthority.NewOrderAndAuthzs:input_type -> sa.NewOrderAndAuthzsRequest + 58, // 121: sa.StorageAuthority.NewRegistration:input_type -> core.Registration + 32, // 122: sa.StorageAuthority.RevokeCertificate:input_type -> sa.RevokeCertificateRequest + 24, // 123: sa.StorageAuthority.SetOrderError:input_type -> sa.SetOrderErrorRequest + 21, // 124: sa.StorageAuthority.SetOrderProcessing:input_type -> sa.OrderRequest + 58, // 125: sa.StorageAuthority.UpdateRegistration:input_type -> core.Registration + 32, // 126: sa.StorageAuthority.UpdateRevokedCertificate:input_type -> sa.RevokeCertificateRequest + 42, // 127: sa.StorageAuthority.LeaseCRLShard:input_type -> sa.LeaseCRLShardRequest + 44, // 128: sa.StorageAuthority.UpdateCRLShard:input_type -> sa.UpdateCRLShardRequest + 47, // 129: sa.StorageAuthority.PauseIdentifiers:input_type -> sa.PauseRequest + 0, // 130: sa.StorageAuthority.UnpauseAccount:input_type -> sa.RegistrationID + 12, // 131: sa.StorageAuthorityReadOnly.CountCertificatesByNames:output_type -> sa.CountByNames + 9, // 132: sa.StorageAuthorityReadOnly.CountFQDNSets:output_type -> sa.Count + 9, // 133: sa.StorageAuthorityReadOnly.CountInvalidAuthorizations2:output_type -> sa.Count + 9, // 134: sa.StorageAuthorityReadOnly.CountOrders:output_type -> sa.Count + 9, // 135: sa.StorageAuthorityReadOnly.CountPendingAuthorizations2:output_type -> sa.Count + 9, // 136: sa.StorageAuthorityReadOnly.CountRegistrationsByIP:output_type -> sa.Count + 9, // 137: sa.StorageAuthorityReadOnly.CountRegistrationsByIPRange:output_type -> sa.Count + 18, // 138: sa.StorageAuthorityReadOnly.FQDNSetExists:output_type -> sa.Exists + 10, // 139: sa.StorageAuthorityReadOnly.FQDNSetTimestampsForWindow:output_type -> sa.Timestamps + 54, // 140: sa.StorageAuthorityReadOnly.GetAuthorization2:output_type -> core.Authorization + 29, // 141: sa.StorageAuthorityReadOnly.GetAuthorizations2:output_type -> sa.Authorizations + 59, // 142: sa.StorageAuthorityReadOnly.GetCertificate:output_type -> core.Certificate + 59, // 143: sa.StorageAuthorityReadOnly.GetLintPrecertificate:output_type -> core.Certificate + 60, // 144: sa.StorageAuthorityReadOnly.GetCertificateStatus:output_type -> core.CertificateStatus + 52, // 145: sa.StorageAuthorityReadOnly.GetMaxExpiration:output_type -> google.protobuf.Timestamp + 61, // 146: sa.StorageAuthorityReadOnly.GetOrder:output_type -> core.Order + 61, // 147: sa.StorageAuthorityReadOnly.GetOrderForNames:output_type -> core.Order + 54, // 148: sa.StorageAuthorityReadOnly.GetPendingAuthorization2:output_type -> core.Authorization + 58, // 149: sa.StorageAuthorityReadOnly.GetRegistration:output_type -> core.Registration + 58, // 150: sa.StorageAuthorityReadOnly.GetRegistrationByKey:output_type -> core.Registration + 41, // 151: sa.StorageAuthorityReadOnly.GetRevocationStatus:output_type -> sa.RevocationStatus + 62, // 152: sa.StorageAuthorityReadOnly.GetRevokedCerts:output_type -> core.CRLEntry + 7, // 153: sa.StorageAuthorityReadOnly.GetSerialMetadata:output_type -> sa.SerialMetadata + 6, // 154: sa.StorageAuthorityReadOnly.GetSerialsByAccount:output_type -> sa.Serial + 6, // 155: sa.StorageAuthorityReadOnly.GetSerialsByKey:output_type -> sa.Serial + 29, // 156: sa.StorageAuthorityReadOnly.GetValidAuthorizations2:output_type -> sa.Authorizations + 29, // 157: sa.StorageAuthorityReadOnly.GetValidOrderAuthorizations2:output_type -> sa.Authorizations + 37, // 158: sa.StorageAuthorityReadOnly.IncidentsForSerial:output_type -> sa.Incidents + 18, // 159: sa.StorageAuthorityReadOnly.KeyBlocked:output_type -> sa.Exists + 18, // 160: sa.StorageAuthorityReadOnly.ReplacementOrderExists:output_type -> sa.Exists + 39, // 161: sa.StorageAuthorityReadOnly.SerialsForIncident:output_type -> sa.IncidentSerial + 46, // 162: sa.StorageAuthorityReadOnly.CheckIdentifiersPaused:output_type -> sa.Identifiers + 46, // 163: sa.StorageAuthorityReadOnly.GetPausedIdentifiers:output_type -> sa.Identifiers + 12, // 164: sa.StorageAuthority.CountCertificatesByNames:output_type -> sa.CountByNames + 9, // 165: sa.StorageAuthority.CountFQDNSets:output_type -> sa.Count + 9, // 166: sa.StorageAuthority.CountInvalidAuthorizations2:output_type -> sa.Count + 9, // 167: sa.StorageAuthority.CountOrders:output_type -> sa.Count + 9, // 168: sa.StorageAuthority.CountPendingAuthorizations2:output_type -> sa.Count + 9, // 169: sa.StorageAuthority.CountRegistrationsByIP:output_type -> sa.Count + 9, // 170: sa.StorageAuthority.CountRegistrationsByIPRange:output_type -> sa.Count + 18, // 171: sa.StorageAuthority.FQDNSetExists:output_type -> sa.Exists + 10, // 172: sa.StorageAuthority.FQDNSetTimestampsForWindow:output_type -> sa.Timestamps + 54, // 173: sa.StorageAuthority.GetAuthorization2:output_type -> core.Authorization + 29, // 174: sa.StorageAuthority.GetAuthorizations2:output_type -> sa.Authorizations + 59, // 175: sa.StorageAuthority.GetCertificate:output_type -> core.Certificate + 59, // 176: sa.StorageAuthority.GetLintPrecertificate:output_type -> core.Certificate + 60, // 177: sa.StorageAuthority.GetCertificateStatus:output_type -> core.CertificateStatus + 52, // 178: sa.StorageAuthority.GetMaxExpiration:output_type -> google.protobuf.Timestamp + 61, // 179: sa.StorageAuthority.GetOrder:output_type -> core.Order + 61, // 180: sa.StorageAuthority.GetOrderForNames:output_type -> core.Order + 54, // 181: sa.StorageAuthority.GetPendingAuthorization2:output_type -> core.Authorization + 58, // 182: sa.StorageAuthority.GetRegistration:output_type -> core.Registration + 58, // 183: sa.StorageAuthority.GetRegistrationByKey:output_type -> core.Registration + 41, // 184: sa.StorageAuthority.GetRevocationStatus:output_type -> sa.RevocationStatus + 62, // 185: sa.StorageAuthority.GetRevokedCerts:output_type -> core.CRLEntry + 7, // 186: sa.StorageAuthority.GetSerialMetadata:output_type -> sa.SerialMetadata + 6, // 187: sa.StorageAuthority.GetSerialsByAccount:output_type -> sa.Serial + 6, // 188: sa.StorageAuthority.GetSerialsByKey:output_type -> sa.Serial + 29, // 189: sa.StorageAuthority.GetValidAuthorizations2:output_type -> sa.Authorizations + 29, // 190: sa.StorageAuthority.GetValidOrderAuthorizations2:output_type -> sa.Authorizations + 37, // 191: sa.StorageAuthority.IncidentsForSerial:output_type -> sa.Incidents + 18, // 192: sa.StorageAuthority.KeyBlocked:output_type -> sa.Exists + 18, // 193: sa.StorageAuthority.ReplacementOrderExists:output_type -> sa.Exists + 39, // 194: sa.StorageAuthority.SerialsForIncident:output_type -> sa.IncidentSerial + 46, // 195: sa.StorageAuthority.CheckIdentifiersPaused:output_type -> sa.Identifiers + 46, // 196: sa.StorageAuthority.GetPausedIdentifiers:output_type -> sa.Identifiers + 57, // 197: sa.StorageAuthority.AddBlockedKey:output_type -> google.protobuf.Empty + 57, // 198: sa.StorageAuthority.AddCertificate:output_type -> google.protobuf.Empty + 57, // 199: sa.StorageAuthority.AddPrecertificate:output_type -> google.protobuf.Empty + 57, // 200: sa.StorageAuthority.SetCertificateStatusReady:output_type -> google.protobuf.Empty + 57, // 201: sa.StorageAuthority.AddSerial:output_type -> google.protobuf.Empty + 57, // 202: sa.StorageAuthority.DeactivateAuthorization2:output_type -> google.protobuf.Empty + 57, // 203: sa.StorageAuthority.DeactivateRegistration:output_type -> google.protobuf.Empty + 57, // 204: sa.StorageAuthority.FinalizeAuthorization2:output_type -> google.protobuf.Empty + 57, // 205: sa.StorageAuthority.FinalizeOrder:output_type -> google.protobuf.Empty + 61, // 206: sa.StorageAuthority.NewOrderAndAuthzs:output_type -> core.Order + 58, // 207: sa.StorageAuthority.NewRegistration:output_type -> core.Registration + 57, // 208: sa.StorageAuthority.RevokeCertificate:output_type -> google.protobuf.Empty + 57, // 209: sa.StorageAuthority.SetOrderError:output_type -> google.protobuf.Empty + 57, // 210: sa.StorageAuthority.SetOrderProcessing:output_type -> google.protobuf.Empty + 57, // 211: sa.StorageAuthority.UpdateRegistration:output_type -> google.protobuf.Empty + 57, // 212: sa.StorageAuthority.UpdateRevokedCertificate:output_type -> google.protobuf.Empty + 43, // 213: sa.StorageAuthority.LeaseCRLShard:output_type -> sa.LeaseCRLShardResponse + 57, // 214: sa.StorageAuthority.UpdateCRLShard:output_type -> google.protobuf.Empty + 48, // 215: sa.StorageAuthority.PauseIdentifiers:output_type -> sa.PauseIdentifiersResponse + 57, // 216: sa.StorageAuthority.UnpauseAccount:output_type -> google.protobuf.Empty + 131, // [131:217] is the sub-list for method output_type + 45, // [45:131] is the sub-list for method input_type + 45, // [45:45] is the sub-list for extension type_name + 45, // [45:45] is the sub-list for extension extendee + 0, // [0:45] is the sub-list for field type_name +} + +func init() { file_sa_proto_init() } +func file_sa_proto_init() { + if File_sa_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_sa_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RegistrationID); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*JSONWebKey); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthorizationID); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetPendingAuthorizationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetValidAuthorizationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidAuthorizations); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Serial); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SerialMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Range); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Count); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Timestamps); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountCertificatesByNamesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountByNames); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountRegistrationsByIPRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountInvalidAuthorizationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountOrdersRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CountFQDNSetsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FQDNSetExistsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Exists); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddSerialRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddCertificateRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NewOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NewOrderAndAuthzsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SetOrderErrorRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetValidOrderAuthorizationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOrderForNamesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetAuthorizationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Authorizations); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthorizationIDs); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthorizationID2); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RevokeCertificateRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeAuthorizationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddBlockedKeyRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SPKIHash); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Incident); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Incidents); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SerialsForIncidentRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IncidentSerial); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetRevokedCertsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RevocationStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LeaseCRLShardRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LeaseCRLShardResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateCRLShardRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Identifier); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Identifiers); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PauseRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PauseIdentifiersResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidAuthorizations_MapElement); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sa_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Authorizations_MapElement); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_sa_proto_rawDesc, + NumEnums: 0, + NumMessages: 52, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_sa_proto_goTypes, + DependencyIndexes: file_sa_proto_depIdxs, + MessageInfos: file_sa_proto_msgTypes, + }.Build() + File_sa_proto = out.File + file_sa_proto_rawDesc = nil + file_sa_proto_goTypes = nil + file_sa_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/proto/sa.proto b/third-party/github.com/letsencrypt/boulder/sa/proto/sa.proto new file mode 100644 index 000000000..ec63feafa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/proto/sa.proto @@ -0,0 +1,441 @@ +syntax = "proto3"; + +package sa; +option go_package = "github.com/letsencrypt/boulder/sa/proto"; + +import "core/proto/core.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; + +// StorageAuthorityReadOnly exposes only those SA methods which are read-only. +service StorageAuthorityReadOnly { + rpc CountCertificatesByNames(CountCertificatesByNamesRequest) returns (CountByNames) {} + rpc CountFQDNSets(CountFQDNSetsRequest) returns (Count) {} + rpc CountInvalidAuthorizations2(CountInvalidAuthorizationsRequest) returns (Count) {} + rpc CountOrders(CountOrdersRequest) returns (Count) {} + rpc CountPendingAuthorizations2(RegistrationID) returns (Count) {} + rpc CountRegistrationsByIP(CountRegistrationsByIPRequest) returns (Count) {} + rpc CountRegistrationsByIPRange(CountRegistrationsByIPRequest) returns (Count) {} + rpc FQDNSetExists(FQDNSetExistsRequest) returns (Exists) {} + rpc FQDNSetTimestampsForWindow(CountFQDNSetsRequest) returns (Timestamps) {} + rpc GetAuthorization2(AuthorizationID2) returns (core.Authorization) {} + rpc GetAuthorizations2(GetAuthorizationsRequest) returns (Authorizations) {} + rpc GetCertificate(Serial) returns (core.Certificate) {} + rpc GetLintPrecertificate(Serial) returns (core.Certificate) {} + rpc GetCertificateStatus(Serial) returns (core.CertificateStatus) {} + rpc GetMaxExpiration(google.protobuf.Empty) returns (google.protobuf.Timestamp) {} + rpc GetOrder(OrderRequest) returns (core.Order) {} + rpc GetOrderForNames(GetOrderForNamesRequest) returns (core.Order) {} + rpc GetPendingAuthorization2(GetPendingAuthorizationRequest) returns (core.Authorization) {} + rpc GetRegistration(RegistrationID) returns (core.Registration) {} + rpc GetRegistrationByKey(JSONWebKey) returns (core.Registration) {} + rpc GetRevocationStatus(Serial) returns (RevocationStatus) {} + rpc GetRevokedCerts(GetRevokedCertsRequest) returns (stream core.CRLEntry) {} + rpc GetSerialMetadata(Serial) returns (SerialMetadata) {} + rpc GetSerialsByAccount(RegistrationID) returns (stream Serial) {} + rpc GetSerialsByKey(SPKIHash) returns (stream Serial) {} + rpc GetValidAuthorizations2(GetValidAuthorizationsRequest) returns (Authorizations) {} + rpc GetValidOrderAuthorizations2(GetValidOrderAuthorizationsRequest) returns (Authorizations) {} + rpc IncidentsForSerial(Serial) returns (Incidents) {} + rpc KeyBlocked(SPKIHash) returns (Exists) {} + rpc ReplacementOrderExists(Serial) returns (Exists) {} + rpc SerialsForIncident (SerialsForIncidentRequest) returns (stream IncidentSerial) {} + rpc CheckIdentifiersPaused (PauseRequest) returns (Identifiers) {} + rpc GetPausedIdentifiers (RegistrationID) returns (Identifiers) {} +} + +// StorageAuthority provides full read/write access to the database. +service StorageAuthority { + // Getters: this list must be identical to the StorageAuthorityReadOnly rpcs. + rpc CountCertificatesByNames(CountCertificatesByNamesRequest) returns (CountByNames) {} + rpc CountFQDNSets(CountFQDNSetsRequest) returns (Count) {} + rpc CountInvalidAuthorizations2(CountInvalidAuthorizationsRequest) returns (Count) {} + rpc CountOrders(CountOrdersRequest) returns (Count) {} + rpc CountPendingAuthorizations2(RegistrationID) returns (Count) {} + rpc CountRegistrationsByIP(CountRegistrationsByIPRequest) returns (Count) {} + rpc CountRegistrationsByIPRange(CountRegistrationsByIPRequest) returns (Count) {} + rpc FQDNSetExists(FQDNSetExistsRequest) returns (Exists) {} + rpc FQDNSetTimestampsForWindow(CountFQDNSetsRequest) returns (Timestamps) {} + rpc GetAuthorization2(AuthorizationID2) returns (core.Authorization) {} + rpc GetAuthorizations2(GetAuthorizationsRequest) returns (Authorizations) {} + rpc GetCertificate(Serial) returns (core.Certificate) {} + rpc GetLintPrecertificate(Serial) returns (core.Certificate) {} + rpc GetCertificateStatus(Serial) returns (core.CertificateStatus) {} + rpc GetMaxExpiration(google.protobuf.Empty) returns (google.protobuf.Timestamp) {} + rpc GetOrder(OrderRequest) returns (core.Order) {} + rpc GetOrderForNames(GetOrderForNamesRequest) returns (core.Order) {} + rpc GetPendingAuthorization2(GetPendingAuthorizationRequest) returns (core.Authorization) {} + rpc GetRegistration(RegistrationID) returns (core.Registration) {} + rpc GetRegistrationByKey(JSONWebKey) returns (core.Registration) {} + rpc GetRevocationStatus(Serial) returns (RevocationStatus) {} + rpc GetRevokedCerts(GetRevokedCertsRequest) returns (stream core.CRLEntry) {} + rpc GetSerialMetadata(Serial) returns (SerialMetadata) {} + rpc GetSerialsByAccount(RegistrationID) returns (stream Serial) {} + rpc GetSerialsByKey(SPKIHash) returns (stream Serial) {} + rpc GetValidAuthorizations2(GetValidAuthorizationsRequest) returns (Authorizations) {} + rpc GetValidOrderAuthorizations2(GetValidOrderAuthorizationsRequest) returns (Authorizations) {} + rpc IncidentsForSerial(Serial) returns (Incidents) {} + rpc KeyBlocked(SPKIHash) returns (Exists) {} + rpc ReplacementOrderExists(Serial) returns (Exists) {} + rpc SerialsForIncident (SerialsForIncidentRequest) returns (stream IncidentSerial) {} + rpc CheckIdentifiersPaused (PauseRequest) returns (Identifiers) {} + rpc GetPausedIdentifiers (RegistrationID) returns (Identifiers) {} + // Adders + rpc AddBlockedKey(AddBlockedKeyRequest) returns (google.protobuf.Empty) {} + rpc AddCertificate(AddCertificateRequest) returns (google.protobuf.Empty) {} + rpc AddPrecertificate(AddCertificateRequest) returns (google.protobuf.Empty) {} + rpc SetCertificateStatusReady(Serial) returns (google.protobuf.Empty) {} + rpc AddSerial(AddSerialRequest) returns (google.protobuf.Empty) {} + rpc DeactivateAuthorization2(AuthorizationID2) returns (google.protobuf.Empty) {} + rpc DeactivateRegistration(RegistrationID) returns (google.protobuf.Empty) {} + rpc FinalizeAuthorization2(FinalizeAuthorizationRequest) returns (google.protobuf.Empty) {} + rpc FinalizeOrder(FinalizeOrderRequest) returns (google.protobuf.Empty) {} + rpc NewOrderAndAuthzs(NewOrderAndAuthzsRequest) returns (core.Order) {} + rpc NewRegistration(core.Registration) returns (core.Registration) {} + rpc RevokeCertificate(RevokeCertificateRequest) returns (google.protobuf.Empty) {} + rpc SetOrderError(SetOrderErrorRequest) returns (google.protobuf.Empty) {} + rpc SetOrderProcessing(OrderRequest) returns (google.protobuf.Empty) {} + rpc UpdateRegistration(core.Registration) returns (google.protobuf.Empty) {} + rpc UpdateRevokedCertificate(RevokeCertificateRequest) returns (google.protobuf.Empty) {} + rpc LeaseCRLShard(LeaseCRLShardRequest) returns (LeaseCRLShardResponse) {} + rpc UpdateCRLShard(UpdateCRLShardRequest) returns (google.protobuf.Empty) {} + rpc PauseIdentifiers(PauseRequest) returns (PauseIdentifiersResponse) {} + rpc UnpauseAccount(RegistrationID) returns (google.protobuf.Empty) {} +} + +message RegistrationID { + int64 id = 1; +} + +message JSONWebKey { + bytes jwk = 1; +} + +message AuthorizationID { + string id = 1; +} + +message GetPendingAuthorizationRequest { + // Next unused field number: 6 + int64 registrationID = 1; + string identifierType = 2; + string identifierValue = 3; + // Result must be valid until at least this Unix timestamp (nanos) + reserved 4; // Previously validUntilNS + google.protobuf.Timestamp validUntil = 5; // Result must be valid until at least this timestamp +} + +message GetValidAuthorizationsRequest { + // Next unused field number: 5 + int64 registrationID = 1; + repeated string domains = 2; + reserved 3; // Previously nowNS + google.protobuf.Timestamp now = 4; +} + +message ValidAuthorizations { + message MapElement { + string domain = 1; + core.Authorization authz = 2; + } + repeated MapElement valid = 1; +} + +message Serial { + string serial = 1; +} + +message SerialMetadata { + // Next unused field number: 7 + string serial = 1; + int64 registrationID = 2; + reserved 3; // Previously createdNS + google.protobuf.Timestamp created = 5; + reserved 4; // Previously expiresNS + google.protobuf.Timestamp expires = 6; +} + +message Range { + // Next unused field number: 5 + reserved 1; // Previously earliestNS + google.protobuf.Timestamp earliest = 3; + reserved 2; // Previously latestNS + google.protobuf.Timestamp latest = 4; +} + +message Count { + int64 count = 1; +} + +message Timestamps { + // Next unused field number: 3 + reserved 1; // Previously repeated timestampsNS + repeated google.protobuf.Timestamp timestamps = 2; +} + +message CountCertificatesByNamesRequest { + Range range = 1; + repeated string names = 2; +} + +message CountByNames { + map counts = 1; + google.protobuf.Timestamp earliest = 2; // Unix timestamp (nanoseconds) +} + +message CountRegistrationsByIPRequest { + bytes ip = 1; + Range range = 2; +} + +message CountInvalidAuthorizationsRequest { + int64 registrationID = 1; + string hostname = 2; + // Count authorizations that expire in this range. + Range range = 3; +} + +message CountOrdersRequest { + int64 accountID = 1; + Range range = 2; +} + +message CountFQDNSetsRequest { + // Next unused field number: 4 + reserved 1; // Previously windowNS + repeated string domains = 2; + google.protobuf.Duration window = 3; +} + +message FQDNSetExistsRequest { + repeated string domains = 1; +} + +message Exists { + bool exists = 1; +} + +message AddSerialRequest { + // Next unused field number: 7 + int64 regID = 1; + string serial = 2; + reserved 3; // Previously createdNS + google.protobuf.Timestamp created = 5; + reserved 4; // Previously expiresNS + google.protobuf.Timestamp expires = 6; +} + +message AddCertificateRequest { + // Next unused field number: 8 + bytes der = 1; + int64 regID = 2; + reserved 3; // previously ocsp + // An issued time. When not present the SA defaults to using + // the current time. + reserved 4; // Previously issuedNS + google.protobuf.Timestamp issued = 7; + int64 issuerNameID = 5; // https://pkg.go.dev/github.com/letsencrypt/boulder/issuance#IssuerNameID + + // If this is set to true, the certificateStatus.status column will be set to + // "wait", which will cause us to serve internalError responses with OCSP is + // queried. This allows us to meet the BRs requirement: + // + // If the OCSP responder receives a request for the status of a certificate + // serial number that is “unused”, then ... + // the responder MUST NOT respond with a “good” status for such requests. + // + // Paraphrasing, a certificate serial number is unused if neither a + // Certificate nor a Precertificate has been issued with it. So when we write + // a linting certificate to the precertificates table, we want to make sure + // we never give a "good" response for that serial until the precertificate + // is actually issued. + bool ocspNotReady = 6; +} + +message OrderRequest { + int64 id = 1; +} + +message NewOrderRequest { + // Next unused field number: 8 + int64 registrationID = 1; + reserved 2; // Previously expiresNS + google.protobuf.Timestamp expires = 5; + repeated string names = 3; + repeated int64 v2Authorizations = 4; + string replacesSerial = 6; + string certificateProfileName = 7; +} + +message NewOrderAndAuthzsRequest { + NewOrderRequest newOrder = 1; + repeated core.Authorization newAuthzs = 2; +} + +message SetOrderErrorRequest { + int64 id = 1; + core.ProblemDetails error = 2; +} + +message GetValidOrderAuthorizationsRequest { + int64 id = 1; + int64 acctID = 2; +} + +message GetOrderForNamesRequest { + int64 acctID = 1; + repeated string names = 2; +} + +message FinalizeOrderRequest { + int64 id = 1; + string certificateSerial = 2; +} + +message GetAuthorizationsRequest { + // Next unused field number: 5 + int64 registrationID = 1; + repeated string domains = 2; + reserved 3; // Previously nowNS + google.protobuf.Timestamp now = 4; +} + +message Authorizations { + message MapElement { + string domain = 1; + core.Authorization authz = 2; + } + repeated MapElement authz = 1; +} + +message AuthorizationIDs { + repeated string ids = 1; +} + +message AuthorizationID2 { + int64 id = 1; +} + +message RevokeCertificateRequest { + // Next unused field number: 10 + string serial = 1; + int64 reason = 2; + reserved 3; // Previously dateNS + google.protobuf.Timestamp date = 8; + reserved 5; // Previously backdateNS + google.protobuf.Timestamp backdate = 9; + bytes response = 4; + int64 issuerID = 6; + int64 shardIdx = 7; +} + +message FinalizeAuthorizationRequest { + // Next unused field number: 10 + int64 id = 1; + string status = 2; + reserved 3; // Previously + google.protobuf.Timestamp expires = 8; + string attempted = 4; + repeated core.ValidationRecord validationRecords = 5; + core.ProblemDetails validationError = 6; + reserved 7; // Previously attemptedAtNS + google.protobuf.Timestamp attemptedAt = 9; +} + +message AddBlockedKeyRequest { + // Next unused field number: 7 + bytes keyHash = 1; + reserved 2; // Previously addedNS + google.protobuf.Timestamp added = 6; + string source = 3; + string comment = 4; + int64 revokedBy = 5; +} + +message SPKIHash { + bytes keyHash = 1; +} + +message Incident { + // Next unused field number: 7 + int64 id = 1; + string serialTable = 2; + string url = 3; + reserved 4; // Previously renewByNS + google.protobuf.Timestamp renewBy = 6; + bool enabled = 5; +} + +message Incidents { + repeated Incident incidents = 1; +} + +message SerialsForIncidentRequest { + string incidentTable = 1; +} + +message IncidentSerial { + // Next unused field number: 6 + string serial = 1; + int64 registrationID = 2; // May be 0 (NULL) + int64 orderID = 3; // May be 0 (NULL) + reserved 4; // Previously lastNoticeSentNS + google.protobuf.Timestamp lastNoticeSent = 5; +} + +message GetRevokedCertsRequest { + // Next unused field number: 9 + int64 issuerNameID = 1; + reserved 2; // Previously expiresAfterNS + google.protobuf.Timestamp expiresAfter = 6; // inclusive + reserved 3; // Previously expiresBeforeNS + google.protobuf.Timestamp expiresBefore = 7; // exclusive + reserved 4; // Previously revokedBeforeNS + google.protobuf.Timestamp revokedBefore = 8; + int64 shardIdx = 5; // Must not be set until the revokedCertificates table has 90+ days of entries. +} + +message RevocationStatus { + int64 status = 1; + int64 revokedReason = 2; + google.protobuf.Timestamp revokedDate = 3; // Unix timestamp (nanoseconds) +} + +message LeaseCRLShardRequest { + int64 issuerNameID = 1; + int64 minShardIdx = 2; + int64 maxShardIdx = 3; + google.protobuf.Timestamp until = 4; +} + +message LeaseCRLShardResponse { + int64 issuerNameID = 1; + int64 shardIdx = 2; +} + +message UpdateCRLShardRequest { + int64 issuerNameID = 1; + int64 shardIdx = 2; + google.protobuf.Timestamp thisUpdate = 3; + google.protobuf.Timestamp nextUpdate = 4; +} + +message Identifier { + string type = 1; + string value = 2; +} + +message Identifiers { + repeated Identifier identifiers = 1; +} + +message PauseRequest { + int64 registrationID = 1; + repeated Identifier identifiers = 2; +} + +message PauseIdentifiersResponse { + int64 paused = 1; + int64 repaused = 2; +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/proto/sa_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/sa/proto/sa_grpc.pb.go new file mode 100644 index 000000000..4736f8fd5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/proto/sa_grpc.pb.go @@ -0,0 +1,3427 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: sa.proto + +package proto + +import ( + context "context" + proto "github.com/letsencrypt/boulder/core/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + StorageAuthorityReadOnly_CountCertificatesByNames_FullMethodName = "/sa.StorageAuthorityReadOnly/CountCertificatesByNames" + StorageAuthorityReadOnly_CountFQDNSets_FullMethodName = "/sa.StorageAuthorityReadOnly/CountFQDNSets" + StorageAuthorityReadOnly_CountInvalidAuthorizations2_FullMethodName = "/sa.StorageAuthorityReadOnly/CountInvalidAuthorizations2" + StorageAuthorityReadOnly_CountOrders_FullMethodName = "/sa.StorageAuthorityReadOnly/CountOrders" + StorageAuthorityReadOnly_CountPendingAuthorizations2_FullMethodName = "/sa.StorageAuthorityReadOnly/CountPendingAuthorizations2" + StorageAuthorityReadOnly_CountRegistrationsByIP_FullMethodName = "/sa.StorageAuthorityReadOnly/CountRegistrationsByIP" + StorageAuthorityReadOnly_CountRegistrationsByIPRange_FullMethodName = "/sa.StorageAuthorityReadOnly/CountRegistrationsByIPRange" + StorageAuthorityReadOnly_FQDNSetExists_FullMethodName = "/sa.StorageAuthorityReadOnly/FQDNSetExists" + StorageAuthorityReadOnly_FQDNSetTimestampsForWindow_FullMethodName = "/sa.StorageAuthorityReadOnly/FQDNSetTimestampsForWindow" + StorageAuthorityReadOnly_GetAuthorization2_FullMethodName = "/sa.StorageAuthorityReadOnly/GetAuthorization2" + StorageAuthorityReadOnly_GetAuthorizations2_FullMethodName = "/sa.StorageAuthorityReadOnly/GetAuthorizations2" + StorageAuthorityReadOnly_GetCertificate_FullMethodName = "/sa.StorageAuthorityReadOnly/GetCertificate" + StorageAuthorityReadOnly_GetLintPrecertificate_FullMethodName = "/sa.StorageAuthorityReadOnly/GetLintPrecertificate" + StorageAuthorityReadOnly_GetCertificateStatus_FullMethodName = "/sa.StorageAuthorityReadOnly/GetCertificateStatus" + StorageAuthorityReadOnly_GetMaxExpiration_FullMethodName = "/sa.StorageAuthorityReadOnly/GetMaxExpiration" + StorageAuthorityReadOnly_GetOrder_FullMethodName = "/sa.StorageAuthorityReadOnly/GetOrder" + StorageAuthorityReadOnly_GetOrderForNames_FullMethodName = "/sa.StorageAuthorityReadOnly/GetOrderForNames" + StorageAuthorityReadOnly_GetPendingAuthorization2_FullMethodName = "/sa.StorageAuthorityReadOnly/GetPendingAuthorization2" + StorageAuthorityReadOnly_GetRegistration_FullMethodName = "/sa.StorageAuthorityReadOnly/GetRegistration" + StorageAuthorityReadOnly_GetRegistrationByKey_FullMethodName = "/sa.StorageAuthorityReadOnly/GetRegistrationByKey" + StorageAuthorityReadOnly_GetRevocationStatus_FullMethodName = "/sa.StorageAuthorityReadOnly/GetRevocationStatus" + StorageAuthorityReadOnly_GetRevokedCerts_FullMethodName = "/sa.StorageAuthorityReadOnly/GetRevokedCerts" + StorageAuthorityReadOnly_GetSerialMetadata_FullMethodName = "/sa.StorageAuthorityReadOnly/GetSerialMetadata" + StorageAuthorityReadOnly_GetSerialsByAccount_FullMethodName = "/sa.StorageAuthorityReadOnly/GetSerialsByAccount" + StorageAuthorityReadOnly_GetSerialsByKey_FullMethodName = "/sa.StorageAuthorityReadOnly/GetSerialsByKey" + StorageAuthorityReadOnly_GetValidAuthorizations2_FullMethodName = "/sa.StorageAuthorityReadOnly/GetValidAuthorizations2" + StorageAuthorityReadOnly_GetValidOrderAuthorizations2_FullMethodName = "/sa.StorageAuthorityReadOnly/GetValidOrderAuthorizations2" + StorageAuthorityReadOnly_IncidentsForSerial_FullMethodName = "/sa.StorageAuthorityReadOnly/IncidentsForSerial" + StorageAuthorityReadOnly_KeyBlocked_FullMethodName = "/sa.StorageAuthorityReadOnly/KeyBlocked" + StorageAuthorityReadOnly_ReplacementOrderExists_FullMethodName = "/sa.StorageAuthorityReadOnly/ReplacementOrderExists" + StorageAuthorityReadOnly_SerialsForIncident_FullMethodName = "/sa.StorageAuthorityReadOnly/SerialsForIncident" + StorageAuthorityReadOnly_CheckIdentifiersPaused_FullMethodName = "/sa.StorageAuthorityReadOnly/CheckIdentifiersPaused" + StorageAuthorityReadOnly_GetPausedIdentifiers_FullMethodName = "/sa.StorageAuthorityReadOnly/GetPausedIdentifiers" +) + +// StorageAuthorityReadOnlyClient is the client API for StorageAuthorityReadOnly service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type StorageAuthorityReadOnlyClient interface { + CountCertificatesByNames(ctx context.Context, in *CountCertificatesByNamesRequest, opts ...grpc.CallOption) (*CountByNames, error) + CountFQDNSets(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Count, error) + CountInvalidAuthorizations2(ctx context.Context, in *CountInvalidAuthorizationsRequest, opts ...grpc.CallOption) (*Count, error) + CountOrders(ctx context.Context, in *CountOrdersRequest, opts ...grpc.CallOption) (*Count, error) + CountPendingAuthorizations2(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Count, error) + CountRegistrationsByIP(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) + CountRegistrationsByIPRange(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) + FQDNSetExists(ctx context.Context, in *FQDNSetExistsRequest, opts ...grpc.CallOption) (*Exists, error) + FQDNSetTimestampsForWindow(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Timestamps, error) + GetAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*proto.Authorization, error) + GetAuthorizations2(ctx context.Context, in *GetAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + GetCertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + GetLintPrecertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + GetCertificateStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.CertificateStatus, error) + GetMaxExpiration(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*timestamppb.Timestamp, error) + GetOrder(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*proto.Order, error) + GetOrderForNames(ctx context.Context, in *GetOrderForNamesRequest, opts ...grpc.CallOption) (*proto.Order, error) + GetPendingAuthorization2(ctx context.Context, in *GetPendingAuthorizationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) + GetRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*proto.Registration, error) + GetRegistrationByKey(ctx context.Context, in *JSONWebKey, opts ...grpc.CallOption) (*proto.Registration, error) + GetRevocationStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*RevocationStatus, error) + GetRevokedCerts(ctx context.Context, in *GetRevokedCertsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[proto.CRLEntry], error) + GetSerialMetadata(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*SerialMetadata, error) + GetSerialsByAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) + GetSerialsByKey(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) + GetValidAuthorizations2(ctx context.Context, in *GetValidAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + GetValidOrderAuthorizations2(ctx context.Context, in *GetValidOrderAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error) + KeyBlocked(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (*Exists, error) + ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) + SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IncidentSerial], error) + CheckIdentifiersPaused(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*Identifiers, error) + GetPausedIdentifiers(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Identifiers, error) +} + +type storageAuthorityReadOnlyClient struct { + cc grpc.ClientConnInterface +} + +func NewStorageAuthorityReadOnlyClient(cc grpc.ClientConnInterface) StorageAuthorityReadOnlyClient { + return &storageAuthorityReadOnlyClient{cc} +} + +func (c *storageAuthorityReadOnlyClient) CountCertificatesByNames(ctx context.Context, in *CountCertificatesByNamesRequest, opts ...grpc.CallOption) (*CountByNames, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CountByNames) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountCertificatesByNames_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountFQDNSets(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountFQDNSets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountInvalidAuthorizations2(ctx context.Context, in *CountInvalidAuthorizationsRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountInvalidAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountOrders(ctx context.Context, in *CountOrdersRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountOrders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountPendingAuthorizations2(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountPendingAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountRegistrationsByIP(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountRegistrationsByIP_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) CountRegistrationsByIPRange(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CountRegistrationsByIPRange_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) FQDNSetExists(ctx context.Context, in *FQDNSetExistsRequest, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_FQDNSetExists_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) FQDNSetTimestampsForWindow(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Timestamps, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Timestamps) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_FQDNSetTimestampsForWindow_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*proto.Authorization, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Authorization) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetAuthorizations2(ctx context.Context, in *GetAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetCertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Certificate) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetLintPrecertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Certificate) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetLintPrecertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetCertificateStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.CertificateStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.CertificateStatus) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetCertificateStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetMaxExpiration(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*timestamppb.Timestamp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(timestamppb.Timestamp) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetMaxExpiration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetOrder(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetOrderForNames(ctx context.Context, in *GetOrderForNamesRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetOrderForNames_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetPendingAuthorization2(ctx context.Context, in *GetPendingAuthorizationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Authorization) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetPendingAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetRegistrationByKey(ctx context.Context, in *JSONWebKey, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetRegistrationByKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetRevocationStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*RevocationStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevocationStatus) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetRevocationStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetRevokedCerts(ctx context.Context, in *GetRevokedCertsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[proto.CRLEntry], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthorityReadOnly_ServiceDesc.Streams[0], StorageAuthorityReadOnly_GetRevokedCerts_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[GetRevokedCertsRequest, proto.CRLEntry]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetRevokedCertsClient = grpc.ServerStreamingClient[proto.CRLEntry] + +func (c *storageAuthorityReadOnlyClient) GetSerialMetadata(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*SerialMetadata, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SerialMetadata) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetSerialMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetSerialsByAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthorityReadOnly_ServiceDesc.Streams[1], StorageAuthorityReadOnly_GetSerialsByAccount_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[RegistrationID, Serial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetSerialsByAccountClient = grpc.ServerStreamingClient[Serial] + +func (c *storageAuthorityReadOnlyClient) GetSerialsByKey(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthorityReadOnly_ServiceDesc.Streams[2], StorageAuthorityReadOnly_GetSerialsByKey_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SPKIHash, Serial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetSerialsByKeyClient = grpc.ServerStreamingClient[Serial] + +func (c *storageAuthorityReadOnlyClient) GetValidAuthorizations2(ctx context.Context, in *GetValidAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetValidAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetValidOrderAuthorizations2(ctx context.Context, in *GetValidOrderAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetValidOrderAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Incidents) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_IncidentsForSerial_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) KeyBlocked(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_KeyBlocked_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_ReplacementOrderExists_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IncidentSerial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthorityReadOnly_ServiceDesc.Streams[3], StorageAuthorityReadOnly_SerialsForIncident_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SerialsForIncidentRequest, IncidentSerial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_SerialsForIncidentClient = grpc.ServerStreamingClient[IncidentSerial] + +func (c *storageAuthorityReadOnlyClient) CheckIdentifiersPaused(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*Identifiers, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Identifiers) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_CheckIdentifiersPaused_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityReadOnlyClient) GetPausedIdentifiers(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Identifiers, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Identifiers) + err := c.cc.Invoke(ctx, StorageAuthorityReadOnly_GetPausedIdentifiers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// StorageAuthorityReadOnlyServer is the server API for StorageAuthorityReadOnly service. +// All implementations must embed UnimplementedStorageAuthorityReadOnlyServer +// for forward compatibility +type StorageAuthorityReadOnlyServer interface { + CountCertificatesByNames(context.Context, *CountCertificatesByNamesRequest) (*CountByNames, error) + CountFQDNSets(context.Context, *CountFQDNSetsRequest) (*Count, error) + CountInvalidAuthorizations2(context.Context, *CountInvalidAuthorizationsRequest) (*Count, error) + CountOrders(context.Context, *CountOrdersRequest) (*Count, error) + CountPendingAuthorizations2(context.Context, *RegistrationID) (*Count, error) + CountRegistrationsByIP(context.Context, *CountRegistrationsByIPRequest) (*Count, error) + CountRegistrationsByIPRange(context.Context, *CountRegistrationsByIPRequest) (*Count, error) + FQDNSetExists(context.Context, *FQDNSetExistsRequest) (*Exists, error) + FQDNSetTimestampsForWindow(context.Context, *CountFQDNSetsRequest) (*Timestamps, error) + GetAuthorization2(context.Context, *AuthorizationID2) (*proto.Authorization, error) + GetAuthorizations2(context.Context, *GetAuthorizationsRequest) (*Authorizations, error) + GetCertificate(context.Context, *Serial) (*proto.Certificate, error) + GetLintPrecertificate(context.Context, *Serial) (*proto.Certificate, error) + GetCertificateStatus(context.Context, *Serial) (*proto.CertificateStatus, error) + GetMaxExpiration(context.Context, *emptypb.Empty) (*timestamppb.Timestamp, error) + GetOrder(context.Context, *OrderRequest) (*proto.Order, error) + GetOrderForNames(context.Context, *GetOrderForNamesRequest) (*proto.Order, error) + GetPendingAuthorization2(context.Context, *GetPendingAuthorizationRequest) (*proto.Authorization, error) + GetRegistration(context.Context, *RegistrationID) (*proto.Registration, error) + GetRegistrationByKey(context.Context, *JSONWebKey) (*proto.Registration, error) + GetRevocationStatus(context.Context, *Serial) (*RevocationStatus, error) + GetRevokedCerts(*GetRevokedCertsRequest, grpc.ServerStreamingServer[proto.CRLEntry]) error + GetSerialMetadata(context.Context, *Serial) (*SerialMetadata, error) + GetSerialsByAccount(*RegistrationID, grpc.ServerStreamingServer[Serial]) error + GetSerialsByKey(*SPKIHash, grpc.ServerStreamingServer[Serial]) error + GetValidAuthorizations2(context.Context, *GetValidAuthorizationsRequest) (*Authorizations, error) + GetValidOrderAuthorizations2(context.Context, *GetValidOrderAuthorizationsRequest) (*Authorizations, error) + IncidentsForSerial(context.Context, *Serial) (*Incidents, error) + KeyBlocked(context.Context, *SPKIHash) (*Exists, error) + ReplacementOrderExists(context.Context, *Serial) (*Exists, error) + SerialsForIncident(*SerialsForIncidentRequest, grpc.ServerStreamingServer[IncidentSerial]) error + CheckIdentifiersPaused(context.Context, *PauseRequest) (*Identifiers, error) + GetPausedIdentifiers(context.Context, *RegistrationID) (*Identifiers, error) + mustEmbedUnimplementedStorageAuthorityReadOnlyServer() +} + +// UnimplementedStorageAuthorityReadOnlyServer must be embedded to have forward compatible implementations. +type UnimplementedStorageAuthorityReadOnlyServer struct { +} + +func (UnimplementedStorageAuthorityReadOnlyServer) CountCertificatesByNames(context.Context, *CountCertificatesByNamesRequest) (*CountByNames, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountCertificatesByNames not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountFQDNSets(context.Context, *CountFQDNSetsRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountFQDNSets not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountInvalidAuthorizations2(context.Context, *CountInvalidAuthorizationsRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountInvalidAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountOrders(context.Context, *CountOrdersRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountOrders not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountPendingAuthorizations2(context.Context, *RegistrationID) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountPendingAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountRegistrationsByIP(context.Context, *CountRegistrationsByIPRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountRegistrationsByIP not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CountRegistrationsByIPRange(context.Context, *CountRegistrationsByIPRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountRegistrationsByIPRange not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) FQDNSetExists(context.Context, *FQDNSetExistsRequest) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method FQDNSetExists not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) FQDNSetTimestampsForWindow(context.Context, *CountFQDNSetsRequest) (*Timestamps, error) { + return nil, status.Errorf(codes.Unimplemented, "method FQDNSetTimestampsForWindow not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetAuthorization2(context.Context, *AuthorizationID2) (*proto.Authorization, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetAuthorizations2(context.Context, *GetAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetCertificate(context.Context, *Serial) (*proto.Certificate, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCertificate not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetLintPrecertificate(context.Context, *Serial) (*proto.Certificate, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetLintPrecertificate not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetCertificateStatus(context.Context, *Serial) (*proto.CertificateStatus, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCertificateStatus not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetMaxExpiration(context.Context, *emptypb.Empty) (*timestamppb.Timestamp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetMaxExpiration not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetOrder(context.Context, *OrderRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrder not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetOrderForNames(context.Context, *GetOrderForNamesRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrderForNames not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetPendingAuthorization2(context.Context, *GetPendingAuthorizationRequest) (*proto.Authorization, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetPendingAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetRegistration(context.Context, *RegistrationID) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRegistration not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetRegistrationByKey(context.Context, *JSONWebKey) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRegistrationByKey not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetRevocationStatus(context.Context, *Serial) (*RevocationStatus, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRevocationStatus not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetRevokedCerts(*GetRevokedCertsRequest, grpc.ServerStreamingServer[proto.CRLEntry]) error { + return status.Errorf(codes.Unimplemented, "method GetRevokedCerts not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetSerialMetadata(context.Context, *Serial) (*SerialMetadata, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSerialMetadata not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetSerialsByAccount(*RegistrationID, grpc.ServerStreamingServer[Serial]) error { + return status.Errorf(codes.Unimplemented, "method GetSerialsByAccount not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetSerialsByKey(*SPKIHash, grpc.ServerStreamingServer[Serial]) error { + return status.Errorf(codes.Unimplemented, "method GetSerialsByKey not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetValidAuthorizations2(context.Context, *GetValidAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetValidAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetValidOrderAuthorizations2(context.Context, *GetValidOrderAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetValidOrderAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) IncidentsForSerial(context.Context, *Serial) (*Incidents, error) { + return nil, status.Errorf(codes.Unimplemented, "method IncidentsForSerial not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) KeyBlocked(context.Context, *SPKIHash) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method KeyBlocked not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) ReplacementOrderExists(context.Context, *Serial) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method ReplacementOrderExists not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) SerialsForIncident(*SerialsForIncidentRequest, grpc.ServerStreamingServer[IncidentSerial]) error { + return status.Errorf(codes.Unimplemented, "method SerialsForIncident not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) CheckIdentifiersPaused(context.Context, *PauseRequest) (*Identifiers, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckIdentifiersPaused not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) GetPausedIdentifiers(context.Context, *RegistrationID) (*Identifiers, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetPausedIdentifiers not implemented") +} +func (UnimplementedStorageAuthorityReadOnlyServer) mustEmbedUnimplementedStorageAuthorityReadOnlyServer() { +} + +// UnsafeStorageAuthorityReadOnlyServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StorageAuthorityReadOnlyServer will +// result in compilation errors. +type UnsafeStorageAuthorityReadOnlyServer interface { + mustEmbedUnimplementedStorageAuthorityReadOnlyServer() +} + +func RegisterStorageAuthorityReadOnlyServer(s grpc.ServiceRegistrar, srv StorageAuthorityReadOnlyServer) { + s.RegisterService(&StorageAuthorityReadOnly_ServiceDesc, srv) +} + +func _StorageAuthorityReadOnly_CountCertificatesByNames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountCertificatesByNamesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountCertificatesByNames(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountCertificatesByNames_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountCertificatesByNames(ctx, req.(*CountCertificatesByNamesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountFQDNSets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountFQDNSetsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountFQDNSets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountFQDNSets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountFQDNSets(ctx, req.(*CountFQDNSetsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountInvalidAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountInvalidAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountInvalidAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountInvalidAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountInvalidAuthorizations2(ctx, req.(*CountInvalidAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountOrdersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountOrders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountOrders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountOrders(ctx, req.(*CountOrdersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountPendingAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountPendingAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountPendingAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountPendingAuthorizations2(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountRegistrationsByIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountRegistrationsByIPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountRegistrationsByIP(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountRegistrationsByIP_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountRegistrationsByIP(ctx, req.(*CountRegistrationsByIPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_CountRegistrationsByIPRange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountRegistrationsByIPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CountRegistrationsByIPRange(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CountRegistrationsByIPRange_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CountRegistrationsByIPRange(ctx, req.(*CountRegistrationsByIPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_FQDNSetExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FQDNSetExistsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).FQDNSetExists(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_FQDNSetExists_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).FQDNSetExists(ctx, req.(*FQDNSetExistsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_FQDNSetTimestampsForWindow_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountFQDNSetsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).FQDNSetTimestampsForWindow(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_FQDNSetTimestampsForWindow_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).FQDNSetTimestampsForWindow(ctx, req.(*CountFQDNSetsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthorizationID2) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetAuthorization2(ctx, req.(*AuthorizationID2)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetAuthorizations2(ctx, req.(*GetAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetCertificate(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetLintPrecertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetLintPrecertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetLintPrecertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetLintPrecertificate(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetCertificateStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetCertificateStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetCertificateStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetCertificateStatus(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetMaxExpiration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetMaxExpiration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetMaxExpiration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetMaxExpiration(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetOrder(ctx, req.(*OrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetOrderForNames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetOrderForNamesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetOrderForNames(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetOrderForNames_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetOrderForNames(ctx, req.(*GetOrderForNamesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetPendingAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPendingAuthorizationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetPendingAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetPendingAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetPendingAuthorization2(ctx, req.(*GetPendingAuthorizationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetRegistration(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetRegistrationByKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(JSONWebKey) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetRegistrationByKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetRegistrationByKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetRegistrationByKey(ctx, req.(*JSONWebKey)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetRevocationStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetRevocationStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetRevocationStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetRevocationStatus(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetRevokedCerts_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(GetRevokedCertsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityReadOnlyServer).GetRevokedCerts(m, &grpc.GenericServerStream[GetRevokedCertsRequest, proto.CRLEntry]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetRevokedCertsServer = grpc.ServerStreamingServer[proto.CRLEntry] + +func _StorageAuthorityReadOnly_GetSerialMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetSerialMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetSerialMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetSerialMetadata(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetSerialsByAccount_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(RegistrationID) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityReadOnlyServer).GetSerialsByAccount(m, &grpc.GenericServerStream[RegistrationID, Serial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetSerialsByAccountServer = grpc.ServerStreamingServer[Serial] + +func _StorageAuthorityReadOnly_GetSerialsByKey_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SPKIHash) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityReadOnlyServer).GetSerialsByKey(m, &grpc.GenericServerStream[SPKIHash, Serial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_GetSerialsByKeyServer = grpc.ServerStreamingServer[Serial] + +func _StorageAuthorityReadOnly_GetValidAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetValidAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetValidAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetValidAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetValidAuthorizations2(ctx, req.(*GetValidAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetValidOrderAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetValidOrderAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetValidOrderAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetValidOrderAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetValidOrderAuthorizations2(ctx, req.(*GetValidOrderAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_IncidentsForSerial_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).IncidentsForSerial(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_IncidentsForSerial_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).IncidentsForSerial(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_KeyBlocked_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SPKIHash) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).KeyBlocked(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_KeyBlocked_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).KeyBlocked(ctx, req.(*SPKIHash)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_ReplacementOrderExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).ReplacementOrderExists(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_ReplacementOrderExists_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).ReplacementOrderExists(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_SerialsForIncident_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SerialsForIncidentRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityReadOnlyServer).SerialsForIncident(m, &grpc.GenericServerStream[SerialsForIncidentRequest, IncidentSerial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthorityReadOnly_SerialsForIncidentServer = grpc.ServerStreamingServer[IncidentSerial] + +func _StorageAuthorityReadOnly_CheckIdentifiersPaused_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PauseRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).CheckIdentifiersPaused(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_CheckIdentifiersPaused_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).CheckIdentifiersPaused(ctx, req.(*PauseRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthorityReadOnly_GetPausedIdentifiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityReadOnlyServer).GetPausedIdentifiers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthorityReadOnly_GetPausedIdentifiers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityReadOnlyServer).GetPausedIdentifiers(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +// StorageAuthorityReadOnly_ServiceDesc is the grpc.ServiceDesc for StorageAuthorityReadOnly service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StorageAuthorityReadOnly_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sa.StorageAuthorityReadOnly", + HandlerType: (*StorageAuthorityReadOnlyServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CountCertificatesByNames", + Handler: _StorageAuthorityReadOnly_CountCertificatesByNames_Handler, + }, + { + MethodName: "CountFQDNSets", + Handler: _StorageAuthorityReadOnly_CountFQDNSets_Handler, + }, + { + MethodName: "CountInvalidAuthorizations2", + Handler: _StorageAuthorityReadOnly_CountInvalidAuthorizations2_Handler, + }, + { + MethodName: "CountOrders", + Handler: _StorageAuthorityReadOnly_CountOrders_Handler, + }, + { + MethodName: "CountPendingAuthorizations2", + Handler: _StorageAuthorityReadOnly_CountPendingAuthorizations2_Handler, + }, + { + MethodName: "CountRegistrationsByIP", + Handler: _StorageAuthorityReadOnly_CountRegistrationsByIP_Handler, + }, + { + MethodName: "CountRegistrationsByIPRange", + Handler: _StorageAuthorityReadOnly_CountRegistrationsByIPRange_Handler, + }, + { + MethodName: "FQDNSetExists", + Handler: _StorageAuthorityReadOnly_FQDNSetExists_Handler, + }, + { + MethodName: "FQDNSetTimestampsForWindow", + Handler: _StorageAuthorityReadOnly_FQDNSetTimestampsForWindow_Handler, + }, + { + MethodName: "GetAuthorization2", + Handler: _StorageAuthorityReadOnly_GetAuthorization2_Handler, + }, + { + MethodName: "GetAuthorizations2", + Handler: _StorageAuthorityReadOnly_GetAuthorizations2_Handler, + }, + { + MethodName: "GetCertificate", + Handler: _StorageAuthorityReadOnly_GetCertificate_Handler, + }, + { + MethodName: "GetLintPrecertificate", + Handler: _StorageAuthorityReadOnly_GetLintPrecertificate_Handler, + }, + { + MethodName: "GetCertificateStatus", + Handler: _StorageAuthorityReadOnly_GetCertificateStatus_Handler, + }, + { + MethodName: "GetMaxExpiration", + Handler: _StorageAuthorityReadOnly_GetMaxExpiration_Handler, + }, + { + MethodName: "GetOrder", + Handler: _StorageAuthorityReadOnly_GetOrder_Handler, + }, + { + MethodName: "GetOrderForNames", + Handler: _StorageAuthorityReadOnly_GetOrderForNames_Handler, + }, + { + MethodName: "GetPendingAuthorization2", + Handler: _StorageAuthorityReadOnly_GetPendingAuthorization2_Handler, + }, + { + MethodName: "GetRegistration", + Handler: _StorageAuthorityReadOnly_GetRegistration_Handler, + }, + { + MethodName: "GetRegistrationByKey", + Handler: _StorageAuthorityReadOnly_GetRegistrationByKey_Handler, + }, + { + MethodName: "GetRevocationStatus", + Handler: _StorageAuthorityReadOnly_GetRevocationStatus_Handler, + }, + { + MethodName: "GetSerialMetadata", + Handler: _StorageAuthorityReadOnly_GetSerialMetadata_Handler, + }, + { + MethodName: "GetValidAuthorizations2", + Handler: _StorageAuthorityReadOnly_GetValidAuthorizations2_Handler, + }, + { + MethodName: "GetValidOrderAuthorizations2", + Handler: _StorageAuthorityReadOnly_GetValidOrderAuthorizations2_Handler, + }, + { + MethodName: "IncidentsForSerial", + Handler: _StorageAuthorityReadOnly_IncidentsForSerial_Handler, + }, + { + MethodName: "KeyBlocked", + Handler: _StorageAuthorityReadOnly_KeyBlocked_Handler, + }, + { + MethodName: "ReplacementOrderExists", + Handler: _StorageAuthorityReadOnly_ReplacementOrderExists_Handler, + }, + { + MethodName: "CheckIdentifiersPaused", + Handler: _StorageAuthorityReadOnly_CheckIdentifiersPaused_Handler, + }, + { + MethodName: "GetPausedIdentifiers", + Handler: _StorageAuthorityReadOnly_GetPausedIdentifiers_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "GetRevokedCerts", + Handler: _StorageAuthorityReadOnly_GetRevokedCerts_Handler, + ServerStreams: true, + }, + { + StreamName: "GetSerialsByAccount", + Handler: _StorageAuthorityReadOnly_GetSerialsByAccount_Handler, + ServerStreams: true, + }, + { + StreamName: "GetSerialsByKey", + Handler: _StorageAuthorityReadOnly_GetSerialsByKey_Handler, + ServerStreams: true, + }, + { + StreamName: "SerialsForIncident", + Handler: _StorageAuthorityReadOnly_SerialsForIncident_Handler, + ServerStreams: true, + }, + }, + Metadata: "sa.proto", +} + +const ( + StorageAuthority_CountCertificatesByNames_FullMethodName = "/sa.StorageAuthority/CountCertificatesByNames" + StorageAuthority_CountFQDNSets_FullMethodName = "/sa.StorageAuthority/CountFQDNSets" + StorageAuthority_CountInvalidAuthorizations2_FullMethodName = "/sa.StorageAuthority/CountInvalidAuthorizations2" + StorageAuthority_CountOrders_FullMethodName = "/sa.StorageAuthority/CountOrders" + StorageAuthority_CountPendingAuthorizations2_FullMethodName = "/sa.StorageAuthority/CountPendingAuthorizations2" + StorageAuthority_CountRegistrationsByIP_FullMethodName = "/sa.StorageAuthority/CountRegistrationsByIP" + StorageAuthority_CountRegistrationsByIPRange_FullMethodName = "/sa.StorageAuthority/CountRegistrationsByIPRange" + StorageAuthority_FQDNSetExists_FullMethodName = "/sa.StorageAuthority/FQDNSetExists" + StorageAuthority_FQDNSetTimestampsForWindow_FullMethodName = "/sa.StorageAuthority/FQDNSetTimestampsForWindow" + StorageAuthority_GetAuthorization2_FullMethodName = "/sa.StorageAuthority/GetAuthorization2" + StorageAuthority_GetAuthorizations2_FullMethodName = "/sa.StorageAuthority/GetAuthorizations2" + StorageAuthority_GetCertificate_FullMethodName = "/sa.StorageAuthority/GetCertificate" + StorageAuthority_GetLintPrecertificate_FullMethodName = "/sa.StorageAuthority/GetLintPrecertificate" + StorageAuthority_GetCertificateStatus_FullMethodName = "/sa.StorageAuthority/GetCertificateStatus" + StorageAuthority_GetMaxExpiration_FullMethodName = "/sa.StorageAuthority/GetMaxExpiration" + StorageAuthority_GetOrder_FullMethodName = "/sa.StorageAuthority/GetOrder" + StorageAuthority_GetOrderForNames_FullMethodName = "/sa.StorageAuthority/GetOrderForNames" + StorageAuthority_GetPendingAuthorization2_FullMethodName = "/sa.StorageAuthority/GetPendingAuthorization2" + StorageAuthority_GetRegistration_FullMethodName = "/sa.StorageAuthority/GetRegistration" + StorageAuthority_GetRegistrationByKey_FullMethodName = "/sa.StorageAuthority/GetRegistrationByKey" + StorageAuthority_GetRevocationStatus_FullMethodName = "/sa.StorageAuthority/GetRevocationStatus" + StorageAuthority_GetRevokedCerts_FullMethodName = "/sa.StorageAuthority/GetRevokedCerts" + StorageAuthority_GetSerialMetadata_FullMethodName = "/sa.StorageAuthority/GetSerialMetadata" + StorageAuthority_GetSerialsByAccount_FullMethodName = "/sa.StorageAuthority/GetSerialsByAccount" + StorageAuthority_GetSerialsByKey_FullMethodName = "/sa.StorageAuthority/GetSerialsByKey" + StorageAuthority_GetValidAuthorizations2_FullMethodName = "/sa.StorageAuthority/GetValidAuthorizations2" + StorageAuthority_GetValidOrderAuthorizations2_FullMethodName = "/sa.StorageAuthority/GetValidOrderAuthorizations2" + StorageAuthority_IncidentsForSerial_FullMethodName = "/sa.StorageAuthority/IncidentsForSerial" + StorageAuthority_KeyBlocked_FullMethodName = "/sa.StorageAuthority/KeyBlocked" + StorageAuthority_ReplacementOrderExists_FullMethodName = "/sa.StorageAuthority/ReplacementOrderExists" + StorageAuthority_SerialsForIncident_FullMethodName = "/sa.StorageAuthority/SerialsForIncident" + StorageAuthority_CheckIdentifiersPaused_FullMethodName = "/sa.StorageAuthority/CheckIdentifiersPaused" + StorageAuthority_GetPausedIdentifiers_FullMethodName = "/sa.StorageAuthority/GetPausedIdentifiers" + StorageAuthority_AddBlockedKey_FullMethodName = "/sa.StorageAuthority/AddBlockedKey" + StorageAuthority_AddCertificate_FullMethodName = "/sa.StorageAuthority/AddCertificate" + StorageAuthority_AddPrecertificate_FullMethodName = "/sa.StorageAuthority/AddPrecertificate" + StorageAuthority_SetCertificateStatusReady_FullMethodName = "/sa.StorageAuthority/SetCertificateStatusReady" + StorageAuthority_AddSerial_FullMethodName = "/sa.StorageAuthority/AddSerial" + StorageAuthority_DeactivateAuthorization2_FullMethodName = "/sa.StorageAuthority/DeactivateAuthorization2" + StorageAuthority_DeactivateRegistration_FullMethodName = "/sa.StorageAuthority/DeactivateRegistration" + StorageAuthority_FinalizeAuthorization2_FullMethodName = "/sa.StorageAuthority/FinalizeAuthorization2" + StorageAuthority_FinalizeOrder_FullMethodName = "/sa.StorageAuthority/FinalizeOrder" + StorageAuthority_NewOrderAndAuthzs_FullMethodName = "/sa.StorageAuthority/NewOrderAndAuthzs" + StorageAuthority_NewRegistration_FullMethodName = "/sa.StorageAuthority/NewRegistration" + StorageAuthority_RevokeCertificate_FullMethodName = "/sa.StorageAuthority/RevokeCertificate" + StorageAuthority_SetOrderError_FullMethodName = "/sa.StorageAuthority/SetOrderError" + StorageAuthority_SetOrderProcessing_FullMethodName = "/sa.StorageAuthority/SetOrderProcessing" + StorageAuthority_UpdateRegistration_FullMethodName = "/sa.StorageAuthority/UpdateRegistration" + StorageAuthority_UpdateRevokedCertificate_FullMethodName = "/sa.StorageAuthority/UpdateRevokedCertificate" + StorageAuthority_LeaseCRLShard_FullMethodName = "/sa.StorageAuthority/LeaseCRLShard" + StorageAuthority_UpdateCRLShard_FullMethodName = "/sa.StorageAuthority/UpdateCRLShard" + StorageAuthority_PauseIdentifiers_FullMethodName = "/sa.StorageAuthority/PauseIdentifiers" + StorageAuthority_UnpauseAccount_FullMethodName = "/sa.StorageAuthority/UnpauseAccount" +) + +// StorageAuthorityClient is the client API for StorageAuthority service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type StorageAuthorityClient interface { + // Getters: this list must be identical to the StorageAuthorityReadOnly rpcs. + CountCertificatesByNames(ctx context.Context, in *CountCertificatesByNamesRequest, opts ...grpc.CallOption) (*CountByNames, error) + CountFQDNSets(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Count, error) + CountInvalidAuthorizations2(ctx context.Context, in *CountInvalidAuthorizationsRequest, opts ...grpc.CallOption) (*Count, error) + CountOrders(ctx context.Context, in *CountOrdersRequest, opts ...grpc.CallOption) (*Count, error) + CountPendingAuthorizations2(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Count, error) + CountRegistrationsByIP(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) + CountRegistrationsByIPRange(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) + FQDNSetExists(ctx context.Context, in *FQDNSetExistsRequest, opts ...grpc.CallOption) (*Exists, error) + FQDNSetTimestampsForWindow(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Timestamps, error) + GetAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*proto.Authorization, error) + GetAuthorizations2(ctx context.Context, in *GetAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + GetCertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + GetLintPrecertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + GetCertificateStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.CertificateStatus, error) + GetMaxExpiration(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*timestamppb.Timestamp, error) + GetOrder(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*proto.Order, error) + GetOrderForNames(ctx context.Context, in *GetOrderForNamesRequest, opts ...grpc.CallOption) (*proto.Order, error) + GetPendingAuthorization2(ctx context.Context, in *GetPendingAuthorizationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) + GetRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*proto.Registration, error) + GetRegistrationByKey(ctx context.Context, in *JSONWebKey, opts ...grpc.CallOption) (*proto.Registration, error) + GetRevocationStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*RevocationStatus, error) + GetRevokedCerts(ctx context.Context, in *GetRevokedCertsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[proto.CRLEntry], error) + GetSerialMetadata(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*SerialMetadata, error) + GetSerialsByAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) + GetSerialsByKey(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) + GetValidAuthorizations2(ctx context.Context, in *GetValidAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + GetValidOrderAuthorizations2(ctx context.Context, in *GetValidOrderAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) + IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error) + KeyBlocked(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (*Exists, error) + ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) + SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IncidentSerial], error) + CheckIdentifiersPaused(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*Identifiers, error) + GetPausedIdentifiers(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Identifiers, error) + // Adders + AddBlockedKey(ctx context.Context, in *AddBlockedKeyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AddCertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AddPrecertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SetCertificateStatusReady(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*emptypb.Empty, error) + AddSerial(ctx context.Context, in *AddSerialRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeactivateAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeactivateRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*emptypb.Empty, error) + FinalizeAuthorization2(ctx context.Context, in *FinalizeAuthorizationRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + FinalizeOrder(ctx context.Context, in *FinalizeOrderRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + NewOrderAndAuthzs(ctx context.Context, in *NewOrderAndAuthzsRequest, opts ...grpc.CallOption) (*proto.Order, error) + NewRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*proto.Registration, error) + RevokeCertificate(ctx context.Context, in *RevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SetOrderError(ctx context.Context, in *SetOrderErrorRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + SetOrderProcessing(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + UpdateRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*emptypb.Empty, error) + UpdateRevokedCertificate(ctx context.Context, in *RevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + LeaseCRLShard(ctx context.Context, in *LeaseCRLShardRequest, opts ...grpc.CallOption) (*LeaseCRLShardResponse, error) + UpdateCRLShard(ctx context.Context, in *UpdateCRLShardRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + PauseIdentifiers(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*PauseIdentifiersResponse, error) + UnpauseAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type storageAuthorityClient struct { + cc grpc.ClientConnInterface +} + +func NewStorageAuthorityClient(cc grpc.ClientConnInterface) StorageAuthorityClient { + return &storageAuthorityClient{cc} +} + +func (c *storageAuthorityClient) CountCertificatesByNames(ctx context.Context, in *CountCertificatesByNamesRequest, opts ...grpc.CallOption) (*CountByNames, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CountByNames) + err := c.cc.Invoke(ctx, StorageAuthority_CountCertificatesByNames_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountFQDNSets(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountFQDNSets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountInvalidAuthorizations2(ctx context.Context, in *CountInvalidAuthorizationsRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountInvalidAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountOrders(ctx context.Context, in *CountOrdersRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountOrders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountPendingAuthorizations2(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountPendingAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountRegistrationsByIP(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountRegistrationsByIP_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) CountRegistrationsByIPRange(ctx context.Context, in *CountRegistrationsByIPRequest, opts ...grpc.CallOption) (*Count, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Count) + err := c.cc.Invoke(ctx, StorageAuthority_CountRegistrationsByIPRange_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) FQDNSetExists(ctx context.Context, in *FQDNSetExistsRequest, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthority_FQDNSetExists_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) FQDNSetTimestampsForWindow(ctx context.Context, in *CountFQDNSetsRequest, opts ...grpc.CallOption) (*Timestamps, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Timestamps) + err := c.cc.Invoke(ctx, StorageAuthority_FQDNSetTimestampsForWindow_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*proto.Authorization, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Authorization) + err := c.cc.Invoke(ctx, StorageAuthority_GetAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetAuthorizations2(ctx context.Context, in *GetAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthority_GetAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetCertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Certificate) + err := c.cc.Invoke(ctx, StorageAuthority_GetCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetLintPrecertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Certificate) + err := c.cc.Invoke(ctx, StorageAuthority_GetLintPrecertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetCertificateStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.CertificateStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.CertificateStatus) + err := c.cc.Invoke(ctx, StorageAuthority_GetCertificateStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetMaxExpiration(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*timestamppb.Timestamp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(timestamppb.Timestamp) + err := c.cc.Invoke(ctx, StorageAuthority_GetMaxExpiration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetOrder(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, StorageAuthority_GetOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetOrderForNames(ctx context.Context, in *GetOrderForNamesRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, StorageAuthority_GetOrderForNames_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetPendingAuthorization2(ctx context.Context, in *GetPendingAuthorizationRequest, opts ...grpc.CallOption) (*proto.Authorization, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Authorization) + err := c.cc.Invoke(ctx, StorageAuthority_GetPendingAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, StorageAuthority_GetRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetRegistrationByKey(ctx context.Context, in *JSONWebKey, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, StorageAuthority_GetRegistrationByKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetRevocationStatus(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*RevocationStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevocationStatus) + err := c.cc.Invoke(ctx, StorageAuthority_GetRevocationStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetRevokedCerts(ctx context.Context, in *GetRevokedCertsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[proto.CRLEntry], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthority_ServiceDesc.Streams[0], StorageAuthority_GetRevokedCerts_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[GetRevokedCertsRequest, proto.CRLEntry]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetRevokedCertsClient = grpc.ServerStreamingClient[proto.CRLEntry] + +func (c *storageAuthorityClient) GetSerialMetadata(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*SerialMetadata, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SerialMetadata) + err := c.cc.Invoke(ctx, StorageAuthority_GetSerialMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetSerialsByAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthority_ServiceDesc.Streams[1], StorageAuthority_GetSerialsByAccount_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[RegistrationID, Serial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetSerialsByAccountClient = grpc.ServerStreamingClient[Serial] + +func (c *storageAuthorityClient) GetSerialsByKey(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Serial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthority_ServiceDesc.Streams[2], StorageAuthority_GetSerialsByKey_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SPKIHash, Serial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetSerialsByKeyClient = grpc.ServerStreamingClient[Serial] + +func (c *storageAuthorityClient) GetValidAuthorizations2(ctx context.Context, in *GetValidAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthority_GetValidAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetValidOrderAuthorizations2(ctx context.Context, in *GetValidOrderAuthorizationsRequest, opts ...grpc.CallOption) (*Authorizations, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Authorizations) + err := c.cc.Invoke(ctx, StorageAuthority_GetValidOrderAuthorizations2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Incidents) + err := c.cc.Invoke(ctx, StorageAuthority_IncidentsForSerial_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) KeyBlocked(ctx context.Context, in *SPKIHash, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthority_KeyBlocked_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Exists) + err := c.cc.Invoke(ctx, StorageAuthority_ReplacementOrderExists_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IncidentSerial], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StorageAuthority_ServiceDesc.Streams[3], StorageAuthority_SerialsForIncident_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SerialsForIncidentRequest, IncidentSerial]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_SerialsForIncidentClient = grpc.ServerStreamingClient[IncidentSerial] + +func (c *storageAuthorityClient) CheckIdentifiersPaused(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*Identifiers, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Identifiers) + err := c.cc.Invoke(ctx, StorageAuthority_CheckIdentifiersPaused_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) GetPausedIdentifiers(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*Identifiers, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Identifiers) + err := c.cc.Invoke(ctx, StorageAuthority_GetPausedIdentifiers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) AddBlockedKey(ctx context.Context, in *AddBlockedKeyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_AddBlockedKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) AddCertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_AddCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) AddPrecertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_AddPrecertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) SetCertificateStatusReady(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_SetCertificateStatusReady_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) AddSerial(ctx context.Context, in *AddSerialRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_AddSerial_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) DeactivateAuthorization2(ctx context.Context, in *AuthorizationID2, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_DeactivateAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) DeactivateRegistration(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_DeactivateRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) FinalizeAuthorization2(ctx context.Context, in *FinalizeAuthorizationRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_FinalizeAuthorization2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) FinalizeOrder(ctx context.Context, in *FinalizeOrderRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_FinalizeOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) NewOrderAndAuthzs(ctx context.Context, in *NewOrderAndAuthzsRequest, opts ...grpc.CallOption) (*proto.Order, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Order) + err := c.cc.Invoke(ctx, StorageAuthority_NewOrderAndAuthzs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) NewRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*proto.Registration, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(proto.Registration) + err := c.cc.Invoke(ctx, StorageAuthority_NewRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) RevokeCertificate(ctx context.Context, in *RevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_RevokeCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) SetOrderError(ctx context.Context, in *SetOrderErrorRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_SetOrderError_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) SetOrderProcessing(ctx context.Context, in *OrderRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_SetOrderProcessing_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) UpdateRegistration(ctx context.Context, in *proto.Registration, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_UpdateRegistration_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) UpdateRevokedCertificate(ctx context.Context, in *RevokeCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_UpdateRevokedCertificate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) LeaseCRLShard(ctx context.Context, in *LeaseCRLShardRequest, opts ...grpc.CallOption) (*LeaseCRLShardResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LeaseCRLShardResponse) + err := c.cc.Invoke(ctx, StorageAuthority_LeaseCRLShard_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) UpdateCRLShard(ctx context.Context, in *UpdateCRLShardRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_UpdateCRLShard_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) PauseIdentifiers(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*PauseIdentifiersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PauseIdentifiersResponse) + err := c.cc.Invoke(ctx, StorageAuthority_PauseIdentifiers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *storageAuthorityClient) UnpauseAccount(ctx context.Context, in *RegistrationID, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StorageAuthority_UnpauseAccount_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// StorageAuthorityServer is the server API for StorageAuthority service. +// All implementations must embed UnimplementedStorageAuthorityServer +// for forward compatibility +type StorageAuthorityServer interface { + // Getters: this list must be identical to the StorageAuthorityReadOnly rpcs. + CountCertificatesByNames(context.Context, *CountCertificatesByNamesRequest) (*CountByNames, error) + CountFQDNSets(context.Context, *CountFQDNSetsRequest) (*Count, error) + CountInvalidAuthorizations2(context.Context, *CountInvalidAuthorizationsRequest) (*Count, error) + CountOrders(context.Context, *CountOrdersRequest) (*Count, error) + CountPendingAuthorizations2(context.Context, *RegistrationID) (*Count, error) + CountRegistrationsByIP(context.Context, *CountRegistrationsByIPRequest) (*Count, error) + CountRegistrationsByIPRange(context.Context, *CountRegistrationsByIPRequest) (*Count, error) + FQDNSetExists(context.Context, *FQDNSetExistsRequest) (*Exists, error) + FQDNSetTimestampsForWindow(context.Context, *CountFQDNSetsRequest) (*Timestamps, error) + GetAuthorization2(context.Context, *AuthorizationID2) (*proto.Authorization, error) + GetAuthorizations2(context.Context, *GetAuthorizationsRequest) (*Authorizations, error) + GetCertificate(context.Context, *Serial) (*proto.Certificate, error) + GetLintPrecertificate(context.Context, *Serial) (*proto.Certificate, error) + GetCertificateStatus(context.Context, *Serial) (*proto.CertificateStatus, error) + GetMaxExpiration(context.Context, *emptypb.Empty) (*timestamppb.Timestamp, error) + GetOrder(context.Context, *OrderRequest) (*proto.Order, error) + GetOrderForNames(context.Context, *GetOrderForNamesRequest) (*proto.Order, error) + GetPendingAuthorization2(context.Context, *GetPendingAuthorizationRequest) (*proto.Authorization, error) + GetRegistration(context.Context, *RegistrationID) (*proto.Registration, error) + GetRegistrationByKey(context.Context, *JSONWebKey) (*proto.Registration, error) + GetRevocationStatus(context.Context, *Serial) (*RevocationStatus, error) + GetRevokedCerts(*GetRevokedCertsRequest, grpc.ServerStreamingServer[proto.CRLEntry]) error + GetSerialMetadata(context.Context, *Serial) (*SerialMetadata, error) + GetSerialsByAccount(*RegistrationID, grpc.ServerStreamingServer[Serial]) error + GetSerialsByKey(*SPKIHash, grpc.ServerStreamingServer[Serial]) error + GetValidAuthorizations2(context.Context, *GetValidAuthorizationsRequest) (*Authorizations, error) + GetValidOrderAuthorizations2(context.Context, *GetValidOrderAuthorizationsRequest) (*Authorizations, error) + IncidentsForSerial(context.Context, *Serial) (*Incidents, error) + KeyBlocked(context.Context, *SPKIHash) (*Exists, error) + ReplacementOrderExists(context.Context, *Serial) (*Exists, error) + SerialsForIncident(*SerialsForIncidentRequest, grpc.ServerStreamingServer[IncidentSerial]) error + CheckIdentifiersPaused(context.Context, *PauseRequest) (*Identifiers, error) + GetPausedIdentifiers(context.Context, *RegistrationID) (*Identifiers, error) + // Adders + AddBlockedKey(context.Context, *AddBlockedKeyRequest) (*emptypb.Empty, error) + AddCertificate(context.Context, *AddCertificateRequest) (*emptypb.Empty, error) + AddPrecertificate(context.Context, *AddCertificateRequest) (*emptypb.Empty, error) + SetCertificateStatusReady(context.Context, *Serial) (*emptypb.Empty, error) + AddSerial(context.Context, *AddSerialRequest) (*emptypb.Empty, error) + DeactivateAuthorization2(context.Context, *AuthorizationID2) (*emptypb.Empty, error) + DeactivateRegistration(context.Context, *RegistrationID) (*emptypb.Empty, error) + FinalizeAuthorization2(context.Context, *FinalizeAuthorizationRequest) (*emptypb.Empty, error) + FinalizeOrder(context.Context, *FinalizeOrderRequest) (*emptypb.Empty, error) + NewOrderAndAuthzs(context.Context, *NewOrderAndAuthzsRequest) (*proto.Order, error) + NewRegistration(context.Context, *proto.Registration) (*proto.Registration, error) + RevokeCertificate(context.Context, *RevokeCertificateRequest) (*emptypb.Empty, error) + SetOrderError(context.Context, *SetOrderErrorRequest) (*emptypb.Empty, error) + SetOrderProcessing(context.Context, *OrderRequest) (*emptypb.Empty, error) + UpdateRegistration(context.Context, *proto.Registration) (*emptypb.Empty, error) + UpdateRevokedCertificate(context.Context, *RevokeCertificateRequest) (*emptypb.Empty, error) + LeaseCRLShard(context.Context, *LeaseCRLShardRequest) (*LeaseCRLShardResponse, error) + UpdateCRLShard(context.Context, *UpdateCRLShardRequest) (*emptypb.Empty, error) + PauseIdentifiers(context.Context, *PauseRequest) (*PauseIdentifiersResponse, error) + UnpauseAccount(context.Context, *RegistrationID) (*emptypb.Empty, error) + mustEmbedUnimplementedStorageAuthorityServer() +} + +// UnimplementedStorageAuthorityServer must be embedded to have forward compatible implementations. +type UnimplementedStorageAuthorityServer struct { +} + +func (UnimplementedStorageAuthorityServer) CountCertificatesByNames(context.Context, *CountCertificatesByNamesRequest) (*CountByNames, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountCertificatesByNames not implemented") +} +func (UnimplementedStorageAuthorityServer) CountFQDNSets(context.Context, *CountFQDNSetsRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountFQDNSets not implemented") +} +func (UnimplementedStorageAuthorityServer) CountInvalidAuthorizations2(context.Context, *CountInvalidAuthorizationsRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountInvalidAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityServer) CountOrders(context.Context, *CountOrdersRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountOrders not implemented") +} +func (UnimplementedStorageAuthorityServer) CountPendingAuthorizations2(context.Context, *RegistrationID) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountPendingAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityServer) CountRegistrationsByIP(context.Context, *CountRegistrationsByIPRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountRegistrationsByIP not implemented") +} +func (UnimplementedStorageAuthorityServer) CountRegistrationsByIPRange(context.Context, *CountRegistrationsByIPRequest) (*Count, error) { + return nil, status.Errorf(codes.Unimplemented, "method CountRegistrationsByIPRange not implemented") +} +func (UnimplementedStorageAuthorityServer) FQDNSetExists(context.Context, *FQDNSetExistsRequest) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method FQDNSetExists not implemented") +} +func (UnimplementedStorageAuthorityServer) FQDNSetTimestampsForWindow(context.Context, *CountFQDNSetsRequest) (*Timestamps, error) { + return nil, status.Errorf(codes.Unimplemented, "method FQDNSetTimestampsForWindow not implemented") +} +func (UnimplementedStorageAuthorityServer) GetAuthorization2(context.Context, *AuthorizationID2) (*proto.Authorization, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityServer) GetAuthorizations2(context.Context, *GetAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityServer) GetCertificate(context.Context, *Serial) (*proto.Certificate, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) GetLintPrecertificate(context.Context, *Serial) (*proto.Certificate, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetLintPrecertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) GetCertificateStatus(context.Context, *Serial) (*proto.CertificateStatus, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCertificateStatus not implemented") +} +func (UnimplementedStorageAuthorityServer) GetMaxExpiration(context.Context, *emptypb.Empty) (*timestamppb.Timestamp, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetMaxExpiration not implemented") +} +func (UnimplementedStorageAuthorityServer) GetOrder(context.Context, *OrderRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrder not implemented") +} +func (UnimplementedStorageAuthorityServer) GetOrderForNames(context.Context, *GetOrderForNamesRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrderForNames not implemented") +} +func (UnimplementedStorageAuthorityServer) GetPendingAuthorization2(context.Context, *GetPendingAuthorizationRequest) (*proto.Authorization, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetPendingAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityServer) GetRegistration(context.Context, *RegistrationID) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRegistration not implemented") +} +func (UnimplementedStorageAuthorityServer) GetRegistrationByKey(context.Context, *JSONWebKey) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRegistrationByKey not implemented") +} +func (UnimplementedStorageAuthorityServer) GetRevocationStatus(context.Context, *Serial) (*RevocationStatus, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRevocationStatus not implemented") +} +func (UnimplementedStorageAuthorityServer) GetRevokedCerts(*GetRevokedCertsRequest, grpc.ServerStreamingServer[proto.CRLEntry]) error { + return status.Errorf(codes.Unimplemented, "method GetRevokedCerts not implemented") +} +func (UnimplementedStorageAuthorityServer) GetSerialMetadata(context.Context, *Serial) (*SerialMetadata, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSerialMetadata not implemented") +} +func (UnimplementedStorageAuthorityServer) GetSerialsByAccount(*RegistrationID, grpc.ServerStreamingServer[Serial]) error { + return status.Errorf(codes.Unimplemented, "method GetSerialsByAccount not implemented") +} +func (UnimplementedStorageAuthorityServer) GetSerialsByKey(*SPKIHash, grpc.ServerStreamingServer[Serial]) error { + return status.Errorf(codes.Unimplemented, "method GetSerialsByKey not implemented") +} +func (UnimplementedStorageAuthorityServer) GetValidAuthorizations2(context.Context, *GetValidAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetValidAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityServer) GetValidOrderAuthorizations2(context.Context, *GetValidOrderAuthorizationsRequest) (*Authorizations, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetValidOrderAuthorizations2 not implemented") +} +func (UnimplementedStorageAuthorityServer) IncidentsForSerial(context.Context, *Serial) (*Incidents, error) { + return nil, status.Errorf(codes.Unimplemented, "method IncidentsForSerial not implemented") +} +func (UnimplementedStorageAuthorityServer) KeyBlocked(context.Context, *SPKIHash) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method KeyBlocked not implemented") +} +func (UnimplementedStorageAuthorityServer) ReplacementOrderExists(context.Context, *Serial) (*Exists, error) { + return nil, status.Errorf(codes.Unimplemented, "method ReplacementOrderExists not implemented") +} +func (UnimplementedStorageAuthorityServer) SerialsForIncident(*SerialsForIncidentRequest, grpc.ServerStreamingServer[IncidentSerial]) error { + return status.Errorf(codes.Unimplemented, "method SerialsForIncident not implemented") +} +func (UnimplementedStorageAuthorityServer) CheckIdentifiersPaused(context.Context, *PauseRequest) (*Identifiers, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckIdentifiersPaused not implemented") +} +func (UnimplementedStorageAuthorityServer) GetPausedIdentifiers(context.Context, *RegistrationID) (*Identifiers, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetPausedIdentifiers not implemented") +} +func (UnimplementedStorageAuthorityServer) AddBlockedKey(context.Context, *AddBlockedKeyRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddBlockedKey not implemented") +} +func (UnimplementedStorageAuthorityServer) AddCertificate(context.Context, *AddCertificateRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddCertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) AddPrecertificate(context.Context, *AddCertificateRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddPrecertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) SetCertificateStatusReady(context.Context, *Serial) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetCertificateStatusReady not implemented") +} +func (UnimplementedStorageAuthorityServer) AddSerial(context.Context, *AddSerialRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddSerial not implemented") +} +func (UnimplementedStorageAuthorityServer) DeactivateAuthorization2(context.Context, *AuthorizationID2) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeactivateAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityServer) DeactivateRegistration(context.Context, *RegistrationID) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeactivateRegistration not implemented") +} +func (UnimplementedStorageAuthorityServer) FinalizeAuthorization2(context.Context, *FinalizeAuthorizationRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method FinalizeAuthorization2 not implemented") +} +func (UnimplementedStorageAuthorityServer) FinalizeOrder(context.Context, *FinalizeOrderRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method FinalizeOrder not implemented") +} +func (UnimplementedStorageAuthorityServer) NewOrderAndAuthzs(context.Context, *NewOrderAndAuthzsRequest) (*proto.Order, error) { + return nil, status.Errorf(codes.Unimplemented, "method NewOrderAndAuthzs not implemented") +} +func (UnimplementedStorageAuthorityServer) NewRegistration(context.Context, *proto.Registration) (*proto.Registration, error) { + return nil, status.Errorf(codes.Unimplemented, "method NewRegistration not implemented") +} +func (UnimplementedStorageAuthorityServer) RevokeCertificate(context.Context, *RevokeCertificateRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeCertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) SetOrderError(context.Context, *SetOrderErrorRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetOrderError not implemented") +} +func (UnimplementedStorageAuthorityServer) SetOrderProcessing(context.Context, *OrderRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetOrderProcessing not implemented") +} +func (UnimplementedStorageAuthorityServer) UpdateRegistration(context.Context, *proto.Registration) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateRegistration not implemented") +} +func (UnimplementedStorageAuthorityServer) UpdateRevokedCertificate(context.Context, *RevokeCertificateRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateRevokedCertificate not implemented") +} +func (UnimplementedStorageAuthorityServer) LeaseCRLShard(context.Context, *LeaseCRLShardRequest) (*LeaseCRLShardResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method LeaseCRLShard not implemented") +} +func (UnimplementedStorageAuthorityServer) UpdateCRLShard(context.Context, *UpdateCRLShardRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateCRLShard not implemented") +} +func (UnimplementedStorageAuthorityServer) PauseIdentifiers(context.Context, *PauseRequest) (*PauseIdentifiersResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PauseIdentifiers not implemented") +} +func (UnimplementedStorageAuthorityServer) UnpauseAccount(context.Context, *RegistrationID) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnpauseAccount not implemented") +} +func (UnimplementedStorageAuthorityServer) mustEmbedUnimplementedStorageAuthorityServer() {} + +// UnsafeStorageAuthorityServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StorageAuthorityServer will +// result in compilation errors. +type UnsafeStorageAuthorityServer interface { + mustEmbedUnimplementedStorageAuthorityServer() +} + +func RegisterStorageAuthorityServer(s grpc.ServiceRegistrar, srv StorageAuthorityServer) { + s.RegisterService(&StorageAuthority_ServiceDesc, srv) +} + +func _StorageAuthority_CountCertificatesByNames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountCertificatesByNamesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountCertificatesByNames(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountCertificatesByNames_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountCertificatesByNames(ctx, req.(*CountCertificatesByNamesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountFQDNSets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountFQDNSetsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountFQDNSets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountFQDNSets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountFQDNSets(ctx, req.(*CountFQDNSetsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountInvalidAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountInvalidAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountInvalidAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountInvalidAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountInvalidAuthorizations2(ctx, req.(*CountInvalidAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountOrdersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountOrders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountOrders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountOrders(ctx, req.(*CountOrdersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountPendingAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountPendingAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountPendingAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountPendingAuthorizations2(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountRegistrationsByIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountRegistrationsByIPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountRegistrationsByIP(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountRegistrationsByIP_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountRegistrationsByIP(ctx, req.(*CountRegistrationsByIPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_CountRegistrationsByIPRange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountRegistrationsByIPRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CountRegistrationsByIPRange(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CountRegistrationsByIPRange_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CountRegistrationsByIPRange(ctx, req.(*CountRegistrationsByIPRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_FQDNSetExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FQDNSetExistsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).FQDNSetExists(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_FQDNSetExists_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).FQDNSetExists(ctx, req.(*FQDNSetExistsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_FQDNSetTimestampsForWindow_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CountFQDNSetsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).FQDNSetTimestampsForWindow(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_FQDNSetTimestampsForWindow_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).FQDNSetTimestampsForWindow(ctx, req.(*CountFQDNSetsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthorizationID2) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetAuthorization2(ctx, req.(*AuthorizationID2)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetAuthorizations2(ctx, req.(*GetAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetCertificate(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetLintPrecertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetLintPrecertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetLintPrecertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetLintPrecertificate(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetCertificateStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetCertificateStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetCertificateStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetCertificateStatus(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetMaxExpiration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetMaxExpiration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetMaxExpiration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetMaxExpiration(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetOrder(ctx, req.(*OrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetOrderForNames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetOrderForNamesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetOrderForNames(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetOrderForNames_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetOrderForNames(ctx, req.(*GetOrderForNamesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetPendingAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPendingAuthorizationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetPendingAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetPendingAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetPendingAuthorization2(ctx, req.(*GetPendingAuthorizationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetRegistration(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetRegistrationByKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(JSONWebKey) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetRegistrationByKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetRegistrationByKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetRegistrationByKey(ctx, req.(*JSONWebKey)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetRevocationStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetRevocationStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetRevocationStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetRevocationStatus(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetRevokedCerts_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(GetRevokedCertsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityServer).GetRevokedCerts(m, &grpc.GenericServerStream[GetRevokedCertsRequest, proto.CRLEntry]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetRevokedCertsServer = grpc.ServerStreamingServer[proto.CRLEntry] + +func _StorageAuthority_GetSerialMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetSerialMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetSerialMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetSerialMetadata(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetSerialsByAccount_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(RegistrationID) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityServer).GetSerialsByAccount(m, &grpc.GenericServerStream[RegistrationID, Serial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetSerialsByAccountServer = grpc.ServerStreamingServer[Serial] + +func _StorageAuthority_GetSerialsByKey_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SPKIHash) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityServer).GetSerialsByKey(m, &grpc.GenericServerStream[SPKIHash, Serial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_GetSerialsByKeyServer = grpc.ServerStreamingServer[Serial] + +func _StorageAuthority_GetValidAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetValidAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetValidAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetValidAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetValidAuthorizations2(ctx, req.(*GetValidAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetValidOrderAuthorizations2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetValidOrderAuthorizationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetValidOrderAuthorizations2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetValidOrderAuthorizations2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetValidOrderAuthorizations2(ctx, req.(*GetValidOrderAuthorizationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_IncidentsForSerial_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).IncidentsForSerial(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_IncidentsForSerial_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).IncidentsForSerial(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_KeyBlocked_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SPKIHash) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).KeyBlocked(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_KeyBlocked_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).KeyBlocked(ctx, req.(*SPKIHash)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_ReplacementOrderExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).ReplacementOrderExists(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_ReplacementOrderExists_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).ReplacementOrderExists(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_SerialsForIncident_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SerialsForIncidentRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StorageAuthorityServer).SerialsForIncident(m, &grpc.GenericServerStream[SerialsForIncidentRequest, IncidentSerial]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StorageAuthority_SerialsForIncidentServer = grpc.ServerStreamingServer[IncidentSerial] + +func _StorageAuthority_CheckIdentifiersPaused_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PauseRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).CheckIdentifiersPaused(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_CheckIdentifiersPaused_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).CheckIdentifiersPaused(ctx, req.(*PauseRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_GetPausedIdentifiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).GetPausedIdentifiers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_GetPausedIdentifiers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).GetPausedIdentifiers(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_AddBlockedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddBlockedKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).AddBlockedKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_AddBlockedKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).AddBlockedKey(ctx, req.(*AddBlockedKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_AddCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).AddCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_AddCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).AddCertificate(ctx, req.(*AddCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_AddPrecertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).AddPrecertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_AddPrecertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).AddPrecertificate(ctx, req.(*AddCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_SetCertificateStatusReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Serial) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).SetCertificateStatusReady(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_SetCertificateStatusReady_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).SetCertificateStatusReady(ctx, req.(*Serial)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_AddSerial_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddSerialRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).AddSerial(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_AddSerial_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).AddSerial(ctx, req.(*AddSerialRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_DeactivateAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthorizationID2) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).DeactivateAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_DeactivateAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).DeactivateAuthorization2(ctx, req.(*AuthorizationID2)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_DeactivateRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).DeactivateRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_DeactivateRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).DeactivateRegistration(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_FinalizeAuthorization2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FinalizeAuthorizationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).FinalizeAuthorization2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_FinalizeAuthorization2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).FinalizeAuthorization2(ctx, req.(*FinalizeAuthorizationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_FinalizeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FinalizeOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).FinalizeOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_FinalizeOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).FinalizeOrder(ctx, req.(*FinalizeOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_NewOrderAndAuthzs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NewOrderAndAuthzsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).NewOrderAndAuthzs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_NewOrderAndAuthzs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).NewOrderAndAuthzs(ctx, req.(*NewOrderAndAuthzsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_NewRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(proto.Registration) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).NewRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_NewRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).NewRegistration(ctx, req.(*proto.Registration)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_RevokeCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).RevokeCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_RevokeCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).RevokeCertificate(ctx, req.(*RevokeCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_SetOrderError_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetOrderErrorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).SetOrderError(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_SetOrderError_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).SetOrderError(ctx, req.(*SetOrderErrorRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_SetOrderProcessing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).SetOrderProcessing(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_SetOrderProcessing_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).SetOrderProcessing(ctx, req.(*OrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_UpdateRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(proto.Registration) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).UpdateRegistration(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_UpdateRegistration_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).UpdateRegistration(ctx, req.(*proto.Registration)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_UpdateRevokedCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeCertificateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).UpdateRevokedCertificate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_UpdateRevokedCertificate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).UpdateRevokedCertificate(ctx, req.(*RevokeCertificateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_LeaseCRLShard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LeaseCRLShardRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).LeaseCRLShard(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_LeaseCRLShard_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).LeaseCRLShard(ctx, req.(*LeaseCRLShardRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_UpdateCRLShard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateCRLShardRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).UpdateCRLShard(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_UpdateCRLShard_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).UpdateCRLShard(ctx, req.(*UpdateCRLShardRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_PauseIdentifiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PauseRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).PauseIdentifiers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_PauseIdentifiers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).PauseIdentifiers(ctx, req.(*PauseRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StorageAuthority_UnpauseAccount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegistrationID) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageAuthorityServer).UnpauseAccount(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageAuthority_UnpauseAccount_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageAuthorityServer).UnpauseAccount(ctx, req.(*RegistrationID)) + } + return interceptor(ctx, in, info, handler) +} + +// StorageAuthority_ServiceDesc is the grpc.ServiceDesc for StorageAuthority service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StorageAuthority_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sa.StorageAuthority", + HandlerType: (*StorageAuthorityServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CountCertificatesByNames", + Handler: _StorageAuthority_CountCertificatesByNames_Handler, + }, + { + MethodName: "CountFQDNSets", + Handler: _StorageAuthority_CountFQDNSets_Handler, + }, + { + MethodName: "CountInvalidAuthorizations2", + Handler: _StorageAuthority_CountInvalidAuthorizations2_Handler, + }, + { + MethodName: "CountOrders", + Handler: _StorageAuthority_CountOrders_Handler, + }, + { + MethodName: "CountPendingAuthorizations2", + Handler: _StorageAuthority_CountPendingAuthorizations2_Handler, + }, + { + MethodName: "CountRegistrationsByIP", + Handler: _StorageAuthority_CountRegistrationsByIP_Handler, + }, + { + MethodName: "CountRegistrationsByIPRange", + Handler: _StorageAuthority_CountRegistrationsByIPRange_Handler, + }, + { + MethodName: "FQDNSetExists", + Handler: _StorageAuthority_FQDNSetExists_Handler, + }, + { + MethodName: "FQDNSetTimestampsForWindow", + Handler: _StorageAuthority_FQDNSetTimestampsForWindow_Handler, + }, + { + MethodName: "GetAuthorization2", + Handler: _StorageAuthority_GetAuthorization2_Handler, + }, + { + MethodName: "GetAuthorizations2", + Handler: _StorageAuthority_GetAuthorizations2_Handler, + }, + { + MethodName: "GetCertificate", + Handler: _StorageAuthority_GetCertificate_Handler, + }, + { + MethodName: "GetLintPrecertificate", + Handler: _StorageAuthority_GetLintPrecertificate_Handler, + }, + { + MethodName: "GetCertificateStatus", + Handler: _StorageAuthority_GetCertificateStatus_Handler, + }, + { + MethodName: "GetMaxExpiration", + Handler: _StorageAuthority_GetMaxExpiration_Handler, + }, + { + MethodName: "GetOrder", + Handler: _StorageAuthority_GetOrder_Handler, + }, + { + MethodName: "GetOrderForNames", + Handler: _StorageAuthority_GetOrderForNames_Handler, + }, + { + MethodName: "GetPendingAuthorization2", + Handler: _StorageAuthority_GetPendingAuthorization2_Handler, + }, + { + MethodName: "GetRegistration", + Handler: _StorageAuthority_GetRegistration_Handler, + }, + { + MethodName: "GetRegistrationByKey", + Handler: _StorageAuthority_GetRegistrationByKey_Handler, + }, + { + MethodName: "GetRevocationStatus", + Handler: _StorageAuthority_GetRevocationStatus_Handler, + }, + { + MethodName: "GetSerialMetadata", + Handler: _StorageAuthority_GetSerialMetadata_Handler, + }, + { + MethodName: "GetValidAuthorizations2", + Handler: _StorageAuthority_GetValidAuthorizations2_Handler, + }, + { + MethodName: "GetValidOrderAuthorizations2", + Handler: _StorageAuthority_GetValidOrderAuthorizations2_Handler, + }, + { + MethodName: "IncidentsForSerial", + Handler: _StorageAuthority_IncidentsForSerial_Handler, + }, + { + MethodName: "KeyBlocked", + Handler: _StorageAuthority_KeyBlocked_Handler, + }, + { + MethodName: "ReplacementOrderExists", + Handler: _StorageAuthority_ReplacementOrderExists_Handler, + }, + { + MethodName: "CheckIdentifiersPaused", + Handler: _StorageAuthority_CheckIdentifiersPaused_Handler, + }, + { + MethodName: "GetPausedIdentifiers", + Handler: _StorageAuthority_GetPausedIdentifiers_Handler, + }, + { + MethodName: "AddBlockedKey", + Handler: _StorageAuthority_AddBlockedKey_Handler, + }, + { + MethodName: "AddCertificate", + Handler: _StorageAuthority_AddCertificate_Handler, + }, + { + MethodName: "AddPrecertificate", + Handler: _StorageAuthority_AddPrecertificate_Handler, + }, + { + MethodName: "SetCertificateStatusReady", + Handler: _StorageAuthority_SetCertificateStatusReady_Handler, + }, + { + MethodName: "AddSerial", + Handler: _StorageAuthority_AddSerial_Handler, + }, + { + MethodName: "DeactivateAuthorization2", + Handler: _StorageAuthority_DeactivateAuthorization2_Handler, + }, + { + MethodName: "DeactivateRegistration", + Handler: _StorageAuthority_DeactivateRegistration_Handler, + }, + { + MethodName: "FinalizeAuthorization2", + Handler: _StorageAuthority_FinalizeAuthorization2_Handler, + }, + { + MethodName: "FinalizeOrder", + Handler: _StorageAuthority_FinalizeOrder_Handler, + }, + { + MethodName: "NewOrderAndAuthzs", + Handler: _StorageAuthority_NewOrderAndAuthzs_Handler, + }, + { + MethodName: "NewRegistration", + Handler: _StorageAuthority_NewRegistration_Handler, + }, + { + MethodName: "RevokeCertificate", + Handler: _StorageAuthority_RevokeCertificate_Handler, + }, + { + MethodName: "SetOrderError", + Handler: _StorageAuthority_SetOrderError_Handler, + }, + { + MethodName: "SetOrderProcessing", + Handler: _StorageAuthority_SetOrderProcessing_Handler, + }, + { + MethodName: "UpdateRegistration", + Handler: _StorageAuthority_UpdateRegistration_Handler, + }, + { + MethodName: "UpdateRevokedCertificate", + Handler: _StorageAuthority_UpdateRevokedCertificate_Handler, + }, + { + MethodName: "LeaseCRLShard", + Handler: _StorageAuthority_LeaseCRLShard_Handler, + }, + { + MethodName: "UpdateCRLShard", + Handler: _StorageAuthority_UpdateCRLShard_Handler, + }, + { + MethodName: "PauseIdentifiers", + Handler: _StorageAuthority_PauseIdentifiers_Handler, + }, + { + MethodName: "UnpauseAccount", + Handler: _StorageAuthority_UnpauseAccount_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "GetRevokedCerts", + Handler: _StorageAuthority_GetRevokedCerts_Handler, + ServerStreams: true, + }, + { + StreamName: "GetSerialsByAccount", + Handler: _StorageAuthority_GetSerialsByAccount_Handler, + ServerStreams: true, + }, + { + StreamName: "GetSerialsByKey", + Handler: _StorageAuthority_GetSerialsByKey_Handler, + ServerStreams: true, + }, + { + StreamName: "SerialsForIncident", + Handler: _StorageAuthority_SerialsForIncident_Handler, + ServerStreams: true, + }, + }, + Metadata: "sa.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/proto/subsets.go b/third-party/github.com/letsencrypt/boulder/sa/proto/subsets.go new file mode 100644 index 000000000..8e0910648 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/proto/subsets.go @@ -0,0 +1,21 @@ +// Copied from the auto-generated sa_grpc.pb.go + +package proto + +import ( + context "context" + + proto "github.com/letsencrypt/boulder/core/proto" + grpc "google.golang.org/grpc" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// StorageAuthorityCertificateClient is a subset of the sapb.StorageAuthorityClient interface that only reads and writes certificates +type StorageAuthorityCertificateClient interface { + AddSerial(ctx context.Context, in *AddSerialRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AddPrecertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AddCertificate(ctx context.Context, in *AddCertificateRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetCertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + GetLintPrecertificate(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*proto.Certificate, error) + SetCertificateStatusReady(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*emptypb.Empty, error) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/rate_limits.go b/third-party/github.com/letsencrypt/boulder/sa/rate_limits.go new file mode 100644 index 000000000..7fb3fa9b5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/rate_limits.go @@ -0,0 +1,146 @@ +package sa + +import ( + "context" + "strings" + "time" + + "github.com/letsencrypt/boulder/db" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/weppos/publicsuffix-go/publicsuffix" +) + +// baseDomain returns the eTLD+1 of a domain name for the purpose of rate +// limiting. For a domain name that is itself an eTLD, it returns its input. +func baseDomain(name string) string { + eTLDPlusOne, err := publicsuffix.Domain(name) + if err != nil { + // publicsuffix.Domain will return an error if the input name is itself a + // public suffix. In that case we use the input name as the key for rate + // limiting. Since all of its subdomains will have separate keys for rate + // limiting (e.g. "foo.bar.publicsuffix.com" will have + // "bar.publicsuffix.com", this means that domains exactly equal to a + // public suffix get their own rate limit bucket. This is important + // because otherwise they might be perpetually unable to issue, assuming + // the rate of issuance from their subdomains was high enough. + return name + } + return eTLDPlusOne +} + +// addCertificatesPerName adds 1 to the rate limit count for the provided +// domains, in a specific time bucket. It must be executed in a transaction, and +// the input timeToTheHour must be a time rounded to an hour. +func (ssa *SQLStorageAuthority) addCertificatesPerName(ctx context.Context, db db.SelectExecer, names []string, timeToTheHour time.Time) error { + // De-duplicate the base domains. + baseDomainsMap := make(map[string]bool) + var qmarks []string + var values []interface{} + for _, name := range names { + base := baseDomain(name) + if !baseDomainsMap[base] { + baseDomainsMap[base] = true + values = append(values, base, timeToTheHour, 1) + qmarks = append(qmarks, "(?, ?, ?)") + } + } + + _, err := db.ExecContext(ctx, `INSERT INTO certificatesPerName (eTLDPlusOne, time, count) VALUES `+ + strings.Join(qmarks, ", ")+` ON DUPLICATE KEY UPDATE count=count+1;`, + values...) + if err != nil { + return err + } + + return nil +} + +// countCertificates returns the count of certificates issued for a domain's +// eTLD+1 (aka base domain), during a given time range. +func (ssa *SQLStorageAuthorityRO) countCertificates(ctx context.Context, dbMap db.Selector, domain string, timeRange *sapb.Range) (int64, time.Time, error) { + latest := timeRange.Latest.AsTime() + var results []struct { + Count int64 + Time time.Time + } + _, err := dbMap.Select( + ctx, + &results, + `SELECT count, time FROM certificatesPerName + WHERE eTLDPlusOne = :baseDomain AND + time > :earliest AND + time <= :latest`, + map[string]interface{}{ + "baseDomain": baseDomain(domain), + "earliest": timeRange.Earliest.AsTime(), + "latest": latest, + }) + if err != nil { + if db.IsNoRows(err) { + return 0, time.Time{}, nil + } + return 0, time.Time{}, err + } + // Set earliest to the latest possible time, so that we can find the + // earliest certificate in the results. + var earliest = latest + var total int64 + for _, r := range results { + total += r.Count + if r.Time.Before(earliest) { + earliest = r.Time + } + } + if total <= 0 && earliest == latest { + // If we didn't find any certificates, return a zero time. + return total, time.Time{}, nil + } + return total, earliest, nil +} + +// addNewOrdersRateLimit adds 1 to the rate limit count for the provided ID, in +// a specific time bucket. It must be executed in a transaction, and the input +// timeToTheMinute must be a time rounded to a minute. +func addNewOrdersRateLimit(ctx context.Context, dbMap db.SelectExecer, regID int64, timeToTheMinute time.Time) error { + _, err := dbMap.ExecContext(ctx, `INSERT INTO newOrdersRL + (regID, time, count) + VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE count=count+1;`, + regID, + timeToTheMinute, + ) + if err != nil { + return err + } + return nil +} + +// countNewOrders returns the count of orders created in the given time range +// for the given registration ID. +func countNewOrders(ctx context.Context, dbMap db.Selector, req *sapb.CountOrdersRequest) (*sapb.Count, error) { + var counts []int64 + _, err := dbMap.Select( + ctx, + &counts, + `SELECT count FROM newOrdersRL + WHERE regID = :regID AND + time > :earliest AND + time <= :latest`, + map[string]interface{}{ + "regID": req.AccountID, + "earliest": req.Range.Earliest.AsTime(), + "latest": req.Range.Latest.AsTime(), + }, + ) + if err != nil { + if db.IsNoRows(err) { + return &sapb.Count{Count: 0}, nil + } + return nil, err + } + var total int64 + for _, count := range counts { + total += count + } + return &sapb.Count{Count: total}, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/rate_limits_test.go b/third-party/github.com/letsencrypt/boulder/sa/rate_limits_test.go new file mode 100644 index 000000000..1fed4f3f4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/rate_limits_test.go @@ -0,0 +1,141 @@ +package sa + +import ( + "context" + "fmt" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +func TestCertsPerNameRateLimitTable(t *testing.T) { + ctx := context.Background() + + sa, _, cleanUp := initSA(t) + defer cleanUp() + + aprilFirst, err := time.Parse(time.RFC3339, "2019-04-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + type inputCase struct { + time time.Time + names []string + } + inputs := []inputCase{ + {aprilFirst, []string{"example.com"}}, + {aprilFirst, []string{"example.com", "www.example.com"}}, + {aprilFirst, []string{"example.com", "other.example.com"}}, + {aprilFirst, []string{"dyndns.org"}}, + {aprilFirst, []string{"mydomain.dyndns.org"}}, + {aprilFirst, []string{"mydomain.dyndns.org"}}, + {aprilFirst, []string{"otherdomain.dyndns.org"}}, + } + + // For each hour in a week, add an entry for a certificate that has + // progressively more names. + var manyNames []string + for i := range 7 * 24 { + manyNames = append(manyNames, fmt.Sprintf("%d.manynames.example.net", i)) + inputs = append(inputs, inputCase{aprilFirst.Add(time.Duration(i) * time.Hour), manyNames}) + } + + for _, input := range inputs { + tx, err := sa.dbMap.BeginTx(ctx) + if err != nil { + t.Fatal(err) + } + err = sa.addCertificatesPerName(ctx, tx, input.names, input.time) + if err != nil { + t.Fatal(err) + } + err = tx.Commit() + if err != nil { + t.Fatal(err) + } + } + + const aWeek = time.Duration(7*24) * time.Hour + + testCases := []struct { + caseName string + domainName string + expected int64 + }{ + {"name doesn't exist", "non.example.org", 0}, + {"base name gets dinged for all certs including it", "example.com", 3}, + {"subdomain gets dinged for neighbors", "www.example.com", 3}, + {"other subdomain", "other.example.com", 3}, + {"many subdomains", "1.manynames.example.net", 168}, + {"public suffix gets its own bucket", "dyndns.org", 1}, + {"subdomain of public suffix gets its own bucket", "mydomain.dyndns.org", 2}, + {"subdomain of public suffix gets its own bucket 2", "otherdomain.dyndns.org", 1}, + } + + for _, tc := range testCases { + t.Run(tc.caseName, func(t *testing.T) { + timeRange := &sapb.Range{ + Earliest: timestamppb.New(aprilFirst.Add(-1 * time.Second)), + Latest: timestamppb.New(aprilFirst.Add(aWeek)), + } + count, earliest, err := sa.countCertificatesByName(ctx, sa.dbMap, tc.domainName, timeRange) + if err != nil { + t.Fatal(err) + } + if count != tc.expected { + t.Errorf("Expected count of %d for %q, got %d", tc.expected, tc.domainName, count) + } + if earliest.IsZero() { + // The count should always be zero if earliest is nil. + test.AssertEquals(t, count, int64(0)) + } else { + test.AssertEquals(t, earliest, aprilFirst) + } + }) + } +} + +func TestNewOrdersRateLimitTable(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + manyCountRegID := int64(2) + start := time.Now().Truncate(time.Minute) + req := &sapb.CountOrdersRequest{ + AccountID: 1, + Range: &sapb.Range{ + Earliest: timestamppb.New(start), + Latest: timestamppb.New(start.Add(time.Minute * 10)), + }, + } + + for i := 0; i <= 10; i++ { + tx, err := sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "failed to open tx") + for j := 0; j < i+1; j++ { + err = addNewOrdersRateLimit(ctx, tx, manyCountRegID, start.Add(time.Minute*time.Duration(i))) + } + test.AssertNotError(t, err, "addNewOrdersRateLimit failed") + test.AssertNotError(t, tx.Commit(), "failed to commit tx") + } + + count, err := countNewOrders(ctx, sa.dbMap, req) + test.AssertNotError(t, err, "countNewOrders failed") + test.AssertEquals(t, count.Count, int64(0)) + + req.AccountID = manyCountRegID + count, err = countNewOrders(ctx, sa.dbMap, req) + test.AssertNotError(t, err, "countNewOrders failed") + test.AssertEquals(t, count.Count, int64(65)) + + req.Range.Earliest = timestamppb.New(start.Add(time.Minute * 5)) + req.Range.Latest = timestamppb.New(start.Add(time.Minute * 10)) + count, err = countNewOrders(ctx, sa.dbMap, req) + test.AssertNotError(t, err, "countNewOrders failed") + test.AssertEquals(t, count.Count, int64(45)) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/sa.go b/third-party/github.com/letsencrypt/boulder/sa/sa.go new file mode 100644 index 000000000..1aa1d6066 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/sa.go @@ -0,0 +1,1442 @@ +package sa + +import ( + "context" + "crypto/x509" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +var ( + errIncompleteRequest = errors.New("incomplete gRPC request message") +) + +// SQLStorageAuthority defines a Storage Authority. +// +// Note that although SQLStorageAuthority does have methods wrapping all of the +// read-only methods provided by the SQLStorageAuthorityRO, those wrapper +// implementations are in saro.go, next to the real implementations. +type SQLStorageAuthority struct { + sapb.UnsafeStorageAuthorityServer + + *SQLStorageAuthorityRO + + dbMap *db.WrappedMap + + // rateLimitWriteErrors is a Counter for the number of times + // a ratelimit update transaction failed during AddCertificate request + // processing. We do not fail the overall AddCertificate call when ratelimit + // transactions fail and so use this stat to maintain visibility into the rate + // this occurs. + rateLimitWriteErrors prometheus.Counter +} + +var _ sapb.StorageAuthorityServer = (*SQLStorageAuthority)(nil) + +// NewSQLStorageAuthorityWrapping provides persistence using a SQL backend for +// Boulder. It takes a read-only storage authority to wrap, which is useful if +// you are constructing both types of implementations and want to share +// read-only database connections between them. +func NewSQLStorageAuthorityWrapping( + ssaro *SQLStorageAuthorityRO, + dbMap *db.WrappedMap, + stats prometheus.Registerer, +) (*SQLStorageAuthority, error) { + rateLimitWriteErrors := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "rate_limit_write_errors", + Help: "number of failed ratelimit update transactions during AddCertificate", + }) + stats.MustRegister(rateLimitWriteErrors) + + ssa := &SQLStorageAuthority{ + SQLStorageAuthorityRO: ssaro, + dbMap: dbMap, + rateLimitWriteErrors: rateLimitWriteErrors, + } + + return ssa, nil +} + +// NewSQLStorageAuthority provides persistence using a SQL backend for +// Boulder. It constructs its own read-only storage authority to wrap. +func NewSQLStorageAuthority( + dbMap *db.WrappedMap, + dbReadOnlyMap *db.WrappedMap, + dbIncidentsMap *db.WrappedMap, + parallelismPerRPC int, + lagFactor time.Duration, + clk clock.Clock, + logger blog.Logger, + stats prometheus.Registerer, +) (*SQLStorageAuthority, error) { + ssaro, err := NewSQLStorageAuthorityRO( + dbReadOnlyMap, dbIncidentsMap, stats, parallelismPerRPC, lagFactor, clk, logger) + if err != nil { + return nil, err + } + + return NewSQLStorageAuthorityWrapping(ssaro, dbMap, stats) +} + +// NewRegistration stores a new Registration +func (ssa *SQLStorageAuthority) NewRegistration(ctx context.Context, req *corepb.Registration) (*corepb.Registration, error) { + if len(req.Key) == 0 || len(req.InitialIP) == 0 { + return nil, errIncompleteRequest + } + + reg, err := registrationPbToModel(req) + if err != nil { + return nil, err + } + + reg.CreatedAt = ssa.clk.Now().Truncate(time.Second) + + err = ssa.dbMap.Insert(ctx, reg) + if err != nil { + if db.IsDuplicate(err) { + // duplicate entry error can only happen when jwk_sha256 collides, indicate + // to caller that the provided key is already in use + return nil, berrors.DuplicateError("key is already in use for a different account") + } + return nil, err + } + return registrationModelToPb(reg) +} + +// UpdateRegistration stores an updated Registration +func (ssa *SQLStorageAuthority) UpdateRegistration(ctx context.Context, req *corepb.Registration) (*emptypb.Empty, error) { + if req == nil || req.Id == 0 || len(req.Key) == 0 || len(req.InitialIP) == 0 { + return nil, errIncompleteRequest + } + + curr, err := selectRegistration(ctx, ssa.dbMap, "id", req.Id) + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("registration with ID '%d' not found", req.Id) + } + return nil, err + } + + update, err := registrationPbToModel(req) + if err != nil { + return nil, err + } + + // The CreatedAt field shouldn't change from the original, so we copy it straight through. + // This also ensures that it's already truncated to second (which happened on creation). + update.CreatedAt = curr.CreatedAt + + // Copy the existing registration model's LockCol to the new updated + // registration model's LockCol + update.LockCol = curr.LockCol + n, err := ssa.dbMap.Update(ctx, update) + if err != nil { + if db.IsDuplicate(err) { + // duplicate entry error can only happen when jwk_sha256 collides, indicate + // to caller that the provided key is already in use + return nil, berrors.DuplicateError("key is already in use for a different account") + } + return nil, err + } + if n == 0 { + return nil, berrors.NotFoundError("registration with ID '%d' not found", req.Id) + } + + return &emptypb.Empty{}, nil +} + +// AddSerial writes a record of a serial number generation to the DB. +func (ssa *SQLStorageAuthority) AddSerial(ctx context.Context, req *sapb.AddSerialRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.Serial == "" || req.RegID == 0 || core.IsAnyNilOrZero(req.Created, req.Expires) { + return nil, errIncompleteRequest + } + err := ssa.dbMap.Insert(ctx, &recordedSerialModel{ + Serial: req.Serial, + RegistrationID: req.RegID, + Created: req.Created.AsTime().Truncate(time.Second), + Expires: req.Expires.AsTime().Truncate(time.Second), + }) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +// SetCertificateStatusReady changes a serial's OCSP status from core.OCSPStatusNotReady to core.OCSPStatusGood. +// Called when precertificate issuance succeeds. returns an error if the serial doesn't have status core.OCSPStatusNotReady. +func (ssa *SQLStorageAuthority) SetCertificateStatusReady(ctx context.Context, req *sapb.Serial) (*emptypb.Empty, error) { + res, err := ssa.dbMap.ExecContext(ctx, + `UPDATE certificateStatus + SET status = ? + WHERE status = ? AND + serial = ?`, + string(core.OCSPStatusGood), + string(core.OCSPStatusNotReady), + req.Serial, + ) + if err != nil { + return nil, err + } + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, errors.New("failed to set certificate status to ready") + } + + return &emptypb.Empty{}, nil +} + +// AddPrecertificate writes a record of a precertificate generation to the DB. +// Note: this is not idempotent: it does not protect against inserting the same +// certificate multiple times. Calling code needs to first insert the cert's +// serial into the Serials table to ensure uniqueness. +func (ssa *SQLStorageAuthority) AddPrecertificate(ctx context.Context, req *sapb.AddCertificateRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Der) == 0 || req.RegID == 0 || req.IssuerNameID == 0 || core.IsAnyNilOrZero(req.Issued) { + return nil, errIncompleteRequest + } + parsed, err := x509.ParseCertificate(req.Der) + if err != nil { + return nil, err + } + serialHex := core.SerialToString(parsed.SerialNumber) + + preCertModel := &precertificateModel{ + Serial: serialHex, + RegistrationID: req.RegID, + DER: req.Der, + Issued: req.Issued.AsTime().Truncate(time.Second), + Expires: parsed.NotAfter, + } + + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + // Select to see if precert exists + var row struct { + Count int64 + } + err := tx.SelectOne(ctx, &row, "SELECT COUNT(*) as count FROM precertificates WHERE serial=?", serialHex) + if err != nil { + return nil, err + } + if row.Count > 0 { + return nil, berrors.DuplicateError("cannot add a duplicate cert") + } + + err = tx.Insert(ctx, preCertModel) + if err != nil { + return nil, err + } + + status := core.OCSPStatusGood + if req.OcspNotReady { + status = core.OCSPStatusNotReady + } + cs := &core.CertificateStatus{ + Serial: serialHex, + Status: status, + OCSPLastUpdated: ssa.clk.Now().Truncate(time.Second), + RevokedDate: time.Time{}, + RevokedReason: 0, + LastExpirationNagSent: time.Time{}, + // No need to truncate because it's already truncated to encode + // per https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.5.1 + NotAfter: parsed.NotAfter, + IsExpired: false, + IssuerNameID: req.IssuerNameID, + } + err = ssa.dbMap.Insert(ctx, cs) + if err != nil { + return nil, err + } + + // NOTE(@cpu): When we collect up names to check if an FQDN set exists (e.g. + // that it is a renewal) we use just the DNSNames from the certificate and + // ignore the Subject Common Name (if any). This is a safe assumption because + // if a certificate we issued were to have a Subj. CN not present as a SAN it + // would be a misissuance and miscalculating whether the cert is a renewal or + // not for the purpose of rate limiting is the least of our troubles. + isRenewal, err := ssa.checkFQDNSetExists( + ctx, + tx.SelectOne, + parsed.DNSNames) + if err != nil { + return nil, err + } + + err = addIssuedNames(ctx, tx, parsed, isRenewal) + if err != nil { + return nil, err + } + + err = addKeyHash(ctx, tx, parsed) + if err != nil { + return nil, err + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + + return &emptypb.Empty{}, nil +} + +// AddCertificate stores an issued certificate, returning an error if it is a +// duplicate or if any other failure occurs. +func (ssa *SQLStorageAuthority) AddCertificate(ctx context.Context, req *sapb.AddCertificateRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Der) == 0 || req.RegID == 0 || core.IsAnyNilOrZero(req.Issued) { + return nil, errIncompleteRequest + } + parsedCertificate, err := x509.ParseCertificate(req.Der) + if err != nil { + return nil, err + } + digest := core.Fingerprint256(req.Der) + serial := core.SerialToString(parsedCertificate.SerialNumber) + + cert := &core.Certificate{ + RegistrationID: req.RegID, + Serial: serial, + Digest: digest, + DER: req.Der, + Issued: req.Issued.AsTime().Truncate(time.Second), + Expires: parsedCertificate.NotAfter, + } + + isRenewalRaw, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + // Select to see if cert exists + var row struct { + Count int64 + } + err := tx.SelectOne(ctx, &row, "SELECT COUNT(*) as count FROM certificates WHERE serial=?", serial) + if err != nil { + return nil, err + } + if row.Count > 0 { + return nil, berrors.DuplicateError("cannot add a duplicate cert") + } + + // Save the final certificate + err = tx.Insert(ctx, cert) + if err != nil { + return nil, err + } + + // NOTE(@cpu): When we collect up names to check if an FQDN set exists (e.g. + // that it is a renewal) we use just the DNSNames from the certificate and + // ignore the Subject Common Name (if any). This is a safe assumption because + // if a certificate we issued were to have a Subj. CN not present as a SAN it + // would be a misissuance and miscalculating whether the cert is a renewal or + // not for the purpose of rate limiting is the least of our troubles. + isRenewal, err := ssa.checkFQDNSetExists( + ctx, + tx.SelectOne, + parsedCertificate.DNSNames) + if err != nil { + return nil, err + } + + return isRenewal, err + }) + if overallError != nil { + return nil, overallError + } + + // Recast the interface{} return from db.WithTransaction as a bool, returning + // an error if we can't. + var isRenewal bool + if boolVal, ok := isRenewalRaw.(bool); !ok { + return nil, fmt.Errorf( + "AddCertificate db.WithTransaction returned %T out var, expected bool", + isRenewalRaw) + } else { + isRenewal = boolVal + } + + // In a separate transaction perform the work required to update tables used + // for rate limits. Since the effects of failing these writes is slight + // miscalculation of rate limits we choose to not fail the AddCertificate + // operation if the rate limit update transaction fails. + _, rlTransactionErr := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + // Add to the rate limit table, but only for new certificates. Renewals + // don't count against the certificatesPerName limit. + if !isRenewal { + timeToTheHour := parsedCertificate.NotBefore.Round(time.Hour) + err := ssa.addCertificatesPerName(ctx, tx, parsedCertificate.DNSNames, timeToTheHour) + if err != nil { + return nil, err + } + } + + // Update the FQDN sets now that there is a final certificate to ensure rate + // limits are calculated correctly. + err = addFQDNSet( + ctx, + tx, + parsedCertificate.DNSNames, + core.SerialToString(parsedCertificate.SerialNumber), + parsedCertificate.NotBefore, + parsedCertificate.NotAfter, + ) + if err != nil { + return nil, err + } + + return nil, nil + }) + // If the ratelimit transaction failed increment a stat and log a warning + // but don't return an error from AddCertificate. + if rlTransactionErr != nil { + ssa.rateLimitWriteErrors.Inc() + ssa.log.AuditErrf("failed AddCertificate ratelimit update transaction: %v", rlTransactionErr) + } + + return &emptypb.Empty{}, nil +} + +// DeactivateRegistration deactivates a currently valid registration +func (ssa *SQLStorageAuthority) DeactivateRegistration(ctx context.Context, req *sapb.RegistrationID) (*emptypb.Empty, error) { + if req == nil || req.Id == 0 { + return nil, errIncompleteRequest + } + _, err := ssa.dbMap.ExecContext(ctx, + "UPDATE registrations SET status = ? WHERE status = ? AND id = ?", + string(core.StatusDeactivated), + string(core.StatusValid), + req.Id, + ) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +// DeactivateAuthorization2 deactivates a currently valid or pending authorization. +func (ssa *SQLStorageAuthority) DeactivateAuthorization2(ctx context.Context, req *sapb.AuthorizationID2) (*emptypb.Empty, error) { + if req.Id == 0 { + return nil, errIncompleteRequest + } + + _, err := ssa.dbMap.ExecContext(ctx, + `UPDATE authz2 SET status = :deactivated WHERE id = :id and status IN (:valid,:pending)`, + map[string]interface{}{ + "deactivated": statusUint(core.StatusDeactivated), + "id": req.Id, + "valid": statusUint(core.StatusValid), + "pending": statusUint(core.StatusPending), + }, + ) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +// NewOrderAndAuthzs adds the given authorizations to the database, adds their +// autogenerated IDs to the given order, and then adds the order to the db. +// This is done inside a single transaction to prevent situations where new +// authorizations are created, but then their corresponding order is never +// created, leading to "invisible" pending authorizations. +func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb.NewOrderAndAuthzsRequest) (*corepb.Order, error) { + if req.NewOrder == nil { + return nil, errIncompleteRequest + } + + output, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + // First, insert all of the new authorizations and record their IDs. + newAuthzIDs := make([]int64, 0) + if len(req.NewAuthzs) != 0 { + inserter, err := db.NewMultiInserter("authz2", strings.Split(authzFields, ", "), "id") + if err != nil { + return nil, err + } + for _, authz := range req.NewAuthzs { + if authz.Status != string(core.StatusPending) { + return nil, berrors.InternalServerError("authorization must be pending") + } + am, err := authzPBToModel(authz) + if err != nil { + return nil, err + } + // These parameters correspond to the fields listed in `authzFields`, as used in the + // `db.NewMultiInserter` call above, and occur in the same order. + err = inserter.Add([]interface{}{ + am.ID, + am.IdentifierType, + am.IdentifierValue, + am.RegistrationID, + statusToUint[core.StatusPending], + am.Expires.Truncate(time.Second), + am.Challenges, + nil, + nil, + am.Token, + nil, + nil, + }) + if err != nil { + return nil, err + } + } + newAuthzIDs, err = inserter.Insert(ctx, tx) + if err != nil { + return nil, err + } + } + + // Second, insert the new order. + var orderID int64 + var err error + created := ssa.clk.Now().Truncate(time.Second) + expires := req.NewOrder.Expires.AsTime().Truncate(time.Second) + if features.Get().MultipleCertificateProfiles { + omv2 := orderModelv2{ + RegistrationID: req.NewOrder.RegistrationID, + Expires: expires, + Created: created, + CertificateProfileName: req.NewOrder.CertificateProfileName, + } + err = tx.Insert(ctx, &omv2) + orderID = omv2.ID + } else { + omv1 := orderModelv1{ + RegistrationID: req.NewOrder.RegistrationID, + Expires: expires, + Created: created, + } + err = tx.Insert(ctx, &omv1) + orderID = omv1.ID + } + if err != nil { + return nil, err + } + + // Third, insert all of the orderToAuthz relations. + inserter, err := db.NewMultiInserter("orderToAuthz2", []string{"orderID", "authzID"}, "") + if err != nil { + return nil, err + } + for _, id := range req.NewOrder.V2Authorizations { + err := inserter.Add([]interface{}{orderID, id}) + if err != nil { + return nil, err + } + } + for _, id := range newAuthzIDs { + err := inserter.Add([]interface{}{orderID, id}) + if err != nil { + return nil, err + } + } + _, err = inserter.Insert(ctx, tx) + if err != nil { + return nil, err + } + + // Fourth, insert the FQDNSet entry for the order. + err = addOrderFQDNSet(ctx, + tx, + req.NewOrder.Names, + orderID, + req.NewOrder.RegistrationID, + expires, + ) + if err != nil { + return nil, err + } + + // Finally, build the overall Order PB to return. + res := &corepb.Order{ + // ID and Created were auto-populated on the order model when it was inserted. + Id: orderID, + Created: timestamppb.New(created), + // These are carried over from the original request unchanged. + RegistrationID: req.NewOrder.RegistrationID, + Expires: timestamppb.New(expires), + Names: req.NewOrder.Names, + // Have to combine the already-associated and newly-reacted authzs. + V2Authorizations: append(req.NewOrder.V2Authorizations, newAuthzIDs...), + // A new order is never processing because it can't be finalized yet. + BeganProcessing: false, + // An empty string is allowed. When the RA retrieves the order and + // transmits it to the CA, the empty string will take the value of + // DefaultCertProfileName from the //issuance package. + CertificateProfileName: req.NewOrder.CertificateProfileName, + } + + if req.NewOrder.ReplacesSerial != "" { + // Update the replacementOrders table to indicate that this order + // replaces the provided certificate serial. + err := addReplacementOrder(ctx, + tx, + req.NewOrder.ReplacesSerial, + orderID, + req.NewOrder.Expires.AsTime().Truncate(time.Second), + ) + if err != nil { + return nil, err + } + } + + // Get the partial Authorization objects for the order + authzValidityInfo, err := getAuthorizationStatuses(ctx, tx, res.V2Authorizations) + // If there was an error getting the authorizations, return it immediately + if err != nil { + return nil, err + } + + // Calculate the order status before returning it. Since it may have reused + // all valid authorizations the order may be "born" in a ready status. + status, err := statusForOrder(res, authzValidityInfo, ssa.clk.Now()) + if err != nil { + return nil, err + } + res.Status = status + + return res, nil + }) + if err != nil { + return nil, err + } + + order, ok := output.(*corepb.Order) + if !ok { + return nil, fmt.Errorf("casting error in NewOrderAndAuthzs") + } + + // Increment the order creation count + err = addNewOrdersRateLimit(ctx, ssa.dbMap, req.NewOrder.RegistrationID, ssa.clk.Now().Truncate(time.Minute)) + if err != nil { + return nil, err + } + + return order, nil +} + +// SetOrderProcessing updates an order from pending status to processing +// status by updating the `beganProcessing` field of the corresponding +// Order table row in the DB. +func (ssa *SQLStorageAuthority) SetOrderProcessing(ctx context.Context, req *sapb.OrderRequest) (*emptypb.Empty, error) { + if req.Id == 0 { + return nil, errIncompleteRequest + } + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + result, err := tx.ExecContext(ctx, ` + UPDATE orders + SET beganProcessing = ? + WHERE id = ? + AND beganProcessing = ?`, + true, + req.Id, + false) + if err != nil { + return nil, berrors.InternalServerError("error updating order to beganProcessing status") + } + + n, err := result.RowsAffected() + if err != nil || n == 0 { + return nil, berrors.OrderNotReadyError("Order was already processing. This may indicate your client finalized the same order multiple times, possibly due to a client bug.") + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + return &emptypb.Empty{}, nil +} + +// SetOrderError updates a provided Order's error field. +func (ssa *SQLStorageAuthority) SetOrderError(ctx context.Context, req *sapb.SetOrderErrorRequest) (*emptypb.Empty, error) { + if req.Id == 0 || req.Error == nil { + return nil, errIncompleteRequest + } + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + om, err := orderToModelv2(&corepb.Order{ + Id: req.Id, + Error: req.Error, + }) + if err != nil { + return nil, err + } + + result, err := tx.ExecContext(ctx, ` + UPDATE orders + SET error = ? + WHERE id = ?`, + om.Error, + om.ID) + if err != nil { + return nil, berrors.InternalServerError("error updating order error field") + } + + n, err := result.RowsAffected() + if err != nil || n == 0 { + return nil, berrors.InternalServerError("no order updated with new error field") + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + return &emptypb.Empty{}, nil +} + +// FinalizeOrder finalizes a provided *corepb.Order by persisting the +// CertificateSerial and a valid status to the database. No fields other than +// CertificateSerial and the order ID on the provided order are processed (e.g. +// this is not a generic update RPC). +func (ssa *SQLStorageAuthority) FinalizeOrder(ctx context.Context, req *sapb.FinalizeOrderRequest) (*emptypb.Empty, error) { + if req.Id == 0 || req.CertificateSerial == "" { + return nil, errIncompleteRequest + } + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + result, err := tx.ExecContext(ctx, ` + UPDATE orders + SET certificateSerial = ? + WHERE id = ? AND + beganProcessing = true`, + req.CertificateSerial, + req.Id) + if err != nil { + return nil, berrors.InternalServerError("error updating order for finalization") + } + + n, err := result.RowsAffected() + if err != nil || n == 0 { + return nil, berrors.InternalServerError("no order updated for finalization") + } + + // Delete the orderFQDNSet row for the order now that it has been finalized. + // We use this table for order reuse and should not reuse a finalized order. + err = deleteOrderFQDNSet(ctx, tx, req.Id) + if err != nil { + return nil, err + } + + if features.Get().TrackReplacementCertificatesARI { + err = setReplacementOrderFinalized(ctx, tx, req.Id) + if err != nil { + return nil, err + } + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + return &emptypb.Empty{}, nil +} + +// FinalizeAuthorization2 moves a pending authorization to either the valid or invalid status. If +// the authorization is being moved to invalid the validationError field must be set. If the +// authorization is being moved to valid the validationRecord and expires fields must be set. +func (ssa *SQLStorageAuthority) FinalizeAuthorization2(ctx context.Context, req *sapb.FinalizeAuthorizationRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.Status == "" || req.Attempted == "" || req.Id == 0 || core.IsAnyNilOrZero(req.Expires) { + return nil, errIncompleteRequest + } + + if req.Status != string(core.StatusValid) && req.Status != string(core.StatusInvalid) { + return nil, berrors.InternalServerError("authorization must have status valid or invalid") + } + query := `UPDATE authz2 SET + status = :status, + attempted = :attempted, + attemptedAt = :attemptedAt, + validationRecord = :validationRecord, + validationError = :validationError, + expires = :expires + WHERE id = :id AND status = :pending` + var validationRecords []core.ValidationRecord + for _, recordPB := range req.ValidationRecords { + record, err := bgrpc.PBToValidationRecord(recordPB) + if err != nil { + return nil, err + } + if req.Attempted == string(core.ChallengeTypeHTTP01) { + // Remove these fields because they can be rehydrated later + // on from the URL field. + record.Hostname = "" + record.Port = "" + } + validationRecords = append(validationRecords, record) + } + vrJSON, err := json.Marshal(validationRecords) + if err != nil { + return nil, err + } + var veJSON []byte + if req.ValidationError != nil { + validationError, err := bgrpc.PBToProblemDetails(req.ValidationError) + if err != nil { + return nil, err + } + j, err := json.Marshal(validationError) + if err != nil { + return nil, err + } + veJSON = j + } + // Check to see if the AttemptedAt time is non zero and convert to + // *time.Time if so. If it is zero, leave nil and don't convert. Keep the + // database attemptedAt field Null instead of 1970-01-01 00:00:00. + var attemptedTime *time.Time + if !core.IsAnyNilOrZero(req.AttemptedAt) { + val := req.AttemptedAt.AsTime().Truncate(time.Second) + attemptedTime = &val + } + params := map[string]interface{}{ + "status": statusToUint[core.AcmeStatus(req.Status)], + "attempted": challTypeToUint[req.Attempted], + "attemptedAt": attemptedTime, + "validationRecord": vrJSON, + "id": req.Id, + "pending": statusUint(core.StatusPending), + "expires": req.Expires.AsTime().Truncate(time.Second), + // if req.ValidationError is nil veJSON should also be nil + // which should result in a NULL field + "validationError": veJSON, + } + + res, err := ssa.dbMap.ExecContext(ctx, query, params) + if err != nil { + return nil, err + } + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, berrors.NotFoundError("no pending authorization with id %d", req.Id) + } else if rows > 1 { + return nil, berrors.InternalServerError("multiple rows updated for authorization id %d", req.Id) + } + return &emptypb.Empty{}, nil +} + +// addRevokedCertificate is a helper used by both RevokeCertificate and +// UpdateRevokedCertificate. It inserts a new row into the revokedCertificates +// table based on the contents of the input request. The second argument must be +// a transaction object so that it is safe to conduct multiple queries with a +// consistent view of the database. It must only be called when the request +// specifies a non-zero ShardIdx. +func addRevokedCertificate(ctx context.Context, tx db.Executor, req *sapb.RevokeCertificateRequest, revokedDate time.Time) error { + if req.ShardIdx == 0 { + return errors.New("cannot add revoked certificate with shard index 0") + } + + var serial struct { + Expires time.Time + } + err := tx.SelectOne( + ctx, &serial, `SELECT expires FROM serials WHERE serial = ?`, req.Serial) + if err != nil { + return fmt.Errorf("retrieving revoked certificate expiration: %w", err) + } + + err = tx.Insert(ctx, &revokedCertModel{ + IssuerID: req.IssuerID, + Serial: req.Serial, + ShardIdx: req.ShardIdx, + RevokedDate: revokedDate, + RevokedReason: revocation.Reason(req.Reason), + // Round the notAfter up to the next hour, to reduce index size while still + // ensuring we correctly serve revocation info past the actual expiration. + NotAfterHour: serial.Expires.Add(time.Hour).Truncate(time.Hour), + }) + if err != nil { + return fmt.Errorf("inserting revoked certificate row: %w", err) + } + + return nil +} + +// RevokeCertificate stores revocation information about a certificate. It will only store this +// information if the certificate is not already marked as revoked. +func (ssa *SQLStorageAuthority) RevokeCertificate(ctx context.Context, req *sapb.RevokeCertificateRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.Serial == "" || req.IssuerID == 0 || core.IsAnyNilOrZero(req.Date) { + return nil, errIncompleteRequest + } + + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + revokedDate := req.Date.AsTime().Truncate(time.Second) + + res, err := tx.ExecContext(ctx, + `UPDATE certificateStatus SET + status = ?, + revokedReason = ?, + revokedDate = ?, + ocspLastUpdated = ? + WHERE serial = ? AND status != ?`, + string(core.OCSPStatusRevoked), + revocation.Reason(req.Reason), + revokedDate, + revokedDate, + req.Serial, + string(core.OCSPStatusRevoked), + ) + if err != nil { + return nil, err + } + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, berrors.AlreadyRevokedError("no certificate with serial %s and status other than %s", req.Serial, string(core.OCSPStatusRevoked)) + } + + if req.ShardIdx != 0 { + err = addRevokedCertificate(ctx, tx, req, revokedDate) + if err != nil { + return nil, err + } + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + + return &emptypb.Empty{}, nil +} + +// UpdateRevokedCertificate stores new revocation information about an +// already-revoked certificate. It will only store this information if the +// cert is already revoked, if the new revocation reason is `KeyCompromise`, +// and if the revokedDate is identical to the current revokedDate. +func (ssa *SQLStorageAuthority) UpdateRevokedCertificate(ctx context.Context, req *sapb.RevokeCertificateRequest) (*emptypb.Empty, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.Serial == "" || req.IssuerID == 0 || core.IsAnyNilOrZero(req.Date, req.Backdate) { + return nil, errIncompleteRequest + } + if req.Reason != ocsp.KeyCompromise { + return nil, fmt.Errorf("cannot update revocation for any reason other than keyCompromise (1); got: %d", req.Reason) + } + + _, overallError := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + thisUpdate := req.Date.AsTime().Truncate(time.Second) + revokedDate := req.Backdate.AsTime().Truncate(time.Second) + + res, err := tx.ExecContext(ctx, + `UPDATE certificateStatus SET + revokedReason = ?, + ocspLastUpdated = ? + WHERE serial = ? AND status = ? AND revokedReason != ? AND revokedDate = ?`, + revocation.Reason(ocsp.KeyCompromise), + thisUpdate, + req.Serial, + string(core.OCSPStatusRevoked), + revocation.Reason(ocsp.KeyCompromise), + revokedDate, + ) + if err != nil { + return nil, err + } + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + // InternalServerError because we expected this certificate status to exist, + // to already be revoked for a different reason, and to have a matching date. + return nil, berrors.InternalServerError("no certificate with serial %s and revoked reason other than keyCompromise", req.Serial) + } + + // Only update the revokedCertificates table if the revocation request + // specifies the CRL shard that this certificate belongs in. Our shards are + // one-indexed, so a ShardIdx of zero means no value was set. + if req.ShardIdx != 0 { + var rcm revokedCertModel + // Note: this query MUST be updated to enforce the same preconditions as + // the "UPDATE certificateStatus SET revokedReason..." above if this + // query ever becomes the first or only query in this transaction. We are + // currently relying on the query above to exit early if the certificate + // does not have an appropriate status. + err = tx.SelectOne( + ctx, &rcm, `SELECT * FROM revokedCertificates WHERE serial = ?`, req.Serial) + if db.IsNoRows(err) { + // TODO: Remove this fallback codepath once we know that all unexpired + // certs marked as revoked in the certificateStatus table have + // corresponding rows in the revokedCertificates table. That should be + // 90+ days after the RA starts sending ShardIdx in its + // RevokeCertificateRequest messages. + err = addRevokedCertificate(ctx, tx, req, revokedDate) + if err != nil { + return nil, err + } + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("retrieving revoked certificate row: %w", err) + } + + rcm.RevokedReason = revocation.Reason(ocsp.KeyCompromise) + _, err = tx.Update(ctx, &rcm) + if err != nil { + return nil, fmt.Errorf("updating revoked certificate row: %w", err) + } + } + + return nil, nil + }) + if overallError != nil { + return nil, overallError + } + + return &emptypb.Empty{}, nil +} + +// AddBlockedKey adds a key hash to the blockedKeys table +func (ssa *SQLStorageAuthority) AddBlockedKey(ctx context.Context, req *sapb.AddBlockedKeyRequest) (*emptypb.Empty, error) { + if core.IsAnyNilOrZero(req.KeyHash, req.Added, req.Source) { + return nil, errIncompleteRequest + } + sourceInt, ok := stringToSourceInt[req.Source] + if !ok { + return nil, errors.New("unknown source") + } + cols, qs := blockedKeysColumns, "?, ?, ?, ?" + vals := []interface{}{ + req.KeyHash, + req.Added.AsTime().Truncate(time.Second), + sourceInt, + req.Comment, + } + if req.RevokedBy != 0 { + cols += ", revokedBy" + qs += ", ?" + vals = append(vals, req.RevokedBy) + } + _, err := ssa.dbMap.ExecContext(ctx, + fmt.Sprintf("INSERT INTO blockedKeys (%s) VALUES (%s)", cols, qs), + vals..., + ) + if err != nil { + if db.IsDuplicate(err) { + // Ignore duplicate inserts so multiple certs with the same key can + // be revoked. + return &emptypb.Empty{}, nil + } + return nil, err + } + return &emptypb.Empty{}, nil +} + +// Health implements the grpc.checker interface. +func (ssa *SQLStorageAuthority) Health(ctx context.Context) error { + err := ssa.dbMap.SelectOne(ctx, new(int), "SELECT 1") + if err != nil { + return err + } + + err = ssa.SQLStorageAuthorityRO.Health(ctx) + if err != nil { + return err + } + return nil +} + +// LeaseCRLShard marks a single crlShards row as leased until the given time. +// If the request names a specific shard, this function will return an error +// if that shard is already leased. Otherwise, this function will return the +// index of the oldest shard for the given issuer. +func (ssa *SQLStorageAuthority) LeaseCRLShard(ctx context.Context, req *sapb.LeaseCRLShardRequest) (*sapb.LeaseCRLShardResponse, error) { + if core.IsAnyNilOrZero(req.Until, req.IssuerNameID) { + return nil, errIncompleteRequest + } + if req.Until.AsTime().Before(ssa.clk.Now()) { + return nil, fmt.Errorf("lease timestamp must be in the future, got %q", req.Until.AsTime()) + } + + if req.MinShardIdx == req.MaxShardIdx { + return ssa.leaseSpecificCRLShard(ctx, req) + } + + return ssa.leaseOldestCRLShard(ctx, req) +} + +// leaseOldestCRLShard finds the oldest unleased crl shard for the given issuer +// and then leases it. Shards within the requested range which have never been +// leased or are previously-unknown indices are considered older than any other +// shard. It returns an error if all shards for the issuer are already leased. +func (ssa *SQLStorageAuthority) leaseOldestCRLShard(ctx context.Context, req *sapb.LeaseCRLShardRequest) (*sapb.LeaseCRLShardResponse, error) { + shardIdx, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + var shards []*crlShardModel + _, err := tx.Select( + ctx, + &shards, + `SELECT id, issuerID, idx, thisUpdate, nextUpdate, leasedUntil + FROM crlShards + WHERE issuerID = ? + AND idx BETWEEN ? AND ?`, + req.IssuerNameID, req.MinShardIdx, req.MaxShardIdx, + ) + if err != nil { + return -1, fmt.Errorf("selecting candidate shards: %w", err) + } + + // Determine which shard index we want to lease. + var shardIdx int + var needToInsert bool + if len(shards) < (int(req.MaxShardIdx + 1 - req.MinShardIdx)) { + // Some expected shards are missing (i.e. never-before-produced), so we + // pick one at random. + missing := make(map[int]struct{}, req.MaxShardIdx+1-req.MinShardIdx) + for i := req.MinShardIdx; i <= req.MaxShardIdx; i++ { + missing[int(i)] = struct{}{} + } + for _, shard := range shards { + delete(missing, shard.Idx) + } + for idx := range missing { + // Go map iteration is guaranteed to be in randomized key order. + shardIdx = idx + break + } + needToInsert = true + } else { + // We got all the shards we expect, so we pick the oldest unleased shard. + var oldest *crlShardModel + for _, shard := range shards { + if shard.LeasedUntil.After(ssa.clk.Now()) { + continue + } + if oldest == nil || + (oldest.ThisUpdate != nil && shard.ThisUpdate == nil) || + (oldest.ThisUpdate != nil && shard.ThisUpdate.Before(*oldest.ThisUpdate)) { + oldest = shard + } + } + if oldest == nil { + return -1, fmt.Errorf("issuer %d has no unleased shards in range %d-%d", req.IssuerNameID, req.MinShardIdx, req.MaxShardIdx) + } + shardIdx = oldest.Idx + needToInsert = false + } + + if needToInsert { + _, err = tx.ExecContext(ctx, + `INSERT INTO crlShards (issuerID, idx, leasedUntil) + VALUES (?, ?, ?)`, + req.IssuerNameID, + shardIdx, + req.Until.AsTime(), + ) + if err != nil { + return -1, fmt.Errorf("inserting selected shard: %w", err) + } + } else { + _, err = tx.ExecContext(ctx, + `UPDATE crlShards + SET leasedUntil = ? + WHERE issuerID = ? + AND idx = ? + LIMIT 1`, + req.Until.AsTime(), + req.IssuerNameID, + shardIdx, + ) + if err != nil { + return -1, fmt.Errorf("updating selected shard: %w", err) + } + } + + return shardIdx, err + }) + if err != nil { + return nil, fmt.Errorf("leasing oldest shard: %w", err) + } + + return &sapb.LeaseCRLShardResponse{ + IssuerNameID: req.IssuerNameID, + ShardIdx: int64(shardIdx.(int)), + }, nil +} + +// leaseSpecificCRLShard attempts to lease the crl shard for the given issuer +// and shard index. It returns an error if the specified shard is already +// leased. +func (ssa *SQLStorageAuthority) leaseSpecificCRLShard(ctx context.Context, req *sapb.LeaseCRLShardRequest) (*sapb.LeaseCRLShardResponse, error) { + if req.MinShardIdx != req.MaxShardIdx { + return nil, fmt.Errorf("request must identify a single shard index: %d != %d", req.MinShardIdx, req.MaxShardIdx) + } + + _, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + needToInsert := false + var shardModel crlShardModel + err := tx.SelectOne(ctx, + &shardModel, + `SELECT leasedUntil + FROM crlShards + WHERE issuerID = ? + AND idx = ? + LIMIT 1`, + req.IssuerNameID, + req.MinShardIdx, + ) + if db.IsNoRows(err) { + needToInsert = true + } else if err != nil { + return nil, fmt.Errorf("selecting requested shard: %w", err) + } else if shardModel.LeasedUntil.After(ssa.clk.Now()) { + return nil, fmt.Errorf("shard %d for issuer %d already leased", req.MinShardIdx, req.IssuerNameID) + } + + if needToInsert { + _, err = tx.ExecContext(ctx, + `INSERT INTO crlShards (issuerID, idx, leasedUntil) + VALUES (?, ?, ?)`, + req.IssuerNameID, + req.MinShardIdx, + req.Until.AsTime(), + ) + if err != nil { + return nil, fmt.Errorf("inserting selected shard: %w", err) + } + } else { + _, err = tx.ExecContext(ctx, + `UPDATE crlShards + SET leasedUntil = ? + WHERE issuerID = ? + AND idx = ? + LIMIT 1`, + req.Until.AsTime(), + req.IssuerNameID, + req.MinShardIdx, + ) + if err != nil { + return nil, fmt.Errorf("updating selected shard: %w", err) + } + } + + return nil, nil + }) + if err != nil { + return nil, fmt.Errorf("leasing specific shard: %w", err) + } + + return &sapb.LeaseCRLShardResponse{ + IssuerNameID: req.IssuerNameID, + ShardIdx: req.MinShardIdx, + }, nil +} + +// UpdateCRLShard updates the thisUpdate and nextUpdate timestamps of a CRL +// shard. It rejects the update if it would cause the thisUpdate timestamp to +// move backwards, but if thisUpdate would stay the same (for instance, multiple +// CRL generations within a single second), it will succeed. +// +// It does *not* reject the update if the shard is no longer +// leased: although this would be unexpected (because the lease timestamp should +// be the same as the crl-updater's context expiration), it's not inherently a +// sign of an update that should be skipped. It does reject the update if the +// identified CRL shard does not exist in the database (it should exist, as +// rows are created if necessary when leased). It also sets the leasedUntil time +// to be equal to thisUpdate, to indicate that the shard is no longer leased. +func (ssa *SQLStorageAuthority) UpdateCRLShard(ctx context.Context, req *sapb.UpdateCRLShardRequest) (*emptypb.Empty, error) { + if core.IsAnyNilOrZero(req.IssuerNameID, req.ThisUpdate) { + return nil, errIncompleteRequest + } + + // Only set the nextUpdate if it's actually present in the request message. + var nextUpdate *time.Time + if req.NextUpdate != nil { + nut := req.NextUpdate.AsTime().Truncate(time.Second) + nextUpdate = &nut + } + + _, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + thisUpdate := req.ThisUpdate.AsTime().Truncate(time.Second) + res, err := tx.ExecContext(ctx, + `UPDATE crlShards + SET thisUpdate = ?, nextUpdate = ?, leasedUntil = ? + WHERE issuerID = ? + AND idx = ? + AND (thisUpdate is NULL OR thisUpdate <= ?) + LIMIT 1`, + thisUpdate, + nextUpdate, + thisUpdate, + req.IssuerNameID, + req.ShardIdx, + thisUpdate, + ) + if err != nil { + return nil, err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rowsAffected == 0 { + return nil, fmt.Errorf("unable to update shard %d for issuer %d; possibly because shard exists", req.ShardIdx, req.IssuerNameID) + } + if rowsAffected != 1 { + return nil, errors.New("update affected unexpected number of rows") + } + return nil, nil + }) + if err != nil { + return nil, err + } + + return &emptypb.Empty{}, nil +} + +// PauseIdentifiers pauses a set of identifiers for the provided account. If an +// identifier is currently paused, this is a no-op. If an identifier was +// previously paused and unpaused, it will be repaused. All work is accomplished +// in a transaction to limit possible race conditions. +func (ssa *SQLStorageAuthority) PauseIdentifiers(ctx context.Context, req *sapb.PauseRequest) (*sapb.PauseIdentifiersResponse, error) { + if core.IsAnyNilOrZero(req.RegistrationID, req.Identifiers) { + return nil, errIncompleteRequest + } + + // Marshal the identifier now that we've crossed the RPC boundary. + identifiers, err := newIdentifierModelsFromPB(req.Identifiers) + if err != nil { + return nil, err + } + + response := &sapb.PauseIdentifiersResponse{} + _, err = db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { + for _, identifier := range identifiers { + pauseError := func(op string, err error) error { + return fmt.Errorf("while %s identifier %s for registration ID %d: %w", + op, identifier.Value, req.RegistrationID, err, + ) + } + + var entry pausedModel + err := tx.SelectOne(ctx, &entry, ` + SELECT pausedAt, unpausedAt + FROM paused + WHERE + registrationID = ? AND + identifierType = ? AND + identifierValue = ?`, + req.RegistrationID, + identifier.Type, + identifier.Value, + ) + + switch { + case err != nil && !errors.Is(err, sql.ErrNoRows): + // Error querying the database. + return nil, pauseError("querying pause status for", err) + + case err != nil && errors.Is(err, sql.ErrNoRows): + // Not currently or previously paused, insert a new pause record. + err = tx.Insert(ctx, &pausedModel{ + RegistrationID: req.RegistrationID, + PausedAt: ssa.clk.Now().Truncate(time.Second), + identifierModel: identifierModel{ + Type: identifier.Type, + Value: identifier.Value, + }, + }) + if err != nil && !db.IsDuplicate(err) { + return nil, pauseError("pausing", err) + } + + // Identifier successfully paused. + response.Paused++ + continue + + case entry.UnpausedAt == nil || entry.PausedAt.After(*entry.UnpausedAt): + // Identifier is already paused. + continue + + case entry.UnpausedAt.After(entry.PausedAt): + // Previously paused (and unpaused), repause the identifier. + _, err := tx.ExecContext(ctx, ` + UPDATE paused + SET pausedAt = ?, + unpausedAt = NULL + WHERE + registrationID = ? AND + identifierType = ? AND + identifierValue = ? AND + unpausedAt IS NOT NULL`, + ssa.clk.Now().Truncate(time.Second), + req.RegistrationID, + identifier.Type, + identifier.Value, + ) + if err != nil { + return nil, pauseError("repausing", err) + } + + // Identifier successfully repaused. + response.Repaused++ + continue + + default: + // This indicates a database state which should never occur. + return nil, fmt.Errorf("impossible database state encountered while pausing identifier %s", + identifier.Value, + ) + } + } + return nil, nil + }) + if err != nil { + // Error occurred during transaction. + return nil, err + } + return response, nil +} + +// UnpauseAccount will unpause all paused identifiers for the provided account. +// If no identifiers are currently paused, this is a no-op. +func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.RegistrationID) (*emptypb.Empty, error) { + if core.IsAnyNilOrZero(req.Id) { + return nil, errIncompleteRequest + } + + _, err := ssa.dbMap.ExecContext(ctx, ` + UPDATE paused + SET unpausedAt = ? + WHERE + registrationID = ? AND + unpausedAt IS NULL`, + ssa.clk.Now().Truncate(time.Second), + req.Id, + ) + if err != nil { + return nil, err + } + + return nil, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/sa_test.go b/third-party/github.com/letsencrypt/boulder/sa/sa_test.go new file mode 100644 index 000000000..74f244c98 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/sa_test.go @@ -0,0 +1,4852 @@ +package sa + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "math/bits" + mrand "math/rand" + "net" + "os" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-sql-driver/mysql" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +var log = blog.UseMock() +var ctx = context.Background() + +var ( + theKey = `{ + "kty": "RSA", + "n": "n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw", + "e": "AQAB" +}` +) + +type fakeServerStream[T any] struct { + grpc.ServerStream + output chan<- *T +} + +func (s *fakeServerStream[T]) Send(msg *T) error { + s.output <- msg + return nil +} + +func (s *fakeServerStream[T]) Context() context.Context { + return context.Background() +} + +// initSA constructs a SQLStorageAuthority and a clean up function that should +// be defer'ed to the end of the test. +func initSA(t *testing.T) (*SQLStorageAuthority, clock.FakeClock, func()) { + t.Helper() + features.Reset() + + dbMap, err := DBMapForTest(vars.DBConnSA) + if err != nil { + t.Fatalf("Failed to create dbMap: %s", err) + } + + dbIncidentsMap, err := DBMapForTest(vars.DBConnIncidents) + if err != nil { + t.Fatalf("Failed to create dbMap: %s", err) + } + + fc := clock.NewFake() + fc.Set(time.Date(2015, 3, 4, 5, 0, 0, 0, time.UTC)) + + saro, err := NewSQLStorageAuthorityRO(dbMap, dbIncidentsMap, metrics.NoopRegisterer, 1, 0, fc, log) + if err != nil { + t.Fatalf("Failed to create SA: %s", err) + } + + sa, err := NewSQLStorageAuthorityWrapping(saro, dbMap, metrics.NoopRegisterer) + if err != nil { + t.Fatalf("Failed to create SA: %s", err) + } + + return sa, fc, test.ResetBoulderTestDatabase(t) +} + +// CreateWorkingTestRegistration inserts a new, correct Registration into the +// given SA. +func createWorkingRegistration(t *testing.T, sa *SQLStorageAuthority) *corepb.Registration { + initialIP, _ := net.ParseIP("88.77.66.11").MarshalText() + reg, err := sa.NewRegistration(context.Background(), &corepb.Registration{ + Key: []byte(theKey), + Contact: []string{"mailto:foo@example.com"}, + InitialIP: initialIP, + CreatedAt: timestamppb.New(time.Date(2003, 5, 10, 0, 0, 0, 0, time.UTC)), + Status: string(core.StatusValid), + }) + if err != nil { + t.Fatalf("Unable to create new registration: %s", err) + } + return reg +} + +func createPendingAuthorization(t *testing.T, sa *SQLStorageAuthority, domain string, exp time.Time) int64 { + t.Helper() + + tokenStr := core.NewToken() + token, err := base64.RawURLEncoding.DecodeString(tokenStr) + test.AssertNotError(t, err, "computing test authorization challenge token") + + am := authzModel{ + IdentifierType: 0, // dnsName + IdentifierValue: domain, + RegistrationID: 1, + Status: statusToUint[core.StatusPending], + Expires: exp, + Challenges: 1 << challTypeToUint[string(core.ChallengeTypeHTTP01)], + Token: token, + } + + err = sa.dbMap.Insert(context.Background(), &am) + test.AssertNotError(t, err, "creating test authorization") + + return am.ID +} + +func createFinalizedAuthorization(t *testing.T, sa *SQLStorageAuthority, domain string, exp time.Time, + status string, attemptedAt time.Time) int64 { + t.Helper() + pendingID := createPendingAuthorization(t, sa, domain, exp) + attempted := string(core.ChallengeTypeHTTP01) + _, err := sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: pendingID, + Status: status, + Expires: timestamppb.New(exp), + Attempted: attempted, + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorizations2 failed") + return pendingID +} + +func goodTestJWK() *jose.JSONWebKey { + var jwk jose.JSONWebKey + err := json.Unmarshal([]byte(theKey), &jwk) + if err != nil { + panic("known-good theKey is no longer known-good") + } + return &jwk +} + +func TestAddRegistration(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + jwk := goodTestJWK() + jwkJSON, _ := jwk.MarshalJSON() + + contacts := []string{"mailto:foo@example.com"} + initialIP, _ := net.ParseIP("43.34.43.34").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: jwkJSON, + Contact: contacts, + InitialIP: initialIP, + }) + if err != nil { + t.Fatalf("Couldn't create new registration: %s", err) + } + test.Assert(t, reg.Id != 0, "ID shouldn't be 0") + test.AssertDeepEquals(t, reg.Contact, contacts) + + _, err = sa.GetRegistration(ctx, &sapb.RegistrationID{Id: 0}) + test.AssertError(t, err, "Registration object for ID 0 was returned") + + dbReg, err := sa.GetRegistration(ctx, &sapb.RegistrationID{Id: reg.Id}) + test.AssertNotError(t, err, fmt.Sprintf("Couldn't get registration with ID %v", reg.Id)) + + createdAt := clk.Now() + test.AssertEquals(t, dbReg.Id, reg.Id) + test.AssertByteEquals(t, dbReg.Key, jwkJSON) + test.AssertDeepEquals(t, dbReg.CreatedAt.AsTime(), createdAt) + + initialIP, _ = net.ParseIP("72.72.72.72").MarshalText() + newReg := &corepb.Registration{ + Id: reg.Id, + Key: jwkJSON, + Contact: []string{"test.com"}, + InitialIP: initialIP, + Agreement: "yes", + } + _, err = sa.UpdateRegistration(ctx, newReg) + test.AssertNotError(t, err, fmt.Sprintf("Couldn't get registration with ID %v", reg.Id)) + dbReg, err = sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: jwkJSON}) + test.AssertNotError(t, err, "Couldn't get registration by key") + + test.AssertEquals(t, dbReg.Id, newReg.Id) + test.AssertEquals(t, dbReg.Agreement, newReg.Agreement) + + anotherKey := `{ + "kty":"RSA", + "n": "vd7rZIoTLEe-z1_8G1FcXSw9CQFEJgV4g9V277sER7yx5Qjz_Pkf2YVth6wwwFJEmzc0hoKY-MMYFNwBE4hQHw", + "e":"AQAB" + }` + + _, err = sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: []byte(anotherKey)}) + test.AssertError(t, err, "Registration object for invalid key was returned") +} + +func TestNoSuchRegistrationErrors(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + _, err := sa.GetRegistration(ctx, &sapb.RegistrationID{Id: 100}) + test.AssertErrorIs(t, err, berrors.NotFound) + + jwk := goodTestJWK() + jwkJSON, _ := jwk.MarshalJSON() + + _, err = sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: jwkJSON}) + test.AssertErrorIs(t, err, berrors.NotFound) + + _, err = sa.UpdateRegistration(ctx, &corepb.Registration{Id: 100, Key: jwkJSON, InitialIP: []byte("foo")}) + test.AssertErrorIs(t, err, berrors.NotFound) +} + +func TestSelectRegistration(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + var ctx = context.Background() + jwk := goodTestJWK() + jwkJSON, _ := jwk.MarshalJSON() + sha, err := core.KeyDigestB64(jwk.Key) + test.AssertNotError(t, err, "couldn't parse jwk.Key") + + initialIP, _ := net.ParseIP("43.34.43.34").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: jwkJSON, + Contact: []string{"mailto:foo@example.com"}, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, fmt.Sprintf("couldn't create new registration: %s", err)) + test.Assert(t, reg.Id != 0, "ID shouldn't be 0") + + _, err = selectRegistration(ctx, sa.dbMap, "id", reg.Id) + test.AssertNotError(t, err, "selecting by id should work") + _, err = selectRegistration(ctx, sa.dbMap, "jwk_sha256", sha) + test.AssertNotError(t, err, "selecting by jwk_sha256 should work") + _, err = selectRegistration(ctx, sa.dbMap, "initialIP", reg.Id) + test.AssertError(t, err, "selecting by any other column should not work") +} + +func TestReplicationLagRetries(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + // First, set the lagFactor to 0. Neither selecting a real registration nor + // selecting a nonexistent registration should cause the clock to advance. + sa.lagFactor = 0 + start := clk.Now() + + _, err := sa.GetRegistration(ctx, &sapb.RegistrationID{Id: reg.Id}) + test.AssertNotError(t, err, "selecting extant registration") + test.AssertEquals(t, clk.Now(), start) + test.AssertMetricWithLabelsEquals(t, sa.lagFactorCounter, prometheus.Labels{"method": "GetRegistration", "result": "notfound"}, 0) + + _, err = sa.GetRegistration(ctx, &sapb.RegistrationID{Id: reg.Id + 1}) + test.AssertError(t, err, "selecting nonexistent registration") + test.AssertEquals(t, clk.Now(), start) + // With lagFactor disabled, we should never enter the retry codepath, as a + // result the metric should not increment. + test.AssertMetricWithLabelsEquals(t, sa.lagFactorCounter, prometheus.Labels{"method": "GetRegistration", "result": "notfound"}, 0) + + // Now, set the lagFactor to 1. Trying to select a nonexistent registration + // should cause the clock to advance when GetRegistration sleeps and retries. + sa.lagFactor = 1 + start = clk.Now() + + _, err = sa.GetRegistration(ctx, &sapb.RegistrationID{Id: reg.Id}) + test.AssertNotError(t, err, "selecting extant registration") + test.AssertEquals(t, clk.Now(), start) + // lagFactor is enabled, but the registration exists. + test.AssertMetricWithLabelsEquals(t, sa.lagFactorCounter, prometheus.Labels{"method": "GetRegistration", "result": "notfound"}, 0) + + _, err = sa.GetRegistration(ctx, &sapb.RegistrationID{Id: reg.Id + 1}) + test.AssertError(t, err, "selecting nonexistent registration") + test.AssertEquals(t, clk.Now(), start.Add(1)) + // With lagFactor enabled, we should enter the retry codepath and as a result + // the metric should increment. + test.AssertMetricWithLabelsEquals(t, sa.lagFactorCounter, prometheus.Labels{"method": "GetRegistration", "result": "notfound"}, 1) +} + +// findIssuedName is a small helper test function to directly query the +// issuedNames table for a given name to find a serial (or return an err). +func findIssuedName(ctx context.Context, dbMap db.OneSelector, name string) (string, error) { + var issuedNamesSerial string + err := dbMap.SelectOne( + ctx, + &issuedNamesSerial, + `SELECT serial FROM issuedNames + WHERE reversedName = ? + ORDER BY notBefore DESC + LIMIT 1`, + ReverseName(name)) + return issuedNamesSerial, err +} + +func TestAddSerial(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + serial, testCert := test.ThrowAwayCert(t, clk) + + _, err := sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + RegID: reg.Id, + Created: timestamppb.New(testCert.NotBefore), + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertError(t, err, "adding without serial should fail") + + _, err = sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + Serial: serial, + Created: timestamppb.New(testCert.NotBefore), + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertError(t, err, "adding without regid should fail") + + _, err = sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + Serial: serial, + RegID: reg.Id, + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertError(t, err, "adding without created should fail") + + _, err = sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + Serial: serial, + RegID: reg.Id, + Created: timestamppb.New(testCert.NotBefore), + }) + test.AssertError(t, err, "adding without expires should fail") + + _, err = sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + Serial: serial, + RegID: reg.Id, + Created: timestamppb.New(testCert.NotBefore), + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertNotError(t, err, "adding serial should have succeeded") +} + +func TestGetSerialMetadata(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + serial, _ := test.ThrowAwayCert(t, clk) + + _, err := sa.GetSerialMetadata(context.Background(), &sapb.Serial{Serial: serial}) + test.AssertError(t, err, "getting nonexistent serial should have failed") + + now := clk.Now() + hourLater := now.Add(time.Hour) + _, err = sa.AddSerial(context.Background(), &sapb.AddSerialRequest{ + Serial: serial, + RegID: reg.Id, + Created: timestamppb.New(now), + Expires: timestamppb.New(hourLater), + }) + test.AssertNotError(t, err, "failed to add test serial") + + m, err := sa.GetSerialMetadata(context.Background(), &sapb.Serial{Serial: serial}) + + test.AssertNotError(t, err, "getting serial should have succeeded") + test.AssertEquals(t, m.Serial, serial) + test.AssertEquals(t, m.RegistrationID, reg.Id) + test.AssertEquals(t, now, timestamppb.New(now).AsTime()) + test.AssertEquals(t, m.Expires.AsTime(), timestamppb.New(hourLater).AsTime()) +} + +func TestAddPrecertificate(t *testing.T) { + ctx := context.Background() + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + // Create a throw-away self signed certificate with a random name and + // serial number + serial, testCert := test.ThrowAwayCert(t, clk) + + // Add the cert as a precertificate + regID := reg.Id + issuedTime := time.Date(2018, 4, 1, 7, 0, 0, 0, time.UTC) + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: regID, + Issued: timestamppb.New(issuedTime), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") + + // It should have the expected certificate status + certStatus, err := sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "Couldn't get status for test cert") + test.AssertEquals(t, certStatus.Status, string(core.OCSPStatusGood)) + now := clk.Now() + test.AssertEquals(t, now, certStatus.OcspLastUpdated.AsTime()) + + // It should show up in the issued names table + issuedNamesSerial, err := findIssuedName(ctx, sa.dbMap, testCert.DNSNames[0]) + test.AssertNotError(t, err, "expected no err querying issuedNames for precert") + test.AssertEquals(t, issuedNamesSerial, serial) + + // We should also be able to call AddCertificate with the same cert + // without it being an error. The duplicate err on inserting to + // issuedNames should be ignored. + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: regID, + Issued: timestamppb.New(issuedTime), + }) + test.AssertNotError(t, err, "unexpected err adding final cert after precert") +} + +func TestAddPrecertificateNoOCSP(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + _, testCert := test.ThrowAwayCert(t, clk) + + regID := reg.Id + issuedTime := time.Date(2018, 4, 1, 7, 0, 0, 0, time.UTC) + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: regID, + Issued: timestamppb.New(issuedTime), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") +} + +func TestAddPreCertificateDuplicate(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + _, testCert := test.ThrowAwayCert(t, clk) + issuedTime := clk.Now() + + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + Issued: timestamppb.New(issuedTime), + RegID: reg.Id, + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test certificate") + + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + Issued: timestamppb.New(issuedTime), + RegID: reg.Id, + IssuerNameID: 1, + }) + test.AssertDeepEquals(t, err, berrors.DuplicateError("cannot add a duplicate cert")) +} + +func TestAddPrecertificateIncomplete(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + // Create a throw-away self signed certificate with a random name and + // serial number + _, testCert := test.ThrowAwayCert(t, clk) + + // Add the cert as a precertificate + regID := reg.Id + issuedTime := time.Date(2018, 4, 1, 7, 0, 0, 0, time.UTC) + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: regID, + Issued: timestamppb.New(issuedTime), + // Leaving out IssuerNameID + }) + + test.AssertError(t, err, "Adding precert with no issuer did not fail") +} + +func TestAddPrecertificateKeyHash(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + reg := createWorkingRegistration(t, sa) + + serial, testCert := test.ThrowAwayCert(t, clk) + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "failed to add precert") + + var keyHashes []keyHashModel + _, err = sa.dbMap.Select(context.Background(), &keyHashes, "SELECT * FROM keyHashToSerial") + test.AssertNotError(t, err, "failed to retrieve rows from keyHashToSerial") + test.AssertEquals(t, len(keyHashes), 1) + test.AssertEquals(t, keyHashes[0].CertSerial, serial) + test.AssertEquals(t, keyHashes[0].CertNotAfter, testCert.NotAfter) + test.AssertEquals(t, keyHashes[0].CertNotAfter, timestamppb.New(testCert.NotAfter).AsTime()) + spkiHash := sha256.Sum256(testCert.RawSubjectPublicKeyInfo) + test.Assert(t, bytes.Equal(keyHashes[0].KeyHash, spkiHash[:]), "spki hash mismatch") +} + +func TestAddCertificate(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + serial, testCert := test.ThrowAwayCert(t, clk) + + issuedTime := sa.clk.Now() + _, err := sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + }) + test.AssertNotError(t, err, "Couldn't add test cert") + + retrievedCert, err := sa.GetCertificate(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "Couldn't get test cert by full serial") + test.AssertByteEquals(t, testCert.Raw, retrievedCert.Der) + test.AssertEquals(t, retrievedCert.Issued.AsTime(), issuedTime) + + // Calling AddCertificate with empty args should fail. + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: nil, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + }) + test.AssertError(t, err, "shouldn't be able to add cert with no DER") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: 0, + Issued: timestamppb.New(issuedTime), + }) + test.AssertError(t, err, "shouldn't be able to add cert with no regID") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: nil, + }) + test.AssertError(t, err, "shouldn't be able to add cert with no issued timestamp") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(time.Time{}), + }) + test.AssertError(t, err, "shouldn't be able to add cert with zero issued timestamp") +} + +func TestAddCertificateDuplicate(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + _, testCert := test.ThrowAwayCert(t, clk) + + issuedTime := clk.Now() + _, err := sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + }) + test.AssertNotError(t, err, "Couldn't add test certificate") + + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + }) + test.AssertDeepEquals(t, err, berrors.DuplicateError("cannot add a duplicate cert")) + +} + +func TestCountCertificatesByNamesTimeRange(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + _, testCert := test.ThrowAwayCert(t, clk) + _, err := sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + }) + test.AssertNotError(t, err, "Couldn't add test cert") + name := testCert.DNSNames[0] + + // Move time forward, so the cert was issued slightly in the past. + clk.Add(time.Hour) + now := clk.Now() + yesterday := clk.Now().Add(-24 * time.Hour) + twoDaysAgo := clk.Now().Add(-48 * time.Hour) + tomorrow := clk.Now().Add(24 * time.Hour) + + // Count for a name that doesn't have any certs + counts, err := sa.CountCertificatesByNames(ctx, &sapb.CountCertificatesByNamesRequest{ + Names: []string{"does.not.exist"}, + Range: &sapb.Range{ + Earliest: timestamppb.New(yesterday), + Latest: timestamppb.New(now), + }, + }) + test.AssertNotError(t, err, "Error counting certs.") + test.AssertEquals(t, len(counts.Counts), 1) + test.AssertEquals(t, counts.Counts["does.not.exist"], int64(0)) + + // Time range including now should find the cert. + counts, err = sa.CountCertificatesByNames(ctx, &sapb.CountCertificatesByNamesRequest{ + Names: testCert.DNSNames, + Range: &sapb.Range{ + Earliest: timestamppb.New(yesterday), + Latest: timestamppb.New(now), + }, + }) + test.AssertNotError(t, err, "sa.CountCertificatesByName failed") + test.AssertEquals(t, len(counts.Counts), 1) + test.AssertEquals(t, counts.Counts[name], int64(1)) + + // Time range between two days ago and yesterday should not find the cert. + counts, err = sa.CountCertificatesByNames(ctx, &sapb.CountCertificatesByNamesRequest{ + Names: testCert.DNSNames, + Range: &sapb.Range{ + Earliest: timestamppb.New(twoDaysAgo), + Latest: timestamppb.New(yesterday), + }, + }) + test.AssertNotError(t, err, "Error counting certs.") + test.AssertEquals(t, len(counts.Counts), 1) + test.AssertEquals(t, counts.Counts[name], int64(0)) + + // Time range between now and tomorrow also should not (time ranges are + // inclusive at the tail end, but not the beginning end). + counts, err = sa.CountCertificatesByNames(ctx, &sapb.CountCertificatesByNamesRequest{ + Names: testCert.DNSNames, + Range: &sapb.Range{ + Earliest: timestamppb.New(now), + Latest: timestamppb.New(tomorrow), + }, + }) + test.AssertNotError(t, err, "Error counting certs.") + test.AssertEquals(t, len(counts.Counts), 1) + test.AssertEquals(t, counts.Counts[name], int64(0)) +} + +func TestCountCertificatesByNamesParallel(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + // Create two certs with different names and add them both to the database. + reg := createWorkingRegistration(t, sa) + + _, testCert := test.ThrowAwayCert(t, clk) + _, err := sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + }) + test.AssertNotError(t, err, "Couldn't add test cert") + + _, testCert2 := test.ThrowAwayCert(t, clk) + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert2.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert2.NotBefore), + }) + test.AssertNotError(t, err, "Couldn't add test cert") + + // Override countCertificatesByName with an implementation of certCountFunc + // that will block forever if it's called in serial, but will succeed if + // called in parallel. + names := []string{"does.not.exist", testCert.DNSNames[0], testCert2.DNSNames[0]} + + var interlocker sync.WaitGroup + interlocker.Add(len(names)) + sa.parallelismPerRPC = len(names) + oldCertCountFunc := sa.countCertificatesByName + sa.countCertificatesByName = func(ctx context.Context, sel db.Selector, domain string, timeRange *sapb.Range) (int64, time.Time, error) { + interlocker.Done() + interlocker.Wait() + return oldCertCountFunc(ctx, sel, domain, timeRange) + } + + counts, err := sa.CountCertificatesByNames(ctx, &sapb.CountCertificatesByNamesRequest{ + Names: names, + Range: &sapb.Range{ + Earliest: timestamppb.New(clk.Now().Add(-time.Hour)), + Latest: timestamppb.New(clk.Now().Add(time.Hour)), + }, + }) + test.AssertNotError(t, err, "Error counting certs.") + test.AssertEquals(t, len(counts.Counts), 3) + + // We expect there to be two of each of the names that do exist, because + // test.ThrowAwayCert creates certs for subdomains of example.com, and + // CountCertificatesByNames counts all certs under the same registered domain. + expected := map[string]int64{ + "does.not.exist": 0, + testCert.DNSNames[0]: 2, + testCert2.DNSNames[0]: 2, + } + for name, count := range expected { + test.AssertEquals(t, count, counts.Counts[name]) + } +} + +func TestCountRegistrationsByIP(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + contact := []string{"mailto:foo@example.com"} + + // Create one IPv4 registration + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("43.34.43.34").MarshalText() + _, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + // Create two IPv6 registrations, both within the same /48 + key, _ = jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(2), E: 1}}.MarshalJSON() + initialIP, _ = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9652").MarshalText() + test.AssertNotError(t, err, "Couldn't insert registration") + _, err = sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + test.AssertNotError(t, err, "Couldn't insert registration") + key, _ = jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(3), E: 1}}.MarshalJSON() + initialIP, _ = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9653").MarshalText() + _, err = sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + test.AssertNotError(t, err, "Couldn't insert registration") + + latest := fc.Now() + earliest := latest.Add(-time.Hour * 24) + req := &sapb.CountRegistrationsByIPRequest{ + Ip: net.ParseIP("1.1.1.1"), + Range: &sapb.Range{ + Earliest: timestamppb.New(earliest), + Latest: timestamppb.New(latest), + }, + } + + // There should be 0 registrations for an IPv4 address we didn't add + // a registration for + count, err := sa.CountRegistrationsByIP(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(0)) + // There should be 1 registration for the IPv4 address we did add + // a registration for. + req.Ip = net.ParseIP("43.34.43.34") + count, err = sa.CountRegistrationsByIP(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(1)) + // There should be 1 registration for the first IPv6 address we added + // a registration for + req.Ip = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9652") + count, err = sa.CountRegistrationsByIP(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(1)) + // There should be 1 registration for the second IPv6 address we added + // a registration for as well + req.Ip = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9653") + count, err = sa.CountRegistrationsByIP(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(1)) + // There should be 0 registrations for an IPv6 address in the same /48 as the + // two IPv6 addresses with registrations + req.Ip = net.ParseIP("2001:cdba:1234:0000:0000:0000:0000:0000") + count, err = sa.CountRegistrationsByIP(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(0)) +} + +func TestCountRegistrationsByIPRange(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + contact := []string{"mailto:foo@example.com"} + + // Create one IPv4 registration + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("43.34.43.34").MarshalText() + _, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + // Create two IPv6 registrations, both within the same /48 + key, _ = jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(2), E: 1}}.MarshalJSON() + initialIP, _ = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9652").MarshalText() + test.AssertNotError(t, err, "Couldn't insert registration") + _, err = sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + test.AssertNotError(t, err, "Couldn't insert registration") + key, _ = jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(3), E: 1}}.MarshalJSON() + initialIP, _ = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9653").MarshalText() + _, err = sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + Contact: contact, + }) + test.AssertNotError(t, err, "Couldn't insert registration") + + latest := fc.Now() + earliest := latest.Add(-time.Hour * 24) + req := &sapb.CountRegistrationsByIPRequest{ + Ip: net.ParseIP("1.1.1.1"), + Range: &sapb.Range{ + Earliest: timestamppb.New(earliest), + Latest: timestamppb.New(latest), + }, + } + + // There should be 0 registrations in the range for an IPv4 address we didn't + // add a registration for + req.Ip = net.ParseIP("1.1.1.1") + count, err := sa.CountRegistrationsByIPRange(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(0)) + // There should be 1 registration in the range for the IPv4 address we did + // add a registration for + req.Ip = net.ParseIP("43.34.43.34") + count, err = sa.CountRegistrationsByIPRange(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(1)) + // There should be 2 registrations in the range for the first IPv6 address we added + // a registration for because it's in the same /48 + req.Ip = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9652") + count, err = sa.CountRegistrationsByIPRange(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(2)) + // There should be 2 registrations in the range for the second IPv6 address + // we added a registration for as well, because it too is in the same /48 + req.Ip = net.ParseIP("2001:cdba:1234:5678:9101:1121:3257:9653") + count, err = sa.CountRegistrationsByIPRange(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(2)) + // There should also be 2 registrations in the range for an arbitrary IPv6 address in + // the same /48 as the registrations we added + req.Ip = net.ParseIP("2001:cdba:1234:0000:0000:0000:0000:0000") + count, err = sa.CountRegistrationsByIPRange(ctx, req) + test.AssertNotError(t, err, "Failed to count registrations") + test.AssertEquals(t, count.Count, int64(2)) +} + +func TestFQDNSets(t *testing.T) { + ctx := context.Background() + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + tx, err := sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + names := []string{"a.example.com", "B.example.com"} + expires := fc.Now().Add(time.Hour * 2).UTC() + issued := fc.Now() + err = addFQDNSet(ctx, tx, names, "serial", issued, expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // Invalid Window + req := &sapb.CountFQDNSetsRequest{ + Domains: names, + Window: nil, + } + _, err = sa.CountFQDNSets(ctx, req) + test.AssertErrorIs(t, err, errIncompleteRequest) + + threeHours := time.Hour * 3 + req = &sapb.CountFQDNSetsRequest{ + Domains: names, + Window: durationpb.New(threeHours), + } + // only one valid + count, err := sa.CountFQDNSets(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, count.Count, int64(1)) + + // check hash isn't affected by changing name order/casing + req.Domains = []string{"b.example.com", "A.example.COM"} + count, err = sa.CountFQDNSets(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, count.Count, int64(1)) + + // add another valid set + tx, err = sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + err = addFQDNSet(ctx, tx, names, "anotherSerial", issued, expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // only two valid + req.Domains = names + count, err = sa.CountFQDNSets(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, count.Count, int64(2)) + + // add an expired set + tx, err = sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + err = addFQDNSet( + ctx, + tx, + names, + "yetAnotherSerial", + issued.Add(-threeHours), + expires.Add(-threeHours), + ) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // only two valid + count, err = sa.CountFQDNSets(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, count.Count, int64(2)) +} + +func TestFQDNSetTimestampsForWindow(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + tx, err := sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + + names := []string{"a.example.com", "B.example.com"} + + // Invalid Window + req := &sapb.CountFQDNSetsRequest{ + Domains: names, + Window: nil, + } + _, err = sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertErrorIs(t, err, errIncompleteRequest) + + window := time.Hour * 3 + req = &sapb.CountFQDNSetsRequest{ + Domains: names, + Window: durationpb.New(window), + } + + // Ensure zero issuance has occurred for names. + resp, err := sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, len(resp.Timestamps), 0) + + // Add an issuance for names inside the window. + expires := fc.Now().Add(time.Hour * 2).UTC() + firstIssued := fc.Now() + err = addFQDNSet(ctx, tx, names, "serial", firstIssued, expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // Ensure there's 1 issuance timestamp for names inside the window. + resp, err = sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, len(resp.Timestamps), 1) + test.AssertEquals(t, firstIssued, resp.Timestamps[len(resp.Timestamps)-1].AsTime()) + + // Ensure that the hash isn't affected by changing name order/casing. + req.Domains = []string{"b.example.com", "A.example.COM"} + resp, err = sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, len(resp.Timestamps), 1) + test.AssertEquals(t, firstIssued, resp.Timestamps[len(resp.Timestamps)-1].AsTime()) + + // Add another issuance for names inside the window. + tx, err = sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + err = addFQDNSet(ctx, tx, names, "anotherSerial", firstIssued, expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // Ensure there are two issuance timestamps for names inside the window. + req.Domains = names + resp, err = sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, len(resp.Timestamps), 2) + test.AssertEquals(t, firstIssued, resp.Timestamps[len(resp.Timestamps)-1].AsTime()) + + // Add another issuance for names but just outside the window. + tx, err = sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + err = addFQDNSet(ctx, tx, names, "yetAnotherSerial", firstIssued.Add(-window), expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + // Ensure there are still only two issuance timestamps in the window. + resp, err = sa.FQDNSetTimestampsForWindow(ctx, req) + test.AssertNotError(t, err, "Failed to count name sets") + test.AssertEquals(t, len(resp.Timestamps), 2) + test.AssertEquals(t, firstIssued, resp.Timestamps[len(resp.Timestamps)-1].AsTime()) +} + +func TestFQDNSetsExists(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + names := []string{"a.example.com", "B.example.com"} + exists, err := sa.FQDNSetExists(ctx, &sapb.FQDNSetExistsRequest{Domains: names}) + test.AssertNotError(t, err, "Failed to check FQDN set existence") + test.Assert(t, !exists.Exists, "FQDN set shouldn't exist") + + tx, err := sa.dbMap.BeginTx(ctx) + test.AssertNotError(t, err, "Failed to open transaction") + expires := fc.Now().Add(time.Hour * 2).UTC() + issued := fc.Now() + err = addFQDNSet(ctx, tx, names, "serial", issued, expires) + test.AssertNotError(t, err, "Failed to add name set") + test.AssertNotError(t, tx.Commit(), "Failed to commit transaction") + + exists, err = sa.FQDNSetExists(ctx, &sapb.FQDNSetExistsRequest{Domains: names}) + test.AssertNotError(t, err, "Failed to check FQDN set existence") + test.Assert(t, exists.Exists, "FQDN set does exist") +} + +type queryRecorder struct { + query string + args []interface{} +} + +func (e *queryRecorder) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + e.query = query + e.args = args + return nil, nil +} + +func TestAddIssuedNames(t *testing.T) { + serial := big.NewInt(1) + expectedSerial := "000000000000000000000000000000000001" + notBefore := time.Date(2018, 2, 14, 12, 0, 0, 0, time.UTC) + placeholdersPerName := "(?,?,?,?)" + baseQuery := "INSERT INTO issuedNames (reversedName,serial,notBefore,renewal) VALUES" + + testCases := []struct { + Name string + IssuedNames []string + SerialNumber *big.Int + NotBefore time.Time + Renewal bool + ExpectedArgs []interface{} + }{ + { + Name: "One domain, not a renewal", + IssuedNames: []string{"example.co.uk"}, + SerialNumber: serial, + NotBefore: notBefore, + Renewal: false, + ExpectedArgs: []interface{}{ + "uk.co.example", + expectedSerial, + notBefore, + false, + }, + }, + { + Name: "Two domains, not a renewal", + IssuedNames: []string{"example.co.uk", "example.xyz"}, + SerialNumber: serial, + NotBefore: notBefore, + Renewal: false, + ExpectedArgs: []interface{}{ + "uk.co.example", + expectedSerial, + notBefore, + false, + "xyz.example", + expectedSerial, + notBefore, + false, + }, + }, + { + Name: "One domain, renewal", + IssuedNames: []string{"example.co.uk"}, + SerialNumber: serial, + NotBefore: notBefore, + Renewal: true, + ExpectedArgs: []interface{}{ + "uk.co.example", + expectedSerial, + notBefore, + true, + }, + }, + { + Name: "Two domains, renewal", + IssuedNames: []string{"example.co.uk", "example.xyz"}, + SerialNumber: serial, + NotBefore: notBefore, + Renewal: true, + ExpectedArgs: []interface{}{ + "uk.co.example", + expectedSerial, + notBefore, + true, + "xyz.example", + expectedSerial, + notBefore, + true, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var e queryRecorder + err := addIssuedNames( + ctx, + &e, + &x509.Certificate{ + DNSNames: tc.IssuedNames, + SerialNumber: tc.SerialNumber, + NotBefore: tc.NotBefore, + }, + tc.Renewal) + test.AssertNotError(t, err, "addIssuedNames failed") + expectedPlaceholders := placeholdersPerName + for range len(tc.IssuedNames) - 1 { + expectedPlaceholders = fmt.Sprintf("%s,%s", expectedPlaceholders, placeholdersPerName) + } + expectedQuery := fmt.Sprintf("%s %s", baseQuery, expectedPlaceholders) + test.AssertEquals(t, e.query, expectedQuery) + if !reflect.DeepEqual(e.args, tc.ExpectedArgs) { + t.Errorf("Wrong args: got\n%#v, expected\n%#v", e.args, tc.ExpectedArgs) + } + }) + } +} + +func TestDeactivateAuthorization2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // deactivate a pending authorization + expires := fc.Now().Add(time.Hour).UTC() + attemptedAt := fc.Now() + authzID := createPendingAuthorization(t, sa, "example.com", expires) + _, err := sa.DeactivateAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.DeactivateAuthorization2 failed") + + // deactivate a valid authorization" + authzID = createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt) + _, err = sa.DeactivateAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.DeactivateAuthorization2 failed") +} + +func TestDeactivateAccount(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + _, err := sa.DeactivateRegistration(context.Background(), &sapb.RegistrationID{Id: reg.Id}) + test.AssertNotError(t, err, "DeactivateRegistration failed") + + dbReg, err := sa.GetRegistration(context.Background(), &sapb.RegistrationID{Id: reg.Id}) + test.AssertNotError(t, err, "GetRegistration failed") + test.AssertEquals(t, core.AcmeStatus(dbReg.Status), core.StatusDeactivated) +} + +func TestReverseName(t *testing.T) { + testCases := []struct { + inputDomain string + inputReversed string + }{ + {"", ""}, + {"...", "..."}, + {"com", "com"}, + {"example.com", "com.example"}, + {"www.example.com", "com.example.www"}, + {"world.wide.web.example.com", "com.example.web.wide.world"}, + } + + for _, tc := range testCases { + output := ReverseName(tc.inputDomain) + test.AssertEquals(t, output, tc.inputReversed) + } +} + +func TestNewOrderAndAuthzs(t *testing.T) { + sa, _, cleanup := initSA(t) + defer cleanup() + + // Create a test registration to reference + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + // Insert two pre-existing authorizations to reference + idA := createPendingAuthorization(t, sa, "a.com", sa.clk.Now().Add(time.Hour)) + idB := createPendingAuthorization(t, sa, "b.com", sa.clk.Now().Add(time.Hour)) + test.AssertEquals(t, idA, int64(1)) + test.AssertEquals(t, idB, int64(2)) + + nowC := sa.clk.Now().Add(time.Hour) + nowD := sa.clk.Now().Add(time.Hour) + expires := sa.clk.Now().Add(2 * time.Hour) + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + // Insert an order for four names, two of which already have authzs + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Names: []string{"a.com", "b.com", "c.com", "d.com"}, + V2Authorizations: []int64{1, 2}, + }, + // And add new authorizations for the other two names. + NewAuthzs: []*corepb.Authorization{ + { + Identifier: "c.com", + RegistrationID: reg.Id, + Expires: timestamppb.New(nowC), + Status: "pending", + Challenges: []*corepb.Challenge{{Token: core.NewToken()}}, + }, + { + Identifier: "d.com", + RegistrationID: reg.Id, + Expires: timestamppb.New(nowD), + Status: "pending", + Challenges: []*corepb.Challenge{{Token: core.NewToken()}}, + }, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + test.AssertEquals(t, order.Id, int64(1)) + test.AssertDeepEquals(t, order.V2Authorizations, []int64{1, 2, 3, 4}) + + var authzIDs []int64 + _, err = sa.dbMap.Select(ctx, &authzIDs, "SELECT authzID FROM orderToAuthz2 WHERE orderID = ?;", order.Id) + test.AssertNotError(t, err, "Failed to count orderToAuthz entries") + test.AssertEquals(t, len(authzIDs), 4) + test.AssertDeepEquals(t, authzIDs, []int64{1, 2, 3, 4}) +} + +// TestNewOrderAndAuthzs_NonNilInnerOrder verifies that a nil +// sapb.NewOrderAndAuthzsRequest NewOrder object returns an error. +func TestNewOrderAndAuthzs_NonNilInnerOrder(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("17.17.17.17").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + expires := fc.Now().Add(2 * time.Hour) + _, err = sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewAuthzs: []*corepb.Authorization{ + { + Identifier: "a.com", + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Status: "pending", + Challenges: []*corepb.Challenge{{Token: core.NewToken()}}, + }, + }, + }) + test.AssertErrorIs(t, err, errIncompleteRequest) +} + +func TestNewOrderAndAuthzs_NewAuthzExpectedFields(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + // Create a test registration to reference. + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("17.17.17.17").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + expires := fc.Now().Add(time.Hour) + domain := "a.com" + + // Create an authz that does not yet exist in the database with some invalid + // data smuggled in. + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewAuthzs: []*corepb.Authorization{ + { + Identifier: domain, + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Status: string(core.StatusPending), + Challenges: []*corepb.Challenge{ + { + Status: "real fake garbage data", + Token: core.NewToken(), + }, + }, + }, + }, + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Names: []string{domain}, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + // Safely get the authz for the order we created above. + obj, err := sa.dbReadOnlyMap.Get(ctx, authzModel{}, order.V2Authorizations[0]) + test.AssertNotError(t, err, fmt.Sprintf("authorization %d not found", order.V2Authorizations[0])) + + // To access the data stored in obj at compile time, we type assert obj + // into a pointer to an authzModel. + am, ok := obj.(*authzModel) + test.Assert(t, ok, "Could not type assert obj into authzModel") + + // If we're making a brand new authz, it should have the pending status + // regardless of what incorrect status value was passed in during construction. + test.AssertEquals(t, am.Status, statusUint(core.StatusPending)) + + // Testing for the existence of these boxed nils is a definite break from + // our paradigm of avoiding passing around boxed nils whenever possible. + // However, the existence of these boxed nils in relation to this test is + // actually expected. If these tests fail, then a possible SA refactor or RA + // bug placed incorrect data into brand new authz input fields. + test.AssertBoxedNil(t, am.Attempted, "am.Attempted should be nil") + test.AssertBoxedNil(t, am.AttemptedAt, "am.AttemptedAt should be nil") + test.AssertBoxedNil(t, am.ValidationError, "am.ValidationError should be nil") + test.AssertBoxedNil(t, am.ValidationRecord, "am.ValidationRecord should be nil") +} + +func TestSetOrderProcessing(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + // Create a test registration to reference + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + // Add one valid authz + expires := fc.Now().Add(time.Hour) + attemptedAt := fc.Now() + authzID := createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt) + + // Add a new order in pending status with no certificate serial + expires1Year := sa.clk.Now().Add(365 * 24 * time.Hour) + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires1Year), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + + // Set the order to be processing + _, err = sa.SetOrderProcessing(context.Background(), &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "SetOrderProcessing failed") + + // Read the order by ID from the DB to check the status was correctly updated + // to processing + updatedOrder, err := sa.GetOrder( + context.Background(), + &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "GetOrder failed") + test.AssertEquals(t, updatedOrder.Status, string(core.StatusProcessing)) + test.AssertEquals(t, updatedOrder.BeganProcessing, true) + + // Try to set the same order to be processing again. We should get an error. + _, err = sa.SetOrderProcessing(context.Background(), &sapb.OrderRequest{Id: order.Id}) + test.AssertError(t, err, "Set the same order processing twice. This should have been an error.") + test.AssertErrorIs(t, err, berrors.OrderNotReady) +} + +func TestFinalizeOrder(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + // Create a test registration to reference + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + // Add one valid authz + expires := fc.Now().Add(time.Hour) + attemptedAt := fc.Now() + authzID := createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt) + + // Add a new order in pending status with no certificate serial + expires1Year := sa.clk.Now().Add(365 * 24 * time.Hour) + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires1Year), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + + // Set the order to processing so it can be finalized + _, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "SetOrderProcessing failed") + + // Finalize the order with a certificate serial + order.CertificateSerial = "eat.serial.for.breakfast" + _, err = sa.FinalizeOrder(context.Background(), &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial}) + test.AssertNotError(t, err, "FinalizeOrder failed") + + // Read the order by ID from the DB to check the certificate serial and status + // was correctly updated + updatedOrder, err := sa.GetOrder( + context.Background(), + &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "GetOrder failed") + test.AssertEquals(t, updatedOrder.CertificateSerial, "eat.serial.for.breakfast") + test.AssertEquals(t, updatedOrder.Status, string(core.StatusValid)) +} + +func TestOrderWithOrderModelv1(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + // Create a test registration to reference + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + authzExpires := fc.Now().Add(time.Hour) + authzID := createPendingAuthorization(t, sa, "example.com", authzExpires) + + // Set the order to expire in two hours + expires := fc.Now().Add(2 * time.Hour) + + inputOrder := &corepb.Order{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + } + + // Create the order + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: inputOrder.RegistrationID, + Expires: inputOrder.Expires, + Names: inputOrder.Names, + V2Authorizations: inputOrder.V2Authorizations, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + // The Order from GetOrder should match the following expected order + created := sa.clk.Now() + expectedOrder := &corepb.Order{ + // The registration ID, authorizations, expiry, and names should match the + // input to NewOrderAndAuthzs + RegistrationID: inputOrder.RegistrationID, + V2Authorizations: inputOrder.V2Authorizations, + Names: inputOrder.Names, + Expires: inputOrder.Expires, + // The ID should have been set to 1 by the SA + Id: 1, + // The status should be pending + Status: string(core.StatusPending), + // The serial should be empty since this is a pending order + CertificateSerial: "", + // We should not be processing it + BeganProcessing: false, + // The created timestamp should have been set to the current time + Created: timestamppb.New(created), + } + + // Fetch the order by its ID and make sure it matches the expected + storedOrder, err := sa.GetOrder(context.Background(), &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "sa.GetOrder failed") + test.AssertDeepEquals(t, storedOrder, expectedOrder) +} + +func TestOrderWithOrderModelv2(t *testing.T) { + if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + t.Skip() + } + + // The feature must be set before the SA is constructed because of a + // conditional on this feature in //sa/database.go. + features.Set(features.Config{MultipleCertificateProfiles: true}) + defer features.Reset() + + fc := clock.NewFake() + fc.Set(time.Date(2015, 3, 4, 5, 0, 0, 0, time.UTC)) + + dbMap, err := DBMapForTest(vars.DBConnSA) + test.AssertNotError(t, err, "Couldn't create dbMap") + + saro, err := NewSQLStorageAuthorityRO(dbMap, nil, metrics.NoopRegisterer, 1, 0, fc, log) + test.AssertNotError(t, err, "Couldn't create SARO") + + sa, err := NewSQLStorageAuthorityWrapping(saro, dbMap, metrics.NoopRegisterer) + test.AssertNotError(t, err, "Couldn't create SA") + defer test.ResetBoulderTestDatabase(t) + + // Create a test registration to reference + key, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(1), E: 1}}.MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + authzExpires := fc.Now().Add(time.Hour) + authzID := createPendingAuthorization(t, sa, "example.com", authzExpires) + + // Set the order to expire in two hours + expires := fc.Now().Add(2 * time.Hour) + + inputOrder := &corepb.Order{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + CertificateProfileName: "tbiapb", + } + + // Create the order + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: inputOrder.RegistrationID, + Expires: inputOrder.Expires, + Names: inputOrder.Names, + V2Authorizations: inputOrder.V2Authorizations, + CertificateProfileName: inputOrder.CertificateProfileName, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + // The Order from GetOrder should match the following expected order + created := sa.clk.Now() + expectedOrder := &corepb.Order{ + // The registration ID, authorizations, expiry, and names should match the + // input to NewOrderAndAuthzs + RegistrationID: inputOrder.RegistrationID, + V2Authorizations: inputOrder.V2Authorizations, + Names: inputOrder.Names, + Expires: inputOrder.Expires, + // The ID should have been set to 1 by the SA + Id: 1, + // The status should be pending + Status: string(core.StatusPending), + // The serial should be empty since this is a pending order + CertificateSerial: "", + // We should not be processing it + BeganProcessing: false, + // The created timestamp should have been set to the current time + Created: timestamppb.New(created), + CertificateProfileName: "tbiapb", + } + + // Fetch the order by its ID and make sure it matches the expected + storedOrder, err := sa.GetOrder(context.Background(), &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "sa.GetOrder failed") + test.AssertDeepEquals(t, storedOrder, expectedOrder) + + // + // Test that an order without a certificate profile name, but with the + // MultipleCertificateProfiles feature flag enabled works as expected. + // + + // Create a test registration to reference + key2, _ := jose.JSONWebKey{Key: &rsa.PublicKey{N: big.NewInt(2), E: 2}}.MarshalJSON() + initialIP2, _ := net.ParseIP("44.44.44.44").MarshalText() + reg2, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key2, + InitialIP: initialIP2, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + inputOrderNoName := &corepb.Order{ + RegistrationID: reg2.Id, + Expires: timestamppb.New(expires), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + } + + // Create the order + orderNoName, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: inputOrderNoName.RegistrationID, + Expires: inputOrderNoName.Expires, + Names: inputOrderNoName.Names, + V2Authorizations: inputOrderNoName.V2Authorizations, + CertificateProfileName: inputOrderNoName.CertificateProfileName, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + // The Order from GetOrder should match the following expected order + created = sa.clk.Now() + expectedOrderNoName := &corepb.Order{ + // The registration ID, authorizations, expiry, and names should match the + // input to NewOrderAndAuthzs + RegistrationID: inputOrderNoName.RegistrationID, + V2Authorizations: inputOrderNoName.V2Authorizations, + Names: inputOrderNoName.Names, + Expires: inputOrderNoName.Expires, + // The ID should have been set to 2 by the SA + Id: 2, + // The status should be pending + Status: string(core.StatusPending), + // The serial should be empty since this is a pending order + CertificateSerial: "", + // We should not be processing it + BeganProcessing: false, + // The created timestamp should have been set to the current time + Created: timestamppb.New(created), + } + + // Fetch the order by its ID and make sure it matches the expected + storedOrderNoName, err := sa.GetOrder(context.Background(), &sapb.OrderRequest{Id: orderNoName.Id}) + test.AssertNotError(t, err, "sa.GetOrder failed") + test.AssertDeepEquals(t, storedOrderNoName, expectedOrderNoName) +} + +// TestGetAuthorization2NoRows ensures that the GetAuthorization2 function returns +// the correct error when there are no results for the provided ID. +func TestGetAuthorization2NoRows(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + // An empty authz ID should result in a not found berror. + id := int64(123) + _, err := sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: id}) + test.AssertError(t, err, "Didn't get an error looking up non-existent authz ID") + test.AssertErrorIs(t, err, berrors.NotFound) +} + +func TestGetAuthorizations2(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + reg := createWorkingRegistration(t, sa) + exp := fc.Now().AddDate(0, 0, 10).UTC() + attemptedAt := fc.Now() + + identA := "aaa" + identB := "bbb" + identC := "ccc" + identD := "ddd" + idents := []string{identA, identB, identC} + + authzIDA := createFinalizedAuthorization(t, sa, "aaa", exp, "valid", attemptedAt) + authzIDB := createPendingAuthorization(t, sa, "bbb", exp) + nearbyExpires := fc.Now().UTC().Add(time.Hour) + authzIDC := createPendingAuthorization(t, sa, "ccc", nearbyExpires) + + // Associate authorizations with an order so that GetAuthorizations2 thinks + // they are WFE2 authorizations. + err := sa.dbMap.Insert(ctx, &orderToAuthzModel{ + OrderID: 1, + AuthzID: authzIDA, + }) + test.AssertNotError(t, err, "sa.dbMap.Insert failed") + err = sa.dbMap.Insert(ctx, &orderToAuthzModel{ + OrderID: 1, + AuthzID: authzIDB, + }) + test.AssertNotError(t, err, "sa.dbMap.Insert failed") + err = sa.dbMap.Insert(ctx, &orderToAuthzModel{ + OrderID: 1, + AuthzID: authzIDC, + }) + test.AssertNotError(t, err, "sa.dbMap.Insert failed") + + // Set an expiry cut off of 1 day in the future similar to `RA.NewOrderAndAuthzs`. This + // should exclude pending authorization C based on its nearbyExpires expiry + // value. + expiryCutoff := fc.Now().AddDate(0, 0, 1) + // Get authorizations for the names used above. + authz, err := sa.GetAuthorizations2(context.Background(), &sapb.GetAuthorizationsRequest{ + RegistrationID: reg.Id, + Domains: idents, + Now: timestamppb.New(expiryCutoff), + }) + // It should not fail + test.AssertNotError(t, err, "sa.GetAuthorizations2 failed") + // We should get back two authorizations since one of the three authorizations + // created above expires too soon. + test.AssertEquals(t, len(authz.Authz), 2) + + // Get authorizations for the names used above, and one name that doesn't exist + authz, err = sa.GetAuthorizations2(context.Background(), &sapb.GetAuthorizationsRequest{ + RegistrationID: reg.Id, + Domains: append(idents, identD), + Now: timestamppb.New(expiryCutoff), + }) + // It should not fail + test.AssertNotError(t, err, "sa.GetAuthorizations2 failed") + // It should still return only two authorizations + test.AssertEquals(t, len(authz.Authz), 2) +} + +func TestCountOrders(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + now := sa.clk.Now() + expires := now.Add(24 * time.Hour) + + req := &sapb.CountOrdersRequest{ + AccountID: 12345, + Range: &sapb.Range{ + Earliest: timestamppb.New(now.Add(-time.Hour)), + Latest: timestamppb.New(now.Add(time.Second)), + }, + } + + // Counting new orders for a reg ID that doesn't exist should return 0 + count, err := sa.CountOrders(ctx, req) + test.AssertNotError(t, err, "Couldn't count new orders for fake reg ID") + test.AssertEquals(t, count.Count, int64(0)) + + // Add a pending authorization + authzID := createPendingAuthorization(t, sa, "example.com", expires) + + // Add one pending order + order, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + }, + }) + test.AssertNotError(t, err, "Couldn't create new pending order") + + // Counting new orders for the reg ID should now yield 1 + req.AccountID = reg.Id + count, err = sa.CountOrders(ctx, req) + test.AssertNotError(t, err, "Couldn't count new orders for reg ID") + test.AssertEquals(t, count.Count, int64(1)) + + // Moving the count window to after the order was created should return the + // count to 0 + earliest := order.Created.AsTime().Add(time.Minute) + latest := earliest.Add(time.Hour) + req.Range.Earliest = timestamppb.New(earliest) + req.Range.Latest = timestamppb.New(latest) + count, err = sa.CountOrders(ctx, req) + test.AssertNotError(t, err, "Couldn't count new orders for reg ID") + test.AssertEquals(t, count.Count, int64(0)) +} + +func TestFasterGetOrderForNames(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + domain := "example.com" + expires := fc.Now().Add(time.Hour) + + key, _ := goodTestJWK().MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + reg, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + authzIDs := createPendingAuthorization(t, sa, domain, expires) + + _, err = sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + V2Authorizations: []int64{authzIDs}, + Names: []string{domain}, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + _, err = sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires), + V2Authorizations: []int64{authzIDs}, + Names: []string{domain}, + }, + }) + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + + _, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: reg.Id, + Names: []string{domain}, + }) + test.AssertNotError(t, err, "sa.GetOrderForNames failed") +} + +func TestGetOrderForNames(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Give the order we create a short lifetime + orderLifetime := time.Hour + expires := fc.Now().Add(orderLifetime) + + // Create two test registrations to associate with orders + key, _ := goodTestJWK().MarshalJSON() + initialIP, _ := net.ParseIP("42.42.42.42").MarshalText() + regA, err := sa.NewRegistration(ctx, &corepb.Registration{ + Key: key, + InitialIP: initialIP, + }) + test.AssertNotError(t, err, "Couldn't create test registration") + + // Add one pending authz for the first name for regA and one + // pending authz for the second name for regA + authzExpires := fc.Now().Add(time.Hour) + authzIDA := createPendingAuthorization(t, sa, "example.com", authzExpires) + authzIDB := createPendingAuthorization(t, sa, "just.another.example.com", authzExpires) + + ctx := context.Background() + names := []string{"example.com", "just.another.example.com"} + + // Call GetOrderForNames for a set of names we haven't created an order for + // yet + result, err := sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regA.Id, + Names: names, + }) + // We expect the result to return an error + test.AssertError(t, err, "sa.GetOrderForNames did not return an error for an empty result") + // The error should be a notfound error + test.AssertErrorIs(t, err, berrors.NotFound) + // The result should be nil + test.Assert(t, result == nil, "sa.GetOrderForNames for non-existent order returned non-nil result") + + // Add a new order for a set of names + order, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: regA.Id, + Expires: timestamppb.New(expires), + V2Authorizations: []int64{authzIDA, authzIDB}, + Names: names, + }, + }) + // It shouldn't error + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + // The order ID shouldn't be nil + test.AssertNotNil(t, order.Id, "NewOrderAndAuthzs returned with a nil Id") + + // Call GetOrderForNames with the same account ID and set of names as the + // above NewOrderAndAuthzs call + result, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regA.Id, + Names: names, + }) + // It shouldn't error + test.AssertNotError(t, err, "sa.GetOrderForNames failed") + // The order returned should have the same ID as the order we created above + test.AssertNotNil(t, result, "Returned order was nil") + test.AssertEquals(t, result.Id, order.Id) + + // Call GetOrderForNames with a different account ID from the NewOrderAndAuthzs call + regB := int64(1337) + result, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regB, + Names: names, + }) + // It should error + test.AssertError(t, err, "sa.GetOrderForNames did not return an error for an empty result") + // The error should be a notfound error + test.AssertErrorIs(t, err, berrors.NotFound) + // The result should be nil + test.Assert(t, result == nil, "sa.GetOrderForNames for diff AcctID returned non-nil result") + + // Advance the clock beyond the initial order's lifetime + fc.Add(2 * orderLifetime) + + // Call GetOrderForNames again with the same account ID and set of names as + // the initial NewOrderAndAuthzs call + result, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regA.Id, + Names: names, + }) + // It should error since there is no result + test.AssertError(t, err, "sa.GetOrderForNames did not return an error for an empty result") + // The error should be a notfound error + test.AssertErrorIs(t, err, berrors.NotFound) + // The result should be nil because the initial order expired & we don't want + // to return expired orders + test.Assert(t, result == nil, "sa.GetOrderForNames returned non-nil result for expired order case") + + // Create two valid authorizations + authzExpires = fc.Now().Add(time.Hour) + attemptedAt := fc.Now() + authzIDC := createFinalizedAuthorization(t, sa, "zombo.com", authzExpires, "valid", attemptedAt) + authzIDD := createFinalizedAuthorization(t, sa, "welcome.to.zombo.com", authzExpires, "valid", attemptedAt) + + // Add a fresh order that uses the authorizations created above + names = []string{"zombo.com", "welcome.to.zombo.com"} + expires = fc.Now().Add(orderLifetime) + order, err = sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: regA.Id, + Expires: timestamppb.New(expires), + V2Authorizations: []int64{authzIDC, authzIDD}, + Names: names, + }, + }) + // It shouldn't error + test.AssertNotError(t, err, "sa.NewOrderAndAuthzs failed") + // The order ID shouldn't be nil + test.AssertNotNil(t, order.Id, "NewOrderAndAuthzs returned with a nil Id") + + // Call GetOrderForNames with the same account ID and set of names as + // the earlier NewOrderAndAuthzs call + result, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regA.Id, + Names: names, + }) + // It should not error since a ready order can be reused. + test.AssertNotError(t, err, "sa.GetOrderForNames returned an unexpected error for ready order reuse") + // The order returned should have the same ID as the order we created above + test.AssertNotNil(t, result, "sa.GetOrderForNames returned nil result") + test.AssertEquals(t, result.Id, order.Id) + + // Set the order processing so it can be finalized + _, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "sa.SetOrderProcessing failed") + + // Finalize the order + order.CertificateSerial = "cinnamon toast crunch" + _, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial}) + test.AssertNotError(t, err, "sa.FinalizeOrder failed") + + // Call GetOrderForNames with the same account ID and set of names as + // the earlier NewOrderAndAuthzs call + result, err = sa.GetOrderForNames(ctx, &sapb.GetOrderForNamesRequest{ + AcctID: regA.Id, + Names: names, + }) + // It should error since a valid order should not be reused. + test.AssertError(t, err, "sa.GetOrderForNames did not return an error for an empty result") + // The error should be a notfound error + test.AssertErrorIs(t, err, berrors.NotFound) + // The result should be nil because the one matching order has been finalized + // already + test.Assert(t, result == nil, "sa.GetOrderForNames returned non-nil result for finalized order case") +} + +func TestStatusForOrder(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + ctx := context.Background() + expires := fc.Now().Add(time.Hour) + alreadyExpired := expires.Add(-2 * time.Hour) + attemptedAt := fc.Now() + + // Create a registration to work with + reg := createWorkingRegistration(t, sa) + + // Create a pending authz, an expired authz, an invalid authz, a deactivated authz, + // and a valid authz + pendingID := createPendingAuthorization(t, sa, "pending.your.order.is.up", expires) + expiredID := createPendingAuthorization(t, sa, "expired.your.order.is.up", alreadyExpired) + invalidID := createFinalizedAuthorization(t, sa, "invalid.your.order.is.up", expires, "invalid", attemptedAt) + validID := createFinalizedAuthorization(t, sa, "valid.your.order.is.up", expires, "valid", attemptedAt) + deactivatedID := createPendingAuthorization(t, sa, "deactivated.your.order.is.up", expires) + _, err := sa.DeactivateAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: deactivatedID}) + test.AssertNotError(t, err, "sa.DeactivateAuthorization2 failed") + + testCases := []struct { + Name string + AuthorizationIDs []int64 + OrderNames []string + OrderExpires *timestamppb.Timestamp + ExpectedStatus string + SetProcessing bool + Finalize bool + }{ + { + Name: "Order with an invalid authz", + OrderNames: []string{"pending.your.order.is.up", "invalid.your.order.is.up", "deactivated.your.order.is.up", "valid.your.order.is.up"}, + AuthorizationIDs: []int64{pendingID, invalidID, deactivatedID, validID}, + ExpectedStatus: string(core.StatusInvalid), + }, + { + Name: "Order with an expired authz", + OrderNames: []string{"pending.your.order.is.up", "expired.your.order.is.up", "deactivated.your.order.is.up", "valid.your.order.is.up"}, + AuthorizationIDs: []int64{pendingID, expiredID, deactivatedID, validID}, + ExpectedStatus: string(core.StatusInvalid), + }, + { + Name: "Order with a deactivated authz", + OrderNames: []string{"pending.your.order.is.up", "deactivated.your.order.is.up", "valid.your.order.is.up"}, + AuthorizationIDs: []int64{pendingID, deactivatedID, validID}, + ExpectedStatus: string(core.StatusInvalid), + }, + { + Name: "Order with a pending authz", + OrderNames: []string{"valid.your.order.is.up", "pending.your.order.is.up"}, + AuthorizationIDs: []int64{validID, pendingID}, + ExpectedStatus: string(core.StatusPending), + }, + { + Name: "Order with only valid authzs, not yet processed or finalized", + OrderNames: []string{"valid.your.order.is.up"}, + AuthorizationIDs: []int64{validID}, + ExpectedStatus: string(core.StatusReady), + }, + { + Name: "Order with only valid authzs, set processing", + OrderNames: []string{"valid.your.order.is.up"}, + AuthorizationIDs: []int64{validID}, + SetProcessing: true, + ExpectedStatus: string(core.StatusProcessing), + }, + { + Name: "Order with only valid authzs, not yet processed or finalized, OrderReadyStatus feature flag", + OrderNames: []string{"valid.your.order.is.up"}, + AuthorizationIDs: []int64{validID}, + ExpectedStatus: string(core.StatusReady), + }, + { + Name: "Order with only valid authzs, set processing", + OrderNames: []string{"valid.your.order.is.up"}, + AuthorizationIDs: []int64{validID}, + SetProcessing: true, + ExpectedStatus: string(core.StatusProcessing), + }, + { + Name: "Order with only valid authzs, set processing and finalized", + OrderNames: []string{"valid.your.order.is.up"}, + AuthorizationIDs: []int64{validID}, + SetProcessing: true, + Finalize: true, + ExpectedStatus: string(core.StatusValid), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // If the testcase doesn't specify an order expiry use a default timestamp + // in the near future. + orderExpiry := tc.OrderExpires + if !orderExpiry.IsValid() { + orderExpiry = timestamppb.New(expires) + } + + newOrder, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: orderExpiry, + V2Authorizations: tc.AuthorizationIDs, + Names: tc.OrderNames, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs errored unexpectedly") + // If requested, set the order to processing + if tc.SetProcessing { + _, err := sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: newOrder.Id}) + test.AssertNotError(t, err, "Error setting order to processing status") + } + // If requested, finalize the order + if tc.Finalize { + newOrder.CertificateSerial = "lucky charms" + _, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: newOrder.Id, CertificateSerial: newOrder.CertificateSerial}) + test.AssertNotError(t, err, "Error finalizing order") + } + // Fetch the order by ID to get its calculated status + storedOrder, err := sa.GetOrder(ctx, &sapb.OrderRequest{Id: newOrder.Id}) + test.AssertNotError(t, err, "GetOrder failed") + // The status shouldn't be nil + test.AssertNotNil(t, storedOrder.Status, "Order status was nil") + // The status should match expected + test.AssertEquals(t, storedOrder.Status, tc.ExpectedStatus) + }) + } + +} + +func TestUpdateChallengesDeleteUnused(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + expires := fc.Now().Add(time.Hour) + ctx := context.Background() + attemptedAt := fc.Now() + + // Create a valid authz + authzID := createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt) + + result, err := sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + + if len(result.Challenges) != 1 { + t.Fatalf("expected 1 challenge left after finalization, got %d", len(result.Challenges)) + } + if result.Challenges[0].Status != string(core.StatusValid) { + t.Errorf("expected challenge status %q, got %q", core.StatusValid, result.Challenges[0].Status) + } + if result.Challenges[0].Type != "http-01" { + t.Errorf("expected challenge type %q, got %q", "http-01", result.Challenges[0].Type) + } +} + +func TestRevokeCertificate(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + // Add a cert to the DB to test with. + serial, testCert := test.ThrowAwayCert(t, fc) + issuedTime := sa.clk.Now() + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") + + status, err := sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusGood) + + fc.Add(1 * time.Hour) + + now := fc.Now() + reason := int64(1) + + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Reason: reason, + }) + test.AssertNotError(t, err, "RevokeCertificate with no OCSP response should succeed") + + status, err = sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusRevoked) + test.AssertEquals(t, status.RevokedReason, reason) + test.AssertEquals(t, status.RevokedDate.AsTime(), now) + test.AssertEquals(t, status.OcspLastUpdated.AsTime(), now) + + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Reason: reason, + }) + test.AssertError(t, err, "RevokeCertificate should've failed when certificate already revoked") +} + +func TestRevokeCertificateWithShard(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires revokedCertificates database table") + } + + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. + reg := createWorkingRegistration(t, sa) + eeCert, err := core.LoadCert("../test/hierarchy/ee-e1.cert.pem") + test.AssertNotError(t, err, "failed to load test cert") + _, err = sa.AddSerial(ctx, &sapb.AddSerialRequest{ + RegID: reg.Id, + Serial: core.SerialToString(eeCert.SerialNumber), + Created: timestamppb.New(eeCert.NotBefore), + Expires: timestamppb.New(eeCert.NotAfter), + }) + test.AssertNotError(t, err, "failed to add test serial") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: eeCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(eeCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "failed to add test cert") + + serial := core.SerialToString(eeCert.SerialNumber) + fc.Add(1 * time.Hour) + now := fc.Now() + reason := int64(1) + + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + ShardIdx: 9, + Serial: serial, + Date: timestamppb.New(now), + Reason: reason, + }) + test.AssertNotError(t, err, "RevokeCertificate with no OCSP response should succeed") + + status, err := sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusRevoked) + test.AssertEquals(t, status.RevokedReason, reason) + test.AssertEquals(t, status.RevokedDate.AsTime(), now) + test.AssertEquals(t, status.OcspLastUpdated.AsTime(), now) + test.AssertEquals(t, status.NotAfter.AsTime(), eeCert.NotAfter) + + var result revokedCertModel + err = sa.dbMap.SelectOne( + ctx, &result, `SELECT * FROM revokedCertificates WHERE serial = ?`, core.SerialToString(eeCert.SerialNumber)) + test.AssertNotError(t, err, "should be exactly one row in revokedCertificates") + test.AssertEquals(t, result.ShardIdx, int64(9)) + test.AssertEquals(t, result.RevokedReason, revocation.Reason(ocsp.KeyCompromise)) +} + +func TestUpdateRevokedCertificate(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. + reg := createWorkingRegistration(t, sa) + serial, testCert := test.ThrowAwayCert(t, fc) + issuedTime := fc.Now() + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(issuedTime), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") + fc.Add(1 * time.Hour) + + // Try to update it before its been revoked + now := fc.Now() + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Backdate: timestamppb.New(now), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertError(t, err, "UpdateRevokedCertificate should have failed") + test.AssertContains(t, err.Error(), "no certificate with serial") + + // Now revoke it, so we can update it. + revokedTime := fc.Now() + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(revokedTime), + Reason: ocsp.CessationOfOperation, + Response: []byte{1, 2, 3}, + }) + test.AssertNotError(t, err, "RevokeCertificate failed") + + // Double check that setup worked. + status, err := sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusRevoked) + test.AssertEquals(t, int(status.RevokedReason), ocsp.CessationOfOperation) + fc.Add(1 * time.Hour) + + // Try to update its revocation info with no backdate + now = fc.Now() + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertError(t, err, "UpdateRevokedCertificate should have failed") + test.AssertContains(t, err.Error(), "incomplete") + + // Try to update its revocation info for a reason other than keyCompromise + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Backdate: timestamppb.New(revokedTime), + Reason: ocsp.Unspecified, + Response: []byte{4, 5, 6}, + }) + test.AssertError(t, err, "UpdateRevokedCertificate should have failed") + test.AssertContains(t, err.Error(), "cannot update revocation for any reason other than keyCompromise") + + // Try to update the revocation info of the wrong certificate + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: "000000000000000000000000000000021bd5", + Date: timestamppb.New(now), + Backdate: timestamppb.New(revokedTime), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertError(t, err, "UpdateRevokedCertificate should have failed") + test.AssertContains(t, err.Error(), "no certificate with serial") + + // Try to update its revocation info with the wrong backdate + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Backdate: timestamppb.New(now), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertError(t, err, "UpdateRevokedCertificate should have failed") + test.AssertContains(t, err.Error(), "no certificate with serial") + + // Try to update its revocation info correctly + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: timestamppb.New(now), + Backdate: timestamppb.New(revokedTime), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertNotError(t, err, "UpdateRevokedCertificate failed") +} + +func TestUpdateRevokedCertificateWithShard(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires revokedCertificates database table") + } + + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. + reg := createWorkingRegistration(t, sa) + serial, testCert := test.ThrowAwayCert(t, fc) + _, err := sa.AddSerial(ctx, &sapb.AddSerialRequest{ + RegID: reg.Id, + Serial: core.SerialToString(testCert.SerialNumber), + Created: timestamppb.New(testCert.NotBefore), + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertNotError(t, err, "failed to add test serial") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") + fc.Add(1 * time.Hour) + + // Now revoke it with a shardIdx, so that it gets updated in both the + // certificateStatus table and the revokedCertificates table. + revokedTime := fc.Now() + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + ShardIdx: 9, + Serial: serial, + Date: timestamppb.New(revokedTime), + Reason: ocsp.CessationOfOperation, + Response: []byte{1, 2, 3}, + }) + test.AssertNotError(t, err, "RevokeCertificate failed") + + // Updating revocation should succeed, with the revokedCertificates row being + // updated. + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + ShardIdx: 9, + Serial: serial, + Date: timestamppb.New(fc.Now()), + Backdate: timestamppb.New(revokedTime), + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertNotError(t, err, "UpdateRevokedCertificate failed") + + var result revokedCertModel + err = sa.dbMap.SelectOne( + ctx, &result, `SELECT * FROM revokedCertificates WHERE serial = ?`, serial) + test.AssertNotError(t, err, "should be exactly one row in revokedCertificates") + test.AssertEquals(t, result.ShardIdx, int64(9)) + test.AssertEquals(t, result.RevokedReason, revocation.Reason(ocsp.KeyCompromise)) +} + +func TestUpdateRevokedCertificateWithShardInterim(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires revokedCertificates database table") + } + + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. + reg := createWorkingRegistration(t, sa) + serial, testCert := test.ThrowAwayCert(t, fc) + _, err := sa.AddSerial(ctx, &sapb.AddSerialRequest{ + RegID: reg.Id, + Serial: serial, + Created: timestamppb.New(testCert.NotBefore), + Expires: timestamppb.New(testCert.NotAfter), + }) + test.AssertNotError(t, err, "failed to add test serial") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Couldn't add test cert") + fc.Add(1 * time.Hour) + + // Now revoke it *without* a shardIdx, so that it only gets updated in the + // certificateStatus table, and not the revokedCertificates table. + revokedTime := timestamppb.New(fc.Now()) + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: serial, + Date: revokedTime, + Reason: ocsp.CessationOfOperation, + Response: []byte{1, 2, 3}, + }) + test.AssertNotError(t, err, "RevokeCertificate failed") + + // Confirm that setup worked as expected. + status, err := sa.GetCertificateStatus( + ctx, &sapb.Serial{Serial: serial}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusRevoked) + + c, err := sa.dbMap.SelectNullInt( + ctx, "SELECT count(*) FROM revokedCertificates") + test.AssertNotError(t, err, "SELECT from revokedCertificates failed") + test.Assert(t, c.Valid, "SELECT from revokedCertificates got no result") + test.AssertEquals(t, c.Int64, int64(0)) + + // Updating revocation should succeed, with a new row being written into the + // revokedCertificates table. + _, err = sa.UpdateRevokedCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + ShardIdx: 9, + Serial: serial, + Date: timestamppb.New(fc.Now()), + Backdate: revokedTime, + Reason: ocsp.KeyCompromise, + Response: []byte{4, 5, 6}, + }) + test.AssertNotError(t, err, "UpdateRevokedCertificate failed") + + var result revokedCertModel + err = sa.dbMap.SelectOne( + ctx, &result, `SELECT * FROM revokedCertificates WHERE serial = ?`, serial) + test.AssertNotError(t, err, "should be exactly one row in revokedCertificates") + test.AssertEquals(t, result.ShardIdx, int64(9)) + test.AssertEquals(t, result.RevokedReason, revocation.Reason(ocsp.KeyCompromise)) +} + +func TestAddCertificateRenewalBit(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + reg := createWorkingRegistration(t, sa) + + assertIsRenewal := func(t *testing.T, name string, expected bool) { + t.Helper() + var count int + err := sa.dbMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) FROM issuedNames + WHERE reversedName = ? + AND renewal = ?`, + ReverseName(name), + expected, + ) + test.AssertNotError(t, err, "Unexpected error from SelectOne on issuedNames") + test.AssertEquals(t, count, 1) + } + + // Add a certificate with a never-before-seen name. + _, testCert := test.ThrowAwayCert(t, fc) + _, err := sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + Issued: timestamppb.New(testCert.NotBefore), + RegID: reg.Id, + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Failed to add precertificate") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + }) + test.AssertNotError(t, err, "Failed to add certificate") + + // None of the names should have a issuedNames row marking it as a renewal. + for _, name := range testCert.DNSNames { + assertIsRenewal(t, name, false) + } + + // Make a new cert and add its FQDN set to the db so it will be considered a + // renewal + serial, testCert := test.ThrowAwayCert(t, fc) + err = addFQDNSet(ctx, sa.dbMap, testCert.DNSNames, serial, testCert.NotBefore, testCert.NotAfter) + test.AssertNotError(t, err, "Failed to add name set") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + Issued: timestamppb.New(testCert.NotBefore), + RegID: reg.Id, + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "Failed to add precertificate") + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: testCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(testCert.NotBefore), + }) + test.AssertNotError(t, err, "Failed to add certificate") + + // All of the names should have a issuedNames row marking it as a renewal. + for _, name := range testCert.DNSNames { + assertIsRenewal(t, name, true) + } +} + +func TestCountCertificatesRenewalBit(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Create a test registration + reg := createWorkingRegistration(t, sa) + + // Create a small throw away key for the test certificates. + testKey, err := rsa.GenerateKey(rand.Reader, 512) + test.AssertNotError(t, err, "error generating test key") + + // Create an initial test certificate for a set of domain names, issued an + // hour ago. + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + DNSNames: []string{"www.not-example.com", "not-example.com", "admin.not-example.com"}, + NotBefore: fc.Now().Add(-time.Hour), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + certADER, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create test cert A") + certA, _ := x509.ParseCertificate(certADER) + + // Update the template with a new serial number and a not before of now and + // create a second test cert for the same names. This will be a renewal. + template.SerialNumber = big.NewInt(7331) + template.NotBefore = fc.Now() + certBDER, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create test cert B") + certB, _ := x509.ParseCertificate(certBDER) + + // Update the template with a third serial number and a partially overlapping + // set of names. This will not be a renewal but will help test the exact name + // counts. + template.SerialNumber = big.NewInt(0xC0FFEE) + template.DNSNames = []string{"www.not-example.com"} + certCDER, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "Failed to create test cert C") + + countName := func(t *testing.T, expectedName string) int64 { + earliest := fc.Now().Add(-5 * time.Hour) + latest := fc.Now().Add(5 * time.Hour) + req := &sapb.CountCertificatesByNamesRequest{ + Names: []string{expectedName}, + Range: &sapb.Range{ + Earliest: timestamppb.New(earliest), + Latest: timestamppb.New(latest), + }, + } + counts, err := sa.CountCertificatesByNames(context.Background(), req) + test.AssertNotError(t, err, "Unexpected err from CountCertificatesByNames") + for name, count := range counts.Counts { + if name == expectedName { + return count + } + } + return 0 + } + + // Add the first certificate - it won't be considered a renewal. + issued := certA.NotBefore + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: certADER, + RegID: reg.Id, + Issued: timestamppb.New(issued), + }) + test.AssertNotError(t, err, "Failed to add CertA test certificate") + + // The count for the base domain should be 1 - just certA has been added. + test.AssertEquals(t, countName(t, "not-example.com"), int64(1)) + + // Add the second certificate - it should be considered a renewal + issued = certB.NotBefore + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: certBDER, + RegID: reg.Id, + Issued: timestamppb.New(issued), + }) + test.AssertNotError(t, err, "Failed to add CertB test certificate") + + // The count for the base domain should still be 1, just certA. CertB should + // be ignored. + test.AssertEquals(t, countName(t, "not-example.com"), int64(1)) + + // Add the third certificate - it should not be considered a renewal + _, err = sa.AddCertificate(ctx, &sapb.AddCertificateRequest{ + Der: certCDER, + RegID: reg.Id, + Issued: timestamppb.New(issued), + }) + test.AssertNotError(t, err, "Failed to add CertC test certificate") + + // The count for the base domain should be 2 now: certA and certC. + // CertB should be ignored. + test.AssertEquals(t, countName(t, "not-example.com"), int64(2)) +} + +func TestFinalizeAuthorization2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + fc.Set(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + + authzID := createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + expires := fc.Now().Add(time.Hour * 2).UTC() + attemptedAt := fc.Now() + ip, _ := net.ParseIP("1.1.1.1").MarshalText() + + _, err := sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + Url: "http://example.com", + AddressUsed: ip, + ResolverAddrs: []string{"resolver:5353"}, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + + dbVer, err := sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + test.AssertEquals(t, dbVer.Status, string(core.StatusValid)) + test.AssertEquals(t, dbVer.Expires.AsTime(), expires) + test.AssertEquals(t, dbVer.Challenges[0].Status, string(core.StatusValid)) + test.AssertEquals(t, len(dbVer.Challenges[0].Validationrecords), 1) + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].Hostname, "example.com") + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].Port, "80") + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].ResolverAddrs[0], "resolver:5353") + test.AssertEquals(t, dbVer.Challenges[0].Validated.AsTime(), attemptedAt) + + authzID = createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + prob, _ := bgrpc.ProblemDetailsToPB(probs.Connection("it went bad captain")) + + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + Url: "http://example.com", + AddressUsed: ip, + ResolverAddrs: []string{"resolver:5353"}, + }, + }, + ValidationError: prob, + Status: string(core.StatusInvalid), + Attempted: string(core.ChallengeTypeHTTP01), + Expires: timestamppb.New(expires), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + + dbVer, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "sa.GetAuthorization2 failed") + test.AssertEquals(t, dbVer.Status, string(core.StatusInvalid)) + test.AssertEquals(t, dbVer.Challenges[0].Status, string(core.StatusInvalid)) + test.AssertEquals(t, len(dbVer.Challenges[0].Validationrecords), 1) + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].Hostname, "example.com") + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].Port, "80") + test.AssertEquals(t, dbVer.Challenges[0].Validationrecords[0].ResolverAddrs[0], "resolver:5353") + test.AssertDeepEquals(t, dbVer.Challenges[0].Error, prob) +} + +func TestRehydrateHostPort(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + fc.Set(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + + expires := fc.Now().Add(time.Hour * 2).UTC() + attemptedAt := fc.Now() + ip, _ := net.ParseIP("1.1.1.1").MarshalText() + + // Implicit good port with good scheme + authzID := createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + _, err := sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + Url: "http://example.com", + AddressUsed: ip, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + _, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "rehydration failed in some fun and interesting way") + + // Explicit good port with good scheme + authzID = createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + Url: "http://example.com:80", + AddressUsed: ip, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + _, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertNotError(t, err, "rehydration failed in some fun and interesting way") + + // Explicit bad port with good scheme + authzID = createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "444", + Url: "http://example.com:444", + AddressUsed: ip, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + _, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertError(t, err, "only ports 80/tcp and 443/tcp are allowed in URL \"http://example.com:444\"") + + // Explicit bad port with bad scheme + authzID = createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + Url: "httpx://example.com", + AddressUsed: ip, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + _, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertError(t, err, "unknown scheme \"httpx\" in URL \"httpx://example.com\"") + + // Missing URL field + authzID = createPendingAuthorization(t, sa, "aaa", fc.Now().Add(time.Hour)) + _, err = sa.FinalizeAuthorization2(context.Background(), &sapb.FinalizeAuthorizationRequest{ + Id: authzID, + ValidationRecords: []*corepb.ValidationRecord{ + { + Hostname: "example.com", + Port: "80", + AddressUsed: ip, + }, + }, + Status: string(core.StatusValid), + Expires: timestamppb.New(expires), + Attempted: string(core.ChallengeTypeHTTP01), + AttemptedAt: timestamppb.New(attemptedAt), + }) + test.AssertNotError(t, err, "sa.FinalizeAuthorization2 failed") + _, err = sa.GetAuthorization2(context.Background(), &sapb.AuthorizationID2{Id: authzID}) + test.AssertError(t, err, "URL field cannot be empty") +} + +func TestGetPendingAuthorization2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + domain := "example.com" + expiresA := fc.Now().Add(time.Hour).UTC() + expiresB := fc.Now().Add(time.Hour * 3).UTC() + authzIDA := createPendingAuthorization(t, sa, domain, expiresA) + authzIDB := createPendingAuthorization(t, sa, domain, expiresB) + + regID := int64(1) + validUntil := fc.Now().Add(time.Hour * 2).UTC() + dbVer, err := sa.GetPendingAuthorization2(context.Background(), &sapb.GetPendingAuthorizationRequest{ + RegistrationID: regID, + IdentifierValue: domain, + ValidUntil: timestamppb.New(validUntil), + }) + test.AssertNotError(t, err, "sa.GetPendingAuthorization2 failed") + test.AssertEquals(t, fmt.Sprintf("%d", authzIDB), dbVer.Id) + + validUntil = fc.Now().UTC() + dbVer, err = sa.GetPendingAuthorization2(context.Background(), &sapb.GetPendingAuthorizationRequest{ + RegistrationID: regID, + IdentifierValue: domain, + ValidUntil: timestamppb.New(validUntil), + }) + test.AssertNotError(t, err, "sa.GetPendingAuthorization2 failed") + test.AssertEquals(t, fmt.Sprintf("%d", authzIDA), dbVer.Id) +} + +func TestCountPendingAuthorizations2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + expiresA := fc.Now().Add(time.Hour).UTC() + expiresB := fc.Now().Add(time.Hour * 3).UTC() + _ = createPendingAuthorization(t, sa, "example.com", expiresA) + _ = createPendingAuthorization(t, sa, "example.com", expiresB) + + // Registration has two new style pending authorizations + regID := int64(1) + count, err := sa.CountPendingAuthorizations2(context.Background(), &sapb.RegistrationID{ + Id: regID, + }) + test.AssertNotError(t, err, "sa.CountPendingAuthorizations2 failed") + test.AssertEquals(t, count.Count, int64(2)) + + // Registration has two new style pending authorizations, one of which has expired + fc.Add(time.Hour * 2) + count, err = sa.CountPendingAuthorizations2(context.Background(), &sapb.RegistrationID{ + Id: regID, + }) + test.AssertNotError(t, err, "sa.CountPendingAuthorizations2 failed") + test.AssertEquals(t, count.Count, int64(1)) + + // Registration with no authorizations should be 0 + noReg := int64(20) + count, err = sa.CountPendingAuthorizations2(context.Background(), &sapb.RegistrationID{ + Id: noReg, + }) + test.AssertNotError(t, err, "sa.CountPendingAuthorizations2 failed") + test.AssertEquals(t, count.Count, int64(0)) +} + +func TestAuthzModelMapToPB(t *testing.T) { + baseExpires := time.Now() + input := map[string]authzModel{ + "example.com": { + ID: 123, + IdentifierType: 0, + IdentifierValue: "example.com", + RegistrationID: 77, + Status: 1, + Expires: baseExpires, + Challenges: 4, + }, + "www.example.com": { + ID: 124, + IdentifierType: 0, + IdentifierValue: "www.example.com", + RegistrationID: 77, + Status: 1, + Expires: baseExpires, + Challenges: 1, + }, + "other.example.net": { + ID: 125, + IdentifierType: 0, + IdentifierValue: "other.example.net", + RegistrationID: 77, + Status: 1, + Expires: baseExpires, + Challenges: 3, + }, + } + + out, err := authzModelMapToPB(input) + if err != nil { + t.Fatal(err) + } + + for _, el := range out.Authz { + model, ok := input[el.Domain] + if !ok { + t.Errorf("output had element for %q, a hostname not present in input", el.Domain) + } + authzPB := el.Authz + test.AssertEquals(t, authzPB.Id, fmt.Sprintf("%d", model.ID)) + test.AssertEquals(t, authzPB.Identifier, model.IdentifierValue) + test.AssertEquals(t, authzPB.RegistrationID, model.RegistrationID) + test.AssertEquals(t, authzPB.Status, string(uintToStatus[model.Status])) + gotTime := authzPB.Expires.AsTime() + if !model.Expires.Equal(gotTime) { + t.Errorf("Times didn't match. Got %s, expected %s (%s)", gotTime, model.Expires, authzPB.Expires.AsTime()) + } + if len(el.Authz.Challenges) != bits.OnesCount(uint(model.Challenges)) { + t.Errorf("wrong number of challenges for %q: got %d, expected %d", el.Domain, + len(el.Authz.Challenges), bits.OnesCount(uint(model.Challenges))) + } + switch model.Challenges { + case 1: + test.AssertEquals(t, el.Authz.Challenges[0].Type, "http-01") + case 3: + test.AssertEquals(t, el.Authz.Challenges[0].Type, "http-01") + test.AssertEquals(t, el.Authz.Challenges[1].Type, "dns-01") + case 4: + test.AssertEquals(t, el.Authz.Challenges[0].Type, "tls-alpn-01") + } + + delete(input, el.Domain) + } + + for k := range input { + t.Errorf("hostname %q was not present in output", k) + } +} + +func TestGetValidOrderAuthorizations2(t *testing.T) { + sa, fc, cleanup := initSA(t) + defer cleanup() + + // Create two new valid authorizations + reg := createWorkingRegistration(t, sa) + identA := "a.example.com" + identB := "b.example.com" + expires := fc.Now().Add(time.Hour * 24 * 7).UTC() + attemptedAt := fc.Now() + + authzIDA := createFinalizedAuthorization(t, sa, identA, expires, "valid", attemptedAt) + authzIDB := createFinalizedAuthorization(t, sa, identB, expires, "valid", attemptedAt) + + orderExpr := fc.Now().Truncate(time.Second) + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(orderExpr), + Names: []string{"a.example.com", "b.example.com"}, + V2Authorizations: []int64{authzIDA, authzIDB}, + }, + }) + test.AssertNotError(t, err, "AddOrder failed") + + authzMap, err := sa.GetValidOrderAuthorizations2( + context.Background(), + &sapb.GetValidOrderAuthorizationsRequest{ + Id: order.Id, + AcctID: reg.Id, + }) + test.AssertNotError(t, err, "sa.GetValidOrderAuthorizations failed") + test.AssertNotNil(t, authzMap, "sa.GetValidOrderAuthorizations result was nil") + test.AssertEquals(t, len(authzMap.Authz), 2) + + namesToCheck := map[string]int64{"a.example.com": authzIDA, "b.example.com": authzIDB} + for _, a := range authzMap.Authz { + if fmt.Sprintf("%d", namesToCheck[a.Authz.Identifier]) != a.Authz.Id { + t.Fatalf("incorrect identifier %q with id %s", a.Authz.Identifier, a.Authz.Id) + } + test.AssertEquals(t, a.Authz.Expires.AsTime(), expires) + delete(namesToCheck, a.Authz.Identifier) + } + + // Getting the order authorizations for an order that doesn't exist should return nothing + missingID := int64(0xC0FFEEEEEEE) + authzMap, err = sa.GetValidOrderAuthorizations2( + context.Background(), + &sapb.GetValidOrderAuthorizationsRequest{ + Id: missingID, + AcctID: reg.Id, + }) + test.AssertNotError(t, err, "sa.GetValidOrderAuthorizations failed") + test.AssertEquals(t, len(authzMap.Authz), 0) + + // Getting the order authorizations for an order that does exist, but for the + // wrong acct ID should return nothing + wrongAcctID := int64(0xDEADDA7ABA5E) + authzMap, err = sa.GetValidOrderAuthorizations2( + context.Background(), + &sapb.GetValidOrderAuthorizationsRequest{ + Id: order.Id, + AcctID: wrongAcctID, + }) + test.AssertNotError(t, err, "sa.GetValidOrderAuthorizations failed") + test.AssertEquals(t, len(authzMap.Authz), 0) +} + +func TestCountInvalidAuthorizations2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Create two authorizations, one pending, one invalid + fc.Add(time.Hour) + reg := createWorkingRegistration(t, sa) + ident := "aaa" + expiresA := fc.Now().Add(time.Hour).UTC() + expiresB := fc.Now().Add(time.Hour * 3).UTC() + attemptedAt := fc.Now() + _ = createFinalizedAuthorization(t, sa, ident, expiresA, "invalid", attemptedAt) + _ = createPendingAuthorization(t, sa, ident, expiresB) + + earliest := fc.Now().Add(-time.Hour).UTC() + latest := fc.Now().Add(time.Hour * 5).UTC() + count, err := sa.CountInvalidAuthorizations2(context.Background(), &sapb.CountInvalidAuthorizationsRequest{ + RegistrationID: reg.Id, + Hostname: ident, + Range: &sapb.Range{ + Earliest: timestamppb.New(earliest), + Latest: timestamppb.New(latest), + }, + }) + test.AssertNotError(t, err, "sa.CountInvalidAuthorizations2 failed") + test.AssertEquals(t, count.Count, int64(1)) +} + +func TestGetValidAuthorizations2(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Create a valid authorization + ident := "aaa" + expires := fc.Now().Add(time.Hour).UTC() + attemptedAt := fc.Now() + authzID := createFinalizedAuthorization(t, sa, ident, expires, "valid", attemptedAt) + + now := fc.Now().UTC() + regID := int64(1) + authzs, err := sa.GetValidAuthorizations2(context.Background(), &sapb.GetValidAuthorizationsRequest{ + Domains: []string{ + "aaa", + "bbb", + }, + RegistrationID: regID, + Now: timestamppb.New(now), + }) + test.AssertNotError(t, err, "sa.GetValidAuthorizations2 failed") + test.AssertEquals(t, len(authzs.Authz), 1) + test.AssertEquals(t, authzs.Authz[0].Domain, ident) + test.AssertEquals(t, authzs.Authz[0].Authz.Id, fmt.Sprintf("%d", authzID)) +} + +func TestGetOrderExpired(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + fc.Add(time.Hour * 5) + now := fc.Now() + reg := createWorkingRegistration(t, sa) + order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(now.Add(-time.Hour)), + Names: []string{"example.com"}, + V2Authorizations: []int64{666}, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + _, err = sa.GetOrder(context.Background(), &sapb.OrderRequest{ + Id: order.Id, + }) + test.AssertError(t, err, "GetOrder didn't fail for an expired order") + test.AssertErrorIs(t, err, berrors.NotFound) +} + +func TestBlockedKey(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + hashA := make([]byte, 32) + hashA[0] = 1 + hashB := make([]byte, 32) + hashB[0] = 2 + + added := time.Now() + source := "API" + _, err := sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: hashA, + Added: timestamppb.New(added), + Source: source, + }) + test.AssertNotError(t, err, "AddBlockedKey failed") + _, err = sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: hashA, + Added: timestamppb.New(added), + Source: source, + }) + test.AssertNotError(t, err, "AddBlockedKey failed with duplicate insert") + + comment := "testing comments" + _, err = sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: hashB, + Added: timestamppb.New(added), + Source: source, + Comment: comment, + }) + test.AssertNotError(t, err, "AddBlockedKey failed") + + exists, err := sa.KeyBlocked(context.Background(), &sapb.SPKIHash{ + KeyHash: hashA, + }) + test.AssertNotError(t, err, "KeyBlocked failed") + test.Assert(t, exists != nil, "*sapb.Exists is nil") + test.Assert(t, exists.Exists, "KeyBlocked returned false for blocked key") + exists, err = sa.KeyBlocked(context.Background(), &sapb.SPKIHash{ + KeyHash: hashB, + }) + test.AssertNotError(t, err, "KeyBlocked failed") + test.Assert(t, exists != nil, "*sapb.Exists is nil") + test.Assert(t, exists.Exists, "KeyBlocked returned false for blocked key") + exists, err = sa.KeyBlocked(context.Background(), &sapb.SPKIHash{ + KeyHash: []byte{5}, + }) + test.AssertNotError(t, err, "KeyBlocked failed") + test.Assert(t, exists != nil, "*sapb.Exists is nil") + test.Assert(t, !exists.Exists, "KeyBlocked returned true for non-blocked key") +} + +func TestAddBlockedKeyUnknownSource(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + _, err := sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: []byte{1, 2, 3}, + Added: timestamppb.New(fc.Now()), + Source: "heyo", + }) + test.AssertError(t, err, "AddBlockedKey didn't fail with unknown source") + test.AssertEquals(t, err.Error(), "unknown source") +} + +func TestBlockedKeyRevokedBy(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + now := fc.Now() + _, err := sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: []byte{1}, + Added: timestamppb.New(now), + Source: "API", + }) + test.AssertNotError(t, err, "AddBlockedKey failed") + + _, err = sa.AddBlockedKey(context.Background(), &sapb.AddBlockedKeyRequest{ + KeyHash: []byte{2}, + Added: timestamppb.New(now), + Source: "API", + RevokedBy: 1, + }) + test.AssertNotError(t, err, "AddBlockedKey failed") +} + +func TestIncidentsForSerial(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + testSADbMap, err := DBMapForTest(vars.DBConnSAFullPerms) + test.AssertNotError(t, err, "Couldn't create test dbMap") + + testIncidentsDbMap, err := DBMapForTest(vars.DBConnIncidentsFullPerms) + test.AssertNotError(t, err, "Couldn't create test dbMap") + defer test.ResetIncidentsTestDatabase(t) + + weekAgo := sa.clk.Now().Add(-time.Hour * 24 * 7) + + // Add a disabled incident. + err = testSADbMap.Insert(ctx, &incidentModel{ + SerialTable: "incident_foo", + URL: "https://example.com/foo-incident", + RenewBy: sa.clk.Now().Add(time.Hour * 24 * 7), + Enabled: false, + }) + test.AssertNotError(t, err, "Failed to insert disabled incident") + + // No incidents are enabled, so this should return in error. + result, err := sa.IncidentsForSerial(context.Background(), &sapb.Serial{Serial: "1337"}) + test.AssertNotError(t, err, "fetching from no incidents") + test.AssertEquals(t, len(result.Incidents), 0) + + // Add an enabled incident. + err = testSADbMap.Insert(ctx, &incidentModel{ + SerialTable: "incident_bar", + URL: "https://example.com/test-incident", + RenewBy: sa.clk.Now().Add(time.Hour * 24 * 7), + Enabled: true, + }) + test.AssertNotError(t, err, "Failed to insert enabled incident") + + // Add a row to the incident table with serial '1338'. + one := int64(1) + affectedCertA := incidentSerialModel{ + Serial: "1338", + RegistrationID: &one, + OrderID: &one, + LastNoticeSent: &weekAgo, + } + _, err = testIncidentsDbMap.ExecContext(ctx, + fmt.Sprintf("INSERT INTO incident_bar (%s) VALUES ('%s', %d, %d, '%s')", + "serial, registrationID, orderID, lastNoticeSent", + affectedCertA.Serial, + affectedCertA.RegistrationID, + affectedCertA.OrderID, + affectedCertA.LastNoticeSent.Format(time.DateTime), + ), + ) + test.AssertNotError(t, err, "Error while inserting row for '1338' into incident table") + + // The incident table should not contain a row with serial '1337'. + result, err = sa.IncidentsForSerial(context.Background(), &sapb.Serial{Serial: "1337"}) + test.AssertNotError(t, err, "fetching from one incident") + test.AssertEquals(t, len(result.Incidents), 0) + + // Add a row to the incident table with serial '1337'. + two := int64(2) + affectedCertB := incidentSerialModel{ + Serial: "1337", + RegistrationID: &two, + OrderID: &two, + LastNoticeSent: &weekAgo, + } + _, err = testIncidentsDbMap.ExecContext(ctx, + fmt.Sprintf("INSERT INTO incident_bar (%s) VALUES ('%s', %d, %d, '%s')", + "serial, registrationID, orderID, lastNoticeSent", + affectedCertB.Serial, + affectedCertB.RegistrationID, + affectedCertB.OrderID, + affectedCertB.LastNoticeSent.Format(time.DateTime), + ), + ) + test.AssertNotError(t, err, "Error while inserting row for '1337' into incident table") + + // The incident table should now contain a row with serial '1337'. + result, err = sa.IncidentsForSerial(context.Background(), &sapb.Serial{Serial: "1337"}) + test.AssertNotError(t, err, "Failed to retrieve incidents for serial") + test.AssertEquals(t, len(result.Incidents), 1) +} + +func TestSerialsForIncident(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + testIncidentsDbMap, err := DBMapForTest(vars.DBConnIncidentsFullPerms) + test.AssertNotError(t, err, "Couldn't create test dbMap") + defer test.ResetIncidentsTestDatabase(t) + + // Request serials from a malformed incident table name. + mockServerStream := &fakeServerStream[sapb.IncidentSerial]{} + err = sa.SerialsForIncident( + &sapb.SerialsForIncidentRequest{ + IncidentTable: "incidesnt_Baz", + }, + mockServerStream, + ) + test.AssertError(t, err, "Expected error for malformed table name") + test.AssertContains(t, err.Error(), "malformed table name \"incidesnt_Baz\"") + + // Request serials from another malformed incident table name. + mockServerStream = &fakeServerStream[sapb.IncidentSerial]{} + longTableName := "incident_l" + strings.Repeat("o", 1000) + "ng" + err = sa.SerialsForIncident( + &sapb.SerialsForIncidentRequest{ + IncidentTable: longTableName, + }, + mockServerStream, + ) + test.AssertError(t, err, "Expected error for long table name") + test.AssertContains(t, err.Error(), fmt.Sprintf("malformed table name %q", longTableName)) + + // Request serials for an incident table which doesn't exists. + mockServerStream = &fakeServerStream[sapb.IncidentSerial]{} + err = sa.SerialsForIncident( + &sapb.SerialsForIncidentRequest{ + IncidentTable: "incident_baz", + }, + mockServerStream, + ) + test.AssertError(t, err, "Expected error for nonexistent table name") + + // Assert that the error is a MySQL error so we can inspect the error code. + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) { + // We expect the error code to be 1146 (ER_NO_SUCH_TABLE): + // https://mariadb.com/kb/en/mariadb-error-codes/ + test.AssertEquals(t, mysqlErr.Number, uint16(1146)) + } else { + t.Fatalf("Expected MySQL Error 1146 (ER_NO_SUCH_TABLE) from Recv(), got %q", err) + } + + // Request serials from table 'incident_foo', which we expect to exist but + // be empty. + stream := make(chan *sapb.IncidentSerial) + mockServerStream = &fakeServerStream[sapb.IncidentSerial]{output: stream} + go func() { + err = sa.SerialsForIncident( + &sapb.SerialsForIncidentRequest{ + IncidentTable: "incident_foo", + }, + mockServerStream, + ) + close(stream) // Let our main test thread continue. + }() + for range stream { + t.Fatal("No serials should have been written to this stream") + } + test.AssertNotError(t, err, "Error calling SerialsForIncident on empty table") + + // Add 4 rows of incident serials to 'incident_foo'. + expectedSerials := map[string]bool{ + "1335": true, "1336": true, "1337": true, "1338": true, + } + for i := range expectedSerials { + randInt := func() int64 { return mrand.Int63() } + _, err := testIncidentsDbMap.ExecContext(ctx, + fmt.Sprintf("INSERT INTO incident_foo (%s) VALUES ('%s', %d, %d, '%s')", + "serial, registrationID, orderID, lastNoticeSent", + i, + randInt(), + randInt(), + sa.clk.Now().Add(time.Hour*24*7).Format(time.DateTime), + ), + ) + test.AssertNotError(t, err, fmt.Sprintf("Error while inserting row for '%s' into incident table", i)) + } + + // Request all 4 serials from the incident table we just added entries to. + stream = make(chan *sapb.IncidentSerial) + mockServerStream = &fakeServerStream[sapb.IncidentSerial]{output: stream} + go func() { + err = sa.SerialsForIncident( + &sapb.SerialsForIncidentRequest{ + IncidentTable: "incident_foo", + }, + mockServerStream, + ) + close(stream) + }() + receivedSerials := make(map[string]bool) + for serial := range stream { + if len(receivedSerials) > 4 { + t.Fatal("Received too many serials") + } + if _, ok := receivedSerials[serial.Serial]; ok { + t.Fatalf("Received serial %q more than once", serial.Serial) + } + receivedSerials[serial.Serial] = true + } + test.AssertDeepEquals(t, receivedSerials, map[string]bool{ + "1335": true, "1336": true, "1337": true, "1338": true, + }) + test.AssertNotError(t, err, "Error getting serials for incident") +} + +func TestGetRevokedCerts(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. We use AddPrecertificate because it sets + // up the certificateStatus row we need. This particular cert has a notAfter + // date of Mar 6 2023, and we lie about its IssuerNameID to make things easy. + reg := createWorkingRegistration(t, sa) + eeCert, err := core.LoadCert("../test/hierarchy/ee-e1.cert.pem") + test.AssertNotError(t, err, "failed to load test cert") + _, err = sa.AddSerial(ctx, &sapb.AddSerialRequest{ + RegID: reg.Id, + Serial: core.SerialToString(eeCert.SerialNumber), + Created: timestamppb.New(eeCert.NotBefore), + Expires: timestamppb.New(eeCert.NotAfter), + }) + test.AssertNotError(t, err, "failed to add test serial") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: eeCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(eeCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "failed to add test cert") + + // Check that it worked. + status, err := sa.GetCertificateStatus( + ctx, &sapb.Serial{Serial: core.SerialToString(eeCert.SerialNumber)}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusGood) + + // Here's a little helper func we'll use to call GetRevokedCerts and count + // how many results it returned. + countRevokedCerts := func(req *sapb.GetRevokedCertsRequest) (int, error) { + stream := make(chan *corepb.CRLEntry) + mockServerStream := &fakeServerStream[corepb.CRLEntry]{output: stream} + var err error + go func() { + err = sa.GetRevokedCerts(req, mockServerStream) + close(stream) + }() + entriesReceived := 0 + for range stream { + entriesReceived++ + } + return entriesReceived, err + } + + // Asking for revoked certs now should return no results. + expiresAfter := time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + expiresBefore := time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + revokedBefore := time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err := countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ExpiresAfter: timestamppb.New(expiresAfter), + ExpiresBefore: timestamppb.New(expiresBefore), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Revoke the certificate. + date := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC) + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: core.SerialToString(eeCert.SerialNumber), + Date: timestamppb.New(date), + Reason: 1, + Response: []byte{1, 2, 3}, + }) + test.AssertNotError(t, err, "failed to revoke test cert") + + // Asking for revoked certs now should return one result. + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ExpiresAfter: timestamppb.New(expiresAfter), + ExpiresBefore: timestamppb.New(expiresBefore), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "normal usage shouldn't result in error") + test.AssertEquals(t, count, 1) + + // Asking for revoked certs with an old RevokedBefore should return no results. + expiresAfter = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + expiresBefore = time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ExpiresAfter: timestamppb.New(expiresAfter), + ExpiresBefore: timestamppb.New(expiresBefore), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Asking for revoked certs in a time period that does not cover this cert's + // notAfter timestamp should return zero results. + expiresAfter = time.Date(2022, time.March, 1, 0, 0, 0, 0, time.UTC) + expiresBefore = time.Date(2022, time.April, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ExpiresAfter: timestamppb.New(expiresAfter), + ExpiresBefore: timestamppb.New(expiresBefore), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Asking for revoked certs from a different issuer should return zero results. + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ExpiresAfter: timestamppb.New(time.Date(2022, time.March, 1, 0, 0, 0, 0, time.UTC)), + ExpiresBefore: timestamppb.New(time.Date(2022, time.April, 1, 0, 0, 0, 0, time.UTC)), + RevokedBefore: timestamppb.New(time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC)), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) +} + +func TestGetRevokedCertsByShard(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires revokedCertificates database table") + } + + sa, _, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. We use AddPrecertificate because it sets + // up the certificateStatus row we need. This particular cert has a notAfter + // date of Mar 6 2023, and we lie about its IssuerNameID to make things easy. + reg := createWorkingRegistration(t, sa) + eeCert, err := core.LoadCert("../test/hierarchy/ee-e1.cert.pem") + test.AssertNotError(t, err, "failed to load test cert") + _, err = sa.AddSerial(ctx, &sapb.AddSerialRequest{ + RegID: reg.Id, + Serial: core.SerialToString(eeCert.SerialNumber), + Created: timestamppb.New(eeCert.NotBefore), + Expires: timestamppb.New(eeCert.NotAfter), + }) + test.AssertNotError(t, err, "failed to add test serial") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: eeCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(eeCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "failed to add test cert") + + // Check that it worked. + status, err := sa.GetCertificateStatus( + ctx, &sapb.Serial{Serial: core.SerialToString(eeCert.SerialNumber)}) + test.AssertNotError(t, err, "GetCertificateStatus failed") + test.AssertEquals(t, core.OCSPStatus(status.Status), core.OCSPStatusGood) + + // Here's a little helper func we'll use to call GetRevokedCerts and count + // how many results it returned. + countRevokedCerts := func(req *sapb.GetRevokedCertsRequest) (int, error) { + stream := make(chan *corepb.CRLEntry) + mockServerStream := &fakeServerStream[corepb.CRLEntry]{output: stream} + var err error + go func() { + err = sa.GetRevokedCerts(req, mockServerStream) + close(stream) + }() + entriesReceived := 0 + for range stream { + entriesReceived++ + } + return entriesReceived, err + } + + // Asking for revoked certs now should return no results. + expiresAfter := time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + revokedBefore := time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err := countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ShardIdx: 9, + ExpiresAfter: timestamppb.New(expiresAfter), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Revoke the certificate, providing the ShardIdx so it gets written into + // both the certificateStatus and revokedCertificates tables. + date := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC) + _, err = sa.RevokeCertificate(context.Background(), &sapb.RevokeCertificateRequest{ + IssuerID: 1, + Serial: core.SerialToString(eeCert.SerialNumber), + Date: timestamppb.New(date), + Reason: 1, + Response: []byte{1, 2, 3}, + ShardIdx: 9, + }) + test.AssertNotError(t, err, "failed to revoke test cert") + + // Check that it worked in the most basic way. + c, err := sa.dbMap.SelectNullInt( + ctx, "SELECT count(*) FROM revokedCertificates") + test.AssertNotError(t, err, "SELECT from revokedCertificates failed") + test.Assert(t, c.Valid, "SELECT from revokedCertificates got no result") + test.AssertEquals(t, c.Int64, int64(1)) + + // Asking for revoked certs now should return one result. + expiresAfter = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ShardIdx: 9, + ExpiresAfter: timestamppb.New(expiresAfter), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "normal usage shouldn't result in error") + test.AssertEquals(t, count, 1) + + // Asking for revoked certs from a different issuer should return zero results. + expiresAfter = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 2, + ShardIdx: 9, + ExpiresAfter: timestamppb.New(expiresAfter), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Asking for revoked certs from a different shard should return zero results. + expiresAfter = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2023, time.April, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ShardIdx: 8, + ExpiresAfter: timestamppb.New(expiresAfter), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) + + // Asking for revoked certs with an old RevokedBefore should return no results. + expiresAfter = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) + revokedBefore = time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC) + count, err = countRevokedCerts(&sapb.GetRevokedCertsRequest{ + IssuerNameID: 1, + ShardIdx: 9, + ExpiresAfter: timestamppb.New(expiresAfter), + RevokedBefore: timestamppb.New(revokedBefore), + }) + test.AssertNotError(t, err, "zero rows shouldn't result in error") + test.AssertEquals(t, count, 0) +} + +func TestGetMaxExpiration(t *testing.T) { + sa, _, cleanUp := initSA(t) + defer cleanUp() + + // Add a cert to the DB to test with. We use AddPrecertificate because it sets + // up the certificateStatus row we need. This particular cert has a notAfter + // date of Mar 6 2023, and we lie about its IssuerNameID to make things easy. + reg := createWorkingRegistration(t, sa) + eeCert, err := core.LoadCert("../test/hierarchy/ee-e1.cert.pem") + test.AssertNotError(t, err, "failed to load test cert") + _, err = sa.AddPrecertificate(ctx, &sapb.AddCertificateRequest{ + Der: eeCert.Raw, + RegID: reg.Id, + Issued: timestamppb.New(eeCert.NotBefore), + IssuerNameID: 1, + }) + test.AssertNotError(t, err, "failed to add test cert") + + lastExpiry, err := sa.GetMaxExpiration(context.Background(), &emptypb.Empty{}) + test.AssertNotError(t, err, "getting last expriy should succeed") + test.Assert(t, lastExpiry.AsTime().Equal(eeCert.NotAfter), "times should be equal") + test.AssertEquals(t, timestamppb.New(eeCert.NotBefore).AsTime(), eeCert.NotBefore) +} + +func TestLeaseOldestCRLShard(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + // Create 8 shards: 4 for each of 2 issuers. For each issuer, one shard is + // currently leased, three are available, and one of those failed to update. + _, err := sa.dbMap.ExecContext(ctx, + `INSERT INTO crlShards (issuerID, idx, thisUpdate, nextUpdate, leasedUntil) VALUES + (1, 0, ?, ?, ?), + (1, 1, ?, ?, ?), + (1, 2, ?, ?, ?), + (1, 3, NULL, NULL, ?), + (2, 0, ?, ?, ?), + (2, 1, ?, ?, ?), + (2, 2, ?, ?, ?), + (2, 3, NULL, NULL, ?);`, + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + ) + test.AssertNotError(t, err, "setting up test shards") + + until := clk.Now().Add(time.Hour).Truncate(time.Second).UTC() + var untilModel struct { + LeasedUntil time.Time `db:"leasedUntil"` + } + + // Leasing from a fully-leased subset should fail. + _, err = sa.leaseOldestCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 0, + MaxShardIdx: 0, + Until: timestamppb.New(until), + }, + ) + test.AssertError(t, err, "leasing when all shards are leased") + + // Leasing any known shard should return the never-before-leased one (3). + res, err := sa.leaseOldestCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 0, + MaxShardIdx: 3, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing available shard") + test.AssertEquals(t, res.IssuerNameID, int64(1)) + test.AssertEquals(t, res.ShardIdx, int64(3)) + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") + + // Leasing any known shard *again* should now return the oldest one (1). + res, err = sa.leaseOldestCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 0, + MaxShardIdx: 3, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing available shard") + test.AssertEquals(t, res.IssuerNameID, int64(1)) + test.AssertEquals(t, res.ShardIdx, int64(1)) + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") + + // Leasing from a superset of known shards should succeed and return one of + // the previously-unknown shards. + res, err = sa.leaseOldestCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 2, + MinShardIdx: 0, + MaxShardIdx: 7, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing available shard") + test.AssertEquals(t, res.IssuerNameID, int64(2)) + test.Assert(t, res.ShardIdx >= 4, "checking leased index") + test.Assert(t, res.ShardIdx <= 7, "checking leased index") + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") +} + +func TestLeaseSpecificCRLShard(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + // Create 8 shards: 4 for each of 2 issuers. For each issuer, one shard is + // currently leased, three are available, and one of those failed to update. + _, err := sa.dbMap.ExecContext(ctx, + `INSERT INTO crlShards (issuerID, idx, thisUpdate, nextUpdate, leasedUntil) VALUES + (1, 0, ?, ?, ?), + (1, 1, ?, ?, ?), + (1, 2, ?, ?, ?), + (1, 3, NULL, NULL, ?), + (2, 0, ?, ?, ?), + (2, 1, ?, ?, ?), + (2, 2, ?, ?, ?), + (2, 3, NULL, NULL, ?);`, + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + ) + test.AssertNotError(t, err, "setting up test shards") + + until := clk.Now().Add(time.Hour).Truncate(time.Second).UTC() + var untilModel struct { + LeasedUntil time.Time `db:"leasedUntil"` + } + + // Leasing an unleased shard should work. + res, err := sa.leaseSpecificCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 1, + MaxShardIdx: 1, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing available shard") + test.AssertEquals(t, res.IssuerNameID, int64(1)) + test.AssertEquals(t, res.ShardIdx, int64(1)) + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") + + // Leasing a never-before-leased shard should work. + res, err = sa.leaseSpecificCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 2, + MinShardIdx: 3, + MaxShardIdx: 3, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing available shard") + test.AssertEquals(t, res.IssuerNameID, int64(2)) + test.AssertEquals(t, res.ShardIdx, int64(3)) + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") + + // Leasing a previously-unknown specific shard should work (to ease the + // transition into using leasing). + res, err = sa.leaseSpecificCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 9, + MaxShardIdx: 9, + Until: timestamppb.New(until), + }, + ) + test.AssertNotError(t, err, "leasing unknown shard") + + err = sa.dbMap.SelectOne( + ctx, + &untilModel, + `SELECT leasedUntil FROM crlShards WHERE issuerID = ? AND idx = ? LIMIT 1`, + res.IssuerNameID, + res.ShardIdx, + ) + test.AssertNotError(t, err, "getting updated lease timestamp") + test.Assert(t, untilModel.LeasedUntil.Equal(until), "checking updated lease timestamp") + + // Leasing a leased shard should fail. + _, err = sa.leaseSpecificCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 0, + MaxShardIdx: 0, + Until: timestamppb.New(until), + }, + ) + test.AssertError(t, err, "leasing unavailable shard") + + // Leasing more than one shard should fail. + _, err = sa.leaseSpecificCRLShard( + context.Background(), + &sapb.LeaseCRLShardRequest{ + IssuerNameID: 1, + MinShardIdx: 1, + MaxShardIdx: 2, + Until: timestamppb.New(until), + }, + ) + test.AssertError(t, err, "did not lease one specific shard") +} + +func TestUpdateCRLShard(t *testing.T) { + sa, clk, cleanUp := initSA(t) + defer cleanUp() + + // Create 8 shards: 4 for each of 2 issuers. For each issuer, one shard is + // currently leased, three are available, and one of those failed to update. + _, err := sa.dbMap.ExecContext(ctx, + `INSERT INTO crlShards (issuerID, idx, thisUpdate, nextUpdate, leasedUntil) VALUES + (1, 0, ?, ?, ?), + (1, 1, ?, ?, ?), + (1, 2, ?, ?, ?), + (1, 3, NULL, NULL, ?), + (2, 0, ?, ?, ?), + (2, 1, ?, ?, ?), + (2, 2, ?, ?, ?), + (2, 3, NULL, NULL, ?);`, + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + clk.Now().Add(-7*24*time.Hour), clk.Now().Add(3*24*time.Hour), clk.Now().Add(time.Hour), + clk.Now().Add(-6*24*time.Hour), clk.Now().Add(4*24*time.Hour), clk.Now().Add(-6*24*time.Hour), + clk.Now().Add(-5*24*time.Hour), clk.Now().Add(5*24*time.Hour), clk.Now().Add(-5*24*time.Hour), + clk.Now().Add(-4*24*time.Hour), + ) + test.AssertNotError(t, err, "setting up test shards") + + thisUpdate := clk.Now().Truncate(time.Second).UTC() + var crlModel struct { + ThisUpdate *time.Time + NextUpdate *time.Time + } + + // Updating a leased shard should work. + _, err = sa.UpdateCRLShard( + context.Background(), + &sapb.UpdateCRLShardRequest{ + IssuerNameID: 1, + ShardIdx: 0, + ThisUpdate: timestamppb.New(thisUpdate), + NextUpdate: timestamppb.New(thisUpdate.Add(10 * 24 * time.Hour)), + }, + ) + test.AssertNotError(t, err, "updating leased shard") + + err = sa.dbMap.SelectOne( + ctx, + &crlModel, + `SELECT thisUpdate FROM crlShards WHERE issuerID = 1 AND idx = 0 LIMIT 1`, + ) + test.AssertNotError(t, err, "getting updated thisUpdate timestamp") + test.AssertEquals(t, *crlModel.ThisUpdate, thisUpdate) + + // Updating an unleased shard should work. + _, err = sa.UpdateCRLShard( + context.Background(), + &sapb.UpdateCRLShardRequest{ + IssuerNameID: 1, + ShardIdx: 1, + ThisUpdate: timestamppb.New(thisUpdate), + NextUpdate: timestamppb.New(thisUpdate.Add(10 * 24 * time.Hour)), + }, + ) + test.AssertNotError(t, err, "updating unleased shard") + + err = sa.dbMap.SelectOne( + ctx, + &crlModel, + `SELECT thisUpdate FROM crlShards WHERE issuerID = 1 AND idx = 1 LIMIT 1`, + ) + test.AssertNotError(t, err, "getting updated thisUpdate timestamp") + test.Assert(t, crlModel.ThisUpdate.Equal(thisUpdate), "checking updated thisUpdate timestamp") + + // Updating without supplying a NextUpdate should work. + _, err = sa.UpdateCRLShard( + context.Background(), + &sapb.UpdateCRLShardRequest{ + IssuerNameID: 1, + ShardIdx: 3, + ThisUpdate: timestamppb.New(thisUpdate.Add(time.Second)), + }, + ) + test.AssertNotError(t, err, "updating shard without NextUpdate") + + err = sa.dbMap.SelectOne( + ctx, + &crlModel, + `SELECT nextUpdate FROM crlShards WHERE issuerID = 1 AND idx = 3 LIMIT 1`, + ) + test.AssertNotError(t, err, "getting updated nextUpdate timestamp") + test.AssertBoxedNil(t, crlModel.NextUpdate, "checking updated nextUpdate timestamp") + + // Updating a shard to an earlier time should fail. + _, err = sa.UpdateCRLShard( + context.Background(), + &sapb.UpdateCRLShardRequest{ + IssuerNameID: 1, + ShardIdx: 1, + ThisUpdate: timestamppb.New(thisUpdate.Add(-24 * time.Hour)), + NextUpdate: timestamppb.New(thisUpdate.Add(9 * 24 * time.Hour)), + }, + ) + test.AssertError(t, err, "updating shard to an earlier time") + + // Updating an unknown shard should fail. + _, err = sa.UpdateCRLShard( + context.Background(), + &sapb.UpdateCRLShardRequest{ + IssuerNameID: 1, + ShardIdx: 4, + ThisUpdate: timestamppb.New(thisUpdate), + NextUpdate: timestamppb.New(thisUpdate.Add(10 * 24 * time.Hour)), + }, + ) + test.AssertError(t, err, "updating an unknown shard") +} + +func TestReplacementOrderExists(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires replacementOrders database table") + } + + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + features.Set(features.Config{TrackReplacementCertificatesARI: true}) + defer features.Reset() + + oldCertSerial := "1234567890" + + // Check that a non-existent replacement order does not exist. + exists, err := sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial}) + test.AssertNotError(t, err, "failed to check for replacement order") + test.Assert(t, !exists.Exists, "replacement for non-existent serial should not exist") + + // Create a test registration to reference. + reg := createWorkingRegistration(t, sa) + + // Add one valid authz. + expires := fc.Now().Add(time.Hour) + attemptedAt := fc.Now() + authzID := createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt) + + // Add a new order in pending status with no certificate serial. + expires1Year := sa.clk.Now().Add(365 * 24 * time.Hour) + order, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires1Year), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + + // Set the order to processing so it can be finalized + _, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "SetOrderProcessing failed") + + // Finalize the order with a certificate oldCertSerial. + order.CertificateSerial = oldCertSerial + _, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial}) + test.AssertNotError(t, err, "FinalizeOrder failed") + + // Create a replacement order. + order, err = sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires1Year), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + ReplacesSerial: oldCertSerial, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + + // Check that a pending replacement order exists. + exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial}) + test.AssertNotError(t, err, "failed to check for replacement order") + test.Assert(t, exists.Exists, "replacement order should exist") + + // Set the order to processing so it can be finalized. + _, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id}) + test.AssertNotError(t, err, "SetOrderProcessing failed") + + // Check that a replacement order in processing still exists. + exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial}) + test.AssertNotError(t, err, "failed to check for replacement order") + test.Assert(t, exists.Exists, "replacement order in processing should still exist") + + order.CertificateSerial = "0123456789" + _, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial}) + test.AssertNotError(t, err, "FinalizeOrder failed") + + // Check that a finalized replacement order still exists. + exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial}) + test.AssertNotError(t, err, "failed to check for replacement order") + test.Assert(t, exists.Exists, "replacement order in processing should still exist") + + // Try updating the replacement order. + + // Create a replacement order. + newReplacementOrder, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{ + NewOrder: &sapb.NewOrderRequest{ + RegistrationID: reg.Id, + Expires: timestamppb.New(expires1Year), + Names: []string{"example.com"}, + V2Authorizations: []int64{authzID}, + ReplacesSerial: oldCertSerial, + }, + }) + test.AssertNotError(t, err, "NewOrderAndAuthzs failed") + + // Fetch the replacement order so we can ensure it was updated. + var replacementRow replacementOrderModel + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &replacementRow, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + oldCertSerial, + ) + test.AssertNotError(t, err, "SELECT from replacementOrders failed") + test.AssertEquals(t, newReplacementOrder.Id, replacementRow.OrderID) + test.AssertEquals(t, newReplacementOrder.Expires.AsTime(), replacementRow.OrderExpires) +} + +func TestGetSerialsByKey(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + // Insert four rows into keyHashToSerial: two that should match the query, + // one that should not match due to keyHash mismatch, and one that should not + // match due to being already expired. + expectedHash := make([]byte, 32) + expectedHash[0] = 1 + differentHash := make([]byte, 32) + differentHash[0] = 2 + inserts := []keyHashModel{ + { + KeyHash: expectedHash, + CertSerial: "1", + CertNotAfter: fc.Now().Add(time.Hour), + }, + { + KeyHash: expectedHash, + CertSerial: "2", + CertNotAfter: fc.Now().Add(2 * time.Hour), + }, + { + KeyHash: expectedHash, + CertSerial: "3", + CertNotAfter: fc.Now().Add(-1 * time.Hour), + }, + { + KeyHash: differentHash, + CertSerial: "4", + CertNotAfter: fc.Now().Add(time.Hour), + }, + } + + for _, row := range inserts { + err := sa.dbMap.Insert(context.Background(), &row) + test.AssertNotError(t, err, "inserting test keyHash") + } + + // Expect the result res to have two entries. + res := make(chan *sapb.Serial) + stream := &fakeServerStream[sapb.Serial]{output: res} + var err error + go func() { + err = sa.GetSerialsByKey(&sapb.SPKIHash{KeyHash: expectedHash}, stream) + close(res) // Let our main test thread continue. + }() + + var seen []string + for serial := range res { + if !slices.Contains([]string{"1", "2"}, serial.Serial) { + t.Errorf("Received unexpected serial %q", serial.Serial) + } + if slices.Contains(seen, serial.Serial) { + t.Errorf("Received serial %q more than once", serial.Serial) + } + seen = append(seen, serial.Serial) + } + test.AssertNotError(t, err, "calling GetSerialsByKey") + test.AssertEquals(t, len(seen), 2) +} + +func TestGetSerialsByAccount(t *testing.T) { + sa, fc, cleanUp := initSA(t) + defer cleanUp() + + expectedReg := createWorkingRegistration(t, sa) + + // Insert three rows into the serials table: two that should match the query, + // and one that should not match due to being already expired. We do not here + // test filtering on the regID itself, because our test setup makes it very + // hard to insert two fake registrations rows with different IDs. + inserts := []recordedSerialModel{ + { + Serial: "1", + RegistrationID: expectedReg.Id, + Created: fc.Now().Add(-23 * time.Hour), + Expires: fc.Now().Add(time.Hour), + }, + { + Serial: "2", + RegistrationID: expectedReg.Id, + Created: fc.Now().Add(-22 * time.Hour), + Expires: fc.Now().Add(2 * time.Hour), + }, + { + Serial: "3", + RegistrationID: expectedReg.Id, + Created: fc.Now().Add(-23 * time.Hour), + Expires: fc.Now().Add(-1 * time.Hour), + }, + } + + for _, row := range inserts { + err := sa.dbMap.Insert(context.Background(), &row) + test.AssertNotError(t, err, "inserting test serial") + } + + // Expect the result stream to have two entries. + res := make(chan *sapb.Serial) + stream := &fakeServerStream[sapb.Serial]{output: res} + var err error + go func() { + err = sa.GetSerialsByAccount(&sapb.RegistrationID{Id: expectedReg.Id}, stream) + close(res) // Let our main test thread continue. + }() + + var seen []string + for serial := range res { + if !slices.Contains([]string{"1", "2"}, serial.Serial) { + t.Errorf("Received unexpected serial %q", serial.Serial) + } + if slices.Contains(seen, serial.Serial) { + t.Errorf("Received serial %q more than once", serial.Serial) + } + seen = append(seen, serial.Serial) + } + test.AssertNotError(t, err, "calling GetSerialsByAccount") + test.AssertEquals(t, len(seen), 2) +} + +func TestUnpauseAccount(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires paused database table") + } + sa, _, cleanUp := initSA(t) + defer cleanUp() + + tests := []struct { + name string + state []pausedModel + req *sapb.RegistrationID + }{ + { + name: "UnpauseAccount with no paused identifiers", + state: nil, + req: &sapb.RegistrationID{Id: 1}, + }, + { + name: "UnpauseAccount with one paused identifier", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + }, + req: &sapb.RegistrationID{Id: 1}, + }, + { + name: "UnpauseAccount with multiple paused identifiers", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.net", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.org", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + }, + req: &sapb.RegistrationID{Id: 1}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + // Drop all rows from the paused table. + _, err := sa.dbMap.ExecContext(ctx, "TRUNCATE TABLE paused") + test.AssertNotError(t, err, "truncating paused table") + }() + + // Setup table state. + for _, state := range tt.state { + err := sa.dbMap.Insert(ctx, &state) + test.AssertNotError(t, err, "inserting test identifier") + } + + _, err := sa.UnpauseAccount(ctx, tt.req) + test.AssertNotError(t, err, "Unexpected error for UnpauseAccount()") + + // Count the number of paused identifiers. + var count int + err = sa.dbReadOnlyMap.SelectOne( + ctx, + &count, + "SELECT COUNT(*) FROM paused WHERE registrationID = ? AND unpausedAt IS NULL", + tt.req.Id, + ) + test.AssertNotError(t, err, "SELECT COUNT(*) failed") + test.AssertEquals(t, count, 0) + }) + } +} + +func TestPauseIdentifiers(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires paused database table") + } + sa, _, cleanUp := initSA(t) + defer cleanUp() + + ptrTime := func(t time.Time) *time.Time { + return &t + } + + tests := []struct { + name string + state []pausedModel + req *sapb.PauseRequest + want *sapb.PauseIdentifiersResponse + }{ + { + name: "An identifier which is not now or previously paused", + state: nil, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + want: &sapb.PauseIdentifiersResponse{ + Paused: 1, + Repaused: 0, + }, + }, + { + name: "One unpaused entry which was previously paused", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + UnpausedAt: ptrTime(sa.clk.Now().Add(-time.Minute)), + }, + }, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + want: &sapb.PauseIdentifiersResponse{ + Paused: 0, + Repaused: 1, + }, + }, + { + name: "An identifier which is currently paused", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + }, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + want: &sapb.PauseIdentifiersResponse{ + Paused: 0, + Repaused: 0, + }, + }, + { + name: "Two previously paused entries and one new entry", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + UnpausedAt: ptrTime(sa.clk.Now().Add(-time.Minute)), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.net", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + UnpausedAt: ptrTime(sa.clk.Now().Add(-time.Minute)), + }, + }, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + { + Type: string(identifier.DNS), + Value: "example.net", + }, + { + Type: string(identifier.DNS), + Value: "example.org", + }, + }, + }, + want: &sapb.PauseIdentifiersResponse{ + Paused: 1, + Repaused: 2, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + // Drop all rows from the paused table. + _, err := sa.dbMap.ExecContext(ctx, "TRUNCATE TABLE paused") + test.AssertNotError(t, err, "Truncate table paused failed") + }() + + // Setup table state. + for _, state := range tt.state { + err := sa.dbMap.Insert(ctx, &state) + test.AssertNotError(t, err, "inserting test identifier") + } + + got, err := sa.PauseIdentifiers(ctx, tt.req) + test.AssertNotError(t, err, "Unexpected error for PauseIdentifiers()") + test.AssertEquals(t, got.Paused, tt.want.Paused) + test.AssertEquals(t, got.Repaused, tt.want.Repaused) + }) + } +} + +func TestCheckIdentifiersPaused(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires paused database table") + } + sa, _, cleanUp := initSA(t) + defer cleanUp() + + ptrTime := func(t time.Time) *time.Time { + return &t + } + + tests := []struct { + name string + state []pausedModel + req *sapb.PauseRequest + want *sapb.Identifiers + }{ + { + name: "No paused identifiers", + state: nil, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{}, + }, + }, + { + name: "One paused identifier", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + }, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + }, + { + name: "Two paused identifiers, one unpaused", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.net", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.org", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + UnpausedAt: ptrTime(sa.clk.Now().Add(-time.Minute)), + }, + }, + req: &sapb.PauseRequest{ + RegistrationID: 1, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + { + Type: string(identifier.DNS), + Value: "example.net", + }, + { + Type: string(identifier.DNS), + Value: "example.org", + }, + }, + }, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + { + Type: string(identifier.DNS), + Value: "example.net", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + // Drop all rows from the paused table. + _, err := sa.dbMap.ExecContext(ctx, "TRUNCATE TABLE paused") + test.AssertNotError(t, err, "Truncate table paused failed") + }() + + // Setup table state. + for _, state := range tt.state { + err := sa.dbMap.Insert(ctx, &state) + test.AssertNotError(t, err, "inserting test identifier") + } + + got, err := sa.CheckIdentifiersPaused(ctx, tt.req) + test.AssertNotError(t, err, "Unexpected error for PauseIdentifiers()") + test.AssertDeepEquals(t, got.Identifiers, tt.want.Identifiers) + }) + } +} + +func TestGetPausedIdentifiers(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires paused database table") + } + sa, _, cleanUp := initSA(t) + defer cleanUp() + + ptrTime := func(t time.Time) *time.Time { + return &t + } + + tests := []struct { + name string + state []pausedModel + req *sapb.RegistrationID + want *sapb.Identifiers + }{ + { + name: "No paused identifiers", + state: nil, + req: &sapb.RegistrationID{Id: 1}, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{}, + }, + }, + { + name: "One paused identifier", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + }, + req: &sapb.RegistrationID{Id: 1}, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + }, + }, + }, + { + name: "Two paused identifiers, one unpaused", + state: []pausedModel{ + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.net", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }, + { + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.org", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + UnpausedAt: ptrTime(sa.clk.Now().Add(-time.Minute)), + }, + }, + req: &sapb.RegistrationID{Id: 1}, + want: &sapb.Identifiers{ + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: "example.com", + }, + { + Type: string(identifier.DNS), + Value: "example.net", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + // Drop all rows from the paused table. + _, err := sa.dbMap.ExecContext(ctx, "TRUNCATE TABLE paused") + test.AssertNotError(t, err, "Truncate table paused failed") + }() + + // Setup table state. + for _, state := range tt.state { + err := sa.dbMap.Insert(ctx, &state) + test.AssertNotError(t, err, "inserting test identifier") + } + + got, err := sa.GetPausedIdentifiers(ctx, tt.req) + test.AssertNotError(t, err, "Unexpected error for PauseIdentifiers()") + test.AssertDeepEquals(t, got.Identifiers, tt.want.Identifiers) + }) + } +} + +func TestGetPausedIdentifiersOnlyUnpausesOneAccount(t *testing.T) { + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Test requires paused database table") + } + sa, _, cleanUp := initSA(t) + defer cleanUp() + + // Insert two paused identifiers for two different accounts. + err := sa.dbMap.Insert(ctx, &pausedModel{ + RegistrationID: 1, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.com", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }) + test.AssertNotError(t, err, "inserting test identifier") + + err = sa.dbMap.Insert(ctx, &pausedModel{ + RegistrationID: 2, + identifierModel: identifierModel{ + Type: identifierTypeToUint[string(identifier.DNS)], + Value: "example.net", + }, + PausedAt: sa.clk.Now().Add(-time.Hour), + }) + test.AssertNotError(t, err, "inserting test identifier") + + // Unpause the first account. + _, err = sa.UnpauseAccount(ctx, &sapb.RegistrationID{Id: 1}) + test.AssertNotError(t, err, "UnpauseAccount failed") + + // Check that the second account's identifier is still paused. + identifiers, err := sa.GetPausedIdentifiers(ctx, &sapb.RegistrationID{Id: 2}) + test.AssertNotError(t, err, "GetPausedIdentifiers failed") + test.AssertEquals(t, len(identifiers.Identifiers), 1) + test.AssertEquals(t, identifiers.Identifiers[0].Value, "example.net") +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/saro.go b/third-party/github.com/letsencrypt/boulder/sa/saro.go new file mode 100644 index 000000000..debc6b212 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/saro.go @@ -0,0 +1,1497 @@ +package sa + +import ( + "context" + "errors" + "fmt" + "math/big" + "net" + "regexp" + "strings" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + sapb "github.com/letsencrypt/boulder/sa/proto" +) + +var ( + validIncidentTableRegexp = regexp.MustCompile(`^incident_[0-9a-zA-Z_]{1,100}$`) +) + +type certCountFunc func(ctx context.Context, db db.Selector, domain string, timeRange *sapb.Range) (int64, time.Time, error) + +// SQLStorageAuthorityRO defines a read-only subset of a Storage Authority +type SQLStorageAuthorityRO struct { + sapb.UnsafeStorageAuthorityReadOnlyServer + + dbReadOnlyMap *db.WrappedMap + dbIncidentsMap *db.WrappedMap + + // For RPCs that generate multiple, parallelizable SQL queries, this is the + // max parallelism they will use (to avoid consuming too many MariaDB + // threads). + parallelismPerRPC int + + // lagFactor is the amount of time we're willing to delay before retrying a + // request that may have failed due to replication lag. For example, a user + // might create a new account and then immediately create a new order, but + // validating that new-order request requires reading their account info from + // a read-only database replica... which may not have their brand new data + // yet. This value should be less than, but about the same order of magnitude + // as, the observed database replication lag. + lagFactor time.Duration + + // We use function types here so we can mock out this internal function in + // unittests. + countCertificatesByName certCountFunc + + clk clock.Clock + log blog.Logger + + // lagFactorCounter is a Prometheus counter that tracks the number of times + // we've retried a query inside of GetRegistration, GetOrder, and + // GetAuthorization2 due to replication lag. It is labeled by method name + // and whether data from the retry attempt was found, notfound, or some + // other error was encountered. + lagFactorCounter *prometheus.CounterVec +} + +var _ sapb.StorageAuthorityReadOnlyServer = (*SQLStorageAuthorityRO)(nil) + +// NewSQLStorageAuthorityRO provides persistence using a SQL backend for +// Boulder. It will modify the given borp.DbMap by adding relevant tables. +func NewSQLStorageAuthorityRO( + dbReadOnlyMap *db.WrappedMap, + dbIncidentsMap *db.WrappedMap, + stats prometheus.Registerer, + parallelismPerRPC int, + lagFactor time.Duration, + clk clock.Clock, + logger blog.Logger, +) (*SQLStorageAuthorityRO, error) { + lagFactorCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "sa_lag_factor", + Help: "A counter of SA lagFactor checks labelled by method and pass/fail", + }, []string{"method", "result"}) + stats.MustRegister(lagFactorCounter) + + ssaro := &SQLStorageAuthorityRO{ + dbReadOnlyMap: dbReadOnlyMap, + dbIncidentsMap: dbIncidentsMap, + parallelismPerRPC: parallelismPerRPC, + lagFactor: lagFactor, + clk: clk, + log: logger, + lagFactorCounter: lagFactorCounter, + } + + ssaro.countCertificatesByName = ssaro.countCertificates + + return ssaro, nil +} + +// GetRegistration obtains a Registration by ID +func (ssa *SQLStorageAuthorityRO) GetRegistration(ctx context.Context, req *sapb.RegistrationID) (*corepb.Registration, error) { + if req == nil || req.Id == 0 { + return nil, errIncompleteRequest + } + + model, err := selectRegistration(ctx, ssa.dbReadOnlyMap, "id", req.Id) + if db.IsNoRows(err) && ssa.lagFactor != 0 { + // GetRegistration is often called to validate a JWK belonging to a brand + // new account whose registrations table row hasn't propagated to the read + // replica yet. If we get a NoRows, wait a little bit and retry, once. + ssa.clk.Sleep(ssa.lagFactor) + model, err = selectRegistration(ctx, ssa.dbReadOnlyMap, "id", req.Id) + if err != nil { + if db.IsNoRows(err) { + ssa.lagFactorCounter.WithLabelValues("GetRegistration", "notfound").Inc() + } else { + ssa.lagFactorCounter.WithLabelValues("GetRegistration", "other").Inc() + } + } else { + ssa.lagFactorCounter.WithLabelValues("GetRegistration", "found").Inc() + } + } + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("registration with ID '%d' not found", req.Id) + } + return nil, err + } + + return registrationModelToPb(model) +} + +// GetRegistrationByKey obtains a Registration by JWK +func (ssa *SQLStorageAuthorityRO) GetRegistrationByKey(ctx context.Context, req *sapb.JSONWebKey) (*corepb.Registration, error) { + if req == nil || len(req.Jwk) == 0 { + return nil, errIncompleteRequest + } + + var jwk jose.JSONWebKey + err := jwk.UnmarshalJSON(req.Jwk) + if err != nil { + return nil, err + } + + sha, err := core.KeyDigestB64(jwk.Key) + if err != nil { + return nil, err + } + model, err := selectRegistration(ctx, ssa.dbReadOnlyMap, "jwk_sha256", sha) + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("no registrations with public key sha256 %q", sha) + } + return nil, err + } + + return registrationModelToPb(model) +} + +// incrementIP returns a copy of `ip` incremented at a bit index `index`, +// or in other words the first IP of the next highest subnet given a mask of +// length `index`. +// In order to easily account for overflow, we treat ip as a big.Int and add to +// it. If the increment overflows the max size of a net.IP, return the highest +// possible net.IP. +func incrementIP(ip net.IP, index int) net.IP { + bigInt := new(big.Int) + bigInt.SetBytes([]byte(ip)) + incr := new(big.Int).Lsh(big.NewInt(1), 128-uint(index)) + bigInt.Add(bigInt, incr) + // bigInt.Bytes can be shorter than 16 bytes, so stick it into a + // full-sized net.IP. + resultBytes := bigInt.Bytes() + if len(resultBytes) > 16 { + return net.ParseIP("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff") + } + result := make(net.IP, 16) + copy(result[16-len(resultBytes):], resultBytes) + return result +} + +// ipRange returns a range of IP addresses suitable for querying MySQL for the +// purpose of rate limiting using a range that is inclusive on the lower end and +// exclusive at the higher end. If ip is an IPv4 address, it returns that address, +// plus the one immediately higher than it. If ip is an IPv6 address, it applies +// a /48 mask to it and returns the lowest IP in the resulting network, and the +// first IP outside of the resulting network. +func ipRange(ip net.IP) (net.IP, net.IP) { + ip = ip.To16() + // For IPv6, match on a certain subnet range, since one person can commonly + // have an entire /48 to themselves. + maskLength := 48 + // For IPv4 addresses, do a match on exact address, so begin = ip and end = + // next higher IP. + if ip.To4() != nil { + maskLength = 128 + } + + mask := net.CIDRMask(maskLength, 128) + begin := ip.Mask(mask) + end := incrementIP(begin, maskLength) + + return begin, end +} + +// CountRegistrationsByIP returns the number of registrations created in the +// time range for a single IP address. +func (ssa *SQLStorageAuthorityRO) CountRegistrationsByIP(ctx context.Context, req *sapb.CountRegistrationsByIPRequest) (*sapb.Count, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Ip) == 0 || core.IsAnyNilOrZero(req.Range.Earliest, req.Range.Latest) { + return nil, errIncompleteRequest + } + + var count int64 + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) FROM registrations + WHERE + initialIP = :ip AND + :earliest < createdAt AND + createdAt <= :latest`, + map[string]interface{}{ + "ip": req.Ip, + "earliest": req.Range.Earliest.AsTime().Truncate(time.Second), + "latest": req.Range.Latest.AsTime().Truncate(time.Second), + }) + if err != nil { + return nil, err + } + return &sapb.Count{Count: count}, nil +} + +// CountRegistrationsByIPRange returns the number of registrations created in +// the time range in an IP range. For IPv4 addresses, that range is limited to +// the single IP. For IPv6 addresses, that range is a /48, since it's not +// uncommon for one person to have a /48 to themselves. +func (ssa *SQLStorageAuthorityRO) CountRegistrationsByIPRange(ctx context.Context, req *sapb.CountRegistrationsByIPRequest) (*sapb.Count, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Ip) == 0 || core.IsAnyNilOrZero(req.Range.Earliest, req.Range.Latest) { + return nil, errIncompleteRequest + } + + var count int64 + beginIP, endIP := ipRange(req.Ip) + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) FROM registrations + WHERE + :beginIP <= initialIP AND + initialIP < :endIP AND + :earliest < createdAt AND + createdAt <= :latest`, + map[string]interface{}{ + "earliest": req.Range.Earliest.AsTime().Truncate(time.Second), + "latest": req.Range.Latest.AsTime().Truncate(time.Second), + "beginIP": beginIP, + "endIP": endIP, + }) + if err != nil { + return nil, err + } + return &sapb.Count{Count: count}, nil +} + +// CountCertificatesByNames counts, for each input domain, the number of +// certificates issued in the given time range for that domain and its +// subdomains. It returns a map from domains to counts and a timestamp. The map +// of domains to counts is guaranteed to contain an entry for each input domain, +// so long as err is nil. The timestamp is the earliest time a certificate was +// issued for any of the domains during the provided range of time. Queries will +// be run in parallel. If any of them error, only one error will be returned. +func (ssa *SQLStorageAuthorityRO) CountCertificatesByNames(ctx context.Context, req *sapb.CountCertificatesByNamesRequest) (*sapb.CountByNames, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Names) == 0 || core.IsAnyNilOrZero(req.Range.Earliest, req.Range.Latest) { + return nil, errIncompleteRequest + } + + work := make(chan string, len(req.Names)) + type result struct { + err error + count int64 + earliest time.Time + domain string + } + results := make(chan result, len(req.Names)) + for _, domain := range req.Names { + work <- domain + } + close(work) + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(ctx) + defer cancel() + // We may perform up to 100 queries, depending on what's in the certificate + // request. Parallelize them so we don't hit our timeout, but limit the + // parallelism so we don't consume too many threads on the database. + for range ssa.parallelismPerRPC { + wg.Add(1) + go func() { + defer wg.Done() + for domain := range work { + select { + case <-ctx.Done(): + results <- result{err: ctx.Err()} + return + default: + } + count, earliest, err := ssa.countCertificatesByName(ctx, ssa.dbReadOnlyMap, domain, req.Range) + if err != nil { + results <- result{err: err} + // Skip any further work + cancel() + return + } + results <- result{ + count: count, + earliest: earliest, + domain: domain, + } + } + }() + } + wg.Wait() + close(results) + + // Set earliest to the latest possible time, so that we can find the + // earliest certificate in the results. + earliest := req.Range.Latest + counts := make(map[string]int64) + for r := range results { + if r.err != nil { + return nil, r.err + } + counts[r.domain] = r.count + if !r.earliest.IsZero() && r.earliest.Before(earliest.AsTime()) { + earliest = timestamppb.New(r.earliest) + } + } + + // If we didn't find any certificates in the range, earliest should be set + // to a zero value. + if len(counts) == 0 { + earliest = ×tamppb.Timestamp{} + } + return &sapb.CountByNames{Counts: counts, Earliest: earliest}, nil +} + +func ReverseName(domain string) string { + labels := strings.Split(domain, ".") + for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 { + labels[i], labels[j] = labels[j], labels[i] + } + return strings.Join(labels, ".") +} + +// GetSerialMetadata returns metadata stored alongside the serial number, +// such as the RegID whose certificate request created that serial, and when +// the certificate with that serial will expire. +func (ssa *SQLStorageAuthorityRO) GetSerialMetadata(ctx context.Context, req *sapb.Serial) (*sapb.SerialMetadata, error) { + if req == nil || req.Serial == "" { + return nil, errIncompleteRequest + } + + if !core.ValidSerial(req.Serial) { + return nil, fmt.Errorf("invalid serial %q", req.Serial) + } + + recordedSerial := recordedSerialModel{} + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &recordedSerial, + "SELECT * FROM serials WHERE serial = ?", + req.Serial, + ) + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("serial %q not found", req.Serial) + } + return nil, err + } + + return &sapb.SerialMetadata{ + Serial: recordedSerial.Serial, + RegistrationID: recordedSerial.RegistrationID, + Created: timestamppb.New(recordedSerial.Created), + Expires: timestamppb.New(recordedSerial.Expires), + }, nil +} + +// GetCertificate takes a serial number and returns the corresponding +// certificate, or error if it does not exist. +func (ssa *SQLStorageAuthorityRO) GetCertificate(ctx context.Context, req *sapb.Serial) (*corepb.Certificate, error) { + if req == nil || req.Serial == "" { + return nil, errIncompleteRequest + } + if !core.ValidSerial(req.Serial) { + return nil, fmt.Errorf("invalid certificate serial %s", req.Serial) + } + + cert, err := SelectCertificate(ctx, ssa.dbReadOnlyMap, req.Serial) + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("certificate with serial %q not found", req.Serial) + } + if err != nil { + return nil, err + } + return bgrpc.CertToPB(cert), nil +} + +// GetLintPrecertificate takes a serial number and returns the corresponding +// linting precertificate, or error if it does not exist. The returned precert +// is identical to the actual submitted-to-CT-logs precertificate, except for +// its signature. +func (ssa *SQLStorageAuthorityRO) GetLintPrecertificate(ctx context.Context, req *sapb.Serial) (*corepb.Certificate, error) { + if req == nil || req.Serial == "" { + return nil, errIncompleteRequest + } + if !core.ValidSerial(req.Serial) { + return nil, fmt.Errorf("invalid precertificate serial %s", req.Serial) + } + + cert, err := SelectPrecertificate(ctx, ssa.dbReadOnlyMap, req.Serial) + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("precertificate with serial %q not found", req.Serial) + } + if err != nil { + return nil, err + } + return bgrpc.CertToPB(cert), nil +} + +// GetCertificateStatus takes a hexadecimal string representing the full 128-bit serial +// number of a certificate and returns data about that certificate's current +// validity. +func (ssa *SQLStorageAuthorityRO) GetCertificateStatus(ctx context.Context, req *sapb.Serial) (*corepb.CertificateStatus, error) { + if req.Serial == "" { + return nil, errIncompleteRequest + } + if !core.ValidSerial(req.Serial) { + err := fmt.Errorf("invalid certificate serial %s", req.Serial) + return nil, err + } + + certStatus, err := SelectCertificateStatus(ctx, ssa.dbReadOnlyMap, req.Serial) + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("certificate status with serial %q not found", req.Serial) + } + if err != nil { + return nil, err + } + + return bgrpc.CertStatusToPB(certStatus), nil +} + +// GetRevocationStatus takes a hexadecimal string representing the full serial +// number of a certificate and returns a minimal set of data about that cert's +// current validity. +func (ssa *SQLStorageAuthorityRO) GetRevocationStatus(ctx context.Context, req *sapb.Serial) (*sapb.RevocationStatus, error) { + if req.Serial == "" { + return nil, errIncompleteRequest + } + if !core.ValidSerial(req.Serial) { + return nil, fmt.Errorf("invalid certificate serial %s", req.Serial) + } + + status, err := SelectRevocationStatus(ctx, ssa.dbReadOnlyMap, req.Serial) + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("certificate status with serial %q not found", req.Serial) + } + return nil, err + } + + return status, nil +} + +func (ssa *SQLStorageAuthorityRO) CountOrders(ctx context.Context, req *sapb.CountOrdersRequest) (*sapb.Count, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.AccountID == 0 || core.IsAnyNilOrZero(req.Range.Earliest, req.Range.Latest) { + return nil, errIncompleteRequest + } + + return countNewOrders(ctx, ssa.dbReadOnlyMap, req) +} + +// CountFQDNSets counts the total number of issuances, for a set of domains, +// that occurred during a given window of time. +func (ssa *SQLStorageAuthorityRO) CountFQDNSets(ctx context.Context, req *sapb.CountFQDNSetsRequest) (*sapb.Count, error) { + if core.IsAnyNilOrZero(req.Window) || len(req.Domains) == 0 { + return nil, errIncompleteRequest + } + + var count int64 + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) FROM fqdnSets + WHERE setHash = ? + AND issued > ?`, + core.HashNames(req.Domains), + ssa.clk.Now().Add(-req.Window.AsDuration()).Truncate(time.Second), + ) + return &sapb.Count{Count: count}, err +} + +// FQDNSetTimestampsForWindow returns the issuance timestamps for each +// certificate, issued for a set of domains, during a given window of time, +// starting from the most recent issuance. +func (ssa *SQLStorageAuthorityRO) FQDNSetTimestampsForWindow(ctx context.Context, req *sapb.CountFQDNSetsRequest) (*sapb.Timestamps, error) { + if core.IsAnyNilOrZero(req.Window) || len(req.Domains) == 0 { + return nil, errIncompleteRequest + } + type row struct { + Issued time.Time + } + var rows []row + _, err := ssa.dbReadOnlyMap.Select( + ctx, + &rows, + `SELECT issued FROM fqdnSets + WHERE setHash = ? + AND issued > ? + ORDER BY issued DESC`, + core.HashNames(req.Domains), + ssa.clk.Now().Add(-req.Window.AsDuration()).Truncate(time.Second), + ) + if err != nil { + return nil, err + } + + var results []*timestamppb.Timestamp + for _, i := range rows { + results = append(results, timestamppb.New(i.Issued)) + } + return &sapb.Timestamps{Timestamps: results}, nil +} + +// FQDNSetExists returns a bool indicating if one or more FQDN sets |names| +// exists in the database +func (ssa *SQLStorageAuthorityRO) FQDNSetExists(ctx context.Context, req *sapb.FQDNSetExistsRequest) (*sapb.Exists, error) { + if len(req.Domains) == 0 { + return nil, errIncompleteRequest + } + exists, err := ssa.checkFQDNSetExists(ctx, ssa.dbReadOnlyMap.SelectOne, req.Domains) + if err != nil { + return nil, err + } + return &sapb.Exists{Exists: exists}, nil +} + +// oneSelectorFunc is a func type that matches both borp.Transaction.SelectOne +// and borp.DbMap.SelectOne. +type oneSelectorFunc func(ctx context.Context, holder interface{}, query string, args ...interface{}) error + +// checkFQDNSetExists uses the given oneSelectorFunc to check whether an fqdnSet +// for the given names exists. +func (ssa *SQLStorageAuthorityRO) checkFQDNSetExists(ctx context.Context, selector oneSelectorFunc, names []string) (bool, error) { + namehash := core.HashNames(names) + var exists bool + err := selector( + ctx, + &exists, + `SELECT EXISTS (SELECT id FROM fqdnSets WHERE setHash = ? LIMIT 1)`, + namehash, + ) + return exists, err +} + +// GetOrder is used to retrieve an already existing order object +func (ssa *SQLStorageAuthorityRO) GetOrder(ctx context.Context, req *sapb.OrderRequest) (*corepb.Order, error) { + if req == nil || req.Id == 0 { + return nil, errIncompleteRequest + } + + txn := func(tx db.Executor) (interface{}, error) { + var omObj interface{} + var err error + if features.Get().MultipleCertificateProfiles { + omObj, err = tx.Get(ctx, orderModelv2{}, req.Id) + } else { + omObj, err = tx.Get(ctx, orderModelv1{}, req.Id) + } + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("no order found for ID %d", req.Id) + } + return nil, err + } + if omObj == nil { + return nil, berrors.NotFoundError("no order found for ID %d", req.Id) + } + + var order *corepb.Order + if features.Get().MultipleCertificateProfiles { + order, err = modelToOrderv2(omObj.(*orderModelv2)) + } else { + order, err = modelToOrderv1(omObj.(*orderModelv1)) + } + if err != nil { + return nil, err + } + + orderExp := order.Expires.AsTime() + if orderExp.Before(ssa.clk.Now()) { + return nil, berrors.NotFoundError("no order found for ID %d", req.Id) + } + + v2AuthzIDs, err := authzForOrder(ctx, tx, order.Id) + if err != nil { + return nil, err + } + order.V2Authorizations = v2AuthzIDs + + // Get the partial Authorization objects for the order + authzValidityInfo, err := getAuthorizationStatuses(ctx, tx, order.V2Authorizations) + // If there was an error getting the authorizations, return it immediately + if err != nil { + return nil, err + } + + names := make([]string, 0, len(authzValidityInfo)) + for _, a := range authzValidityInfo { + names = append(names, a.IdentifierValue) + } + order.Names = names + + // Calculate the status for the order + status, err := statusForOrder(order, authzValidityInfo, ssa.clk.Now()) + if err != nil { + return nil, err + } + order.Status = status + + return order, nil + } + + output, err := db.WithTransaction(ctx, ssa.dbReadOnlyMap, txn) + if (db.IsNoRows(err) || errors.Is(err, berrors.NotFound)) && ssa.lagFactor != 0 { + // GetOrder is often called shortly after a new order is created, sometimes + // before the order or its associated rows have propagated to the read + // replica yet. If we get a NoRows, wait a little bit and retry, once. + ssa.clk.Sleep(ssa.lagFactor) + output, err = db.WithTransaction(ctx, ssa.dbReadOnlyMap, txn) + if err != nil { + if db.IsNoRows(err) || errors.Is(err, berrors.NotFound) { + ssa.lagFactorCounter.WithLabelValues("GetOrder", "notfound").Inc() + } else { + ssa.lagFactorCounter.WithLabelValues("GetOrder", "other").Inc() + } + } else { + ssa.lagFactorCounter.WithLabelValues("GetOrder", "found").Inc() + } + } + if err != nil { + return nil, err + } + + order, ok := output.(*corepb.Order) + if !ok { + return nil, fmt.Errorf("casting error in GetOrder") + } + + return order, nil +} + +// GetOrderForNames tries to find a **pending** or **ready** order with the +// exact set of names requested, associated with the given accountID. Only +// unexpired orders are considered. If no order meeting these requirements is +// found a nil corepb.Order pointer is returned. +func (ssa *SQLStorageAuthorityRO) GetOrderForNames(ctx context.Context, req *sapb.GetOrderForNamesRequest) (*corepb.Order, error) { + if req.AcctID == 0 || len(req.Names) == 0 { + return nil, errIncompleteRequest + } + + // Hash the names requested for lookup in the orderFqdnSets table + fqdnHash := core.HashNames(req.Names) + + // Find a possibly-suitable order. We don't include the account ID or order + // status in this query because there's no index that includes those, so + // including them could require the DB to scan extra rows. + // Instead, we select one unexpired order that matches the fqdnSet. If + // that order doesn't match the account ID or status we need, just return + // nothing. We use `ORDER BY expires ASC` because the index on + // (setHash, expires) is in ASC order. DESC would be slightly nicer from a + // user experience perspective but would be slow when there are many entries + // to sort. + // This approach works fine because in most cases there's only one account + // issuing for a given name. If there are other accounts issuing for the same + // name, it just means order reuse happens less often. + var result struct { + OrderID int64 + RegistrationID int64 + } + var err error + err = ssa.dbReadOnlyMap.SelectOne(ctx, &result, ` + SELECT orderID, registrationID + FROM orderFqdnSets + WHERE setHash = ? + AND expires > ? + ORDER BY expires ASC + LIMIT 1`, + fqdnHash, + ssa.clk.Now().Truncate(time.Second)) + + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("no order matching request found") + } else if err != nil { + return nil, err + } + + if result.RegistrationID != req.AcctID { + return nil, berrors.NotFoundError("no order matching request found") + } + + // Get the order + order, err := ssa.GetOrder(ctx, &sapb.OrderRequest{Id: result.OrderID}) + if err != nil { + return nil, err + } + // Only return a pending or ready order + if order.Status != string(core.StatusPending) && + order.Status != string(core.StatusReady) { + return nil, berrors.NotFoundError("no order matching request found") + } + return order, nil +} + +// GetAuthorization2 returns the authz2 style authorization identified by the provided ID or an error. +// If no authorization is found matching the ID a berrors.NotFound type error is returned. +func (ssa *SQLStorageAuthorityRO) GetAuthorization2(ctx context.Context, req *sapb.AuthorizationID2) (*corepb.Authorization, error) { + if req.Id == 0 { + return nil, errIncompleteRequest + } + obj, err := ssa.dbReadOnlyMap.Get(ctx, authzModel{}, req.Id) + if db.IsNoRows(err) && ssa.lagFactor != 0 { + // GetAuthorization2 is often called shortly after a new order is created, + // sometimes before the order's associated authz rows have propagated to the + // read replica yet. If we get a NoRows, wait a little bit and retry, once. + ssa.clk.Sleep(ssa.lagFactor) + obj, err = ssa.dbReadOnlyMap.Get(ctx, authzModel{}, req.Id) + if err != nil { + if db.IsNoRows(err) { + ssa.lagFactorCounter.WithLabelValues("GetAuthorization2", "notfound").Inc() + } else { + ssa.lagFactorCounter.WithLabelValues("GetAuthorization2", "other").Inc() + } + } else { + ssa.lagFactorCounter.WithLabelValues("GetAuthorization2", "found").Inc() + } + } + if err != nil { + return nil, err + } + if obj == nil { + return nil, berrors.NotFoundError("authorization %d not found", req.Id) + } + return modelToAuthzPB(*(obj.(*authzModel))) +} + +// authzModelMapToPB converts a mapping of domain name to authzModels into a +// protobuf authorizations map +func authzModelMapToPB(m map[string]authzModel) (*sapb.Authorizations, error) { + resp := &sapb.Authorizations{} + for k, v := range m { + authzPB, err := modelToAuthzPB(v) + if err != nil { + return nil, err + } + resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: k, Authz: authzPB}) + } + return resp, nil +} + +// GetAuthorizations2 returns any valid or pending authorizations that exist for the list of domains +// provided. If both a valid and pending authorization exist only the valid one will be returned. +func (ssa *SQLStorageAuthorityRO) GetAuthorizations2(ctx context.Context, req *sapb.GetAuthorizationsRequest) (*sapb.Authorizations, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Domains) == 0 || req.RegistrationID == 0 || core.IsAnyNilOrZero(req.Now) { + return nil, errIncompleteRequest + } + var authzModels []authzModel + params := []interface{}{ + req.RegistrationID, + statusUint(core.StatusValid), + statusUint(core.StatusPending), + req.Now.AsTime().Truncate(time.Second), + identifierTypeToUint[string(identifier.DNS)], + } + + for _, name := range req.Domains { + params = append(params, name) + } + + query := fmt.Sprintf( + `SELECT %s FROM authz2 + USE INDEX (regID_identifier_status_expires_idx) + WHERE registrationID = ? AND + status IN (?,?) AND + expires > ? AND + identifierType = ? AND + identifierValue IN (%s)`, + authzFields, + db.QuestionMarks(len(req.Domains)), + ) + + _, err := ssa.dbReadOnlyMap.Select( + ctx, + &authzModels, + query, + params..., + ) + if err != nil { + return nil, err + } + + if len(authzModels) == 0 { + return &sapb.Authorizations{}, nil + } + + authzModelMap := make(map[string]authzModel) + for _, am := range authzModels { + existing, present := authzModelMap[am.IdentifierValue] + if !present || uintToStatus[existing.Status] == core.StatusPending && uintToStatus[am.Status] == core.StatusValid { + authzModelMap[am.IdentifierValue] = am + } + } + + return authzModelMapToPB(authzModelMap) +} + +// GetPendingAuthorization2 returns the most recent Pending authorization with +// the given identifier, if available. This method only supports DNS identifier types. +// TODO(#5816): Consider removing this method, as it has no callers. +func (ssa *SQLStorageAuthorityRO) GetPendingAuthorization2(ctx context.Context, req *sapb.GetPendingAuthorizationRequest) (*corepb.Authorization, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.RegistrationID == 0 || req.IdentifierValue == "" || core.IsAnyNilOrZero(req.ValidUntil) { + return nil, errIncompleteRequest + } + var am authzModel + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &am, + fmt.Sprintf(`SELECT %s FROM authz2 WHERE + registrationID = :regID AND + status = :status AND + expires > :validUntil AND + identifierType = :dnsType AND + identifierValue = :ident + ORDER BY expires ASC + LIMIT 1 `, authzFields), + map[string]interface{}{ + "regID": req.RegistrationID, + "status": statusUint(core.StatusPending), + "validUntil": req.ValidUntil.AsTime().Truncate(time.Second), + "dnsType": identifierTypeToUint[string(identifier.DNS)], + "ident": req.IdentifierValue, + }, + ) + if err != nil { + if db.IsNoRows(err) { + return nil, berrors.NotFoundError("pending authz not found") + } + return nil, err + } + return modelToAuthzPB(am) +} + +// CountPendingAuthorizations2 returns the number of pending, unexpired authorizations +// for the given registration. +func (ssa *SQLStorageAuthorityRO) CountPendingAuthorizations2(ctx context.Context, req *sapb.RegistrationID) (*sapb.Count, error) { + if req.Id == 0 { + return nil, errIncompleteRequest + } + + var count int64 + err := ssa.dbReadOnlyMap.SelectOne(ctx, &count, + `SELECT COUNT(*) FROM authz2 WHERE + registrationID = :regID AND + expires > :expires AND + status = :status`, + map[string]interface{}{ + "regID": req.Id, + "expires": ssa.clk.Now().Truncate(time.Second), + "status": statusUint(core.StatusPending), + }, + ) + if err != nil { + return nil, err + } + return &sapb.Count{Count: count}, nil +} + +// GetValidOrderAuthorizations2 is used to find the valid, unexpired authorizations +// associated with a specific order and account ID. +func (ssa *SQLStorageAuthorityRO) GetValidOrderAuthorizations2(ctx context.Context, req *sapb.GetValidOrderAuthorizationsRequest) (*sapb.Authorizations, error) { + if req.AcctID == 0 || req.Id == 0 { + return nil, errIncompleteRequest + } + + // The authz2 and orderToAuthz2 tables both have a column named "id", so we + // need to be explicit about which table's "id" column we want to select. + qualifiedAuthzFields := strings.Split(authzFields, " ") + for i, field := range qualifiedAuthzFields { + if field == "id," { + qualifiedAuthzFields[i] = "authz2.id," + break + } + } + + var ams []authzModel + _, err := ssa.dbReadOnlyMap.Select( + ctx, + &ams, + fmt.Sprintf(`SELECT %s FROM authz2 + LEFT JOIN orderToAuthz2 ON authz2.ID = orderToAuthz2.authzID + WHERE authz2.registrationID = :regID AND + authz2.expires > :expires AND + authz2.status = :status AND + orderToAuthz2.orderID = :orderID`, + strings.Join(qualifiedAuthzFields, " "), + ), + map[string]interface{}{ + "regID": req.AcctID, + "expires": ssa.clk.Now().Truncate(time.Second), + "status": statusUint(core.StatusValid), + "orderID": req.Id, + }, + ) + if err != nil { + return nil, err + } + + byName := make(map[string]authzModel) + for _, am := range ams { + if uintToIdentifierType[am.IdentifierType] != string(identifier.DNS) { + return nil, fmt.Errorf("unknown identifier type: %q on authz id %d", am.IdentifierType, am.ID) + } + existing, present := byName[am.IdentifierValue] + if !present || am.Expires.After(existing.Expires) { + byName[am.IdentifierValue] = am + } + } + + return authzModelMapToPB(byName) +} + +// CountInvalidAuthorizations2 counts invalid authorizations for a user expiring +// in a given time range. This method only supports DNS identifier types. +func (ssa *SQLStorageAuthorityRO) CountInvalidAuthorizations2(ctx context.Context, req *sapb.CountInvalidAuthorizationsRequest) (*sapb.Count, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if req.RegistrationID == 0 || req.Hostname == "" || core.IsAnyNilOrZero(req.Range.Earliest, req.Range.Latest) { + return nil, errIncompleteRequest + } + + var count int64 + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &count, + `SELECT COUNT(*) FROM authz2 WHERE + registrationID = :regID AND + status = :status AND + expires > :expiresEarliest AND + expires <= :expiresLatest AND + identifierType = :dnsType AND + identifierValue = :ident`, + map[string]interface{}{ + "regID": req.RegistrationID, + "dnsType": identifierTypeToUint[string(identifier.DNS)], + "ident": req.Hostname, + "expiresEarliest": req.Range.Earliest.AsTime().Truncate(time.Second), + "expiresLatest": req.Range.Latest.AsTime().Truncate(time.Second), + "status": statusUint(core.StatusInvalid), + }, + ) + if err != nil { + return nil, err + } + return &sapb.Count{Count: count}, nil +} + +// GetValidAuthorizations2 returns the latest authorization for all +// domain names that the account has authorizations for. This method +// only supports DNS identifier types. +func (ssa *SQLStorageAuthorityRO) GetValidAuthorizations2(ctx context.Context, req *sapb.GetValidAuthorizationsRequest) (*sapb.Authorizations, error) { + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if len(req.Domains) == 0 || req.RegistrationID == 0 || core.IsAnyNilOrZero(req.Now) { + return nil, errIncompleteRequest + } + + query := fmt.Sprintf( + `SELECT %s FROM authz2 WHERE + registrationID = ? AND + status = ? AND + expires > ? AND + identifierType = ? AND + identifierValue IN (%s)`, + authzFields, + db.QuestionMarks(len(req.Domains)), + ) + + params := []interface{}{ + req.RegistrationID, + statusUint(core.StatusValid), + req.Now.AsTime().Truncate(time.Second), + identifierTypeToUint[string(identifier.DNS)], + } + for _, domain := range req.Domains { + params = append(params, domain) + } + + var authzModels []authzModel + _, err := ssa.dbReadOnlyMap.Select( + ctx, + &authzModels, + query, + params..., + ) + if err != nil { + return nil, err + } + + authzMap := make(map[string]authzModel, len(authzModels)) + for _, am := range authzModels { + // Only allow DNS identifiers + if uintToIdentifierType[am.IdentifierType] != string(identifier.DNS) { + continue + } + // If there is an existing authorization in the map only replace it with one + // which has a later expiry. + if existing, present := authzMap[am.IdentifierValue]; present && am.Expires.Before(existing.Expires) { + continue + } + authzMap[am.IdentifierValue] = am + } + return authzModelMapToPB(authzMap) +} + +// KeyBlocked checks if a key, indicated by a hash, is present in the blockedKeys table +func (ssa *SQLStorageAuthorityRO) KeyBlocked(ctx context.Context, req *sapb.SPKIHash) (*sapb.Exists, error) { + if req == nil || req.KeyHash == nil { + return nil, errIncompleteRequest + } + + var id int64 + err := ssa.dbReadOnlyMap.SelectOne(ctx, &id, `SELECT ID FROM blockedKeys WHERE keyHash = ?`, req.KeyHash) + if err != nil { + if db.IsNoRows(err) { + return &sapb.Exists{Exists: false}, nil + } + return nil, err + } + + return &sapb.Exists{Exists: true}, nil +} + +// IncidentsForSerial queries each active incident table and returns every +// incident that currently impacts `req.Serial`. +func (ssa *SQLStorageAuthorityRO) IncidentsForSerial(ctx context.Context, req *sapb.Serial) (*sapb.Incidents, error) { + if req == nil { + return nil, errIncompleteRequest + } + + var activeIncidents []incidentModel + _, err := ssa.dbReadOnlyMap.Select(ctx, &activeIncidents, `SELECT * FROM incidents WHERE enabled = 1`) + if err != nil { + if db.IsNoRows(err) { + return &sapb.Incidents{}, nil + } + return nil, err + } + + var incidentsForSerial []*sapb.Incident + for _, i := range activeIncidents { + var count int + err := ssa.dbIncidentsMap.SelectOne(ctx, &count, fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE serial = ?", + i.SerialTable), req.Serial) + if err != nil { + if db.IsNoRows(err) { + continue + } + return nil, err + } + if count > 0 { + incident := incidentModelToPB(i) + incidentsForSerial = append(incidentsForSerial, &incident) + } + + } + if len(incidentsForSerial) == 0 { + return &sapb.Incidents{}, nil + } + return &sapb.Incidents{Incidents: incidentsForSerial}, nil +} + +// SerialsForIncident queries the provided incident table and returns the +// resulting rows as a stream of `*sapb.IncidentSerial`s. An `io.EOF` error +// signals that there are no more serials to send. If the incident table in +// question contains zero rows, only an `io.EOF` error is returned. The +// IncidentSerial messages returned may have the zero-value for their OrderID, +// RegistrationID, and LastNoticeSent fields, if those are NULL in the database. +func (ssa *SQLStorageAuthorityRO) SerialsForIncident(req *sapb.SerialsForIncidentRequest, stream grpc.ServerStreamingServer[sapb.IncidentSerial]) error { + if req.IncidentTable == "" { + return errIncompleteRequest + } + + // Check that `req.IncidentTable` is a valid incident table name. + if !validIncidentTableRegexp.MatchString(req.IncidentTable) { + return fmt.Errorf("malformed table name %q", req.IncidentTable) + } + + selector, err := db.NewMappedSelector[incidentSerialModel](ssa.dbIncidentsMap) + if err != nil { + return fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryFrom(stream.Context(), req.IncidentTable, "") + if err != nil { + return fmt.Errorf("starting db query: %w", err) + } + + return rows.ForEach(func(row *incidentSerialModel) error { + // Scan the row into the model. Note: the fields must be passed in the + // same order as the columns returned by the query above. + ism, err := rows.Get() + if err != nil { + return err + } + + ispb := &sapb.IncidentSerial{ + Serial: ism.Serial, + } + if ism.RegistrationID != nil { + ispb.RegistrationID = *ism.RegistrationID + } + if ism.OrderID != nil { + ispb.OrderID = *ism.OrderID + } + if ism.LastNoticeSent != nil { + ispb.LastNoticeSent = timestamppb.New(*ism.LastNoticeSent) + } + + return stream.Send(ispb) + }) +} + +// GetRevokedCerts gets a request specifying an issuer and a period of time, +// and writes to the output stream the set of all certificates issued by that +// issuer which expire during that period of time and which have been revoked. +// The starting timestamp is treated as inclusive (certs with exactly that +// notAfter date are included), but the ending timestamp is exclusive (certs +// with exactly that notAfter date are *not* included). +func (ssa *SQLStorageAuthorityRO) GetRevokedCerts(req *sapb.GetRevokedCertsRequest, stream grpc.ServerStreamingServer[corepb.CRLEntry]) error { + if req.ShardIdx != 0 { + return ssa.getRevokedCertsFromRevokedCertificatesTable(req, stream) + } else { + return ssa.getRevokedCertsFromCertificateStatusTable(req, stream) + } +} + +// getRevokedCertsFromRevokedCertificatesTable uses the new revokedCertificates +// table to implement GetRevokedCerts. It must only be called when the request +// contains a non-zero ShardIdx. +func (ssa *SQLStorageAuthorityRO) getRevokedCertsFromRevokedCertificatesTable(req *sapb.GetRevokedCertsRequest, stream grpc.ServerStreamingServer[corepb.CRLEntry]) error { + if req.ShardIdx == 0 { + return errors.New("can't select shard 0 from revokedCertificates table") + } + + atTime := req.RevokedBefore.AsTime() + + clauses := ` + WHERE issuerID = ? + AND shardIdx = ? + AND notAfterHour >= ?` + params := []interface{}{ + req.IssuerNameID, + req.ShardIdx, + // Round the expiry down to the nearest hour, to take advantage of our + // smaller index while still capturing at least as many certs as intended. + req.ExpiresAfter.AsTime().Truncate(time.Hour), + } + + selector, err := db.NewMappedSelector[revokedCertModel](ssa.dbReadOnlyMap) + if err != nil { + return fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryContext(stream.Context(), clauses, params...) + if err != nil { + return fmt.Errorf("reading db: %w", err) + } + + return rows.ForEach(func(row *revokedCertModel) error { + // Double-check that the cert wasn't revoked between the time at which we're + // constructing this snapshot CRL and right now. If the cert was revoked + // at-or-after the "atTime", we'll just include it in the next generation + // of CRLs. + if row.RevokedDate.After(atTime) || row.RevokedDate.Equal(atTime) { + return nil + } + + return stream.Send(&corepb.CRLEntry{ + Serial: row.Serial, + Reason: int32(row.RevokedReason), + RevokedAt: timestamppb.New(row.RevokedDate), + }) + }) +} + +// getRevokedCertsFromCertificateStatusTable uses the old certificateStatus +// table to implement GetRevokedCerts. +func (ssa *SQLStorageAuthorityRO) getRevokedCertsFromCertificateStatusTable(req *sapb.GetRevokedCertsRequest, stream grpc.ServerStreamingServer[corepb.CRLEntry]) error { + atTime := req.RevokedBefore.AsTime() + + clauses := ` + WHERE notAfter >= ? + AND notAfter < ? + AND issuerID = ? + AND status = ?` + params := []interface{}{ + req.ExpiresAfter.AsTime().Truncate(time.Second), + req.ExpiresBefore.AsTime().Truncate(time.Second), + req.IssuerNameID, + core.OCSPStatusRevoked, + } + + selector, err := db.NewMappedSelector[crlEntryModel](ssa.dbReadOnlyMap) + if err != nil { + return fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryContext(stream.Context(), clauses, params...) + if err != nil { + return fmt.Errorf("reading db: %w", err) + } + + return rows.ForEach(func(row *crlEntryModel) error { + // Double-check that the cert wasn't revoked between the time at which we're + // constructing this snapshot CRL and right now. If the cert was revoked + // at-or-after the "atTime", we'll just include it in the next generation + // of CRLs. + if row.RevokedDate.After(atTime) || row.RevokedDate.Equal(atTime) { + return nil + } + + return stream.Send(&corepb.CRLEntry{ + Serial: row.Serial, + Reason: int32(row.RevokedReason), + RevokedAt: timestamppb.New(row.RevokedDate), + }) + }) +} + +// GetMaxExpiration returns the timestamp of the farthest-future notAfter date +// found in the certificateStatus table. This provides an upper bound on how far +// forward operations that need to cover all currently-unexpired certificates +// have to look. +func (ssa *SQLStorageAuthorityRO) GetMaxExpiration(ctx context.Context, req *emptypb.Empty) (*timestamppb.Timestamp, error) { + var model struct { + MaxNotAfter *time.Time `db:"maxNotAfter"` + } + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &model, + "SELECT MAX(notAfter) AS maxNotAfter FROM certificateStatus", + ) + if err != nil { + return nil, fmt.Errorf("selecting max notAfter: %w", err) + } + if model.MaxNotAfter == nil { + return nil, errors.New("certificateStatus table notAfter column is empty") + } + return timestamppb.New(*model.MaxNotAfter), err +} + +// Health implements the grpc.checker interface. +func (ssa *SQLStorageAuthorityRO) Health(ctx context.Context) error { + err := ssa.dbReadOnlyMap.SelectOne(ctx, new(int), "SELECT 1") + if err != nil { + return err + } + return nil +} + +// ReplacementOrderExists returns whether a valid replacement order exists for +// the given certificate serial number. An existing but expired or otherwise +// invalid replacement order is not considered to exist. +func (ssa *SQLStorageAuthorityRO) ReplacementOrderExists(ctx context.Context, req *sapb.Serial) (*sapb.Exists, error) { + if req == nil || req.Serial == "" { + return nil, errIncompleteRequest + } + + var replacement replacementOrderModel + err := ssa.dbReadOnlyMap.SelectOne( + ctx, + &replacement, + "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", + req.Serial, + ) + if err != nil { + if db.IsNoRows(err) { + // No replacement order exists. + return &sapb.Exists{Exists: false}, nil + } + return nil, err + } + if replacement.Replaced { + // Certificate has already been replaced. + return &sapb.Exists{Exists: true}, nil + } + if replacement.OrderExpires.Before(ssa.clk.Now()) { + // The existing replacement order has expired. + return &sapb.Exists{Exists: false}, nil + } + + // Pull the replacement order so we can inspect its status. + replacementOrder, err := ssa.GetOrder(ctx, &sapb.OrderRequest{Id: replacement.OrderID}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + // The existing replacement order has been deleted. This should + // never happen. + ssa.log.Errf("replacement order %d for serial %q not found", replacement.OrderID, req.Serial) + return &sapb.Exists{Exists: false}, nil + } + } + + switch replacementOrder.Status { + case string(core.StatusPending), string(core.StatusReady), string(core.StatusProcessing), string(core.StatusValid): + // An existing replacement order is either still being worked on or has + // already been finalized. + return &sapb.Exists{Exists: true}, nil + + case string(core.StatusInvalid): + // The existing replacement order cannot be finalized. The requester + // should create a new replacement order. + return &sapb.Exists{Exists: false}, nil + + default: + // Replacement order is in an unknown state. This should never happen. + return nil, fmt.Errorf("unknown replacement order status: %q", replacementOrder.Status) + } +} + +// GetSerialsByKey returns a stream of serials for all unexpired certificates +// whose public key matches the given SPKIHash. This is useful for revoking all +// certificates affected by a key compromise. +func (ssa *SQLStorageAuthorityRO) GetSerialsByKey(req *sapb.SPKIHash, stream grpc.ServerStreamingServer[sapb.Serial]) error { + clauses := ` + WHERE keyHash = ? + AND certNotAfter > ?` + params := []interface{}{ + req.KeyHash, + ssa.clk.Now().Truncate(time.Second), + } + + selector, err := db.NewMappedSelector[keyHashModel](ssa.dbReadOnlyMap) + if err != nil { + return fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryContext(stream.Context(), clauses, params...) + if err != nil { + return fmt.Errorf("reading db: %w", err) + } + + return rows.ForEach(func(row *keyHashModel) error { + return stream.Send(&sapb.Serial{Serial: row.CertSerial}) + }) +} + +// GetSerialsByAccount returns a stream of all serials for all unexpired +// certificates issued to the given RegID. This is useful for revoking all of +// an account's certs upon their request. +func (ssa *SQLStorageAuthorityRO) GetSerialsByAccount(req *sapb.RegistrationID, stream grpc.ServerStreamingServer[sapb.Serial]) error { + clauses := ` + WHERE registrationID = ? + AND expires > ?` + params := []interface{}{ + req.Id, + ssa.clk.Now().Truncate(time.Second), + } + + selector, err := db.NewMappedSelector[recordedSerialModel](ssa.dbReadOnlyMap) + if err != nil { + return fmt.Errorf("initializing db map: %w", err) + } + + rows, err := selector.QueryContext(stream.Context(), clauses, params...) + if err != nil { + return fmt.Errorf("reading db: %w", err) + } + + return rows.ForEach(func(row *recordedSerialModel) error { + return stream.Send(&sapb.Serial{Serial: row.Serial}) + }) +} + +// CheckIdentifiersPaused takes a slice of identifiers and returns a slice of +// the first 15 identifier values which are currently paused for the provided +// account. If no matches are found, an empty slice is returned. +func (ssa *SQLStorageAuthorityRO) CheckIdentifiersPaused(ctx context.Context, req *sapb.PauseRequest) (*sapb.Identifiers, error) { + if core.IsAnyNilOrZero(req.RegistrationID, req.Identifiers) { + return nil, errIncompleteRequest + } + + identifiers, err := newIdentifierModelsFromPB(req.Identifiers) + if err != nil { + return nil, err + } + + if len(identifiers) == 0 { + // No identifier values to check. + return nil, nil + } + + identifiersByType := map[uint8][]string{} + for _, id := range identifiers { + identifiersByType[id.Type] = append(identifiersByType[id.Type], id.Value) + } + + // Build a query to retrieve up to 15 paused identifiers using OR clauses + // for conditions specific to each type. This approach handles mixed + // identifier types in a single query. Assuming 3 DNS identifiers and 1 IP + // identifier, the resulting query would look like: + // + // SELECT identifierType, identifierValue + // FROM paused WHERE registrationID = ? AND + // unpausedAt IS NULL AND + // ((identifierType = ? AND identifierValue IN (?, ?, ?)) OR + // (identifierType = ? AND identifierValue IN (?))) + // LIMIT 15 + // + // Corresponding args array for placeholders: [, 0, "example.com", + // "example.net", "example.org", 1, "1.2.3.4"] + + var conditions []string + args := []interface{}{req.RegistrationID} + for idType, values := range identifiersByType { + conditions = append(conditions, + fmt.Sprintf("identifierType = ? AND identifierValue IN (%s)", + db.QuestionMarks(len(values)), + ), + ) + args = append(args, idType) + for _, value := range values { + args = append(args, value) + } + } + + query := fmt.Sprintf(` + SELECT identifierType, identifierValue + FROM paused + WHERE registrationID = ? AND unpausedAt IS NULL AND (%s) LIMIT 15`, + strings.Join(conditions, " OR ")) + + var matches []identifierModel + _, err = ssa.dbReadOnlyMap.Select(ctx, &matches, query, args...) + if err != nil && !db.IsNoRows(err) { + // Error querying the database. + return nil, err + } + + return newPBFromIdentifierModels(matches) +} + +// GetPausedIdentifiers returns a slice of paused identifiers for the provided +// account. If no paused identifiers are found, an empty slice is returned. The +// results are limited to the first 15 paused identifiers. +func (ssa *SQLStorageAuthorityRO) GetPausedIdentifiers(ctx context.Context, req *sapb.RegistrationID) (*sapb.Identifiers, error) { + if core.IsAnyNilOrZero(req.Id) { + return nil, errIncompleteRequest + } + + var matches []identifierModel + _, err := ssa.dbReadOnlyMap.Select(ctx, &matches, ` + SELECT identifierType, identifierValue + FROM paused + WHERE + registrationID = ? AND + unpausedAt IS NULL + LIMIT 15`, + req.Id, + ) + if err != nil && !db.IsNoRows(err) { + return nil, err + } + + return newPBFromIdentifierModels(matches) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/satest/satest.go b/third-party/github.com/letsencrypt/boulder/sa/satest/satest.go new file mode 100644 index 000000000..be4795fee --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/satest/satest.go @@ -0,0 +1,35 @@ +package satest + +import ( + "context" + "net" + "testing" + "time" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + sapb "github.com/letsencrypt/boulder/sa/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// CreateWorkingRegistration inserts a new, correct Registration into +// SA using GoodKey under the hood. This is used by various non-SA tests +// to initialize the a registration for the test to reference. +func CreateWorkingRegistration(t *testing.T, sa sapb.StorageAuthorityClient) *corepb.Registration { + initialIP, _ := net.ParseIP("88.77.66.11").MarshalText() + reg, err := sa.NewRegistration(context.Background(), &corepb.Registration{ + Key: []byte(`{ + "kty": "RSA", + "n": "n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw", + "e": "AQAB" +}`), + Contact: []string{"mailto:foo@example.com"}, + InitialIP: initialIP, + CreatedAt: timestamppb.New(time.Date(2003, 5, 10, 0, 0, 0, 0, time.UTC)), + Status: string(core.StatusValid), + }) + if err != nil { + t.Fatalf("Unable to create new registration: %s", err) + } + return reg +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/sysvars.go b/third-party/github.com/letsencrypt/boulder/sa/sysvars.go new file mode 100644 index 000000000..6039c82e7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/sysvars.go @@ -0,0 +1,235 @@ +package sa + +import ( + "fmt" + "regexp" +) + +var ( + checkStringQuoteRE = regexp.MustCompile(`^'[0-9A-Za-z_\-=:]+'$`) + checkIntRE = regexp.MustCompile(`^\d+$`) + checkImproperIntRE = regexp.MustCompile(`^'\d+'$`) + checkNumericRE = regexp.MustCompile(`^\d+(\.\d+)?$`) + checkBooleanRE = regexp.MustCompile(`^([0-1])|(?i)(true|false)|(?i)(on|off)`) +) + +// checkMariaDBSystemVariables validates a MariaDB config passed in via SA +// setDefault or DSN. This manually curated list of system variables was +// partially generated by a tool in issue #6687. An overview of the validations +// performed are: +// +// - Correct quoting for strings and string enums prevent future +// problems such as PR #6683 from occurring. +// +// - Regex validation is performed for the various booleans, floats, integers, and strings. +// +// Only session scoped variables should be included. A session variable is one +// that affects the current session only. Passing a session variable that only +// works in the global scope causes database connection error 1045. +// https://mariadb.com/kb/en/set/#global-session +func checkMariaDBSystemVariables(name string, value string) error { + // System variable names will be indexed into the appropriate hash sets + // below and can possibly exist in several sets. + + // Check the list of currently known MariaDB string type system variables + // and determine if the value is a properly formatted string e.g. + // sql_mode='STRICT_TABLES' + mariaDBStringTypes := map[string]struct{}{ + "character_set_client": {}, + "character_set_connection": {}, + "character_set_database": {}, + "character_set_filesystem": {}, + "character_set_results": {}, + "character_set_server": {}, + "collation_connection": {}, + "collation_database": {}, + "collation_server": {}, + "debug/debug_dbug": {}, + "debug_sync": {}, + "enforce_storage_engine": {}, + "external_user": {}, + "lc_messages": {}, + "lc_time_names": {}, + "old_alter_table": {}, + "old_mode": {}, + "optimizer_switch": {}, + "proxy_user": {}, + "session_track_system_variables": {}, + "sql_mode": {}, + "time_zone": {}, + } + + if _, found := mariaDBStringTypes[name]; found { + if checkStringQuoteRE.FindString(value) != value { + return fmt.Errorf("%s=%s string is not properly quoted", name, value) + } + return nil + } + + // MariaDB numerics which may either be integers or floats. + // https://mariadb.com/kb/en/numeric-data-type-overview/ + mariaDBNumericTypes := map[string]struct{}{ + "bulk_insert_buffer_size": {}, + "default_week_format": {}, + "eq_range_index_dive_limit": {}, + "error_count": {}, + "expensive_subquery_limit": {}, + "group_concat_max_len": {}, + "histogram_size": {}, + "idle_readonly_transaction_timeout": {}, + "idle_transaction_timeout": {}, + "idle_write_transaction_timeout": {}, + "in_predicate_conversion_threshold": {}, + "insert_id": {}, + "interactive_timeout": {}, + "join_buffer_size": {}, + "join_buffer_space_limit": {}, + "join_cache_level": {}, + "last_insert_id": {}, + "lock_wait_timeout": {}, + "log_slow_min_examined_row_limit": {}, + "log_slow_query_time": {}, + "log_slow_rate_limit": {}, + "long_query_time": {}, + "max_allowed_packet": {}, + "max_delayed_threads": {}, + "max_digest_length": {}, + "max_error_count": {}, + "max_heap_table_size": {}, + "max_join_size": {}, + "max_length_for_sort_data": {}, + "max_recursive_iterations": {}, + "max_rowid_filter_size": {}, + "max_seeks_for_key": {}, + "max_session_mem_used": {}, + "max_sort_length": {}, + "max_sp_recursion_depth": {}, + "max_statement_time": {}, + "max_user_connections": {}, + "min_examined_row_limit": {}, + "mrr_buffer_size": {}, + "net_buffer_length": {}, + "net_read_timeout": {}, + "net_retry_count": {}, + "net_write_timeout": {}, + "optimizer_extra_pruning_depth": {}, + "optimizer_max_sel_arg_weight": {}, + "optimizer_prune_level": {}, + "optimizer_search_depth": {}, + "optimizer_selectivity_sampling_limit": {}, + "optimizer_trace_max_mem_size": {}, + "optimizer_use_condition_selectivity": {}, + "preload_buffer_size": {}, + "profiling_history_size": {}, + "progress_report_time": {}, + "pseudo_slave_mode": {}, + "pseudo_thread_id": {}, + "query_alloc_block_size": {}, + "query_prealloc_size": {}, + "rand_seed1": {}, + "range_alloc_block_size": {}, + "read_rnd_buffer_size": {}, + "rowid_merge_buff_size": {}, + "sql_select_limit": {}, + "tmp_disk_table_size": {}, + "tmp_table_size": {}, + "transaction_alloc_block_size": {}, + "transaction_prealloc_size": {}, + "wait_timeout": {}, + "warning_count": {}, + } + + if _, found := mariaDBNumericTypes[name]; found { + if checkNumericRE.FindString(value) != value { + return fmt.Errorf("%s=%s requires a numeric value, but is not formatted like a number", name, value) + } + return nil + } + + // Certain MariaDB enums can have both string and integer values. + mariaDBIntEnumTypes := map[string]struct{}{ + "completion_type": {}, + "query_cache_type": {}, + } + + mariaDBStringEnumTypes := map[string]struct{}{ + "completion_type": {}, + "default_regex_flags": {}, + "default_storage_engine": {}, + "default_tmp_storage_engine": {}, + "histogram_type": {}, + "log_slow_filter": {}, + "log_slow_verbosity": {}, + "optimizer_trace": {}, + "query_cache_type": {}, + "session_track_transaction_info": {}, + "transaction_isolation": {}, + "tx_isolation": {}, + "use_stat_tables": {}, + } + + // Check the list of currently known MariaDB enumeration type system + // variables and determine if the value is either: + // 1) A properly formatted integer e.g. completion_type=1 + if _, found := mariaDBIntEnumTypes[name]; found { + if checkIntRE.FindString(value) == value { + return nil + } + if checkImproperIntRE.FindString(value) == value { + return fmt.Errorf("%s=%s integer enum is quoted, but should not be", name, value) + } + } + + // 2) A properly formatted string e.g. completion_type='CHAIN' + if _, found := mariaDBStringEnumTypes[name]; found { + if checkStringQuoteRE.FindString(value) != value { + return fmt.Errorf("%s=%s string enum is not properly quoted", name, value) + } + return nil + } + + // MariaDB booleans can be (0, false) or (1, true). + // https://mariadb.com/kb/en/boolean/ + mariaDBBooleanTypes := map[string]struct{}{ + "autocommit": {}, + "big_tables": {}, + "check_constraint_checks": {}, + "foreign_key_checks": {}, + "in_transaction": {}, + "keep_files_on_create": {}, + "log_slow_query": {}, + "low_priority_updates": {}, + "old": {}, + "old_passwords": {}, + "profiling": {}, + "query_cache_strip_comments": {}, + "query_cache_wlock_invalidate": {}, + "session_track_schema": {}, + "session_track_state_change": {}, + "slow_query_log": {}, + "sql_auto_is_null": {}, + "sql_big_selects": {}, + "sql_buffer_result": {}, + "sql_if_exists": {}, + "sql_log_off": {}, + "sql_notes": {}, + "sql_quote_show_create": {}, + "sql_safe_updates": {}, + "sql_warnings": {}, + "standard_compliant_cte": {}, + "tcp_nodelay": {}, + "transaction_read_only": {}, + "tx_read_only": {}, + "unique_checks": {}, + "updatable_views_with_limit": {}, + } + + if _, found := mariaDBBooleanTypes[name]; found { + if checkBooleanRE.FindString(value) != value { + return fmt.Errorf("%s=%s expected boolean value", name, value) + } + return nil + } + + return fmt.Errorf("%s=%s was unexpected", name, value) +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/sysvars_test.go b/third-party/github.com/letsencrypt/boulder/sa/sysvars_test.go new file mode 100644 index 000000000..8c39b6235 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/sysvars_test.go @@ -0,0 +1,46 @@ +package sa + +import ( + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestCheckMariaDBSystemVariables(t *testing.T) { + type testCase struct { + key string + value string + expectErr string + } + + for _, tc := range []testCase{ + {"sql_select_limit", "'0.1", "requires a numeric value"}, + {"max_statement_time", "0", ""}, + {"myBabies", "kids_I_tell_ya", "was unexpected"}, + {"sql_mode", "'STRICT_ALL_TABLES", "string is not properly quoted"}, + {"sql_mode", "%27STRICT_ALL_TABLES%27", "string is not properly quoted"}, + {"completion_type", "1", ""}, + {"completion_type", "'2'", "integer enum is quoted, but should not be"}, + {"completion_type", "RELEASE", "string enum is not properly quoted"}, + {"completion_type", "'CHAIN'", ""}, + {"autocommit", "0", ""}, + {"check_constraint_checks", "1", ""}, + {"log_slow_query", "true", ""}, + {"foreign_key_checks", "false", ""}, + {"sql_warnings", "TrUe", ""}, + {"tx_read_only", "FalSe", ""}, + {"sql_notes", "on", ""}, + {"tcp_nodelay", "off", ""}, + {"autocommit", "2", "expected boolean value"}, + } { + t.Run(tc.key, func(t *testing.T) { + err := checkMariaDBSystemVariables(tc.key, tc.value) + if tc.expectErr == "" { + test.AssertNotError(t, err, "Unexpected error received") + } else { + test.AssertError(t, err, "Error expected, but not found") + test.AssertContains(t, err.Error(), tc.expectErr) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/testdata/ocsp.response b/third-party/github.com/letsencrypt/boulder/sa/testdata/ocsp.response new file mode 100644 index 000000000..c52cbbc1e Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/sa/testdata/ocsp.response differ diff --git a/third-party/github.com/letsencrypt/boulder/sa/type-converter.go b/third-party/github.com/letsencrypt/boulder/sa/type-converter.go new file mode 100644 index 000000000..2ffb5bc1b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/type-converter.go @@ -0,0 +1,120 @@ +package sa + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/go-jose/go-jose/v4" + + "github.com/letsencrypt/borp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/identifier" +) + +// BoulderTypeConverter is used by borp for storing objects in DB. +type BoulderTypeConverter struct{} + +// ToDb converts a Boulder object to one suitable for the DB representation. +func (tc BoulderTypeConverter) ToDb(val interface{}) (interface{}, error) { + switch t := val.(type) { + case identifier.ACMEIdentifier, []core.Challenge, []string, [][]int: + jsonBytes, err := json.Marshal(t) + if err != nil { + return nil, err + } + return string(jsonBytes), nil + case jose.JSONWebKey: + jsonBytes, err := t.MarshalJSON() + if err != nil { + return "", err + } + return string(jsonBytes), nil + case core.AcmeStatus: + return string(t), nil + case core.OCSPStatus: + return string(t), nil + default: + return val, nil + } +} + +// FromDb converts a DB representation back into a Boulder object. +func (tc BoulderTypeConverter) FromDb(target interface{}) (borp.CustomScanner, bool) { + switch target.(type) { + case *identifier.ACMEIdentifier, *[]core.Challenge, *[]string, *[][]int: + binder := func(holder, target interface{}) error { + s, ok := holder.(*string) + if !ok { + return errors.New("FromDb: Unable to convert *string") + } + b := []byte(*s) + err := json.Unmarshal(b, target) + if err != nil { + return badJSONError( + fmt.Sprintf("binder failed to unmarshal %T", target), + b, + err) + } + return nil + } + return borp.CustomScanner{Holder: new(string), Target: target, Binder: binder}, true + case *jose.JSONWebKey: + binder := func(holder, target interface{}) error { + s, ok := holder.(*string) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *string", holder) + } + if *s == "" { + return errors.New("FromDb: Empty JWK field.") + } + b := []byte(*s) + k, ok := target.(*jose.JSONWebKey) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *jose.JSONWebKey", target) + } + err := k.UnmarshalJSON(b) + if err != nil { + return badJSONError( + "binder failed to unmarshal JWK", + b, + err) + } + return nil + } + return borp.CustomScanner{Holder: new(string), Target: target, Binder: binder}, true + case *core.AcmeStatus: + binder := func(holder, target interface{}) error { + s, ok := holder.(*string) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *string", holder) + } + st, ok := target.(*core.AcmeStatus) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *core.AcmeStatus", target) + } + + *st = core.AcmeStatus(*s) + return nil + } + return borp.CustomScanner{Holder: new(string), Target: target, Binder: binder}, true + case *core.OCSPStatus: + binder := func(holder, target interface{}) error { + s, ok := holder.(*string) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *string", holder) + } + st, ok := target.(*core.OCSPStatus) + if !ok { + return fmt.Errorf("FromDb: Unable to convert %T to *core.OCSPStatus", target) + } + + *st = core.OCSPStatus(*s) + return nil + } + return borp.CustomScanner{Holder: new(string), Target: target, Binder: binder}, true + default: + return borp.CustomScanner{}, false + } +} diff --git a/third-party/github.com/letsencrypt/boulder/sa/type-converter_test.go b/third-party/github.com/letsencrypt/boulder/sa/type-converter_test.go new file mode 100644 index 000000000..c0849e759 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/sa/type-converter_test.go @@ -0,0 +1,153 @@ +package sa + +import ( + "encoding/json" + "testing" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/test" + + "github.com/go-jose/go-jose/v4" +) + +const JWK1JSON = `{ + "kty": "RSA", + "n": "vuc785P8lBj3fUxyZchF_uZw6WtbxcorqgTyq-qapF5lrO1U82Tp93rpXlmctj6fyFHBVVB5aXnUHJ7LZeVPod7Wnfl8p5OyhlHQHC8BnzdzCqCMKmWZNX5DtETDId0qzU7dPzh0LP0idt5buU7L9QNaabChw3nnaL47iu_1Di5Wp264p2TwACeedv2hfRDjDlJmaQXuS8Rtv9GnRWyC9JBu7XmGvGDziumnJH7Hyzh3VNu-kSPQD3vuAFgMZS6uUzOztCkT0fpOalZI6hqxtWLvXUMj-crXrn-Maavz8qRhpAyp5kcYk3jiHGgQIi7QSK2JIdRJ8APyX9HlmTN5AQ", + "e": "AQAB" +}` + +func TestAcmeIdentifier(t *testing.T) { + tc := BoulderTypeConverter{} + + ai := identifier.ACMEIdentifier{Type: "data1", Value: "data2"} + out := identifier.ACMEIdentifier{} + + marshaledI, err := tc.ToDb(ai) + test.AssertNotError(t, err, "Could not ToDb") + + scanner, ok := tc.FromDb(&out) + test.Assert(t, ok, "FromDb failed") + if !ok { + t.FailNow() + return + } + + marshaled := marshaledI.(string) + err = scanner.Binder(&marshaled, &out) + test.AssertNotError(t, err, "failed to scanner.Binder") + test.AssertMarshaledEquals(t, ai, out) +} + +func TestAcmeIdentifierBadJSON(t *testing.T) { + badJSON := `{` + tc := BoulderTypeConverter{} + out := identifier.ACMEIdentifier{} + scanner, _ := tc.FromDb(&out) + err := scanner.Binder(&badJSON, &out) + test.AssertError(t, err, "expected error from scanner.Binder") + var badJSONErr errBadJSON + test.AssertErrorWraps(t, err, &badJSONErr) + test.AssertEquals(t, string(badJSONErr.json), badJSON) +} + +func TestJSONWebKey(t *testing.T) { + tc := BoulderTypeConverter{} + + var jwk, out jose.JSONWebKey + err := json.Unmarshal([]byte(JWK1JSON), &jwk) + if err != nil { + t.Fatal(err) + } + + marshaledI, err := tc.ToDb(jwk) + test.AssertNotError(t, err, "Could not ToDb") + + scanner, ok := tc.FromDb(&out) + test.Assert(t, ok, "FromDb failed") + if !ok { + t.FailNow() + return + } + + marshaled := marshaledI.(string) + err = scanner.Binder(&marshaled, &out) + test.AssertNotError(t, err, "failed to scanner.Binder") + test.AssertMarshaledEquals(t, jwk, out) +} + +func TestJSONWebKeyBadJSON(t *testing.T) { + badJSON := `{` + tc := BoulderTypeConverter{} + out := jose.JSONWebKey{} + scanner, _ := tc.FromDb(&out) + err := scanner.Binder(&badJSON, &out) + test.AssertError(t, err, "expected error from scanner.Binder") + var badJSONErr errBadJSON + test.AssertErrorWraps(t, err, &badJSONErr) + test.AssertEquals(t, string(badJSONErr.json), badJSON) +} + +func TestAcmeStatus(t *testing.T) { + tc := BoulderTypeConverter{} + + var as, out core.AcmeStatus + as = "core.AcmeStatus" + + marshaledI, err := tc.ToDb(as) + test.AssertNotError(t, err, "Could not ToDb") + + scanner, ok := tc.FromDb(&out) + test.Assert(t, ok, "FromDb failed") + if !ok { + t.FailNow() + return + } + + marshaled := marshaledI.(string) + err = scanner.Binder(&marshaled, &out) + test.AssertNotError(t, err, "failed to scanner.Binder") + test.AssertMarshaledEquals(t, as, out) +} + +func TestOCSPStatus(t *testing.T) { + tc := BoulderTypeConverter{} + + var os, out core.OCSPStatus + os = "core.OCSPStatus" + + marshaledI, err := tc.ToDb(os) + test.AssertNotError(t, err, "Could not ToDb") + + scanner, ok := tc.FromDb(&out) + test.Assert(t, ok, "FromDb failed") + if !ok { + t.FailNow() + return + } + + marshaled := marshaledI.(string) + err = scanner.Binder(&marshaled, &out) + test.AssertNotError(t, err, "failed to scanner.Binder") + test.AssertMarshaledEquals(t, os, out) +} + +func TestStringSlice(t *testing.T) { + tc := BoulderTypeConverter{} + var au, out []string + + marshaledI, err := tc.ToDb(au) + test.AssertNotError(t, err, "Could not ToDb") + + scanner, ok := tc.FromDb(&out) + test.Assert(t, ok, "FromDb failed") + if !ok { + t.FailNow() + return + } + + marshaled := marshaledI.(string) + err = scanner.Binder(&marshaled, &out) + test.AssertNotError(t, err, "failed to scanner.Binder") + test.AssertMarshaledEquals(t, au, out) +} diff --git a/third-party/github.com/letsencrypt/boulder/semaphore/semaphore.go b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore.go new file mode 100644 index 000000000..305966898 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore.go @@ -0,0 +1,159 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// Modified by Boulder to provide a load-shedding mechanism. + +// Package semaphore provides a weighted semaphore implementation. +package semaphore // import "golang.org/x/sync/semaphore" + +import ( + "container/list" + "context" + "errors" + "sync" +) + +type waiter struct { + n int64 + ready chan<- struct{} // Closed when semaphore acquired. +} + +// ErrMaxWaiters is returned when Acquire is called, but there are more than +// maxWaiters waiters. +var ErrMaxWaiters = errors.New("too many waiters") + +// NewWeighted creates a new weighted semaphore with the given +// maximum combined weight for concurrent access. +// maxWaiters provides a limit such that calls to Acquire +// will immediately error if the number of waiters is that high. +// A maxWaiters of zero means no limit. +func NewWeighted(n int64, maxWaiters int) *Weighted { + w := &Weighted{size: n, maxWaiters: maxWaiters} + return w +} + +// Weighted provides a way to bound concurrent access to a resource. +// The callers can request access with a given weight. +type Weighted struct { + size int64 + cur int64 + mu sync.Mutex + waiters list.List + maxWaiters int +} + +// Acquire acquires the semaphore with a weight of n, blocking until resources +// are available or ctx is done. On success, returns nil. On failure, returns +// ctx.Err() and leaves the semaphore unchanged. +// +// If ctx is already done, Acquire may still succeed without blocking. +// +// If there are maxWaiters waiters, Acquire will return an error immediately. +func (s *Weighted) Acquire(ctx context.Context, n int64) error { + s.mu.Lock() + if s.size-s.cur >= n && s.waiters.Len() == 0 { + s.cur += n + s.mu.Unlock() + return nil + } + + if n > s.size { + // Don't make other Acquire calls block on one that's doomed to fail. + s.mu.Unlock() + <-ctx.Done() + return ctx.Err() + } + + if s.maxWaiters > 0 && s.waiters.Len() >= s.maxWaiters { + s.mu.Unlock() + return ErrMaxWaiters + } + + ready := make(chan struct{}) + w := waiter{n: n, ready: ready} + elem := s.waiters.PushBack(w) + s.mu.Unlock() + + select { + case <-ctx.Done(): + err := ctx.Err() + s.mu.Lock() + select { + case <-ready: + // Acquired the semaphore after we were canceled. Rather than trying to + // fix up the queue, just pretend we didn't notice the cancellation. + err = nil + default: + isFront := s.waiters.Front() == elem + s.waiters.Remove(elem) + // If we're at the front and there're extra tokens left, notify other waiters. + if isFront && s.size > s.cur { + s.notifyWaiters() + } + } + s.mu.Unlock() + return err + + case <-ready: + return nil + } +} + +// TryAcquire acquires the semaphore with a weight of n without blocking. +// On success, returns true. On failure, returns false and leaves the semaphore unchanged. +func (s *Weighted) TryAcquire(n int64) bool { + s.mu.Lock() + success := s.size-s.cur >= n && s.waiters.Len() == 0 + if success { + s.cur += n + } + s.mu.Unlock() + return success +} + +// Release releases the semaphore with a weight of n. +func (s *Weighted) Release(n int64) { + s.mu.Lock() + s.cur -= n + if s.cur < 0 { + s.mu.Unlock() + panic("semaphore: released more than held") + } + s.notifyWaiters() + s.mu.Unlock() +} + +func (s *Weighted) NumWaiters() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.waiters.Len() +} + +func (s *Weighted) notifyWaiters() { + for { + next := s.waiters.Front() + if next == nil { + break // No more waiters blocked. + } + + w := next.Value.(waiter) + if s.size-s.cur < w.n { + // Not enough tokens for the next waiter. We could keep going (to try to + // find a waiter with a smaller request), but under load that could cause + // starvation for large requests; instead, we leave all remaining waiters + // blocked. + // + // Consider a semaphore used as a read-write lock, with N tokens, N + // readers, and one writer. Each reader can Acquire(1) to obtain a read + // lock. The writer can Acquire(N) to obtain a write lock, excluding all + // of the readers. If we allow the readers to jump ahead in the queue, + // the writer will starve — there is always one token available for every + // reader. + break + } + + s.cur += w.n + s.waiters.Remove(next) + close(w.ready) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_bench_test.go b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_bench_test.go new file mode 100644 index 000000000..991dd6fdc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_bench_test.go @@ -0,0 +1,132 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.7 +// +build go1.7 + +package semaphore_test + +import ( + "context" + "fmt" + "testing" + + "github.com/letsencrypt/boulder/semaphore" +) + +// weighted is an interface matching a subset of *Weighted. It allows +// alternate implementations for testing and benchmarking. +type weighted interface { + Acquire(context.Context, int64) error + TryAcquire(int64) bool + Release(int64) +} + +// semChan implements Weighted using a channel for +// comparing against the condition variable-based implementation. +type semChan chan struct{} + +func newSemChan(n int64) semChan { + return semChan(make(chan struct{}, n)) +} + +func (s semChan) Acquire(_ context.Context, n int64) error { + for i := int64(0); i < n; i++ { + s <- struct{}{} + } + return nil +} + +func (s semChan) TryAcquire(n int64) bool { + if int64(len(s))+n > int64(cap(s)) { + return false + } + + for i := int64(0); i < n; i++ { + s <- struct{}{} + } + return true +} + +func (s semChan) Release(n int64) { + for i := int64(0); i < n; i++ { + <-s + } +} + +// acquireN calls Acquire(size) on sem N times and then calls Release(size) N times. +func acquireN(b *testing.B, sem weighted, size int64, N int) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + for j := 0; j < N; j++ { + _ = sem.Acquire(context.Background(), size) + } + for j := 0; j < N; j++ { + sem.Release(size) + } + } +} + +// tryAcquireN calls TryAcquire(size) on sem N times and then calls Release(size) N times. +func tryAcquireN(b *testing.B, sem weighted, size int64, N int) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + for j := 0; j < N; j++ { + if !sem.TryAcquire(size) { + b.Fatalf("TryAcquire(%v) = false, want true", size) + } + } + for j := 0; j < N; j++ { + sem.Release(size) + } + } +} + +func BenchmarkNewSeq(b *testing.B) { + for _, cap := range []int64{1, 128} { + b.Run(fmt.Sprintf("Weighted-%d", cap), func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = semaphore.NewWeighted(cap, 0) + } + }) + b.Run(fmt.Sprintf("semChan-%d", cap), func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = newSemChan(cap) + } + }) + } +} + +func BenchmarkAcquireSeq(b *testing.B) { + for _, c := range []struct { + cap, size int64 + N int + }{ + {1, 1, 1}, + {2, 1, 1}, + {16, 1, 1}, + {128, 1, 1}, + {2, 2, 1}, + {16, 2, 8}, + {128, 2, 64}, + {2, 1, 2}, + {16, 8, 2}, + {128, 64, 2}, + } { + for _, w := range []struct { + name string + w weighted + }{ + {"Weighted", semaphore.NewWeighted(c.cap, 0)}, + {"semChan", newSemChan(c.cap)}, + } { + b.Run(fmt.Sprintf("%s-acquire-%d-%d-%d", w.name, c.cap, c.size, c.N), func(b *testing.B) { + acquireN(b, w.w, c.size, c.N) + }) + b.Run(fmt.Sprintf("%s-tryAcquire-%d-%d-%d", w.name, c.cap, c.size, c.N), func(b *testing.B) { + tryAcquireN(b, w.w, c.size, c.N) + }) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_example_test.go b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_example_test.go new file mode 100644 index 000000000..e75cd79f5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_example_test.go @@ -0,0 +1,84 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package semaphore_test + +import ( + "context" + "fmt" + "log" + "runtime" + + "golang.org/x/sync/semaphore" +) + +// Example_workerPool demonstrates how to use a semaphore to limit the number of +// goroutines working on parallel tasks. +// +// This use of a semaphore mimics a typical “worker pool” pattern, but without +// the need to explicitly shut down idle workers when the work is done. +func Example_workerPool() { + ctx := context.TODO() + + var ( + maxWorkers = runtime.GOMAXPROCS(0) + sem = semaphore.NewWeighted(int64(maxWorkers)) + out = make([]int, 32) + ) + + // Compute the output using up to maxWorkers goroutines at a time. + for i := range out { + // When maxWorkers goroutines are in flight, Acquire blocks until one of the + // workers finishes. + if err := sem.Acquire(ctx, 1); err != nil { + log.Printf("Failed to acquire semaphore: %v", err) + break + } + + go func(i int) { + defer sem.Release(1) + out[i] = collatzSteps(i + 1) + }(i) + } + + // Acquire all of the tokens to wait for any remaining workers to finish. + // + // If you are already waiting for the workers by some other means (such as an + // errgroup.Group), you can omit this final Acquire call. + if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil { + log.Printf("Failed to acquire semaphore: %v", err) + } + + fmt.Println(out) + + // Output: + // [0 1 7 2 5 8 16 3 19 6 14 9 9 17 17 4 12 20 20 7 7 15 15 10 23 10 111 18 18 18 106 5] +} + +// collatzSteps computes the number of steps to reach 1 under the Collatz +// conjecture. (See https://en.wikipedia.org/wiki/Collatz_conjecture.) +func collatzSteps(n int) (steps int) { + if n <= 0 { + panic("nonpositive input") + } + + for ; n > 1; steps++ { + if steps < 0 { + panic("too many steps") + } + + if n%2 == 0 { + n /= 2 + continue + } + + const maxInt = int(^uint(0) >> 1) + if n > (maxInt-1)/3 { + panic("overflow") + } + n = 3*n + 1 + } + + return steps +} diff --git a/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_test.go b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_test.go new file mode 100644 index 000000000..71a5d2340 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/semaphore/semaphore_test.go @@ -0,0 +1,229 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package semaphore_test + +import ( + "context" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + "github.com/letsencrypt/boulder/semaphore" + "golang.org/x/sync/errgroup" +) + +const maxSleep = 1 * time.Millisecond + +func HammerWeighted(sem *semaphore.Weighted, n int64, loops int) { + for i := 0; i < loops; i++ { + _ = sem.Acquire(context.Background(), n) + time.Sleep(time.Duration(rand.Int63n(int64(maxSleep/time.Nanosecond))) * time.Nanosecond) + sem.Release(n) + } +} + +func TestWeighted(t *testing.T) { + t.Parallel() + + n := runtime.GOMAXPROCS(0) + loops := 10000 / n + sem := semaphore.NewWeighted(int64(n), 0) + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + HammerWeighted(sem, int64(i), loops) + }() + } + wg.Wait() +} + +func TestWeightedPanic(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Fatal("release of an unacquired weighted semaphore did not panic") + } + }() + w := semaphore.NewWeighted(1, 0) + w.Release(1) +} + +func TestWeightedTryAcquire(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sem := semaphore.NewWeighted(2, 0) + tries := []bool{} + _ = sem.Acquire(ctx, 1) + tries = append(tries, sem.TryAcquire(1)) + tries = append(tries, sem.TryAcquire(1)) + + sem.Release(2) + + tries = append(tries, sem.TryAcquire(1)) + _ = sem.Acquire(ctx, 1) + tries = append(tries, sem.TryAcquire(1)) + + want := []bool{true, false, true, false} + for i := range tries { + if tries[i] != want[i] { + t.Errorf("tries[%d]: got %t, want %t", i, tries[i], want[i]) + } + } +} + +func TestWeightedAcquire(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sem := semaphore.NewWeighted(2, 0) + tryAcquire := func(n int64) bool { + ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + defer cancel() + return sem.Acquire(ctx, n) == nil + } + + tries := []bool{} + _ = sem.Acquire(ctx, 1) + tries = append(tries, tryAcquire(1)) + tries = append(tries, tryAcquire(1)) + + sem.Release(2) + + tries = append(tries, tryAcquire(1)) + _ = sem.Acquire(ctx, 1) + tries = append(tries, tryAcquire(1)) + + want := []bool{true, false, true, false} + for i := range tries { + if tries[i] != want[i] { + t.Errorf("tries[%d]: got %t, want %t", i, tries[i], want[i]) + } + } +} + +func TestWeightedDoesntBlockIfTooBig(t *testing.T) { + t.Parallel() + + const n = 2 + sem := semaphore.NewWeighted(n, 0) + { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = sem.Acquire(ctx, n+1) + }() + } + + g, ctx := errgroup.WithContext(context.Background()) + for i := n * 3; i > 0; i-- { + g.Go(func() error { + err := sem.Acquire(ctx, 1) + if err == nil { + time.Sleep(1 * time.Millisecond) + sem.Release(1) + } + return err + }) + } + if err := g.Wait(); err != nil { + t.Errorf("semaphore.NewWeighted(%v, 0) failed to AcquireCtx(_, 1) with AcquireCtx(_, %v) pending", n, n+1) + } +} + +// TestLargeAcquireDoesntStarve times out if a large call to Acquire starves. +// Merely returning from the test function indicates success. +func TestLargeAcquireDoesntStarve(t *testing.T) { + t.Parallel() + + ctx := context.Background() + n := int64(runtime.GOMAXPROCS(0)) + sem := semaphore.NewWeighted(n, 0) + running := true + + var wg sync.WaitGroup + wg.Add(int(n)) + for i := n; i > 0; i-- { + _ = sem.Acquire(ctx, 1) + go func() { + defer func() { + sem.Release(1) + wg.Done() + }() + for running { + time.Sleep(1 * time.Millisecond) + sem.Release(1) + _ = sem.Acquire(ctx, 1) + } + }() + } + + _ = sem.Acquire(ctx, n) + running = false + sem.Release(n) + wg.Wait() +} + +// translated from https://github.com/zhiqiangxu/util/blob/master/mutex/crwmutex_test.go#L43 +func TestAllocCancelDoesntStarve(t *testing.T) { + sem := semaphore.NewWeighted(10, 0) + + // Block off a portion of the semaphore so that Acquire(_, 10) can eventually succeed. + _ = sem.Acquire(context.Background(), 1) + + // In the background, Acquire(_, 10). + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = sem.Acquire(ctx, 10) + }() + + // Wait until the Acquire(_, 10) call blocks. + for sem.TryAcquire(1) { + sem.Release(1) + runtime.Gosched() + } + + // Now try to grab a read lock, and simultaneously unblock the Acquire(_, 10) call. + // Both Acquire calls should unblock and return, in either order. + go cancel() + + err := sem.Acquire(context.Background(), 1) + if err != nil { + t.Fatalf("Acquire(_, 1) failed unexpectedly: %v", err) + } + sem.Release(1) +} + +func TestMaxWaiters(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sem := semaphore.NewWeighted(1, 10) + _ = sem.Acquire(ctx, 1) + + for i := 0; i < 10; i++ { + go func() { + _ = sem.Acquire(ctx, 1) + <-ctx.Done() + }() + } + + // Since the goroutines that act as waiters are intended to block in + // sem.Acquire, there's no principled wait to trigger here once they're + // blocked. Instead, loop until we reach the expected number of waiters. + for sem.NumWaiters() < 10 { + time.Sleep(10 * time.Millisecond) + } + err := sem.Acquire(ctx, 1) + if err != semaphore.ErrMaxWaiters { + t.Errorf("expected error when maxWaiters was reached, but got %#v", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/start.py b/third-party/github.com/letsencrypt/boulder/start.py new file mode 100644 index 000000000..f224b9e6c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/start.py @@ -0,0 +1,37 @@ +#!/usr/bin/env -S python3 -u +""" +Run a local instance of Boulder for testing purposes. + +Boulder always runs as a collection of services. This script will +start them all on their own ports (see test/startservers.py) + +Keeps servers alive until ^C. Exit non-zero if any servers fail to +start, or die before ^C. +""" + +import errno +import os +import sys +import time + +sys.path.append('./test') +import startservers + +if not startservers.install(race_detection=False): + raise(Exception("failed to build")) + +if not startservers.start(fakeclock=None): + sys.exit(1) +try: + os.wait() + + # If we reach here, a child died early. Log what died: + startservers.check() + sys.exit(1) +except KeyboardInterrupt: + print("\nstopping servers.") +except OSError as v: + # Ignore EINTR, which happens when we get SIGTERM or SIGINT (i.e. when + # someone hits Ctrl-C after running `docker compose up` or start.py. + if v.errno != errno.EINTR: + raise diff --git a/third-party/github.com/letsencrypt/boulder/staticcheck.conf b/third-party/github.com/letsencrypt/boulder/staticcheck.conf new file mode 100644 index 000000000..00370524d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/staticcheck.conf @@ -0,0 +1,8 @@ +# Ignores the following: +# SA1019: Using a deprecated function, variable, constant or field +# SA6003: Converting a string to a slice of runes before ranging over it +# ST1000: Incorrect or missing package comment +# ST1003: Poorly chosen identifier +# ST1005: Incorrectly formatted error string + +checks = ["all", "-SA1019", "-SA6003", "-ST1000", "-ST1003", "-ST1005"] diff --git a/third-party/github.com/letsencrypt/boulder/strictyaml/yaml.go b/third-party/github.com/letsencrypt/boulder/strictyaml/yaml.go new file mode 100644 index 000000000..8e3bae996 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/strictyaml/yaml.go @@ -0,0 +1,46 @@ +// Package strictyaml provides a strict YAML unmarshaller based on `go-yaml/yaml` +package strictyaml + +import ( + "bytes" + "errors" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +// Unmarshal takes a byte array and an interface passed by reference. The +// d.Decode will read the next YAML-encoded value from its input and store it in +// the value pointed to by yamlObj. Any config keys from the incoming YAML +// document which do not correspond to expected keys in the config struct will +// result in errors. +// +// TODO(https://github.com/go-yaml/yaml/issues/639): Replace this function with +// yaml.Unmarshal once a more ergonomic way to set unmarshal options is added +// upstream. +func Unmarshal(b []byte, yamlObj interface{}) error { + r := bytes.NewReader(b) + + d := yaml.NewDecoder(r) + d.KnownFields(true) + + // d.Decode will mutate yamlObj + err := d.Decode(yamlObj) + + if err != nil { + // io.EOF is returned when the YAML document is empty. + if errors.Is(err, io.EOF) { + return fmt.Errorf("unmarshalling YAML, bytes cannot be nil: %w", err) + } + return fmt.Errorf("unmarshalling YAML: %w", err) + } + + // As bytes are read by the decoder, the length of the byte buffer should + // decrease. If it doesn't, there's a problem. + if r.Len() != 0 { + return fmt.Errorf("yaml object of size %d bytes had %d bytes of unexpected unconsumed trailers", r.Size(), r.Len()) + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/strictyaml/yaml_test.go b/third-party/github.com/letsencrypt/boulder/strictyaml/yaml_test.go new file mode 100644 index 000000000..c6d9b3f1a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/strictyaml/yaml_test.go @@ -0,0 +1,47 @@ +package strictyaml + +import ( + "io" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +var ( + emptyConfig = []byte(``) + validConfig = []byte(` +a: c +d: c +`) + invalidConfig1 = []byte(` +x: y +`) + + invalidConfig2 = []byte(` +a: c +d: c +x: + - hey +`) +) + +func TestStrictYAMLUnmarshal(t *testing.T) { + var config struct { + A string `yaml:"a"` + D string `yaml:"d"` + } + + err := Unmarshal(validConfig, &config) + test.AssertNotError(t, err, "yaml: unmarshal errors") + test.AssertNotError(t, err, "EOF") + + err = Unmarshal(invalidConfig1, &config) + test.AssertError(t, err, "yaml: unmarshal errors") + + err = Unmarshal(invalidConfig2, &config) + test.AssertError(t, err, "yaml: unmarshal errors") + + // Test an empty buffer (config file) + err = Unmarshal(emptyConfig, &config) + test.AssertErrorIs(t, err, io.EOF) +} diff --git a/third-party/github.com/letsencrypt/boulder/t.sh b/third-party/github.com/letsencrypt/boulder/t.sh new file mode 100644 index 000000000..08f181f59 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/t.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Outer wrapper for invoking test.sh inside docker-compose. +# + +set -o errexit + +if type realpath >/dev/null 2>&1 ; then + cd "$(realpath -- $(dirname -- "$0"))" +fi + +# Generate the test keys and certs necessary for the integration tests. +docker compose run bsetup + +# Use a predictable name for the container so we can grab the logs later +# for use when testing logs analysis tools. +docker rm boulder_tests || true +exec docker compose run --name boulder_tests boulder ./test.sh "$@" diff --git a/third-party/github.com/letsencrypt/boulder/test.sh b/third-party/github.com/letsencrypt/boulder/test.sh new file mode 100644 index 000000000..6f8bedd76 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash + +# -e Stops execution in the instance of a command or pipeline error +# -u Treat unset variables as an error and exit immediately +set -eu + +if type realpath >/dev/null 2>&1 ; then + cd "$(realpath -- $(dirname -- "$0"))" +fi + +# +# Defaults +# +export RACE="false" +STAGE="starting" +STATUS="FAILURE" +RUN=() +UNIT_PACKAGES=() +UNIT_FLAGS=() +FILTER=() + +# +# Print Functions +# +function print_outcome() { + if [ "$STATUS" == SUCCESS ] + then + echo -e "\e[32m"$STATUS"\e[0m" + else + echo -e "\e[31m"$STATUS"\e[0m while running \e[31m"$STAGE"\e[0m" + fi +} + +function print_list_of_integration_tests() { + go test -tags integration -list=. ./test/integration/... | grep '^Test' + exit 0 +} + +function exit_msg() { + # complain to STDERR and exit with error + echo "$*" >&2 + exit 2 +} + +function check_arg() { + if [ -z "$OPTARG" ] + then + exit_msg "No arg for --$OPT option, use: -h for help">&2 + fi +} + +function print_usage_exit() { + echo "$USAGE" + exit 0 +} + +function print_heading { + echo + echo -e "\e[34m\e[1m"$1"\e[0m" +} + +function run_and_expect_silence() { + echo "$@" + result_file=$(mktemp -t bouldertestXXXX) + "$@" 2>&1 | tee "${result_file}" + + # Fail if result_file is nonempty. + if [ -s "${result_file}" ]; then + rm "${result_file}" + exit 1 + fi + rm "${result_file}" +} + +# +# Testing Helpers +# +function run_unit_tests() { + go test "${UNIT_FLAGS[@]}" "${UNIT_PACKAGES[@]}" "${FILTER[@]}" +} + +# +# Main CLI Parser +# +USAGE="$(cat -- <<-EOM + +Usage: +Boulder test suite CLI, intended to be run inside of a Docker container: + + docker compose run --use-aliases boulder ./$(basename "${0}") [OPTION]... + +With no options passed, runs standard battery of tests (lint, unit, and integration) + + -l, --lints Adds lint to the list of tests to run + -u, --unit Adds unit to the list of tests to run + -v, --unit-verbose Enables verbose output for unit tests + -w, --unit-without-cache Disables go test caching for unit tests + -p , --unit-test-package= Run unit tests for specific go package(s) + -e, --enable-race-detection Enables race detection for unit and integration tests + -n, --config-next Changes BOULDER_CONFIG_DIR from test/config to test/config-next + -i, --integration Adds integration to the list of tests to run + -s, --start-py Adds start to the list of tests to run + -g, --generate Adds generate to the list of tests to run + -o, --list-integration-tests Outputs a list of the available integration tests + -f , --filter= Run only those tests matching the regular expression + + Note: + This option disables the '"back in time"' integration test setup + + For tests, the regular expression is split by unbracketed slash (/) + characters into a sequence of regular expressions + + Example: + TestAkamaiPurgerDrainQueueFails/TestWFECORS + -h, --help Shows this help message + +EOM +)" + +while getopts luvweciosmgnhp:f:-: OPT; do + if [ "$OPT" = - ]; then # long option: reformulate OPT and OPTARG + OPT="${OPTARG%%=*}" # extract long option name + OPTARG="${OPTARG#$OPT}" # extract long option argument (may be empty) + OPTARG="${OPTARG#=}" # if long option argument, remove assigning `=` + fi + case "$OPT" in + l | lints ) RUN+=("lints") ;; + u | unit ) RUN+=("unit") ;; + v | unit-verbose ) UNIT_FLAGS+=("-v") ;; + w | unit-without-cache ) UNIT_FLAGS+=("-count=1") ;; + p | unit-test-package ) check_arg; UNIT_PACKAGES+=("${OPTARG}") ;; + e | enable-race-detection ) RACE="true"; UNIT_FLAGS+=("-race") ;; + i | integration ) RUN+=("integration") ;; + o | list-integration-tests ) print_list_of_integration_tests ;; + f | filter ) check_arg; FILTER+=("${OPTARG}") ;; + s | start-py ) RUN+=("start") ;; + g | generate ) RUN+=("generate") ;; + n | config-next ) BOULDER_CONFIG_DIR="test/config-next" ;; + h | help ) print_usage_exit ;; + ??* ) exit_msg "Illegal option --$OPT" ;; # bad long option + ? ) exit 2 ;; # bad short option (error reported via getopts) + esac +done +shift $((OPTIND-1)) # remove parsed options and args from $@ list + +# The list of segments to run. Order doesn't matter. +if [ -z "${RUN[@]+x}" ] +then + RUN+=("lints" "unit" "integration") +fi + +# Filter is used by unit and integration but should not be used for both at the same time +if [[ "${RUN[@]}" =~ unit ]] && [[ "${RUN[@]}" =~ integration ]] && [[ -n "${FILTER[@]+x}" ]] +then + exit_msg "Illegal option: (-f, --filter) when specifying both (-u, --unit) and (-i, --integration)" +fi + +# If unit + filter: set correct flags for go test +if [[ "${RUN[@]}" =~ unit ]] && [[ -n "${FILTER[@]+x}" ]] +then + FILTER=(--test.run "${FILTER[@]}") +fi + +# If integration + filter: set correct flags for test/integration-test.py +if [[ "${RUN[@]}" =~ integration ]] && [[ -n "${FILTER[@]+x}" ]] +then + FILTER=(--filter "${FILTER[@]}") +fi + +# If unit test packages are not specified: set flags to run unit tests +# for all boulder packages +if [ -z "${UNIT_PACKAGES[@]+x}" ] +then + # '-p=1' configures unit tests to run serially, rather than in parallel. Our + # unit tests depend on mutating a database and then cleaning up after + # themselves. If these test were run in parallel, they could fail spuriously + # due to one test modifying a table (especially registrations) while another + # test is reading from it. + # https://github.com/letsencrypt/boulder/issues/1499 + # https://pkg.go.dev/cmd/go#hdr-Testing_flags + UNIT_FLAGS+=("-p=1") + UNIT_PACKAGES+=("./...") +fi + +print_heading "Boulder Test Suite CLI" +print_heading "Settings:" + +# On EXIT, trap and print outcome +trap "print_outcome" EXIT + +settings="$(cat -- <<-EOM + RUN: ${RUN[@]} + BOULDER_CONFIG_DIR: $BOULDER_CONFIG_DIR + GOCACHE: $(go env GOCACHE) + UNIT_PACKAGES: ${UNIT_PACKAGES[@]} + UNIT_FLAGS: ${UNIT_FLAGS[@]} + FILTER: ${FILTER[@]} + +EOM +)" + +echo "$settings" +print_heading "Starting..." + +# +# Run various linters. +# +STAGE="lints" +if [[ "${RUN[@]}" =~ "$STAGE" ]] ; then + print_heading "Running Lints" + golangci-lint run --timeout 9m ./... + # Implicitly loads staticcheck.conf from the root of the boulder repository + staticcheck ./... + python3 test/grafana/lint.py + # Check for common spelling errors using typos. + # Update .typos.toml if you find false positives + run_and_expect_silence typos + # Check test JSON configs are formatted consistently + run_and_expect_silence ./test/format-configs.py 'test/config*/*.json' +fi + +# +# Unit Tests. +# +STAGE="unit" +if [[ "${RUN[@]}" =~ "$STAGE" ]] ; then + print_heading "Running Unit Tests" + run_unit_tests +fi + +# +# Integration tests +# +STAGE="integration" +if [[ "${RUN[@]}" =~ "$STAGE" ]] ; then + print_heading "Running Integration Tests" + python3 test/integration-test.py --chisel --gotest "${FILTER[@]}" +fi + +# Test that just ./start.py works, which is a proxy for testing that +# `docker compose up` works, since that just runs start.py (via entrypoint.sh). +STAGE="start" +if [[ "${RUN[@]}" =~ "$STAGE" ]] ; then + print_heading "Running Start Test" + python3 start.py & + for I in {1..115}; do + sleep 1 + curl -s http://localhost:4001/directory && echo "Boulder took ${I} seconds to come up" && break + done + if [ "${I}" -eq 115 ]; then + echo "Boulder did not come up after ${I} seconds during ./start.py." + exit 1 + fi +fi + +# Run generate to make sure all our generated code can be re-generated with +# current tools. +# Note: Some of the tools we use seemingly don't understand ./vendor yet, and +# so will fail if imports are not available in $GOPATH. +STAGE="generate" +if [[ "${RUN[@]}" =~ "$STAGE" ]] ; then + print_heading "Running Generate" + # Additionally, we need to run go install before go generate because the stringer command + # (using in ./grpc/) checks imports, and depends on the presence of a built .a + # file to determine an import really exists. See + # https://golang.org/src/go/internal/gcimporter/gcimporter.go#L30 + # Without this, we get error messages like: + # stringer: checking package: grpc/bcodes.go:6:2: could not import + # github.com/letsencrypt/boulder/probs (can't find import: + # github.com/letsencrypt/boulder/probs) + go install ./probs + go install ./vendor/google.golang.org/grpc/codes + run_and_expect_silence go generate ./... + run_and_expect_silence git diff --exit-code . +fi + +# Because set -e stops execution in the instance of a command or pipeline +# error; if we got here we assume success +STATUS="SUCCESS" diff --git a/third-party/github.com/letsencrypt/boulder/test/aia-test-srv/main.go b/third-party/github.com/letsencrypt/boulder/test/aia-test-srv/main.go new file mode 100644 index 000000000..542e34fc1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/aia-test-srv/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "net/url" + "os" + "path" + "regexp" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/issuance" +) + +type aiaTestSrv struct { + issuersByName map[string]*issuance.Certificate +} + +func (srv *aiaTestSrv) handleIssuer(w http.ResponseWriter, r *http.Request) { + issuerName, err := url.PathUnescape(r.URL.Path[1:]) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + issuerName = strings.ReplaceAll(issuerName, "-", " ") + + issuer, ok := srv.issuersByName[issuerName] + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(fmt.Sprintf("issuer %q not found", issuerName))) + return + } + + w.Header().Set("Content-Type", "application/pkix-cert") + w.WriteHeader(http.StatusOK) + w.Write(issuer.Certificate.Raw) +} + +// This regex excludes the "...-cross.cert.pem" files, since we don't serve our +// cross-signed certs at AIA URLs. +var issuerCertRegex = regexp.MustCompile(`int-(rsa|ecdsa)-[a-z]\.cert\.pem$`) + +func main() { + listenAddr := flag.String("addr", "", "Address to listen on") + hierarchyDir := flag.String("hierarchy", "", "Directory to load certs from") + flag.Parse() + + files, err := os.ReadDir(*hierarchyDir) + cmd.FailOnError(err, "opening hierarchy directory") + + byName := make(map[string]*issuance.Certificate) + for _, file := range files { + if issuerCertRegex.Match([]byte(file.Name())) { + cert, err := issuance.LoadCertificate(path.Join(*hierarchyDir, file.Name())) + cmd.FailOnError(err, "loading issuer certificate") + + name := cert.Certificate.Subject.CommonName + if _, found := byName[name]; found { + cmd.FailOnError(fmt.Errorf("loaded two certs with CN %q", name), "") + } + byName[name] = cert + } + } + + srv := aiaTestSrv{ + issuersByName: byName, + } + + http.HandleFunc("/", srv.handleIssuer) + + s := http.Server{ + ReadTimeout: 30 * time.Second, + Addr: *listenAddr, + } + + go func() { + err := s.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running TLS server") + } + }() + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }() + + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/akamai-test-srv/main.go b/third-party/github.com/letsencrypt/boulder/test/akamai-test-srv/main.go new file mode 100644 index 000000000..f53138133 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/akamai-test-srv/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/letsencrypt/boulder/akamai" + "github.com/letsencrypt/boulder/cmd" +) + +func main() { + listenAddr := flag.String("listen", "localhost:6789", "Address to listen on") + secret := flag.String("secret", "", "Akamai client secret") + flag.Parse() + + v3Purges := [][]string{} + mu := sync.Mutex{} + + http.HandleFunc("/debug/get-purges", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + body, err := json.Marshal(struct { + V3 [][]string + }{V3: v3Purges}) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write(body) + }) + + http.HandleFunc("/debug/reset-purges", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + v3Purges = [][]string{} + w.WriteHeader(http.StatusOK) + }) + + http.HandleFunc("/ccu/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Println("Wrong method:", r.Method) + return + } + mu.Lock() + defer mu.Unlock() + var purgeRequest struct { + Objects []string `json:"objects"` + } + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Println("Can't read body:", err) + return + } + if err = akamai.CheckSignature(*secret, "http://"+*listenAddr, r, body); err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Println("Bad signature:", err) + return + } + if err = json.Unmarshal(body, &purgeRequest); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Println("Can't unmarshal:", err) + return + } + if len(purgeRequest.Objects) == 0 { + w.WriteHeader(http.StatusBadRequest) + fmt.Println("Bad parameters:", purgeRequest) + return + } + v3Purges = append(v3Purges, purgeRequest.Objects) + + respObj := struct { + PurgeID string + HTTPStatus int + EstimatedSeconds int + }{ + PurgeID: "welcome-to-the-purge", + HTTPStatus: http.StatusCreated, + EstimatedSeconds: 153, + } + w.WriteHeader(http.StatusCreated) + resp, err := json.Marshal(respObj) + if err != nil { + return + } + w.Write(resp) + }) + + s := http.Server{ + ReadTimeout: 30 * time.Second, + Addr: *listenAddr, + } + + go func() { + err := s.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running TLS server") + } + }() + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }() + + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/asserts.go b/third-party/github.com/letsencrypt/boulder/test/asserts.go new file mode 100644 index 000000000..73377423f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/asserts.go @@ -0,0 +1,251 @@ +package test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + io_prometheus_client "github.com/prometheus/client_model/go" +) + +// Assert a boolean +func Assert(t *testing.T, result bool, message string) { + t.Helper() + if !result { + t.Fatal(message) + } +} + +// AssertNil checks that an object is nil. Being a "boxed nil" (a nil value +// wrapped in a non-nil interface type) is not good enough. +func AssertNil(t *testing.T, obj interface{}, message string) { + t.Helper() + if obj != nil { + t.Fatal(message) + } +} + +// AssertNotNil checks an object to be non-nil. Being a "boxed nil" (a nil value +// wrapped in a non-nil interface type) is not good enough. +// Note that there is a gap between AssertNil and AssertNotNil. Both fail when +// called with a boxed nil. This is intentional: we want to avoid boxed nils. +func AssertNotNil(t *testing.T, obj interface{}, message string) { + t.Helper() + if obj == nil { + t.Fatal(message) + } + switch reflect.TypeOf(obj).Kind() { + // .IsNil() only works on chan, func, interface, map, pointer, and slice. + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if reflect.ValueOf(obj).IsNil() { + t.Fatal(message) + } + } +} + +// AssertBoxedNil checks that an inner object is nil. This is intentional for +// testing purposes only. +func AssertBoxedNil(t *testing.T, obj interface{}, message string) { + t.Helper() + typ := reflect.TypeOf(obj).Kind() + switch typ { + // .IsNil() only works on chan, func, interface, map, pointer, and slice. + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if !reflect.ValueOf(obj).IsNil() { + t.Fatal(message) + } + default: + t.Fatalf("Cannot check type \"%s\". Needs to be of type chan, func, interface, map, pointer, or slice.", typ) + } +} + +// AssertNotError checks that err is nil +func AssertNotError(t *testing.T, err error, message string) { + t.Helper() + if err != nil { + t.Fatalf("%s: %s", message, err) + } +} + +// AssertError checks that err is non-nil +func AssertError(t *testing.T, err error, message string) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected error but received none", message) + } +} + +// AssertErrorWraps checks that err can be unwrapped into the given target. +// NOTE: Has the side effect of actually performing that unwrapping. +func AssertErrorWraps(t *testing.T, err error, target interface{}) { + t.Helper() + if !errors.As(err, target) { + t.Fatalf("error does not wrap an error of the expected type: %q !> %+T", err.Error(), target) + } +} + +// AssertErrorIs checks that err wraps the given error +func AssertErrorIs(t *testing.T, err error, target error) { + t.Helper() + + if err == nil { + t.Fatal("err was unexpectedly nil and should not have been") + } + + if !errors.Is(err, target) { + t.Fatalf("error does not wrap expected error: %q !> %q", err.Error(), target.Error()) + } +} + +// AssertEquals uses the equality operator (==) to measure one and two +func AssertEquals(t *testing.T, one interface{}, two interface{}) { + t.Helper() + if reflect.TypeOf(one) != reflect.TypeOf(two) { + t.Fatalf("cannot test equality of different types: %T != %T", one, two) + } + if one != two { + t.Fatalf("%#v != %#v", one, two) + } +} + +// AssertDeepEquals uses the reflect.DeepEqual method to measure one and two +func AssertDeepEquals(t *testing.T, one interface{}, two interface{}) { + t.Helper() + if !reflect.DeepEqual(one, two) { + t.Fatalf("[%#v] !(deep)= [%#v]", one, two) + } +} + +// AssertMarshaledEquals marshals one and two to JSON, and then uses +// the equality operator to measure them +func AssertMarshaledEquals(t *testing.T, one interface{}, two interface{}) { + t.Helper() + oneJSON, err := json.Marshal(one) + AssertNotError(t, err, "Could not marshal 1st argument") + twoJSON, err := json.Marshal(two) + AssertNotError(t, err, "Could not marshal 2nd argument") + + if !bytes.Equal(oneJSON, twoJSON) { + t.Fatalf("[%s] !(json)= [%s]", oneJSON, twoJSON) + } +} + +// AssertUnmarshaledEquals unmarshals two JSON strings (got and expected) to +// a map[string]interface{} and then uses reflect.DeepEqual to check they are +// the same +func AssertUnmarshaledEquals(t *testing.T, got, expected string) { + t.Helper() + var gotMap, expectedMap map[string]interface{} + err := json.Unmarshal([]byte(got), &gotMap) + AssertNotError(t, err, "Could not unmarshal 'got'") + err = json.Unmarshal([]byte(expected), &expectedMap) + AssertNotError(t, err, "Could not unmarshal 'expected'") + if len(gotMap) != len(expectedMap) { + t.Errorf("Expected had %d keys, got had %d", len(gotMap), len(expectedMap)) + } + for k, v := range expectedMap { + if !reflect.DeepEqual(v, gotMap[k]) { + t.Errorf("Field %q: Expected \"%v\", got \"%v\"", k, v, gotMap[k]) + } + } +} + +// AssertNotEquals uses the equality operator to measure that one and two +// are different +func AssertNotEquals(t *testing.T, one interface{}, two interface{}) { + t.Helper() + if one == two { + t.Fatalf("%#v == %#v", one, two) + } +} + +// AssertByteEquals uses bytes.Equal to measure one and two for equality. +func AssertByteEquals(t *testing.T, one []byte, two []byte) { + t.Helper() + if !bytes.Equal(one, two) { + t.Fatalf("Byte [%s] != [%s]", + base64.StdEncoding.EncodeToString(one), + base64.StdEncoding.EncodeToString(two)) + } +} + +// AssertContains determines whether needle can be found in haystack +func AssertContains(t *testing.T, haystack string, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Fatalf("String [%s] does not contain [%s]", haystack, needle) + } +} + +// AssertNotContains determines if needle is not found in haystack +func AssertNotContains(t *testing.T, haystack string, needle string) { + t.Helper() + if strings.Contains(haystack, needle) { + t.Fatalf("String [%s] contains [%s]", haystack, needle) + } +} + +// AssertSliceContains determines if needle can be found in haystack +func AssertSliceContains[T comparable](t *testing.T, haystack []T, needle T) { + t.Helper() + for _, item := range haystack { + if item == needle { + return + } + } + t.Fatalf("Slice %v does not contain %v", haystack, needle) +} + +// AssertMetricWithLabelsEquals determines whether the value held by a prometheus Collector +// (e.g. Gauge, Counter, CounterVec, etc) is equal to the expected float64. +// In order to make useful assertions about just a subset of labels (e.g. for a +// CounterVec with fields "host" and "valid", being able to assert that two +// "valid": "true" increments occurred, without caring which host was tagged in +// each), takes a set of labels and ignores any metrics which have different +// label values. +// Only works for simple metrics (Counters and Gauges), or for the *count* +// (not value) of data points in a Histogram. +func AssertMetricWithLabelsEquals(t *testing.T, c prometheus.Collector, l prometheus.Labels, expected float64) { + t.Helper() + ch := make(chan prometheus.Metric) + done := make(chan struct{}) + go func() { + c.Collect(ch) + close(done) + }() + var total float64 + timeout := time.After(time.Second) +loop: + for { + metric: + select { + case <-timeout: + t.Fatal("timed out collecting metrics") + case <-done: + break loop + case m := <-ch: + var iom io_prometheus_client.Metric + _ = m.Write(&iom) + for _, lp := range iom.Label { + // If any of the labels on this metric have the same name as but + // different value than a label in `l`, skip this metric. + val, ok := l[lp.GetName()] + if ok && lp.GetValue() != val { + break metric + } + } + // Exactly one of the Counter, Gauge, or Histogram values will be set by + // the .Write() operation, so add them all because the others will be 0. + total += iom.Counter.GetValue() + total += iom.Gauge.GetValue() + total += float64(iom.Histogram.GetSampleCount()) + } + } + AssertEquals(t, total, expected) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/main.go b/third-party/github.com/letsencrypt/boulder/test/block-a-key/main.go new file mode 100644 index 000000000..0d027712a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/main.go @@ -0,0 +1,108 @@ +// block-a-key is a small utility for creating key blocklist entries. +package main + +import ( + "crypto" + "errors" + "flag" + "fmt" + "log" + "os" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/web" +) + +const usageHelp = ` +block-a-key is utility tool for generating a SHA256 hash of the SubjectPublicKeyInfo +from a certificate or a synthetic SubjectPublicKeyInfo generated from a JWK public key. +It outputs the Base64 encoding of that hash. + +The produced encoded digest can be used with Boulder's key blocklist to block +any ACME account creation or certificate requests that use the same public +key. + +If you already have an SPKI hash, and it's a SHA256 hash, you can add it directly +to the key blocklist. If it's in hex form you'll need to convert it to base64 first. + +installation: + go install github.com/letsencrypt/boulder/test/block-a-key/... + +usage: + block-a-key -cert + block-a-key -jwk + +output format: + # + - "" + +examples: + $> block-a-key -jwk ./test/block-a-key/test/test.ecdsa.jwk.json + ./test/block-a-key/test/test.ecdsa.jwk.json cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + $> block-a-key -cert ./test/block-a-key/test/test.rsa.cert.pem + ./test/block-a-key/test/test.rsa.cert.pem Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= +` + +// keyFromCert returns the public key from a PEM encoded certificate located in +// pemFile or returns an error. +func keyFromCert(pemFile string) (crypto.PublicKey, error) { + c, err := core.LoadCert(pemFile) + if err != nil { + return nil, err + } + return c.PublicKey, nil +} + +// keyFromJWK returns the public key from a JSON encoded JOSE JWK located in +// jsonFile or returns an error. +func keyFromJWK(jsonFile string) (crypto.PublicKey, error) { + jwk, err := web.LoadJWK(jsonFile) + if err != nil { + return nil, err + } + return jwk.Key, nil +} + +func main() { + certFileArg := flag.String("cert", "", "path to a PEM encoded X509 certificate file") + jwkFileArg := flag.String("jwk", "", "path to a JSON encoded JWK file") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s\n\n", usageHelp) + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + + if *certFileArg == "" && *jwkFileArg == "" { + log.Fatalf("error: a -cert or -jwk argument must be provided") + } + + if *certFileArg != "" && *jwkFileArg != "" { + log.Fatalf("error: -cert and -jwk arguments are mutually exclusive") + } + + var file string + var key crypto.PublicKey + var err error + + if *certFileArg != "" { + file = *certFileArg + key, err = keyFromCert(file) + } else if *jwkFileArg != "" { + file = *jwkFileArg + key, err = keyFromJWK(file) + } else { + err = errors.New("unexpected command line state") + } + if err != nil { + log.Fatalf("error loading public key: %v", err) + } + + spkiHash, err := core.KeyDigestB64(key) + if err != nil { + log.Fatalf("error computing spki hash: %v", err) + } + fmt.Printf(" # %s\n - %s\n", file, spkiHash) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/main_test.go b/third-party/github.com/letsencrypt/boulder/test/block-a-key/main_test.go new file mode 100644 index 000000000..6dbe265e0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "crypto" + "testing" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" +) + +func TestKeyBlocking(t *testing.T) { + testCases := []struct { + name string + certPath string + jwkPath string + expected string + }{ + // NOTE(@cpu): The JWKs and certificates were generated with the same + // keypair within an algorithm/parameter family. E.g. the RSA JWK public key + // matches the RSA certificate public key. The ECDSA JWK public key matches + // the ECDSA certificate public key. + { + name: "P-256 ECDSA JWK", + jwkPath: "test/test.ecdsa.jwk.json", + expected: "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + }, + { + name: "2048 RSA JWK", + jwkPath: "test/test.rsa.jwk.json", + expected: "Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE=", + }, + { + name: "P-256 ECDSA Certificate", + certPath: "test/test.ecdsa.cert.pem", + expected: "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + }, + { + name: "2048 RSA Certificate", + certPath: "test/test.rsa.cert.pem", + expected: "Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE=", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var key crypto.PublicKey + var err error + if tc.jwkPath != "" { + key, err = keyFromJWK(tc.jwkPath) + } else { + key, err = keyFromCert(tc.certPath) + } + test.AssertNotError(t, err, "error getting key from input file") + spkiHash, err := core.KeyDigestB64(key) + test.AssertNotError(t, err, "error computing spki hash") + test.AssertEquals(t, spkiHash, tc.expected) + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/README.txt b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/README.txt new file mode 100644 index 000000000..9035a4a56 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/README.txt @@ -0,0 +1,7 @@ +The test files in this directory can be recreated with the following small program: + + https://gist.github.com/cpu/df50564a473b3e8556917eb80d99ea56 + +Crucially the public keys in the generated JWKs/Certs are shared within +algorithm/parameters. E.g. the ECDSA JWK has the same public key as the ECDSA +Cert. diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.cert.pem b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.cert.pem new file mode 100644 index 000000000..09bc304f1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.cert.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIH1MIGboAMCAQICAQEwCgYIKoZIzj0EAwIwADAiGA8wMDAxMDEwMTAwMDAwMFoY +DzAwMDEwMTAxMDAwMDAwWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4LqG +kzIYWSgmyTS+B9Eet1xx1wpCKiSklMPnHfFp8eSHr1uNk6ilWv/s4AoKHSvMNAb/ +1uPfxjlijEIjK2bOQKMCMAAwCgYIKoZIzj0EAwIDSQAwRgIhAJBK1/C1BYDnzSCu +cR2pE40d8dyrRuHKj8htO/fzRgCgAiEA0UG0Vda8w0Tp84AMlJpZHOx9QUbwExSl +oFEDADJ9WQM= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.jwk.json b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.jwk.json new file mode 100644 index 000000000..364a666d2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.ecdsa.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ECDSA","x":"4LqGkzIYWSgmyTS-B9Eet1xx1wpCKiSklMPnHfFp8eQ","y":"h69bjZOopVr_7OAKCh0rzDQG_9bj38Y5YoxCIytmzkA"} diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.cert.pem b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.cert.pem new file mode 100644 index 000000000..502f94f99 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.cert.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICgTCCAWmgAwIBAgIBATANBgkqhkiG9w0BAQsFADAAMCIYDzAwMDEwMTAxMDAw +MDAwWhgPMDAwMTAxMDEwMDAwMDBaMAAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQC+epImi+GdM4ypmQ7LeWSYbbX0AHeZJvRScp5+JvkVQNTIDjQGnYxw +7omOW1dkn0qGkQckFmvUmCHXuK6oF0GYOvRzEdOwb6KeTb+ONYQHGLirKU2bt+um +JxiB/9PMaV5yPwpyNVi0XV5Rr+BpHdV1i9lm542+4zwfWiYRKT1+tjpvicmyK0av +T/60U0kfeeSdAU0TcSFR4RDEw1fudXIRk7FPgd2GHjeJeAeMmLL4Vabr+uSecGpp +THdkbnPDV51WVPHcyoOV6rdicSEoqE9aoeMjQXZ6SntXGjY4pqlyuwjqocLZStEK +ztxp3D7eyeHub9nrCgp+UsxaWns1DtP3AgMBAAGjAjAAMA0GCSqGSIb3DQEBCwUA +A4IBAQA9sazSAm6umbleFWDrh3oyGaFBzYvRfeOAEquJky36qREjBWvrS2Yi66eX +L9Uoavr/CIk+U9qRPl81cHi5qsFBuDi+OKZzG32Uq7Rw8h+7f/9HVEUyVVy1p7v8 +iqZvygU70NeT0cT91eSl6LV88BdjhbjI6Hk1+AVF6UPAmzkgJIFAwwUWa2HUT+Ni +nMxzRThuLyPbYt4clz6bGzk26LIdoByJH4pYabXh05OwalBJjMVR/4ek9blrVMAg +b4a7Eq/WXq+CVwWnb3oholDOJo3l/KwNuG6HD90JU0Vu4fipFqmsXhBHYVNVu94y +wJWm+dAtEeAcp8KfOv/IBMCjDkyt +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.jwk.json b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.jwk.json new file mode 100644 index 000000000..958a78ba3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/block-a-key/test/test.rsa.jwk.json @@ -0,0 +1 @@ +{"kty":"RSA","alg":"RS256","n":"vnqSJovhnTOMqZkOy3lkmG219AB3mSb0UnKefib5FUDUyA40Bp2McO6JjltXZJ9KhpEHJBZr1Jgh17iuqBdBmDr0cxHTsG-ink2_jjWEBxi4qylNm7frpicYgf_TzGlecj8KcjVYtF1eUa_gaR3VdYvZZueNvuM8H1omESk9frY6b4nJsitGr0_-tFNJH3nknQFNE3EhUeEQxMNX7nVyEZOxT4Hdhh43iXgHjJiy-FWm6_rknnBqaUx3ZG5zw1edVlTx3MqDleq3YnEhKKhPWqHjI0F2ekp7Vxo2OKapcrsI6qHC2UrRCs7cadw-3snh7m_Z6woKflLMWlp7NQ7T9w","e":"AQAB"} diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/Dockerfile b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/Dockerfile new file mode 100644 index 000000000..3e3680b55 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/Dockerfile @@ -0,0 +1,52 @@ +FROM buildpack-deps:focal-scm as godeps +ARG GO_VERSION +# Provided automatically by docker build. +ARG TARGETPLATFORM +ARG BUILDPLATFORM +ENV TARGETPLATFORM=${TARGETPLATFORM:-$BUILDPLATFORM} +ENV GO_VERSION=$GO_VERSION +ENV PATH /usr/local/go/bin:/usr/local/protoc/bin:$PATH +ENV GOBIN /usr/local/bin/ +RUN curl "https://dl.google.com/go/go${GO_VERSION}.$(echo $TARGETPLATFORM | sed 's|\/|-|').tar.gz" |\ + tar -C /usr/local -xz +RUN go install github.com/rubenv/sql-migrate/sql-migrate@v1.1.2 +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.1 +RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@bb9882e6ae58f0a80a6390b50a5ec3bd63e46a3c +RUN go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@66511d8 +RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.57.2 +RUN go install honnef.co/go/tools/cmd/staticcheck@2023.1.7 +RUN go install github.com/jsha/minica@v1.1.0 + +FROM rust:bullseye as rustdeps +# Provided automatically by docker build. +ARG TARGETPLATFORM +ARG BUILDPLATFORM +ENV TARGETPLATFORM=${TARGETPLATFORM:-$BUILDPLATFORM} +COPY build-rust-deps.sh /tmp/build-rust-deps.sh +RUN /tmp/build-rust-deps.sh + +# When the version of Ubuntu (focal, jammy, etc) changes, ensure that the +# version of libc6 is compatible with the rustdeps container above. See +# https://github.com/letsencrypt/boulder/pull/7248#issuecomment-1896612920 for +# more information. +# +# Run this command in each container: dpkg -l libc6 +FROM buildpack-deps:focal-scm +# Provided automatically by docker build. +ARG TARGETPLATFORM +ARG BUILDPLATFORM +ENV TARGETPLATFORM=${TARGETPLATFORM:-$BUILDPLATFORM} +COPY requirements.txt /tmp/requirements.txt +COPY boulder.rsyslog.conf /etc/rsyslog.d/ +COPY build.sh /tmp/build.sh +RUN /tmp/build.sh + +RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf +RUN sed -i '/$ActionFileDefaultTemplate/s/^/#/' /etc/rsyslog.conf +RUN sed -i '/$RepeatedMsgReduction on/s/^/#/' /etc/rsyslog.conf + +COPY --from=godeps /usr/local/bin/* /usr/local/bin/ +COPY --from=godeps /usr/local/go/ /usr/local/go/ +COPY --from=rustdeps /usr/local/cargo/bin/typos /usr/local/bin/typos + +ENV PATH /usr/local/go/bin:/usr/local/protoc/bin:$PATH diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/README.md b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/README.md new file mode 100644 index 000000000..2a418e57a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/README.md @@ -0,0 +1,57 @@ +# Boulder-Tools Docker Image Utilities + +In CI and our development environment we do not rely on the Go environment of +the host machine, and instead use Go installed in a container. To simplify +things we separate all of Boulder's build dependencies into its own +`boulder-tools` Docker image. + +## Setup + +To build boulder-tools images, you'll need a Docker set up to do cross-platform +builds (we build for both amd64 and arm64 so developers with Apple silicon can use +boulder-tools in their dev environment). + +### Ubuntu steps: +```sh +sudo apt-get install qemu binfmt-support qemu-user-static +docker buildx create --use --name=cross +``` + +After setup, the output of `docker buildx ls` should contain an entry like: + +```sh +cross0 unix:///var/run/docker.sock running linux/amd64, linux/386, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6 +``` + +If you see an entry like: + +```sh +cross0 unix:///var/run/docker.sock stopped +``` + +That's probably fine; the instance will be started when you run +`tag_and_upload.sh` (which runs `docker buildx build`). + +### macOS steps: +Developers running macOS 12 and later with Docker Desktop 4 and later should +be able to use boulder-tools without any pre-setup. + +## Go Versions + +Rather than install multiple versions of Go within the same `boulder-tools` +container we maintain separate images for each Go version we support. + +When a new Go version is available we perform several steps to integrate it +to our workflow: + +1. We add it to the `GO_VERSIONS` array in `tag_and_upload.sh`. +2. We run the `tag_and_upload.sh` script to build, tag, and upload + a `boulder-tools` image for each of the `GO_VERSIONS`. +3. We update `.github/workflows/boulder-ci.yml` to add the new image tag(s). +4. We update the remaining `.github/workflows/` yaml files that use a `GO_VERSION` matrix with the new version of Go. +5. We update `docker-compose.yml` to update the default image tag (optional). + +After some time when we have spot checked the new Go release and coordinated +a staging/prod environment upgrade with the operations team we can remove the +old `GO_VERSIONS` entries, delete their respective build matrix items, and update +`docker-compose.yml`. diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/boulder.rsyslog.conf b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/boulder.rsyslog.conf new file mode 100644 index 000000000..a1b8d6036 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/boulder.rsyslog.conf @@ -0,0 +1,18 @@ +module( load="builtin:omfile" template="LELogFormat" ) + +template( name="LELogFormat" type="list" ) { + property(name="timereported" dateFormat="rfc3339") + constant(value=" ") + property(name="hostname" field.delimiter="46" field.number="1") + constant(value=" datacenter ") + property(name="syslogseverity") + constant(value=" ") + property(name="syslogtag") + property(name="msg" spifno1stsp="on" ) + property(name="msg" droplastlf="on" ) + constant(value="\n") +} + +template( name="TmplAll" type="string" string="/var/log/%PROGRAMNAME%.log" ) + +action( type="omfile" dynaFile="TmplAll" ) diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build-rust-deps.sh b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build-rust-deps.sh new file mode 100644 index 000000000..21074baa9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build-rust-deps.sh @@ -0,0 +1,9 @@ +#!/bin/bash -ex + +PROTO_ARCH=x86_64 +if [ "${TARGETPLATFORM}" = linux/arm64 ]; then + # For our Mac using friends on Apple Silicon and other 64bit ARM chips. + PROTO_ARCH=aarch64 +fi + +cargo install typos-cli --target "${PROTO_ARCH}-unknown-linux-gnu" diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build.sh b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build.sh new file mode 100644 index 000000000..bfa5cebd6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/build.sh @@ -0,0 +1,34 @@ +#!/bin/bash -ex + +apt-get update + +# Install system deps +apt-get install -y --no-install-recommends \ + mariadb-client-core-10.3 \ + rsyslog \ + build-essential \ + opensc \ + unzip \ + python3-pip \ + gcc \ + ca-certificates \ + softhsm2 + +PROTO_ARCH=x86_64 +if [ "${TARGETPLATFORM}" = linux/arm64 ] +then + PROTO_ARCH=aarch_64 +fi + +curl -L https://github.com/google/protobuf/releases/download/v3.20.1/protoc-3.20.1-linux-"${PROTO_ARCH}".zip -o /tmp/protoc.zip +unzip /tmp/protoc.zip -d /usr/local/protoc + +pip3 install -r /tmp/requirements.txt + +apt-get clean -y + +# Tell git to trust the directory where the boulder repo volume is mounted +# by `docker compose`. +git config --global --add safe.directory /boulder + +rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/requirements.txt b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/requirements.txt new file mode 100644 index 000000000..b3f7766a4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/requirements.txt @@ -0,0 +1,4 @@ +acme>=2.0 +cryptography>=0.7 +PyOpenSSL +requests diff --git a/third-party/github.com/letsencrypt/boulder/test/boulder-tools/tag_and_upload.sh b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/tag_and_upload.sh new file mode 100644 index 000000000..991b23fa5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/boulder-tools/tag_and_upload.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -feuxo pipefail + +cd $(dirname $0) + +DATESTAMP=$(date +%Y-%m-%d) +DOCKER_REPO="letsencrypt/boulder-tools" + +# These versions are only built for platforms that we run in CI. +# When updating these GO_CI_VERSIONS, please also update +# .github/workflows/release.yml, +# .github/workflows/try-release.yml if appropriate, +# and .github/workflows/boulder-ci.yml with the new container tag. +GO_CI_VERSIONS=( "1.22.3" ) + +echo "Please login to allow push to DockerHub" +docker login + +# Usage: build_and_push_image $GO_VERSION +build_and_push_image() { + GO_VERSION="$1" + TAG_NAME="${DOCKER_REPO}:go${GO_VERSION}_${DATESTAMP}" + echo "Building boulder-tools image ${TAG_NAME}" + + # build, tag, and push the image. + docker buildx build \ + --build-arg "GO_VERSION=${GO_VERSION}" \ + --progress plain \ + --push \ + --tag "${TAG_NAME}" \ + --platform "linux/amd64" \ + . +} + +for GO_VERSION in "${GO_CI_VERSIONS[@]}" +do + build_and_push_image $GO_VERSION +done diff --git a/third-party/github.com/letsencrypt/boulder/test/certs.go b/third-party/github.com/letsencrypt/boulder/test/certs.go new file mode 100644 index 000000000..6dd1ce5a2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs.go @@ -0,0 +1,95 @@ +package test + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "math/big" + "os" + "testing" + "time" + + "github.com/jmhodges/clock" +) + +// LoadSigner loads a PEM private key specified by filename or returns an error. +// Can be paired with issuance.LoadCertificate to get both a CA cert and its +// associated private key for use in signing throwaway test certs. +func LoadSigner(filename string) (crypto.Signer, error) { + keyBytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + // pem.Decode does not return an error as its 2nd arg, but instead the "rest" + // that was leftover from parsing the PEM block. We only care if the decoded + // PEM block was empty for this test function. + block, _ := pem.Decode(keyBytes) + if block == nil { + return nil, errors.New("Unable to decode private key PEM bytes") + } + + // Try decoding as an RSA private key + if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return rsaKey, nil + } + + // Try decoding as a PKCS8 private key + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + // Determine the key's true type and return it as a crypto.Signer + switch k := key.(type) { + case *rsa.PrivateKey: + return k, nil + case *ecdsa.PrivateKey: + return k, nil + } + } + + // Try as an ECDSA private key + if ecdsaKey, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return ecdsaKey, nil + } + + // Nothing worked! Fail hard. + return nil, errors.New("Unable to decode private key PEM bytes") +} + +// ThrowAwayCert is a small test helper function that creates a self-signed +// certificate with one SAN. It returns the parsed certificate and its serial +// in string form for convenience. +// The certificate returned from this function is the bare minimum needed for +// most tests and isn't a robust example of a complete end entity certificate. +func ThrowAwayCert(t *testing.T, clk clock.Clock) (string, *x509.Certificate) { + var nameBytes [3]byte + _, _ = rand.Read(nameBytes[:]) + name := fmt.Sprintf("%s.example.com", hex.EncodeToString(nameBytes[:])) + + var serialBytes [16]byte + _, _ = rand.Read(serialBytes[:]) + serial := big.NewInt(0).SetBytes(serialBytes[:]) + + key, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + AssertNotError(t, err, "rsa.GenerateKey failed") + + template := &x509.Certificate{ + SerialNumber: serial, + DNSNames: []string{name}, + NotBefore: clk.Now(), + NotAfter: clk.Now().Add(6 * 24 * time.Hour), + IssuingCertificateURL: []string{"http://localhost:4001/acme/issuer-cert/1234"}, + } + + testCertDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) + AssertNotError(t, err, "x509.CreateCertificate failed") + testCert, err := x509.ParseCertificate(testCertDER) + AssertNotError(t, err, "failed to parse self-signed cert DER") + + return fmt.Sprintf("%036x", serial), testCert +} diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/.gitignore b/third-party/github.com/letsencrypt/boulder/test/certs/.gitignore new file mode 100644 index 000000000..7d1b67231 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/.gitignore @@ -0,0 +1,4 @@ +/ipki +/misc +/webpki +/.softhsm-tokens diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/README.md b/third-party/github.com/letsencrypt/boulder/test/certs/README.md new file mode 100644 index 000000000..8d0f8a411 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/README.md @@ -0,0 +1,83 @@ +# Test keys and certificates + +## Dynamically-Generated PKIs + +This directory contains scripts and programs which generate PKIs (collections of +keys and certificates) for use in our integration tests. Each PKI has its own +subdirectory. The scripts do not regenerate a directory if it already exists, to +allow the generated files to be re-used across many runs on a developer's +machine. To force the scripts to regenerate a PKI, simply delete its whole +directory. + +This script is invoked automatically by the `bsetup` container in our docker +compose system. It is invoked automatically by `t.sh` and `tn.sh`. If you want +to run it manually, the expected way to do so is: + +```sh +$ docker compose up bsetup +[+] Running 0/1 +Attaching to bsetup-1 +bsetup-1 | Generating ipki/... +bsetup-1 | Generating webpki/... +bsetup-1 exited with code 0 +``` + +To add new certificates to an existing PKI, edit the script which generates that +PKI's subdirectory. To add a whole new PKI, create a new generation script, +execute that script from this directory's top-level `generate.sh`, and add the +new subdirectory to this directory's `.gitignore` file. + +### webpki + +The "webpki" PKI emulates our publicly-trusted hierarchy. It consists of RSA and +ECDSA roots, several intermediates and cross-signed intermediates, and CRLs. +These certificates and their keys are generated using the `ceremony` tool. The +private keys are stored in SoftHSM in the `.softhsm-tokens` subdirectory. + +This PKI is loaded by the CA, RA, and other components. It is used as the +issuance hierarchy for all end-entity certificates issued as part of the +integration tests. + +### ipki + +The "ipki" PKI emulates our internal PKI that the various Boulder services use +to authenticate each other when establishing gRPC connections. It includes one +certificate for each service which participates in our gRPC cluster. Some of +these certificates (for the services that we run multiple copies of) have +multiple names, so the same certificate can be loaded by each copy of that +service. + +It also contains some non-gRPC certificates which are nonetheless serving the +role of internal authentication between Let's Encrypt components: + +- The IP-address certificate used by challtestsrv (which acts as the integration + test environment's recursive resolver) for DoH handshakes. +- The certificate presented by mail-test-srv's SMTP endpoint. +- The certificate presented by the test redis cluster. +- The certificate presented by the WFE's API TLS handler (which is usually + behind some other load-balancer like nginx). + +This PKI is loaded by virtually every Boulder component. + +**Note:** the minica issuer certificate and the "localhost" end-entity +certificate are also used by several rocsp and ratelimit unit tests. The tests +use these certificates to authenticate to the docker-compose redis cluster, and +therefore cannot succeed outside of the docker environment anyway, so a +dependency on the ipki hierarchy having been generated does not break them +further. + +## Other Test PKIs + +A variety of other PKIs (collections of keys and certificates) exist in this +repository for the sake of unit and integration testing. We list them here as a +TODO-list of PKIs to remove and clean up: + +- unit test hierarchy: the //test/hierarchy/ directory holds a collection of + certificates used by unit tests which want access to realistic issuer certs + but don't want to rely on the //test/certs/webpki directory being generated. + These should be replaced by certs which the unit tests dynamically generate + in-memory, rather than loading from disk. +- unit test mocks: //test/test-key-5.der and //wfe2/wfe_test.go contain keys and + certificates which are used to elicit specific behavior from //mocks/mocks.go. + These should be replaced with dynamically-generated keys and more flexible + mocks. diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/generate.sh b/third-party/github.com/letsencrypt/boulder/test/certs/generate.sh new file mode 100644 index 000000000..0b33f8c18 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/generate.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +cd "$(realpath -- $(dirname -- "$0"))" + +# Check that `minica` is installed +command -v minica >/dev/null 2>&1 || { + echo >&2 "No 'minica' command available."; + echo >&2 "Check your GOPATH and run: 'go install github.com/jsha/minica@latest'."; + exit 1; +} + +ipki() ( + # Minica generates everything in-place, so we need to cd into the subdirectory. + # This function executes in a subshell, so this cd does not affect the parent + # script. + mkdir ipki + cd ipki + + # Create a generic cert which can be used by our test-only services (like + # mail-test-srv) that aren't sophisticated enough to present a different name. + # This first invocation also creates the issuer key, so the loops below can + # run in the background without racing to create it. + minica -domains localhost + + # Used by challtestsrv to negotiate DoH handshakes. Even though we think of + # challtestsrv as being external to our infrastructure (because it hosts the + # DNS records that the tests validate), it *also* takes the place of our + # recursive resolvers, so the DoH certificate that it presents to the VAs is + # part of our internal PKI. + minica -ip-addresses 10.77.77.77,10.88.88.88 + + # Presented by the WFE's TLS server, when configured. Normally the WFE lives + # behind another TLS-terminating server like nginx or apache, so the cert that + # it presents to that layer is also part of the internal PKI. + minica -domains "boulder" + + # Presented by the test redis cluster. Contains IP addresses because Boulder + # components find individual redis servers via SRV records. + minica -domains redis -ip-addresses 10.33.33.2,10.33.33.3,10.33.33.4,10.33.33.5,10.33.33.6,10.33.33.7,10.33.33.8,10.33.33.9 + + # Used by Boulder gRPC services as both server and client mTLS certificates. + for SERVICE in admin-revoker expiration-mailer ocsp-responder consul \ + wfe akamai-purger bad-key-revoker crl-updater crl-storer \ + health-checker rocsp-tool; do + minica -domains "${SERVICE}.boulder" & + done + + # Same as above, for services that we run multiple copies of. + for SERVICE in publisher nonce ra ca sa va rva ; do + minica -domains "${SERVICE}.boulder,${SERVICE}1.boulder,${SERVICE}2.boulder" & + done + + wait + + # minica sets restrictive directory permissions, but we don't want that + chmod -R go+rX . +) + +webpki() ( + # Because it invokes the ceremony tool, webpki.go expects to be invoked with + # the root of the boulder repo as the current working directory. + # This function executes in a subshell, so this cd does not affect the parent + # script. + cd ../.. + mkdir ./test/certs/webpki + go run ./test/certs/webpki.go +) + +if ! [ -d ipki ]; then + echo "Generating ipki/..." + ipki +fi + +if ! [ -d webpki ]; then + echo "Generating webpki/..." + webpki +fi diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa-cross.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa-cross.yaml new file mode 100644 index 000000000..1b0409045 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa-cross.yaml @@ -0,0 +1,33 @@ +ceremony-type: cross-certificate +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + signing-key-slot: {{ .SlotID }} + signing-key-label: root rsa +inputs: + public-key-path: test/certs/webpki/{{ .FileName }}.pubkey.pem + issuer-certificate-path: test/certs/webpki/root-rsa.cert.pem + certificate-to-cross-sign-path: test/certs/webpki/{{ .FileName }}.cert.pem +outputs: + certificate-path: test/certs/webpki/{{ .FileName }}-cross.cert.pem +certificate-profile: + signature-algorithm: SHA256WithRSA + common-name: {{ .CommonName }} + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + crl-url: http://rsa.example.com/crl + issuer-url: http://rsa.example.com/cert + policies: + - oid: 2.23.140.1.2.1 + key-usages: + - Digital Signature + - Cert Sign + - CRL Sign +skip-lints: + # The extKeyUsage extension is required for intermediate certificates, but is + # optional for cross-signed certs which share a Subject DN and Public Key with + # a Root Certificate (BRs 7.1.2.2.g). This cert is a cross-sign. + - n_mp_allowed_eku + - n_sub_ca_eku_missing diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa.yaml new file mode 100644 index 000000000..f5a4fc241 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-ecdsa.yaml @@ -0,0 +1,26 @@ +ceremony-type: intermediate +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + signing-key-slot: {{ .SlotID }} + signing-key-label: root ecdsa +inputs: + public-key-path: test/certs/webpki/{{ .FileName }}.pubkey.pem + issuer-certificate-path: test/certs/webpki/root-ecdsa.cert.pem +outputs: + certificate-path: test/certs/webpki/{{ .FileName }}.cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: {{ .CommonName }} + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + crl-url: http://ecdsa.example.com/crl + issuer-url: http://ecdsa.example.com/cert + policies: + - oid: 2.23.140.1.2.1 + key-usages: + - Digital Signature + - Cert Sign + - CRL Sign diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-rsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-rsa.yaml new file mode 100644 index 000000000..6ed8ddaff --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-cert-ceremony-rsa.yaml @@ -0,0 +1,26 @@ +ceremony-type: intermediate +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + signing-key-slot: {{ .SlotID }} + signing-key-label: root rsa +inputs: + public-key-path: test/certs/webpki/{{ .FileName }}.pubkey.pem + issuer-certificate-path: test/certs/webpki/root-rsa.cert.pem +outputs: + certificate-path: test/certs/webpki/{{ .FileName }}.cert.pem +certificate-profile: + signature-algorithm: SHA256WithRSA + common-name: {{ .CommonName }} + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + crl-url: http://rsa.example.com/crl + issuer-url: http://rsa.example.com/cert + policies: + - oid: 2.23.140.1.2.1 + key-usages: + - Digital Signature + - Cert Sign + - CRL Sign diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-ecdsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-ecdsa.yaml new file mode 100644 index 000000000..13835efe7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-ecdsa.yaml @@ -0,0 +1,12 @@ +ceremony-type: key +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + store-key-in-slot: {{ .SlotID }} + store-key-with-label: {{ .Label }} +key: + type: ecdsa + ecdsa-curve: P-384 +outputs: + public-key-path: test/certs/webpki/{{ .FileName }}.pubkey.pem + pkcs11-config-path: test/certs/webpki/{{ .FileName }}.pkcs11.json diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-rsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-rsa.yaml new file mode 100644 index 000000000..439abf15c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/intermediate-key-ceremony-rsa.yaml @@ -0,0 +1,12 @@ +ceremony-type: key +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + store-key-in-slot: {{ .SlotID }} + store-key-with-label: {{ .Label }} +key: + type: rsa + rsa-mod-length: 2048 +outputs: + public-key-path: test/certs/webpki/{{ .FileName }}.pubkey.pem + pkcs11-config-path: test/certs/webpki/{{ .FileName }}.pkcs11.json diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-ecdsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-ecdsa.yaml new file mode 100644 index 000000000..573533d48 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-ecdsa.yaml @@ -0,0 +1,25 @@ +ceremony-type: root +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + store-key-in-slot: {{ .SlotID }} + store-key-with-label: root ecdsa +key: + type: ecdsa + ecdsa-curve: P-384 +outputs: + public-key-path: test/certs/webpki/root-ecdsa.pubkey.pem + certificate-path: test/certs/webpki/root-ecdsa.cert.pem +certificate-profile: + signature-algorithm: ECDSAWithSHA384 + common-name: root ecdsa + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + key-usages: + - Cert Sign + - CRL Sign +skip-lints: + # Our roots don't sign OCSP, so they don't need the Digital Signature KU. + - n_ca_digital_signature_not_set diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-rsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-rsa.yaml new file mode 100644 index 000000000..1bc5a3230 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/root-ceremony-rsa.yaml @@ -0,0 +1,25 @@ +ceremony-type: root +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + store-key-in-slot: {{ .SlotID }} + store-key-with-label: root rsa +key: + type: rsa + rsa-mod-length: 4096 +outputs: + public-key-path: test/certs/webpki/root-rsa.pubkey.pem + certificate-path: test/certs/webpki/root-rsa.cert.pem +certificate-profile: + signature-algorithm: SHA256WithRSA + common-name: root rsa + organization: good guys + country: US + not-before: 2020-01-01 12:00:00 + not-after: 2040-01-01 12:00:00 + key-usages: + - Cert Sign + - CRL Sign +skip-lints: + # Our roots don't sign OCSP, so they don't need the Digital Signature KU. + - n_ca_digital_signature_not_set diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-ecdsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-ecdsa.yaml new file mode 100644 index 000000000..b68f36316 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-ecdsa.yaml @@ -0,0 +1,14 @@ +ceremony-type: crl +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + signing-key-slot: {{ .SlotID }} + signing-key-label: root ecdsa +inputs: + issuer-certificate-path: test/certs/webpki/root-ecdsa.cert.pem +outputs: + crl-path: test/certs/webpki/root-ecdsa.crl.pem +crl-profile: + this-update: 2023-01-01 12:00:00 + next-update: 2023-12-15 12:00:00 + number: 100 diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-rsa.yaml b/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-rsa.yaml new file mode 100644 index 000000000..ee23302e7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/root-crl-rsa.yaml @@ -0,0 +1,14 @@ +ceremony-type: crl +pkcs11: + module: /usr/lib/softhsm/libsofthsm2.so + pin: 1234 + signing-key-slot: {{ .SlotID }} + signing-key-label: root rsa +inputs: + issuer-certificate-path: test/certs/webpki/root-rsa.cert.pem +outputs: + crl-path: test/certs/webpki/root-rsa.crl.pem +crl-profile: + this-update: 2023-01-01 12:00:00 + next-update: 2023-12-15 12:00:00 + number: 100 diff --git a/third-party/github.com/letsencrypt/boulder/test/certs/webpki.go b/third-party/github.com/letsencrypt/boulder/test/certs/webpki.go new file mode 100644 index 000000000..759c11694 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/certs/webpki.go @@ -0,0 +1,176 @@ +// generate.go is a helper utility for integration tests. +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "text/template" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" +) + +// createSlot initializes a SoftHSM slot and token. SoftHSM chooses the highest empty +// slot, initializes it, and then assigns it a new randomly chosen slot ID. Since we can't +// predict this ID we need to parse out the new ID so that we can use it in the ceremony +// configs. +func createSlot(label string) (string, error) { + output, err := exec.Command("softhsm2-util", "--init-token", "--free", "--label", label, "--pin", "1234", "--so-pin", "5678").CombinedOutput() + if err != nil { + return "", err + } + re := regexp.MustCompile(`to slot (\d+)`) + matches := re.FindSubmatch(output) + if len(matches) != 2 { + return "", errors.New("unexpected number of slot matches") + } + return string(matches[1]), nil +} + +// genKey is used to run a root key ceremony with a given config, replacing +// SlotID in the YAML with a specific slot ID. +func genKey(path string, inSlot string) error { + tmpPath, err := rewriteConfig(path, map[string]string{"SlotID": inSlot}) + if err != nil { + return err + } + output, err := exec.Command("./bin/ceremony", "-config", tmpPath).CombinedOutput() + if err != nil { + return fmt.Errorf("error running ceremony for %s: %s:\n%s", tmpPath, err, string(output)) + } + return nil +} + +// rewriteConfig creates a temporary config based on the template at path +// using the variables in rewrites. +func rewriteConfig(path string, rewrites map[string]string) (string, error) { + tmplBytes, err := os.ReadFile(path) + if err != nil { + return "", err + } + tmp, err := os.CreateTemp(os.TempDir(), "ceremony-config") + if err != nil { + return "", err + } + defer tmp.Close() + tmpl, err := template.New("config").Parse(string(tmplBytes)) + if err != nil { + return "", err + } + err = tmpl.Execute(tmp, rewrites) + if err != nil { + return "", err + } + return tmp.Name(), nil +} + +// runCeremony is used to run a ceremony with a given config. +func runCeremony(path string) error { + output, err := exec.Command("./bin/ceremony", "-config", path).CombinedOutput() + if err != nil { + return fmt.Errorf("error running ceremony for %s: %s:\n%s", path, err, string(output)) + } + return nil +} + +func main() { + _ = blog.Set(blog.StdoutLogger(6)) + defer cmd.AuditPanic() + + // Compile the ceremony binary for easy re-use. + _, err := exec.Command("make", "build").CombinedOutput() + cmd.FailOnError(err, "compiling ceremony tool") + + // Create SoftHSM slots for the root signing keys + rsaRootKeySlot, err := createSlot("Root RSA") + cmd.FailOnError(err, "failed creating softhsm2 slot for RSA root key") + ecdsaRootKeySlot, err := createSlot("Root ECDSA") + cmd.FailOnError(err, "failed creating softhsm2 slot for ECDSA root key") + + // Generate the root signing keys and certificates + err = genKey("test/certs/root-ceremony-rsa.yaml", rsaRootKeySlot) + cmd.FailOnError(err, "failed to generate RSA root key + root cert") + err = genKey("test/certs/root-ceremony-ecdsa.yaml", ecdsaRootKeySlot) + cmd.FailOnError(err, "failed to generate ECDSA root key + root cert") + + // Do everything for all of the intermediates + for _, alg := range []string{"rsa", "ecdsa"} { + rootKeySlot := rsaRootKeySlot + if alg == "ecdsa" { + rootKeySlot = ecdsaRootKeySlot + } + + for _, inst := range []string{"a", "b", "c"} { + name := fmt.Sprintf("int %s %s", alg, inst) + // Note: The file names produced by this script (as a combination of this + // line, and the rest of the file name as specified in the various yaml + // template files) are meaningful and are consumed by aia-test-srv. If + // you change the structure of these file names, you will need to change + // aia-test-srv as well to recognize and consume the resulting files. + fileName := strings.Replace(name, " ", "-", -1) + + // Create SoftHSM slot + keySlot, err := createSlot(name) + cmd.FailOnError(err, "failed to create softhsm2 slot for intermediate key") + + // Generate key + keyConfigTemplate := fmt.Sprintf("test/certs/intermediate-key-ceremony-%s.yaml", alg) + keyConfig, err := rewriteConfig(keyConfigTemplate, map[string]string{ + "SlotID": keySlot, + "Label": name, + "FileName": fileName, + }) + cmd.FailOnError(err, "failed to rewrite intermediate key ceremony config") + + err = runCeremony(keyConfig) + cmd.FailOnError(err, "failed to generate intermediate key") + + // Generate cert + certConfigTemplate := fmt.Sprintf("test/certs/intermediate-cert-ceremony-%s.yaml", alg) + certConfig, err := rewriteConfig(certConfigTemplate, map[string]string{ + "SlotID": rootKeySlot, + "CommonName": name, + "FileName": fileName, + }) + cmd.FailOnError(err, "failed to rewrite intermediate cert ceremony config") + + err = runCeremony(certConfig) + cmd.FailOnError(err, "failed to generate intermediate cert") + + // Generate cross-certs, if necessary + if alg == "rsa" { + continue + } + + crossConfigTemplate := fmt.Sprintf("test/certs/intermediate-cert-ceremony-%s-cross.yaml", alg) + crossConfig, err := rewriteConfig(crossConfigTemplate, map[string]string{ + "SlotID": rsaRootKeySlot, + "CommonName": name, + "FileName": fileName, + }) + cmd.FailOnError(err, "failed to rewrite intermediate cross-cert ceremony config") + + err = runCeremony(crossConfig) + cmd.FailOnError(err, "failed to generate intermediate cross-cert") + } + } + + // Create CRLs stating that the intermediates are not revoked. + rsaTmpCRLConfig, err := rewriteConfig("test/certs/root-crl-rsa.yaml", map[string]string{ + "SlotID": rsaRootKeySlot, + }) + cmd.FailOnError(err, "failed to rewrite RSA root CRL config with key ID") + err = runCeremony(rsaTmpCRLConfig) + cmd.FailOnError(err, "failed to generate RSA root CRL") + + ecdsaTmpCRLConfig, err := rewriteConfig("test/certs/root-crl-ecdsa.yaml", map[string]string{ + "SlotID": ecdsaRootKeySlot, + }) + cmd.FailOnError(err, "failed to rewrite ECDSA root CRL config with key ID") + err = runCeremony(ecdsaTmpCRLConfig) + cmd.FailOnError(err, "failed to generate ECDSA root CRL") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/challtestsrv.py b/third-party/github.com/letsencrypt/boulder/test/challtestsrv.py new file mode 100644 index 000000000..56e589207 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/challtestsrv.py @@ -0,0 +1,291 @@ +import json +import requests + +class ChallTestServer: + """ + ChallTestServer is a wrapper around pebble-challtestsrv's HTTP management + API. If the pebble-challtestsrv process you want to interact with is using + a -management argument other than the default ('http://10.77.77.77:8055') you + can instantiate the ChallTestServer using the -management address in use. If + no custom address is provided the default is assumed. + """ + _baseURL = "http://10.77.77.77:8055" + + _paths = { + "set-ipv4": "/set-default-ipv4", + "set-ipv6": "/set-default-ipv6", + "del-history": "/clear-request-history", + "get-http-history": "/http-request-history", + "get-dns-history": "/dns-request-history", + "get-alpn-history": "/tlsalpn01-request-history", + "add-a": "/add-a", + "del-a": "/clear-a", + "add-aaaa": "/add-aaaa", + "del-aaaa": "/clear-aaaa", + "add-caa": "/add-caa", + "del-caa": "/clear-caa", + "add-redirect": "/add-redirect", + "del-redirect": "/del-redirect", + "add-http": "/add-http01", + "del-http": "/del-http01", + "add-txt": "/set-txt", + "del-txt": "/clear-txt", + "add-alpn": "/add-tlsalpn01", + "del-alpn": "/del-tlsalpn01", + "add-servfail": "/set-servfail", + "del-servfail": "/clear-servfail", + } + + def __init__(self, url=None): + if url is not None: + self._baseURL = url + + def _postURL(self, url, body): + response = requests.post( + url, + data=json.dumps(body)) + return response.text + + def _URL(self, path): + urlPath = self._paths.get(path, None) + if urlPath is None: + raise Exception("No challenge test server URL path known for {0}".format(path)) + return self._baseURL + urlPath + + def _clear_request_history(self, host, typ): + return self._postURL( + self._URL("del-history"), + { "host": host, "type": typ }) + + def set_default_ipv4(self, address): + """ + set_default_ipv4 sets the challenge server's default IPv4 address used + to respond to A queries when there are no specific mock A addresses for + the hostname being queried. Provide an empty string as the default + address to disable answering A queries except for hosts that have mock + A addresses added. + """ + return self._postURL( + self._URL("set-ipv4"), + { "ip": address }) + + def set_default_ipv6(self, address): + """ + set_default_ipv6 sets the challenge server's default IPv6 address used + to respond to AAAA queries when there are no specific mock AAAA + addresses for the hostname being queried. Provide an empty string as the + default address to disable answering AAAA queries except for hosts that + have mock AAAA addresses added. + """ + return self._postURL( + self._URL("set-ipv6"), + { "ip": address }) + + def add_a_record(self, host, addresses): + """ + add_a_record adds a mock A response to the challenge server's DNS + interface for the given host and IPv4 addresses. + """ + return self._postURL( + self._URL("add-a"), + { "host": host, "addresses": addresses }) + + def remove_a_record(self, host): + """ + remove_a_record removes a mock A response from the challenge server's DNS + interface for the given host. + """ + return self._postURL( + self._URL("del-a"), + { "host": host }) + + def add_aaaa_record(self, host, addresses): + """ + add_aaaa_record adds a mock AAAA response to the challenge server's DNS + interface for the given host and IPv6 addresses. + """ + return self._postURL( + self._URL("add-aaaa"), + { "host": host, "addresses": addresses }) + + def remove_aaaa_record(self, host): + """ + remove_aaaa_record removes mock AAAA response from the challenge server's DNS + interface for the given host. + """ + return self._postURL( + self._URL("del-aaaa"), + { "host": host }) + + def add_caa_issue(self, host, value): + """ + add_caa_issue adds a mock CAA response to the challenge server's DNS + interface. The mock CAA response will contain one policy with an "issue" + tag specifying the provided value. + """ + return self._postURL( + self._URL("add-caa"), + { + "host": host, + "policies": [{ "tag": "issue", "value": value}], + }) + + def remove_caa_issue(self, host): + """ + remove_caa_issue removes a mock CAA response from the challenge server's + DNS interface for the given host. + """ + return self._postURL( + self._URL("del-caa"), + { "host": host }) + + def http_request_history(self, host): + """ + http_request_history fetches the challenge server's HTTP request history for the given host. + """ + return json.loads(self._postURL( + self._URL("get-http-history"), + { "host": host })) + + def clear_http_request_history(self, host): + """ + clear_http_request_history clears the challenge server's HTTP request history for the given host. + """ + return self._clear_request_history(host, "http") + + def add_http_redirect(self, path, targetURL): + """ + add_http_redirect adds a redirect to the challenge server's HTTP + interfaces for HTTP requests to the given path directing the client to + the targetURL. Redirects are not served for HTTPS requests. + """ + return self._postURL( + self._URL("add-redirect"), + { "path": path, "targetURL": targetURL }) + + def remove_http_redirect(self, path): + """ + remove_http_redirect removes a redirect from the challenge server's HTTP + interfaces for the given path. + """ + return self._postURL( + self._URL("del-redirect"), + { "path": path }) + + def add_http01_response(self, token, keyauth): + """ + add_http01_response adds an ACME HTTP-01 challenge response for the + provided token under the /.well-known/acme-challenge/ path of the + challenge test server's HTTP interfaces. The given keyauth will be + returned as the HTTP response body for requests to the challenge token. + """ + return self._postURL( + self._URL("add-http"), + { "token": token, "content": keyauth }) + + def remove_http01_response(self, token): + """ + remove_http01_response removes an ACME HTTP-01 challenge response for + the provided token from the challenge test server. + """ + return self._postURL( + self._URL("del-http"), + { "token": token }) + + def add_servfail_response(self, host): + """ + add_servfail_response configures the challenge test server to return + SERVFAIL for all queries made for the provided host. This will override + any other mocks for the host until removed with remove_servfail_response. + """ + return self._postURL( + self._URL("add-servfail"), + { "host": host}) + + def remove_servfail_response(self, host): + """ + remove_servfail_response undoes the work of add_servfail_response, + removing the SERVFAIL configuration for the given host. + """ + return self._postURL( + self._URL("del-servfail"), + { "host": host}) + + def add_dns01_response(self, host, value): + """ + add_dns01_response adds an ACME DNS-01 challenge response for the + provided host to the challenge test server's DNS interfaces. The + provided value will be served for TXT queries for + _acme-challenge.. + """ + if host.endswith(".") is False: + host = host + "." + return self._postURL( + self._URL("add-txt"), + { "host": host, "value": value}) + + def remove_dns01_response(self, host): + """ + remove_dns01_response removes an ACME DNS-01 challenge response for the + provided host from the challenge test server's DNS interfaces. + """ + return self._postURL( + self._URL("del-txt"), + { "host": host }) + + def dns_request_history(self, host): + """ + dns_request_history returns the history of DNS requests made to the + challenge test server's DNS interfaces for the given host. + """ + return json.loads(self._postURL( + self._URL("get-dns-history"), + { "host": host })) + + def clear_dns_request_history(self, host): + """ + clear_dns_request_history clears the history of DNS requests made to the + challenge test server's DNS interfaces for the given host. + """ + return self._clear_request_history(host, "dns") + + def add_tlsalpn01_response(self, host, value): + """ + add_tlsalpn01_response adds an ACME TLS-ALPN-01 challenge response + certificate to the challenge test server's TLS-ALPN-01 interface for the + given host. The provided key authorization value will be embedded in the + response certificate served to clients that initiate a TLS-ALPN-01 + challenge validation with the challenge test server for the provided + host. + """ + return self._postURL( + self._URL("add-alpn"), + { "host": host, "content": value}) + + def remove_tlsalpn01_response(self, host): + """ + remove_tlsalpn01_response removes an ACME TLS-ALPN-01 challenge response + certificate from the challenge test server's TLS-ALPN-01 interface for + the given host. + """ + return self._postURL( + self._URL("del-alpn"), + { "host": host }) + + def tlsalpn01_request_history(self, host): + """ + tls_alpn01_request_history returns the history of TLS-ALPN-01 requests + made to the challenge test server's TLS-ALPN-01 interface for the given + host. + """ + return json.loads(self._postURL( + self._URL("get-alpn-history"), + { "host": host })) + + def clear_tlsalpn01_request_history(self, host): + """ + clear_tlsalpn01_request_history clears the history of TLS-ALPN-01 + requests made to the challenge test server's TLS-ALPN-01 interface for + the given host. + """ + return self._clear_request_history(host, "tlsalpn") diff --git a/third-party/github.com/letsencrypt/boulder/test/chisel2.py b/third-party/github.com/letsencrypt/boulder/test/chisel2.py new file mode 100644 index 000000000..6cf99efaf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/chisel2.py @@ -0,0 +1,228 @@ +""" +A simple client that uses the Python ACME library to run a test issuance against +a local Boulder server. +Usage: + +$ virtualenv venv +$ . venv/bin/activate +$ pip install -r requirements.txt +$ python chisel2.py foo.com bar.com +""" +import json +import logging +import os +import sys +import signal +import threading +import time + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.hazmat.primitives import hashes + +import OpenSSL +import josepy + +from acme import challenges +from acme import client as acme_client +from acme import crypto_util as acme_crypto_util +from acme import errors as acme_errors +from acme import messages +from acme import standalone + +logging.basicConfig() +logger = logging.getLogger() +logger.setLevel(int(os.getenv('LOGLEVEL', 20))) + +DIRECTORY_V2 = os.getenv('DIRECTORY_V2', 'http://boulder.service.consul:4001/directory') +ACCEPTABLE_TOS = os.getenv('ACCEPTABLE_TOS',"https://boulder.service.consul:4431/terms/v7") +PORT = os.getenv('PORT', '80') + +os.environ.setdefault('REQUESTS_CA_BUNDLE', 'test/certs/ipki/minica.pem') + +import challtestsrv +challSrv = challtestsrv.ChallTestServer() + +def uninitialized_client(key=None): + if key is None: + key = josepy.JWKRSA(key=rsa.generate_private_key(65537, 2048, default_backend())) + net = acme_client.ClientNetwork(key, user_agent="Boulder integration tester") + directory = messages.Directory.from_json(net.get(DIRECTORY_V2).json()) + return acme_client.ClientV2(directory, net) + +def make_client(email=None): + """Build an acme.Client and register a new account with a random key.""" + client = uninitialized_client() + tos = client.directory.meta.terms_of_service + if tos == ACCEPTABLE_TOS: + client.net.account = client.new_account(messages.NewRegistration.from_data(email=email, + terms_of_service_agreed=True)) + else: + raise Exception("Unrecognized terms of service URL %s" % tos) + return client + +class NoClientError(ValueError): + """ + An error that occurs when no acme.Client is provided to a function that + requires one. + """ + pass + +class EmailRequiredError(ValueError): + """ + An error that occurs when a None email is provided to update_email. + """ + +def update_email(client, email): + """ + Use a provided acme.Client to update the client's account to the specified + email. + """ + if client is None: + raise(NoClientError("update_email requires a valid acme.Client argument")) + if email is None: + raise(EmailRequiredError("update_email requires an email argument")) + if not email.startswith("mailto:"): + email = "mailto:"+ email + acct = client.net.account + updatedAcct = acct.update(body=acct.body.update(contact=(email,))) + return client.update_registration(updatedAcct) + + +def get_chall(authz, typ): + for chall_body in authz.body.challenges: + if isinstance(chall_body.chall, typ): + return chall_body + raise Exception("No %s challenge found" % typ.typ) + +def make_csr(domains): + key = OpenSSL.crypto.PKey() + key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) + pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key) + return acme_crypto_util.make_csr(pem, domains, False) + +def http_01_answer(client, chall_body): + """Return an HTTP01Resource to server in response to the given challenge.""" + response, validation = chall_body.response_and_validation(client.net.key) + return standalone.HTTP01RequestHandler.HTTP01Resource( + chall=chall_body.chall, response=response, + validation=validation) + +def auth_and_issue(domains, chall_type="dns-01", email=None, cert_output=None, client=None): + """Make authzs for each of the given domains, set up a server to answer the + challenges in those authzs, tell the ACME server to validate the challenges, + then poll for the authzs to be ready and issue a cert.""" + if client is None: + client = make_client(email) + + csr_pem = make_csr(domains) + order = client.new_order(csr_pem) + authzs = order.authorizations + + if chall_type == "http-01": + cleanup = do_http_challenges(client, authzs) + elif chall_type == "dns-01": + cleanup = do_dns_challenges(client, authzs) + elif chall_type == "tls-alpn-01": + cleanup = do_tlsalpn_challenges(client, authzs) + else: + raise Exception("invalid challenge type %s" % chall_type) + + try: + order = client.poll_and_finalize(order) + if cert_output is not None: + with open(cert_output, "w") as f: + f.write(order.fullchain_pem) + finally: + cleanup() + + return order + +def do_dns_challenges(client, authzs): + cleanup_hosts = [] + for a in authzs: + c = get_chall(a, challenges.DNS01) + name, value = (c.validation_domain_name(a.body.identifier.value), + c.validation(client.net.key)) + cleanup_hosts.append(name) + challSrv.add_dns01_response(name, value) + client.answer_challenge(c, c.response(client.net.key)) + def cleanup(): + for host in cleanup_hosts: + challSrv.remove_dns01_response(host) + return cleanup + +def do_http_challenges(client, authzs): + cleanup_tokens = [] + challs = [get_chall(a, challenges.HTTP01) for a in authzs] + + for chall_body in challs: + # Determine the token and key auth for the challenge + token = chall_body.chall.encode("token") + resp = chall_body.response(client.net.key) + keyauth = resp.key_authorization + + # Add the HTTP-01 challenge response for this token/key auth to the + # challtestsrv + challSrv.add_http01_response(token, keyauth) + cleanup_tokens.append(token) + + # Then proceed initiating the challenges with the ACME server + client.answer_challenge(chall_body, chall_body.response(client.net.key)) + + def cleanup(): + # Cleanup requires removing each of the HTTP-01 challenge responses for + # the tokens we added. + for token in cleanup_tokens: + challSrv.remove_http01_response(token) + return cleanup + +def do_tlsalpn_challenges(client, authzs): + cleanup_hosts = [] + for a in authzs: + c = get_chall(a, challenges.TLSALPN01) + name, value = (a.body.identifier.value, c.key_authorization(client.net.key)) + cleanup_hosts.append(name) + challSrv.add_tlsalpn01_response(name, value) + client.answer_challenge(c, c.response(client.net.key)) + def cleanup(): + for host in cleanup_hosts: + challSrv.remove_tlsalpn01_response(host) + return cleanup + +def expect_problem(problem_type, func): + """Run a function. If it raises an acme_errors.ValidationError or messages.Error that + contains the given problem_type, return. If it raises no error or the wrong + error, raise an exception.""" + ok = False + try: + func() + except messages.Error as e: + if e.typ == problem_type: + ok = True + else: + raise Exception("Expected %s, got %s" % (problem_type, e.__str__())) + except acme_errors.ValidationError as e: + for authzr in e.failed_authzrs: + for chall in authzr.body.challenges: + error = chall.error + if error and error.typ == problem_type: + ok = True + elif error: + raise Exception("Expected %s, got %s" % (problem_type, error.__str__())) + if not ok: + raise Exception('Expected %s, got no error' % problem_type) + +if __name__ == "__main__": + # Die on SIGINT + signal.signal(signal.SIGINT, signal.SIG_DFL) + domains = sys.argv[1:] + if len(domains) == 0: + print(__doc__) + sys.exit(0) + try: + auth_and_issue(domains) + except messages.Error as e: + print(e) + sys.exit(1) diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/admin-revoker.json b/third-party/github.com/letsencrypt/boulder/test/config-next/admin-revoker.json new file mode 100644 index 000000000..389fc0080 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/admin-revoker.json @@ -0,0 +1,38 @@ +{ + "revoker": { + "db": { + "dbConnectFile": "test/secrets/revoker_dburl", + "maxOpenConns": 1 + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/admin-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/admin-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/admin.json b/third-party/github.com/letsencrypt/boulder/test/config-next/admin.json new file mode 100644 index 000000000..09dfe167d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/admin.json @@ -0,0 +1,43 @@ +{ + "admin": { + "db": { + "dbConnectFile": "test/secrets/revoker_dburl", + "maxOpenConns": 1 + }, + "debugAddr": ":8014", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/admin-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/admin-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/akamai-purger.json b/third-party/github.com/letsencrypt/boulder/test/config-next/akamai-purger.json new file mode 100644 index 000000000..538ddac76 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/akamai-purger.json @@ -0,0 +1,43 @@ +{ + "akamaiPurger": { + "purgeRetries": 10, + "purgeRetryBackoff": "50ms", + "throughput": { + "totalInstances": 1 + }, + "baseURL": "http://localhost:6789", + "clientToken": "its-a-token", + "clientSecret": "its-a-secret", + "accessToken": "idk-how-this-is-different-from-client-token-but-okay", + "v3Network": "staging", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/akamai-purger.boulder/cert.pem", + "keyFile": "test/certs/ipki/akamai-purger.boulder/key.pem" + }, + "grpc": { + "address": ":9099", + "maxConnectionAge": "30s", + "services": { + "akamai.AkamaiPurger": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/bad-key-revoker.json b/third-party/github.com/letsencrypt/boulder/test/config-next/bad-key-revoker.json new file mode 100644 index 000000000..cc98591c6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/bad-key-revoker.json @@ -0,0 +1,45 @@ +{ + "BadKeyRevoker": { + "db": { + "dbConnectFile": "test/secrets/badkeyrevoker_dburl", + "maxOpenConns": 10 + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/bad-key-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/bad-key-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "mailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "from": "bad key revoker ", + "passwordFile": "test/secrets/smtp_password", + "SMTPTrustedRootFile": "test/certs/ipki/minica.pem", + "emailSubject": "Certificates you've issued have been revoked due to key compromise", + "emailTemplate": "test/example-bad-key-revoker-template" + }, + "maximumRevocations": 15, + "findCertificatesBatchSize": 10, + "interval": "50ms", + "backoffIntervalMax": "2s" + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/ca.json b/third-party/github.com/letsencrypt/boulder/test/config-next/ca.json new file mode 100644 index 000000000..58c335d9f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/ca.json @@ -0,0 +1,171 @@ +{ + "ca": { + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ca.boulder/cert.pem", + "keyFile": "test/certs/ipki/ca.boulder/key.pem" + }, + "hostnamePolicyFile": "test/hostname-policy.yaml", + "grpcCA": { + "maxConnectionAge": "30s", + "services": { + "ca.CertificateAuthority": { + "clientNames": [ + "ra.boulder" + ] + }, + "ca.OCSPGenerator": { + "clientNames": [ + "ra.boulder" + ] + }, + "ca.CRLGenerator": { + "clientNames": [ + "crl-updater.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "issuance": { + "defaultCertificateProfileName": "defaultBoulderCertificateProfile", + "certProfiles": { + "defaultBoulderCertificateProfile": { + "allowMustStaple": true, + "allowCTPoison": true, + "allowSCTList": true, + "allowCommonName": true, + "policies": [ + { + "oid": "2.23.140.1.2.1" + } + ], + "maxValidityPeriod": "7776000s", + "maxValidityBackdate": "1h5m" + } + }, + "crlProfile": { + "validityInterval": "216h", + "maxBackdate": "1h5m" + }, + "issuers": [ + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-a", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-a/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-a.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-a.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-b", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-b/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-b.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-b.cert.pem", + "numSessions": 2 + } + }, + { + "active": false, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-c", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-c/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-c.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-c.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-rsa-a", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-a/", + "location": { + "configFile": "test/certs/webpki/int-rsa-a.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-a.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-rsa-b", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-b/", + "location": { + "configFile": "test/certs/webpki/int-rsa-b.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-b.cert.pem", + "numSessions": 2 + } + }, + { + "active": false, + "issuerURL": "http://ca.example.org:4502/int-rsa-c", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-c/", + "location": { + "configFile": "test/certs/webpki/int-rsa-c.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-c.cert.pem", + "numSessions": 2 + } + } + ], + "lintConfig": "test/config-next/zlint.toml", + "ignoredLints": [ + "w_subject_common_name_included", + "w_sub_cert_aia_contains_internal_names" + ] + }, + "expiry": "7776000s", + "backdate": "1h", + "serialPrefix": 127, + "maxNames": 100, + "lifespanOCSP": "96h", + "goodkey": { + "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", + "fermatRounds": 100 + }, + "ocspLogMaxLength": 4000, + "ocspLogPeriod": "500ms", + "ctLogListFile": "test/ct-test-srv/log_list.json", + "features": { + "ECDSAForAll": true + } + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/cert-checker.json b/third-party/github.com/letsencrypt/boulder/test/config-next/cert-checker.json new file mode 100644 index 000000000..a4e7d2179 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/cert-checker.json @@ -0,0 +1,40 @@ +{ + "certChecker": { + "db": { + "dbConnectFile": "test/secrets/cert_checker_dburl", + "maxOpenConns": 10 + }, + "hostnamePolicyFile": "test/hostname-policy.yaml", + "goodkey": { + "fermatRounds": 100 + }, + "workers": 16, + "unexpiredOnly": true, + "badResultsOnly": true, + "checkPeriod": "72h", + "acceptableValidityDurations": [ + "7776000s" + ], + "ignoredLints": [ + "w_subject_common_name_included", + "w_sub_cert_aia_contains_internal_names" + ], + "ctLogListFile": "test/ct-test-srv/log_list.json", + "features": { + "CertCheckerRequiresCorrespondence": true, + "CertCheckerChecksValidations": true, + "CertCheckerRequiresValidations": true + } + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/contact-auditor.json b/third-party/github.com/letsencrypt/boulder/test/config-next/contact-auditor.json new file mode 100644 index 000000000..23287c4a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/contact-auditor.json @@ -0,0 +1,8 @@ +{ + "contactAuditor": { + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.ini b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.ini new file mode 100644 index 000000000..858669f58 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.ini @@ -0,0 +1,2 @@ +[default] +region=us-west-1 diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.json b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.json new file mode 100644 index 000000000..0934bcef0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-storer.json @@ -0,0 +1,44 @@ +{ + "crlStorer": { + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/crl-storer.boulder/cert.pem", + "keyFile": "test/certs/ipki/crl-storer.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "storer.CRLStorer": { + "clientNames": [ + "crl-updater.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-rsa-c.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-c.cert.pem" + ], + "s3Endpoint": "http://localhost:4501", + "s3Bucket": "lets-encrypt-crls", + "awsConfigFile": "test/config-next/crl-storer.ini", + "awsCredsFile": "test/secrets/aws_creds.ini" + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/crl-updater.json b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-updater.json new file mode 100644 index 000000000..86f7e601d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/crl-updater.json @@ -0,0 +1,63 @@ +{ + "crlUpdater": { + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/crl-updater.boulder/cert.pem", + "keyFile": "test/certs/ipki/crl-updater.boulder/key.pem" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "crlGeneratorService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "crlStorerService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "crl-storer", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "crl-storer.boulder" + }, + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-rsa-c.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-c.cert.pem" + ], + "numShards": 10, + "shardWidth": "240h", + "lookbackPeriod": "24h", + "updatePeriod": "10m", + "updateTimeout": "1m", + "maxParallelism": 10, + "maxAttempts": 2, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/ecdsaAllowList.yml b/third-party/github.com/letsencrypt/boulder/test/config-next/ecdsaAllowList.yml new file mode 100644 index 000000000..a648abda3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/ecdsaAllowList.yml @@ -0,0 +1,2 @@ +--- +- 1337 diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.gotmpl b/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.gotmpl new file mode 100644 index 000000000..5fdab3e30 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.gotmpl @@ -0,0 +1,6 @@ +Hello, + +Your SSL certificate for names {{.TruncatedDNSNames}}{{if(gt .NumDNSNamesOmitted 0)}} (and {{.NumDNSNamesOmitted}} more){{end}} is going to expire in {{.DaysToExpiration}} +days ({{.ExpirationDate}}), make sure you run the renewer before then! + +Regards diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.json b/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.json new file mode 100644 index 000000000..5289be50d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/expiration-mailer.json @@ -0,0 +1,50 @@ +{ + "mailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "from": "Expiry bot ", + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + }, + "certLimit": 100000, + "mailsPerAddressPerDay": 4, + "updateChunkSize": 1000, + "nagTimes": [ + "480h", + "240h" + ], + "emailTemplate": "test/config-next/expiration-mailer.gotmpl", + "parallelSends": 10, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/expiration-mailer.boulder/cert.pem", + "keyFile": "test/certs/ipki/expiration-mailer.boulder/key.pem" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "SMTPTrustedRootFile": "test/certs/ipki/minica.pem", + "frequency": "1h", + "features": { + "ExpirationMailerUsesJoin": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/health-checker.json b/third-party/github.com/letsencrypt/boulder/test/config-next/health-checker.json new file mode 100644 index 000000000..e2663f510 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/health-checker.json @@ -0,0 +1,10 @@ +{ + "grpc": { + "timeout": "1s" + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/health-checker.boulder/cert.pem", + "keyFile": "test/certs/ipki/health-checker.boulder/key.pem" + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/id-exporter.json b/third-party/github.com/letsencrypt/boulder/test/config-next/id-exporter.json new file mode 100644 index 000000000..526da6251 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/id-exporter.json @@ -0,0 +1,9 @@ +{ + "contactExporter": { + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/log-validator.json b/third-party/github.com/letsencrypt/boulder/test/config-next/log-validator.json new file mode 100644 index 000000000..40dc121ca --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/log-validator.json @@ -0,0 +1,17 @@ +{ + "syslog": { + "stdoutLevel": 7 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + }, + "files": [ + "/var/log/akamai-purger.log", + "/var/log/bad-key-revoker.log", + "/var/log/boulder-*.log", + "/var/log/crl-*.log", + "/var/log/nonce-service.log", + "/var/log/ocsp-responder.log" + ] +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-a.json b/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-a.json new file mode 100644 index 000000000..75df81b6e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-a.json @@ -0,0 +1,36 @@ +{ + "NonceService": { + "maxUsed": 131072, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "syslog": { + "stdoutLevel": 6, + "syslogLevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "nonce.NonceService": { + "clientNames": [ + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/nonce.boulder/cert.pem", + "keyFile": "test/certs/ipki/nonce.boulder/key.pem" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-b.json b/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-b.json new file mode 100644 index 000000000..75df81b6e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/nonce-b.json @@ -0,0 +1,36 @@ +{ + "NonceService": { + "maxUsed": 131072, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "syslog": { + "stdoutLevel": 6, + "syslogLevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "nonce.NonceService": { + "clientNames": [ + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/nonce.boulder/cert.pem", + "keyFile": "test/certs/ipki/nonce.boulder/key.pem" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/notify-mailer.json b/third-party/github.com/letsencrypt/boulder/test/config-next/notify-mailer.json new file mode 100644 index 000000000..5aadfc4e9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/notify-mailer.json @@ -0,0 +1,16 @@ +{ + "notifyMailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + }, + "syslog": { + "stdoutLevel": 7, + "syslogLevel": -1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/observer.yml b/third-party/github.com/letsencrypt/boulder/test/config-next/observer.yml new file mode 100644 index 000000000..d4cbc54fa --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/observer.yml @@ -0,0 +1,92 @@ +--- +buckets: [.001, .002, .005, .01, .02, .05, .1, .2, .5, 1, 2, 5, 10] +syslog: + stdoutlevel: 6 + sysloglevel: 6 +monitors: + - + period: 5s + kind: DNS + settings: + protocol: udp + server: owen.ns.cloudflare.com:53 + recurse: false + query_name: letsencrypt.org + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: udp + server: 1.1.1.1:53 + recurse: true + query_name: google.com + query_type: A + - + period: 10s + kind: DNS + settings: + protocol: tcp + server: 8.8.8.8:53 + recurse: true + query_name: google.com + query_type: A + - + period: 2s + kind: HTTP + settings: + url: https://letsencrypt.org + rcodes: [200] + useragent: "letsencrypt/boulder-observer-http-client" + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: owen.ns.cloudflare.com:53 + recurse: false + query_name: letsencrypt.org + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: 1.1.1.1:53 + recurse: true + query_name: google.com + query_type: A + - + period: 10s + kind: DNS + settings: + protocol: udp + server: 8.8.8.8:53 + recurse: true + query_name: google.com + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: "[2606:4700:4700::1111]:53" + recurse: true + query_name: google.com + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: udp + server: "[2606:4700:4700::1111]:53" + recurse: true + query_name: google.com + query_type: A + - + period: 2s + kind: HTTP + settings: + url: http://letsencrypt.org/foo + rcodes: [200, 404] + useragent: "letsencrypt/boulder-observer-http-client" diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/ocsp-responder.json b/third-party/github.com/letsencrypt/boulder/test/config-next/ocsp-responder.json new file mode 100644 index 000000000..bae653044 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/ocsp-responder.json @@ -0,0 +1,75 @@ +{ + "ocspResponder": { + "redis": { + "username": "ocsp-responder", + "passwordFile": "test/secrets/ocsp_responder_redis_password", + "shardAddrs": { + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218" + }, + "timeout": "5s", + "poolSize": 100, + "routeRandomly": true, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ocsp-responder.boulder/cert.pem", + "keyFile": "test/certs/ipki/ocsp-responder.boulder/key.pem" + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ocsp-responder.boulder/cert.pem", + "keyFile": "test/certs/ipki/ocsp-responder.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "logSampleRate": 1, + "path": "/", + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-rsa-c.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-c.cert.pem" + ], + "liveSigningPeriod": "60h", + "timeout": "4.9s", + "maxInflightSignings": 2, + "maxSigningWaiters": 1, + "shutdownStopTimeout": "10s", + "requiredSerialPrefixes": [ + "7f" + ], + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + }, + "openTelemetryHttpConfig": { + "trustIncomingSpans": true + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/publisher.json b/third-party/github.com/letsencrypt/boulder/test/config-next/publisher.json new file mode 100644 index 000000000..3d0a0fb7e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/publisher.json @@ -0,0 +1,53 @@ +{ + "publisher": { + "userAgent": "boulder/1.0", + "blockProfileRate": 1000000000, + "chains": [ + [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ] + ], + "grpc": { + "maxConnectionAge": "30s", + "services": { + "Publisher": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/publisher.boulder/cert.pem", + "keyFile": "test/certs/ipki/publisher.boulder/key.pem" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/ra.json b/third-party/github.com/letsencrypt/boulder/test/config-next/ra.json new file mode 100644 index 000000000..6ead49561 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/ra.json @@ -0,0 +1,150 @@ +{ + "ra": { + "rateLimitPoliciesFilename": "test/rate-limit-policies.yml", + "maxContactsPerRegistration": 3, + "hostnamePolicyFile": "test/hostname-policy.yaml", + "maxNames": 100, + "authorizationLifetimeDays": 30, + "pendingAuthorizationLifetimeDays": 7, + "goodkey": { + "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", + "fermatRounds": 100 + }, + "orderLifetime": "168h", + "finalizeTimeout": "30s", + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-rsa-c.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-c.cert.pem" + ], + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ra.boulder/cert.pem", + "keyFile": "test/certs/ipki/ra.boulder/key.pem" + }, + "vaService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "va", + "domain": "service.consul" + }, + "timeout": "20s", + "noWaitForReady": true, + "hostOverride": "va.boulder" + }, + "caService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "ocspService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "publisherService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "publisher", + "domain": "service.consul" + }, + "timeout": "300s", + "noWaitForReady": true, + "hostOverride": "publisher.boulder" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "akamaiPurgerService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "akamai-purger", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "akamai-purger.boulder" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "ra.RegistrationAuthority": { + "clientNames": [ + "admin-revoker.boulder", + "bad-key-revoker.boulder", + "ocsp-responder.boulder", + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "AsyncFinalize": true + }, + "ctLogs": { + "stagger": "500ms", + "logListFile": "test/ct-test-srv/log_list.json", + "sctLogs": [ + "A1 Current", + "A1 Future", + "A2 Past", + "A2 Current", + "B1", + "B2", + "C1", + "D1", + "E1" + ], + "infoLogs": [ + "F1" + ], + "finalLogs": [ + "A1 Current", + "A1 Future", + "C1", + "F1" + ] + } + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-a.json b/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-a.json new file mode 100644 index 000000000..4085a6e14 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-a.json @@ -0,0 +1,49 @@ +{ + "rva": { + "userAgent": "remoteva-a", + "dnsTries": 3, + "dnsStaticResolvers": [ + "10.77.77.77:8343", + "10.77.77.77:8443" + ], + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "skipGRPCClientCertVerification": true, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "DOH": true + }, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-b.json b/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-b.json new file mode 100644 index 000000000..8e9a44e84 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/remoteva-b.json @@ -0,0 +1,49 @@ +{ + "rva": { + "userAgent": "remoteva-b", + "dnsTries": 3, + "dnsStaticResolvers": [ + "10.77.77.77:8343", + "10.77.77.77:8443" + ], + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "skipGRPCClientCertVerification": true, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "DOH": true + }, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/rocsp-tool.json b/third-party/github.com/letsencrypt/boulder/test/config-next/rocsp-tool.json new file mode 100644 index 000000000..a3a1d400c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/rocsp-tool.json @@ -0,0 +1,26 @@ +{ + "rocspTool": { + "redis": { + "username": "rocsp-tool", + "passwordFile": "test/secrets/rocsp_tool_password", + "shardAddrs": { + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218" + }, + "timeout": "5s", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rocsp-tool.boulder/cert.pem", + "keyFile": "test/certs/ipki/rocsp-tool.boulder/key.pem" + } + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/sa.json b/third-party/github.com/letsencrypt/boulder/test/config-next/sa.json new file mode 100644 index 000000000..c11cc9b43 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/sa.json @@ -0,0 +1,63 @@ +{ + "sa": { + "db": { + "dbConnectFile": "test/secrets/sa_dburl", + "maxOpenConns": 100 + }, + "readOnlyDB": { + "dbConnectFile": "test/secrets/sa_ro_dburl", + "maxOpenConns": 100 + }, + "incidentsDB": { + "dbConnectFile": "test/secrets/incidents_dburl", + "maxOpenConns": 100 + }, + "ParallelismPerRPC": 20, + "lagFactor": "200ms", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/sa.boulder/cert.pem", + "keyFile": "test/certs/ipki/sa.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "sa.StorageAuthority": { + "clientNames": [ + "admin-revoker.boulder", + "ca.boulder", + "crl-updater.boulder", + "expiration-mailer.boulder", + "ra.boulder" + ] + }, + "sa.StorageAuthorityReadOnly": { + "clientNames": [ + "admin-revoker.boulder", + "ocsp-responder.boulder", + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder", + "consul.boulder" + ] + } + } + }, + "healthCheckInterval": "4s", + "features": { + "MultipleCertificateProfiles": true, + "TrackReplacementCertificatesARI": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-a.json b/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-a.json new file mode 100644 index 000000000..15cac91de --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-a.json @@ -0,0 +1,48 @@ +{ + "va": { + "userAgent": "boulder-remoteva-a", + "dnsTries": 3, + "dnsStaticResolvers": [ + "10.77.77.77:8343", + "10.77.77.77:8443" + ], + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "DOH": true + }, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-b.json b/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-b.json new file mode 100644 index 000000000..e7fd187a5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/va-remote-b.json @@ -0,0 +1,48 @@ +{ + "va": { + "userAgent": "boulder-remoteva-b", + "dnsTries": 3, + "dnsStaticResolvers": [ + "10.77.77.77:8343", + "10.77.77.77:8443" + ], + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "DOH": true + }, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/va.json b/third-party/github.com/letsencrypt/boulder/test/config-next/va.json new file mode 100644 index 000000000..12efd33bc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/va.json @@ -0,0 +1,81 @@ +{ + "va": { + "userAgent": "boulder", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "doh", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/va.boulder/cert.pem", + "keyFile": "test/certs/ipki/va.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "ra.boulder" + ] + }, + "va.CAA": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": { + "EnforceMultiCAA": true, + "MultiCAAFullResults": true, + "DOH": true + }, + "remoteVAs": [ + { + "serverAddress": "rva1.service.consul:9397", + "timeout": "15s", + "hostOverride": "rva1.boulder" + }, + { + "serverAddress": "rva1.service.consul:9498", + "timeout": "15s", + "hostOverride": "rva1.boulder" + }, + { + "serverAddress": "rva2.service.consul:9897", + "timeout": "15s", + "hostOverride": "rva2.boulder" + }, + { + "serverAddress": "rva2.service.consul:9998", + "timeout": "15s", + "hostOverride": "rva2.boulder" + } + ], + "maxRemoteValidationFailures": 1, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-defaults.yml b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-defaults.yml new file mode 100644 index 000000000..0192c4bb3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-defaults.yml @@ -0,0 +1,24 @@ +NewRegistrationsPerIPAddress: + count: 10000 + burst: 10000 + period: 168h +NewRegistrationsPerIPv6Range: + count: 99999 + burst: 99999 + period: 168h +CertificatesPerDomain: + count: 2 + burst: 2 + period: 2160h +FailedAuthorizationsPerDomainPerAccount: + count: 3 + burst: 3 + period: 5m +NewOrdersPerAccount: + count: 1500 + burst: 1500 + period: 3h +CertificatesPerFQDNSet: + count: 6 + burst: 6 + period: 168h diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-overrides.yml b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-overrides.yml new file mode 100644 index 000000000..95303173d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2-ratelimit-overrides.yml @@ -0,0 +1,60 @@ +- NewRegistrationsPerIPAddress: + burst: 1000000 + count: 1000000 + period: 168h + ids: + - id: 127.0.0.1 + comment: localhost +- CertificatesPerDomain: + burst: 1 + count: 1 + period: 2160h + ids: + - id: ratelimit.me + comment: Rate Limit Test Domain +- CertificatesPerDomain: + burst: 10000 + count: 10000 + period: 2160h + ids: + - id: le.wtf + comment: Let's Encrypt Test Domain + - id: le1.wtf + comment: Let's Encrypt Test Domain 1 + - id: le2.wtf + comment: Let's Encrypt Test Domain 2 + - id: le3.wtf + comment: Let's Encrypt Test Domain 3 + - id: nginx.wtf + comment: Nginx Test Domain + - id: good-caa-reserved.com + comment: Good CAA Reserved Domain + - id: bad-caa-reserved.com + comment: Bad CAA Reserved Domain + - id: ecdsa.le.wtf + comment: ECDSA Let's Encrypt Test Domain + - id: must-staple.le.wtf + comment: Must-Staple Let's Encrypt Test Domain +- CertificatesPerFQDNSet: + burst: 10000 + count: 10000 + period: 168h + ids: + - id: le.wtf + comment: Let's Encrypt Test Domain + - id: le1.wtf + comment: Let's Encrypt Test Domain 1 + - id: le2.wtf + comment: Let's Encrypt Test Domain 2 + - id: le3.wtf + comment: Let's Encrypt Test Domain 3 + - id: le.wtf,le1.wtf + comment: Let's Encrypt Test Domain, Let's Encrypt Test Domain 1 + - id: good-caa-reserved.com + comment: Good CAA Reserved Domain + - id: nginx.wtf + comment: Nginx Test Domain + - id: ecdsa.le.wtf + comment: ECDSA Let's Encrypt Test Domain + - id: must-staple.le.wtf + comment: Must-Staple Let's Encrypt Test Domain diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2.json b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2.json new file mode 100644 index 000000000..15d480cb6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/wfe2.json @@ -0,0 +1,148 @@ +{ + "wfe": { + "timeout": "30s", + "serverCertificatePath": "test/certs/ipki/boulder/cert.pem", + "serverKeyPath": "test/certs/ipki/boulder/key.pem", + "allowOrigins": [ + "*" + ], + "shutdownStopTimeout": "10s", + "subscriberAgreementURL": "https://boulder.service.consul:4431/terms/v7", + "directoryCAAIdentity": "happy-hacker-ca.invalid", + "directoryWebsite": "https://github.com/letsencrypt/boulder", + "legacyKeyIDPrefix": "http://boulder.service.consul:4000/reg/", + "goodkey": { + "blockedKeyFile": "test/example-blocked-keys.yaml" + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/wfe.boulder/cert.pem", + "keyFile": "test/certs/ipki/wfe.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ra.boulder" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "accountCache": { + "size": 9000, + "ttl": "5s" + }, + "getNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "nonce-taro", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "redeemNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "nonce-taro", + "domain": "service.consul" + }, + { + "service": "nonce-zinc", + "domain": "service.consul" + } + ], + "srvResolver": "nonce-srv", + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "chains": [ + [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a-cross.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b-cross.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ] + ], + "staleTimeout": "5m", + "authorizationLifetimeDays": 30, + "pendingAuthorizationLifetimeDays": 7, + "limiter": { + "redis": { + "username": "boulder-wfe", + "passwordFile": "test/secrets/wfe_ratelimits_redis_password", + "lookups": [ + { + "Service": "redisratelimits", + "Domain": "service.consul" + } + ], + "lookupDNSAuthority": "consul.service.consul", + "readTimeout": "250ms", + "writeTimeout": "250ms", + "poolSize": 100, + "routeRandomly": true, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/wfe.boulder/cert.pem", + "keyFile": "test/certs/ipki/wfe.boulder/key.pem" + } + }, + "Defaults": "test/config-next/wfe2-ratelimit-defaults.yml", + "Overrides": "test/config-next/wfe2-ratelimit-overrides.yml" + }, + "features": { + "ServeRenewalInfo": true, + "TrackReplacementCertificatesARI": true + }, + "certificateProfileNames": [ + "defaultBoulderCertificateProfile" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": -1 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + }, + "openTelemetryHttpConfig": { + "trustIncomingSpans": true + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config-next/zlint.toml b/third-party/github.com/letsencrypt/boulder/test/config-next/zlint.toml new file mode 100644 index 000000000..1ce7c7d9f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config-next/zlint.toml @@ -0,0 +1,18 @@ +[e_pkilint_lint_cabf_serverauth_cert] +pkilint_addr = "http://10.77.77.9" +pkilint_timeout = 200000000 # 200 milliseconds +ignore_lints = [ + # We include the CN in (almost) all of our certificates, on purpose. + # See https://github.com/letsencrypt/boulder/issues/5112 for details. + "DvSubcriberAttributeAllowanceValidator:cabf.serverauth.dv.common_name_attribute_present", + # We include the SKID in all of our certs, on purpose. + # See https://github.com/letsencrypt/boulder/issues/7446 for details. + "SubscriberExtensionAllowanceValidator:cabf.serverauth.subscriber.subject_key_identifier_extension_present", + # We compute the skid using RFC7093 Method 1, on purpose. + # See https://github.com/letsencrypt/boulder/pull/7179 for details. + "SubjectKeyIdentifierValidator:pkix.subject_key_identifier_rfc7093_method_1_identified", + # We include the keyEncipherment key usage in RSA certs, on purpose. + # It is only necessary for old versions of TLS, and is included for backwards + # compatibility. We intend to remove this in the short-lived profile. + "SubscriberKeyUsageValidator:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present", +] diff --git a/third-party/github.com/letsencrypt/boulder/test/config/admin-revoker.json b/third-party/github.com/letsencrypt/boulder/test/config/admin-revoker.json new file mode 100644 index 000000000..c450e0087 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/admin-revoker.json @@ -0,0 +1,38 @@ +{ + "revoker": { + "db": { + "dbConnectFile": "test/secrets/revoker_dburl", + "maxOpenConns": 1 + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/admin-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/admin-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/admin.json b/third-party/github.com/letsencrypt/boulder/test/config/admin.json new file mode 100644 index 000000000..44ff407af --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/admin.json @@ -0,0 +1,39 @@ +{ + "admin": { + "db": { + "dbConnectFile": "test/secrets/revoker_dburl", + "maxOpenConns": 1 + }, + "debugAddr": ":8014", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/admin-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/admin-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/akamai-purger.json b/third-party/github.com/letsencrypt/boulder/test/config/akamai-purger.json new file mode 100644 index 000000000..3b2fe51b7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/akamai-purger.json @@ -0,0 +1,37 @@ +{ + "akamaiPurger": { + "debugAddr": ":9666", + "purgeRetries": 10, + "purgeRetryBackoff": "50ms", + "baseURL": "http://localhost:6789", + "clientToken": "its-a-token", + "clientSecret": "its-a-secret", + "accessToken": "idk-how-this-is-different-from-client-token-but-okay", + "v3Network": "staging", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/akamai-purger.boulder/cert.pem", + "keyFile": "test/certs/ipki/akamai-purger.boulder/key.pem" + }, + "grpc": { + "address": ":9099", + "maxConnectionAge": "30s", + "services": { + "akamai.AkamaiPurger": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/bad-key-revoker.json b/third-party/github.com/letsencrypt/boulder/test/config/bad-key-revoker.json new file mode 100644 index 000000000..d70aadc5f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/bad-key-revoker.json @@ -0,0 +1,42 @@ +{ + "BadKeyRevoker": { + "db": { + "dbConnectFile": "test/secrets/badkeyrevoker_dburl", + "maxOpenConns": 10 + }, + "debugAddr": ":8020", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/bad-key-revoker.boulder/cert.pem", + "keyFile": "test/certs/ipki/bad-key-revoker.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "mailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "from": "bad key revoker ", + "passwordFile": "test/secrets/smtp_password", + "SMTPTrustedRootFile": "test/certs/ipki/minica.pem", + "emailSubject": "Certificates you've issued have been revoked due to key compromise", + "emailTemplate": "test/example-bad-key-revoker-template" + }, + "maximumRevocations": 15, + "findCertificatesBatchSize": 10, + "interval": "50ms", + "backoffIntervalMax": "2s" + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/ca.json b/third-party/github.com/letsencrypt/boulder/test/config/ca.json new file mode 100644 index 000000000..cc4728363 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/ca.json @@ -0,0 +1,161 @@ +{ + "ca": { + "debugAddr": ":8001", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ca.boulder/cert.pem", + "keyFile": "test/certs/ipki/ca.boulder/key.pem" + }, + "hostnamePolicyFile": "test/hostname-policy.yaml", + "grpcCA": { + "maxConnectionAge": "30s", + "address": ":9093", + "services": { + "ca.CertificateAuthority": { + "clientNames": [ + "ra.boulder" + ] + }, + "ca.OCSPGenerator": { + "clientNames": [ + "ra.boulder" + ] + }, + "ca.CRLGenerator": { + "clientNames": [ + "crl-updater.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "issuance": { + "profile": { + "allowMustStaple": true, + "allowCTPoison": true, + "allowSCTList": true, + "allowCommonName": true, + "policies": [ + { + "oid": "2.23.140.1.2.1" + } + ], + "maxValidityPeriod": "7776000s", + "maxValidityBackdate": "1h5m" + }, + "issuers": [ + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-a", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-a/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-a.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-a.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-b", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-b/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-b.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-b.cert.pem", + "numSessions": 2 + } + }, + { + "active": false, + "issuerURL": "http://ca.example.org:4502/int-ecdsa-c", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/ecdsa-c/", + "location": { + "configFile": "test/certs/webpki/int-ecdsa-c.pkcs11.json", + "certFile": "test/certs/webpki/int-ecdsa-c.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-rsa-a", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-a/", + "location": { + "configFile": "test/certs/webpki/int-rsa-a.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-a.cert.pem", + "numSessions": 2 + } + }, + { + "active": true, + "issuerURL": "http://ca.example.org:4502/int-rsa-b", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-b/", + "location": { + "configFile": "test/certs/webpki/int-rsa-b.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-b.cert.pem", + "numSessions": 2 + } + }, + { + "active": false, + "issuerURL": "http://ca.example.org:4502/int-rsa-c", + "ocspURL": "http://ca.example.org:4002/", + "crlURLBase": "http://ca.example.org:4501/rsa-c/", + "location": { + "configFile": "test/certs/webpki/int-rsa-c.pkcs11.json", + "certFile": "test/certs/webpki/int-rsa-c.cert.pem", + "numSessions": 2 + } + } + ], + "lintConfig": "test/config/zlint.toml", + "ignoredLints": [ + "w_subject_common_name_included", + "w_sub_cert_aia_contains_internal_names" + ] + }, + "expiry": "7776000s", + "backdate": "1h", + "serialPrefix": 127, + "maxNames": 100, + "lifespanOCSP": "96h", + "lifespanCRL": "216h", + "goodkey": { + "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", + "fermatRounds": 100 + }, + "ocspLogMaxLength": 4000, + "ocspLogPeriod": "500ms", + "ecdsaAllowListFilename": "test/config/ecdsaAllowList.yml", + "features": {} + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/cert-checker.json b/third-party/github.com/letsencrypt/boulder/test/config/cert-checker.json new file mode 100644 index 000000000..eb3d73cab --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/cert-checker.json @@ -0,0 +1,34 @@ +{ + "certChecker": { + "db": { + "dbConnectFile": "test/secrets/cert_checker_dburl", + "maxOpenConns": 10 + }, + "hostnamePolicyFile": "test/hostname-policy.yaml", + "goodkey": { + "fermatRounds": 100 + }, + "workers": 16, + "unexpiredOnly": true, + "badResultsOnly": true, + "checkPeriod": "72h", + "acceptableValidityDurations": [ + "7776000s" + ], + "ignoredLints": [ + "w_subject_common_name_included", + "w_sub_cert_aia_contains_internal_names" + ] + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/contact-auditor.json b/third-party/github.com/letsencrypt/boulder/test/config/contact-auditor.json new file mode 100644 index 000000000..23287c4a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/contact-auditor.json @@ -0,0 +1,8 @@ +{ + "contactAuditor": { + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.ini b/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.ini new file mode 100644 index 000000000..858669f58 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.ini @@ -0,0 +1,2 @@ +[default] +region=us-west-1 diff --git a/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.json b/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.json new file mode 100644 index 000000000..ee3285d0a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/crl-storer.json @@ -0,0 +1,39 @@ +{ + "crlStorer": { + "debugAddr": ":9667", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/crl-storer.boulder/cert.pem", + "keyFile": "test/certs/ipki/crl-storer.boulder/key.pem" + }, + "grpc": { + "address": ":9309", + "maxConnectionAge": "30s", + "services": { + "storer.CRLStorer": { + "clientNames": [ + "crl-updater.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem" + ], + "s3Endpoint": "http://localhost:4501", + "s3Bucket": "lets-encrypt-crls", + "awsConfigFile": "test/config/crl-storer.ini", + "awsCredsFile": "test/secrets/aws_creds.ini" + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/crl-updater.json b/third-party/github.com/letsencrypt/boulder/test/config/crl-updater.json new file mode 100644 index 000000000..aabfad987 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/crl-updater.json @@ -0,0 +1,56 @@ +{ + "crlUpdater": { + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/crl-updater.boulder/cert.pem", + "keyFile": "test/certs/ipki/crl-updater.boulder/key.pem" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "crlGeneratorService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "crlStorerService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "crl-storer", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "crl-storer.boulder" + }, + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem" + ], + "numShards": 10, + "shardWidth": "240h", + "lookbackPeriod": "24h", + "updatePeriod": "6h", + "updateOffset": "9120s", + "maxParallelism": 10, + "maxAttempts": 5, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/ecdsaAllowList.yml b/third-party/github.com/letsencrypt/boulder/test/config/ecdsaAllowList.yml new file mode 100644 index 000000000..a648abda3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/ecdsaAllowList.yml @@ -0,0 +1,2 @@ +--- +- 1337 diff --git a/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.gotmpl b/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.gotmpl new file mode 100644 index 000000000..844ecfce5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.gotmpl @@ -0,0 +1,6 @@ +Hello, + +Your SSL certificate for names {{.DNSNames}} is going to expire in {{.DaysToExpiration}} +days ({{.ExpirationDate}}), make sure you run the renewer before then! + +Regards diff --git a/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.json b/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.json new file mode 100644 index 000000000..6f43bf25e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/expiration-mailer.json @@ -0,0 +1,41 @@ +{ + "mailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "from": "Expiry bot ", + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + }, + "certLimit": 100000, + "nagTimes": [ + "480h", + "240h" + ], + "emailTemplate": "test/config/expiration-mailer.gotmpl", + "debugAddr": ":8008", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/expiration-mailer.boulder/cert.pem", + "keyFile": "test/certs/ipki/expiration-mailer.boulder/key.pem" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "SMTPTrustedRootFile": "test/certs/ipki/minica.pem", + "frequency": "1h" + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/health-checker.json b/third-party/github.com/letsencrypt/boulder/test/config/health-checker.json new file mode 100644 index 000000000..e2663f510 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/health-checker.json @@ -0,0 +1,10 @@ +{ + "grpc": { + "timeout": "1s" + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/health-checker.boulder/cert.pem", + "keyFile": "test/certs/ipki/health-checker.boulder/key.pem" + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/id-exporter.json b/third-party/github.com/letsencrypt/boulder/test/config/id-exporter.json new file mode 100644 index 000000000..526da6251 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/id-exporter.json @@ -0,0 +1,9 @@ +{ + "contactExporter": { + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/log-validator.json b/third-party/github.com/letsencrypt/boulder/test/config/log-validator.json new file mode 100644 index 000000000..bff0ca1f7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/log-validator.json @@ -0,0 +1,22 @@ +{ + "syslog": { + "stdoutLevel": 7 + }, + "debugAddr": ":8016", + "files": [ + "/var/log/akamai-purger.log", + "/var/log/bad-key-revoker.log", + "/var/log/boulder-ca.log", + "/var/log/boulder-observer.log", + "/var/log/boulder-publisher.log", + "/var/log/boulder-ra.log", + "/var/log/boulder-remoteva.log", + "/var/log/boulder-sa.log", + "/var/log/boulder-va.log", + "/var/log/boulder-wfe2.log", + "/var/log/crl-storer.log", + "/var/log/crl-updater.log", + "/var/log/nonce-service.log", + "/var/log/ocsp-responder.log" + ] +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/nonce-a.json b/third-party/github.com/letsencrypt/boulder/test/config/nonce-a.json new file mode 100644 index 000000000..c2dd9765c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/nonce-a.json @@ -0,0 +1,35 @@ +{ + "NonceService": { + "maxUsed": 131072, + "useDerivablePrefix": true, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "syslog": { + "stdoutLevel": 6, + "syslogLevel": 6 + }, + "debugAddr": ":8111", + "grpc": { + "maxConnectionAge": "30s", + "address": ":9101", + "services": { + "nonce.NonceService": { + "clientNames": [ + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/nonce.boulder/cert.pem", + "keyFile": "test/certs/ipki/nonce.boulder/key.pem" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/nonce-b.json b/third-party/github.com/letsencrypt/boulder/test/config/nonce-b.json new file mode 100644 index 000000000..c2dd9765c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/nonce-b.json @@ -0,0 +1,35 @@ +{ + "NonceService": { + "maxUsed": 131072, + "useDerivablePrefix": true, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "syslog": { + "stdoutLevel": 6, + "syslogLevel": 6 + }, + "debugAddr": ":8111", + "grpc": { + "maxConnectionAge": "30s", + "address": ":9101", + "services": { + "nonce.NonceService": { + "clientNames": [ + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/nonce.boulder/cert.pem", + "keyFile": "test/certs/ipki/nonce.boulder/key.pem" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/notify-mailer.json b/third-party/github.com/letsencrypt/boulder/test/config/notify-mailer.json new file mode 100644 index 000000000..f6813a696 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/notify-mailer.json @@ -0,0 +1,16 @@ +{ + "notifyMailer": { + "server": "localhost", + "port": "9380", + "username": "cert-manager@example.com", + "passwordFile": "test/secrets/smtp_password", + "db": { + "dbConnectFile": "test/secrets/mailer_dburl", + "maxOpenConns": 10 + } + }, + "syslog": { + "stdoutLevel": 7, + "syslogLevel": 7 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/observer.yml b/third-party/github.com/letsencrypt/boulder/test/config/observer.yml new file mode 100644 index 000000000..150a76112 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/observer.yml @@ -0,0 +1,96 @@ +--- +debugaddr: :8040 +buckets: [.001, .002, .005, .01, .02, .05, .1, .2, .5, 1, 2, 5, 10] +syslog: + stdoutlevel: 6 + sysloglevel: 6 +monitors: + - + period: 5s + kind: DNS + settings: + protocol: udp + server: owen.ns.cloudflare.com:53 + recurse: false + query_name: letsencrypt.org + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: udp + server: 1.1.1.1:53 + recurse: true + query_name: google.com + query_type: A + - + period: 10s + kind: DNS + settings: + protocol: tcp + server: 8.8.8.8:53 + recurse: true + query_name: google.com + query_type: A + - + period: 2s + kind: HTTP + settings: + url: https://letsencrypt.org + rcodes: [200] + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: owen.ns.cloudflare.com:53 + recurse: false + query_name: letsencrypt.org + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: 1.1.1.1:53 + recurse: true + query_name: google.com + query_type: A + - + period: 10s + kind: DNS + settings: + protocol: udp + server: 8.8.8.8:53 + recurse: true + query_name: google.com + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: tcp + server: "[2606:4700:4700::1111]:53" + recurse: true + query_name: google.com + query_type: A + - + period: 5s + kind: DNS + settings: + protocol: udp + server: "[2606:4700:4700::1111]:53" + recurse: true + query_name: google.com + query_type: A + - + period: 2s + kind: HTTP + settings: + url: http://letsencrypt.org/foo + rcodes: [200, 404] + - + period: 10s + kind: TCP + settings: + hostport: acme-v02.api.letsencrypt.org:443 diff --git a/third-party/github.com/letsencrypt/boulder/test/config/ocsp-responder.json b/third-party/github.com/letsencrypt/boulder/test/config/ocsp-responder.json new file mode 100644 index 000000000..80e155bce --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/ocsp-responder.json @@ -0,0 +1,69 @@ +{ + "ocspResponder": { + "db": { + "dbConnectFile": "test/secrets/ocsp_responder_dburl", + "maxOpenConns": 10 + }, + "redis": { + "username": "ocsp-responder", + "passwordFile": "test/secrets/ocsp_responder_redis_password", + "shardAddrs": { + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218" + }, + "timeout": "5s", + "poolSize": 100, + "routeRandomly": true, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ocsp-responder.boulder/cert.pem", + "keyFile": "test/certs/ipki/ocsp-responder.boulder/key.pem" + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ocsp-responder.boulder/cert.pem", + "keyFile": "test/certs/ipki/ocsp-responder.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "hostOverride": "ra.boulder", + "noWaitForReady": true, + "timeout": "15s" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "logSampleRate": 1, + "path": "/", + "listenAddress": "0.0.0.0:4002", + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem" + ], + "liveSigningPeriod": "60h", + "timeout": "4.9s", + "shutdownStopTimeout": "10s", + "debugAddr": ":8005", + "requiredSerialPrefixes": [ + "7f" + ], + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/publisher.json b/third-party/github.com/letsencrypt/boulder/test/config/publisher.json new file mode 100644 index 000000000..8b67b0bc7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/publisher.json @@ -0,0 +1,51 @@ +{ + "publisher": { + "userAgent": "boulder/1.0", + "blockProfileRate": 1000000000, + "chains": [ + [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ] + ], + "debugAddr": ":8009", + "grpc": { + "maxConnectionAge": "30s", + "address": ":9091", + "services": { + "Publisher": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/publisher.boulder/cert.pem", + "keyFile": "test/certs/ipki/publisher.boulder/key.pem" + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/ra.json b/third-party/github.com/letsencrypt/boulder/test/config/ra.json new file mode 100644 index 000000000..add1779ab --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/ra.json @@ -0,0 +1,142 @@ +{ + "ra": { + "rateLimitPoliciesFilename": "test/rate-limit-policies.yml", + "maxContactsPerRegistration": 3, + "debugAddr": ":8002", + "hostnamePolicyFile": "test/hostname-policy.yaml", + "maxNames": 100, + "authorizationLifetimeDays": 30, + "pendingAuthorizationLifetimeDays": 7, + "goodkey": { + "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", + "fermatRounds": 100 + }, + "orderLifetime": "168h", + "issuerCerts": [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/int-ecdsa-a.cert.pem" + ], + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/ra.boulder/cert.pem", + "keyFile": "test/certs/ipki/ra.boulder/key.pem" + }, + "vaService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "va", + "domain": "service.consul" + }, + "timeout": "20s", + "noWaitForReady": true, + "hostOverride": "va.boulder" + }, + "caService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "ocspService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ca", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ca.boulder" + }, + "publisherService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "publisher", + "domain": "service.consul" + }, + "timeout": "300s", + "noWaitForReady": true, + "hostOverride": "publisher.boulder" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "akamaiPurgerService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "akamai-purger", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "akamai-purger.boulder" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9094", + "services": { + "ra.RegistrationAuthority": { + "clientNames": [ + "admin-revoker.boulder", + "bad-key-revoker.boulder", + "ocsp-responder.boulder", + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "ctLogs": { + "stagger": "500ms", + "logListFile": "test/ct-test-srv/log_list.json", + "sctLogs": [ + "A1 Current", + "A1 Future", + "A2 Past", + "A2 Current", + "B1", + "B2", + "C1", + "D1", + "E1" + ], + "infoLogs": [ + "F1" + ], + "finalLogs": [ + "A1 Current", + "A1 Future", + "C1", + "F1" + ] + } + }, + "pa": { + "challenges": { + "http-01": true, + "dns-01": true, + "tls-alpn-01": true + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/remoteva-a.json b/third-party/github.com/letsencrypt/boulder/test/config/remoteva-a.json new file mode 100644 index 000000000..ca21d7c89 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/remoteva-a.json @@ -0,0 +1,47 @@ +{ + "rva": { + "userAgent": "remoteva-a", + "debugAddr": ":8211", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "dns", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9897", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/remoteva-b.json b/third-party/github.com/letsencrypt/boulder/test/config/remoteva-b.json new file mode 100644 index 000000000..f49cd16c1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/remoteva-b.json @@ -0,0 +1,47 @@ +{ + "rva": { + "userAgent": "remoteva-b", + "debugAddr": ":8212", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "dns", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9998", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/rocsp-tool.json b/third-party/github.com/letsencrypt/boulder/test/config/rocsp-tool.json new file mode 100644 index 000000000..3f6170358 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/rocsp-tool.json @@ -0,0 +1,23 @@ +{ + "rocspTool": { + "debugAddr": ":9101", + "redis": { + "username": "rocsp-tool", + "passwordFile": "test/secrets/rocsp_tool_password", + "shardAddrs": { + "shard1": "10.33.33.2:4218", + "shard2": "10.33.33.3:4218" + }, + "timeout": "5s", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rocsp-tool.boulder/cert.pem", + "keyFile": "test/certs/ipki/rocsp-tool.boulder/key.pem" + } + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/sa.json b/third-party/github.com/letsencrypt/boulder/test/config/sa.json new file mode 100644 index 000000000..24f635628 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/sa.json @@ -0,0 +1,55 @@ +{ + "sa": { + "db": { + "dbConnectFile": "test/secrets/sa_dburl", + "maxOpenConns": 100 + }, + "readOnlyDB": { + "dbConnectFile": "test/secrets/sa_ro_dburl", + "maxOpenConns": 100 + }, + "ParallelismPerRPC": 20, + "debugAddr": ":8003", + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/sa.boulder/cert.pem", + "keyFile": "test/certs/ipki/sa.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9095", + "services": { + "sa.StorageAuthority": { + "clientNames": [ + "admin-revoker.boulder", + "ca.boulder", + "crl-updater.boulder", + "expiration-mailer.boulder", + "ocsp-responder.boulder", + "ra.boulder", + "wfe.boulder" + ] + }, + "sa.StorageAuthorityReadOnly": { + "clientNames": [ + "admin-revoker.boulder", + "crl-updater.boulder", + "ocsp-responder.boulder", + "wfe.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder", + "consul.boulder" + ] + } + } + }, + "features": {} + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/va-remote-a.json b/third-party/github.com/letsencrypt/boulder/test/config/va-remote-a.json new file mode 100644 index 000000000..c9571b5c4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/va-remote-a.json @@ -0,0 +1,47 @@ +{ + "va": { + "userAgent": "boulder-remoteva-a", + "debugAddr": ":8011", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "dns", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9397", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/va-remote-b.json b/third-party/github.com/letsencrypt/boulder/test/config/va-remote-b.json new file mode 100644 index 000000000..c853f0cd9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/va-remote-b.json @@ -0,0 +1,47 @@ +{ + "va": { + "userAgent": "boulder-remoteva-b", + "debugAddr": ":8012", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "dns", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/rva.boulder/cert.pem", + "keyFile": "test/certs/ipki/rva.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "address": ":9498", + "services": { + "va.VA": { + "clientNames": [ + "va.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 4 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/va.json b/third-party/github.com/letsencrypt/boulder/test/config/va.json new file mode 100644 index 000000000..a04a35380 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/va.json @@ -0,0 +1,74 @@ +{ + "va": { + "userAgent": "boulder", + "debugAddr": ":8004", + "dnsTries": 3, + "dnsProvider": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "dns", + "domain": "service.consul" + } + }, + "dnsTimeout": "1s", + "dnsAllowLoopbackAddresses": true, + "issuerDomain": "happy-hacker-ca.invalid", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/va.boulder/cert.pem", + "keyFile": "test/certs/ipki/va.boulder/key.pem" + }, + "grpc": { + "maxConnectionAge": "30s", + "services": { + "va.VA": { + "clientNames": [ + "ra.boulder" + ] + }, + "va.CAA": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + }, + "features": {}, + "remoteVAs": [ + { + "serverAddress": "rva1.service.consul:9397", + "timeout": "15s", + "hostOverride": "rva1.boulder" + }, + { + "serverAddress": "rva1.service.consul:9498", + "timeout": "15s", + "hostOverride": "rva1.boulder" + }, + { + "serverAddress": "rva2.service.consul:9897", + "timeout": "15s", + "hostOverride": "rva2.boulder" + }, + { + "serverAddress": "rva2.service.consul:9998", + "timeout": "15s", + "hostOverride": "rva2.boulder" + } + ], + "maxRemoteValidationFailures": 1, + "accountURIPrefixes": [ + "http://boulder.service.consul:4000/acme/reg/", + "http://boulder.service.consul:4001/acme/acct/" + ] + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/wfe2.json b/third-party/github.com/letsencrypt/boulder/test/config/wfe2.json new file mode 100644 index 000000000..05d46fe95 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/wfe2.json @@ -0,0 +1,115 @@ +{ + "wfe": { + "listenAddress": "0.0.0.0:4001", + "TLSListenAddress": "0.0.0.0:4431", + "serverCertificatePath": "test/certs/ipki/boulder/cert.pem", + "serverKeyPath": "test/certs/ipki/boulder/key.pem", + "allowOrigins": [ + "*" + ], + "shutdownStopTimeout": "10s", + "subscriberAgreementURL": "https://boulder.service.consul:4431/terms/v7", + "debugAddr": ":8013", + "directoryCAAIdentity": "happy-hacker-ca.invalid", + "directoryWebsite": "https://github.com/letsencrypt/boulder", + "legacyKeyIDPrefix": "http://boulder.service.consul:4000/reg/", + "goodkey": { + "blockedKeyFile": "test/example-blocked-keys.yaml" + }, + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/wfe.boulder/cert.pem", + "keyFile": "test/certs/ipki/wfe.boulder/key.pem" + }, + "raService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "ra", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "ra.boulder" + }, + "saService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "sa", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "sa.boulder" + }, + "accountCache": { + "size": 9000, + "ttl": "5s" + }, + "getNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "nonce-taro", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "redeemNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "nonce-taro", + "domain": "service.consul" + }, + { + "service": "nonce-zinc", + "domain": "service.consul" + } + ], + "srvResolver": "nonce-srv", + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + }, + "chains": [ + [ + "test/certs/webpki/int-rsa-a.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-rsa-b.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b.cert.pem", + "test/certs/webpki/root-ecdsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-a-cross.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ], + [ + "test/certs/webpki/int-ecdsa-b-cross.cert.pem", + "test/certs/webpki/root-rsa.cert.pem" + ] + ], + "staleTimeout": "5m", + "authorizationLifetimeDays": 30, + "pendingAuthorizationLifetimeDays": 7, + "features": { + "ServeRenewalInfo": true + } + }, + "syslog": { + "stdoutlevel": 4, + "sysloglevel": 6 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/config/zlint.toml b/third-party/github.com/letsencrypt/boulder/test/config/zlint.toml new file mode 100644 index 000000000..1ce7c7d9f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/config/zlint.toml @@ -0,0 +1,18 @@ +[e_pkilint_lint_cabf_serverauth_cert] +pkilint_addr = "http://10.77.77.9" +pkilint_timeout = 200000000 # 200 milliseconds +ignore_lints = [ + # We include the CN in (almost) all of our certificates, on purpose. + # See https://github.com/letsencrypt/boulder/issues/5112 for details. + "DvSubcriberAttributeAllowanceValidator:cabf.serverauth.dv.common_name_attribute_present", + # We include the SKID in all of our certs, on purpose. + # See https://github.com/letsencrypt/boulder/issues/7446 for details. + "SubscriberExtensionAllowanceValidator:cabf.serverauth.subscriber.subject_key_identifier_extension_present", + # We compute the skid using RFC7093 Method 1, on purpose. + # See https://github.com/letsencrypt/boulder/pull/7179 for details. + "SubjectKeyIdentifierValidator:pkix.subject_key_identifier_rfc7093_method_1_identified", + # We include the keyEncipherment key usage in RSA certs, on purpose. + # It is only necessary for old versions of TLS, and is included for backwards + # compatibility. We intend to remove this in the short-lived profile. + "SubscriberKeyUsageValidator:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present", +] diff --git a/third-party/github.com/letsencrypt/boulder/test/consul/README.md b/third-party/github.com/letsencrypt/boulder/test/consul/README.md new file mode 100644 index 000000000..0fb228957 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/consul/README.md @@ -0,0 +1,90 @@ +# Consul in Boulder +We use Consul in development mode (flag: `-dev`), which configures Consul as an +in-memory server and client with persistence disabled for ease of use. + +## Configuring the Service Registry + +- Open `./test/consul/config.hcl` +- Add a `services` stanza for each IP address and (optional) port combination + you wish to have returned as an DNS record. The following stanza will return + two records when resolving `foo-purger`. + ([docs](https://www.consul.io/docs/discovery/services)). + + ```hcl + services { + id = "foo-purger-a" + name = "foo-purger" + address = "10.77.77.77" + port = 1338 + } + + services { + id = "foo-purger-b" + name = "foo-purger" + address = "10.77.77.77" + port = 1438 + } + ``` +- To target individual `foo-purger`'s, add these additional `service` sections + which allow resolving `foo-purger-1` and `foo-purger-2` respectively. + + ```hcl + services { + id = "foo-purger-1" + name = "foo-purger-1" + address = "10.77.77.77" + port = 1338 + } + + services { + id = "foo-purger-2" + name = "foo-purger-2" + address = "10.77.77.77" + port = 1438 + } + ``` +- For RFC 2782 (SRV RR) lookups to work ensure you that you add a tag for the + supported protocol (usually `"tcp"` and or `"udp"`) to the `tags` field. + Consul implemented the `Proto` field as a tag filter for SRV RR lookups. + For more information see the + [docs](https://www.consul.io/docs/discovery/dns#rfc-2782-lookup). + + ```hcl + services { + id = "foo-purger-a" + name = "foo-purger" + address = "10.77.77.77" + port = 1338 + tags = ["udp", "tcp"] + } + ... + ``` +- Services are **not** live-reloaded. You will need to cycle the container for + every Service Registry change. + +## Accessing the web UI + +### Linux + +Consul should be accessible at http://10.55.55.10:8500. + +### Mac + +Docker desktop on macOS doesn't expose the bridge network adapter so you'll need +to add the following port lines (temporarily) to `docker-compose.yml`: + +```yaml + bconsul: + ports: + - 8500:8500 # forwards 127.0.0.1:8500 -> 10.55.55.10:8500 +``` + +For testing DNS resolution locally using `dig` you'll need to add the following: +```yaml + bconsul: + ports: + - 53:53/udp # forwards 127.0.0.1:53 -> 10.55.55.10:53 +``` + +The next time you bring the container up you should be able to access the web UI +at http://127.0.0.1:8500. diff --git a/third-party/github.com/letsencrypt/boulder/test/consul/config.hcl b/third-party/github.com/letsencrypt/boulder/test/consul/config.hcl new file mode 100644 index 000000000..08e3c2d1d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/consul/config.hcl @@ -0,0 +1,383 @@ +# Keep this file in sync with the ports bound in test/startservers.py + +client_addr = "0.0.0.0" +bind_addr = "10.55.55.10" +log_level = "ERROR" +// When set, uses a subset of the agent's TLS configuration (key_file, +// cert_file, ca_file, ca_path, and server_name) to set up the client for HTTP +// or gRPC health checks. This allows services requiring 2-way TLS to be checked +// using the agent's credentials. +enable_agent_tls_for_checks = true +tls { + defaults { + ca_file = "test/certs/ipki/minica.pem" + ca_path = "test/certs/ipki/minica-key.pem" + cert_file = "test/certs/ipki/consul.boulder/cert.pem" + key_file = "test/certs/ipki/consul.boulder/key.pem" + verify_incoming = false + } +} +ui_config { + enabled = true +} +ports { + dns = 53 + grpc_tls = 8503 +} + +services { + id = "akamai-purger-a" + name = "akamai-purger" + address = "10.77.77.77" + port = 9399 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "boulder-a" + name = "boulder" + address = "10.77.77.77" +} + +services { + id = "boulder-a" + name = "boulder" + address = "10.77.77.77" +} + +services { + id = "ca-a" + name = "ca" + address = "10.77.77.77" + port = 9393 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "ca-b" + name = "ca" + address = "10.77.77.77" + port = 9493 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "crl-storer-a" + name = "crl-storer" + address = "10.77.77.77" + port = 9309 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "dns-a" + name = "dns" + address = "10.77.77.77" + port = 8053 + tags = ["udp"] // Required for SRV RR support in VA RVA. +} + +services { + id = "dns-b" + name = "dns" + address = "10.77.77.77" + port = 8054 + tags = ["udp"] // Required for SRV RR support in VA RVA. +} + +services { + id = "doh-a" + name = "doh" + address = "10.77.77.77" + port = 8343 + tags = ["tcp"] +} + +services { + id = "doh-b" + name = "doh" + address = "10.77.77.77" + port = 8443 + tags = ["tcp"] +} + +# Unlike most components, we have two completely independent nonce services, +# simulating two sets of nonce servers running in two different datacenters: +# taro and zinc. +services { + id = "nonce-taro-a" + name = "nonce-taro" + address = "10.77.77.77" + port = 9301 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "nonce-taro-b" + name = "nonce-taro" + address = "10.77.77.77" + port = 9501 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "nonce-zinc" + name = "nonce-zinc" + address = "10.77.77.77" + port = 9401 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "publisher-a" + name = "publisher" + address = "10.77.77.77" + port = 9391 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "publisher-b" + name = "publisher" + address = "10.77.77.77" + port = 9491 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "ra-a" + name = "ra" + address = "10.77.77.77" + port = 9394 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "ra-b" + name = "ra" + address = "10.77.77.77" + port = 9494 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "rva1-a" + name = "rva1" + address = "10.77.77.77" + port = 9397 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "rva1-b" + name = "rva1" + address = "10.77.77.77" + port = 9498 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +# TODO(#5294) Remove rva2-a/b in favor of rva1-a/b +services { + id = "rva2-a" + name = "rva2" + address = "10.77.77.77" + port = 9897 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "rva2-b" + name = "rva2" + address = "10.77.77.77" + port = 9998 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "sa-a" + name = "sa" + address = "10.77.77.77" + port = 9395 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + checks = [ + { + id = "sa-a-grpc" + name = "sa-a-grpc" + grpc = "10.77.77.77:9395" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + }, + { + id = "sa-a-grpc-sa" + name = "sa-a-grpc-sa" + grpc = "10.77.77.77:9395/sa.StorageAuthority" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + }, + { + id = "sa-a-grpc-saro" + name = "sa-a-grpc-saro" + grpc = "10.77.77.77:9395/sa.StorageAuthorityReadOnly" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + } + ] +} + +services { + id = "sa-b" + name = "sa" + address = "10.77.77.77" + port = 9495 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + checks = [ + { + id = "sa-b-grpc" + name = "sa-b-grpc" + grpc = "10.77.77.77:9495" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + }, + { + id = "sa-b-grpc-sa" + name = "sa-b-grpc-sa" + grpc = "10.77.77.77:9495/sa.StorageAuthority" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + }, + { + id = "sa-b-grpc-saro" + name = "sa-b-grpc-saro" + grpc = "10.77.77.77:9495/sa.StorageAuthorityReadOnly" + grpc_use_tls = true + tls_server_name = "sa.boulder" + tls_skip_verify = false + interval = "2s" + } + ] +} + +services { + id = "va-a" + name = "va" + address = "10.77.77.77" + port = 9392 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "va-b" + name = "va" + address = "10.77.77.77" + port = 9492 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +services { + id = "bredis3" + name = "redisratelimits" + address = "10.33.33.4" + port = 4218 + tags = ["tcp"] // Required for SRV RR support in DNS resolution. +} + +services { + id = "bredis4" + name = "redisratelimits" + address = "10.33.33.5" + port = 4218 + tags = ["tcp"] // Required for SRV RR support in DNS resolution. +} + +// +// The following services are used for testing the gRPC DNS resolver in +// test/integration/srv_resolver_test.go and +// test/integration/testdata/srv-resolver-config.json. +// + +// CaseOne config will have 2 SRV records. The first will have 0 backends, the +// second will have 1. +services { + id = "case1a" + name = "case1a" + address = "10.77.77.77" + port = 9301 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + checks = [ + { + id = "case1a-failing" + name = "case1a-failing" + http = "http://localhost:12345" // invalid url + method = "GET" + interval = "2s" + } + ] +} + +services { + id = "case1b" + name = "case1b" + address = "10.77.77.77" + port = 9401 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +// CaseTwo config will have 2 SRV records. The first will not be configured in +// Consul, the second will have 1 backend. +services { + id = "case2b" + name = "case2b" + address = "10.77.77.77" + port = 9401 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. +} + +// CaseThree config will have 2 SRV records. Neither will be configured in +// Consul. + + +// CaseFour config will have 2 SRV records. Neither will have backends. +services { + id = "case4a" + name = "case4a" + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + address = "10.77.77.77" + port = 9301 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + checks = [ + { + id = "case4a-failing" + name = "case4a-failing" + http = "http://localhost:12345" // invalid url + method = "GET" + interval = "2s" + } + ] +} + +services { + id = "case4b" + name = "case4b" + address = "10.77.77.77" + port = 9401 + tags = ["tcp"] // Required for SRV RR support in gRPC DNS resolution. + checks = [ + { + id = "case4b-failing" + name = "case4b-failing" + http = "http://localhost:12345" // invalid url + method = "GET" + interval = "2s" + } + ] +} diff --git a/third-party/github.com/letsencrypt/boulder/test/create_db.sh b/third-party/github.com/letsencrypt/boulder/test/create_db.sh new file mode 100644 index 000000000..8bc3f24c8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/create_db.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -o errexit +cd $(dirname $0)/.. + + +# If you modify DBS or ENVS, you must also modify the corresponding keys in +# sa/db/dbconfig.yml, see: https://github.com/rubenv/sql-migrate#readme + +DBS="boulder_sa +incidents_sa" + +ENVS="test +integration" + +# /path/to/boulder/repo +root_dir=$(dirname $(dirname $(readlink -f "$0"))) + +# posix compliant escape sequence +esc=$'\033'"[" +res="${esc}0m" + +function print_heading() { + echo + # newline + bold magenta + echo -e "${esc}0;34;1m${1}${res}" +} + +function exit_err() { + if [ ! -z "$1" ] + then + echo $1 > /dev/stderr + fi + exit 1 +} + +function create_empty_db() { + local db="${1}" + local dbconn="${2}" + create_script="drop database if exists \`${db}\`; create database if not exists \`${db}\`;" + mysql ${dbconn} -e "${create_script}" || exit_err "unable to create ${db}" +} + +# set db connection for if running in a separate container or not +dbconn="-u root" +if [[ $MYSQL_CONTAINER ]] +then + dbconn="-u root -h boulder-mysql --port 3306" +fi + +# MariaDB sets the default binlog_format to STATEMENT, +# which causes warnings that fail tests. Instead set it +# to the format we use in production, MIXED. +mysql ${dbconn} -e "SET GLOBAL binlog_format = 'MIXED';" + +# MariaDB sets the default @@max_connections value to 100. The SA alone is +# configured to use up to 100 connections. We increase the max connections here +# to give headroom for other components (ocsp-responder for example). +mysql ${dbconn} -e "SET GLOBAL max_connections = 500;" + +for db in $DBS; do + for env in $ENVS; do + dbname="${db}_${env}" + print_heading "${dbname}" + if mysql ${dbconn} -e 'show databases;' | grep "${dbname}" > /dev/null; then + echo "Already exists - skipping create" + else + echo "Doesn't exist - creating" + create_empty_db "${dbname}" "${dbconn}" + fi + + if [[ "${BOULDER_CONFIG_DIR}" == "test/config-next" ]] + then + dbpath="./sa/db-next" + else + dbpath="./sa/db" + fi + + # sql-migrate will default to ./dbconfig.yml and treat all configured dirs + # as relative. + cd "${dbpath}" + r=`sql-migrate up -env="${dbname}" | xargs -0 echo` + if [[ "${r}" == "Migration failed"* ]] + then + echo "Migration failed - dropping and recreating" + create_empty_db "${dbname}" "${dbconn}" + sql-migrate up -env="${dbname}" || exit_err "Migration failed after dropping and recreating" + else + echo "${r}" + fi + + USERS_SQL="../db-users/${db}.sql" + if [[ ${MYSQL_CONTAINER} ]] + then + sed -e "s/'localhost'/'%'/g" < ${USERS_SQL} | \ + mysql ${dbconn} -D "${dbname}" -f || exit_err "Unable to add users from ${USERS_SQL}" + else + sed -e "s/'localhost'/'127.%'/g" < $USERS_SQL | \ + mysql ${dbconn} -D "${dbname}" -f < $USERS_SQL || exit_err "Unable to add users from ${USERS_SQL}" + fi + echo "Added users from ${USERS_SQL}" + + # return to the root directory + cd "${root_dir}" + done +done + +echo +echo "database setup complete" diff --git a/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/ct-test-srv.json b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/ct-test-srv.json new file mode 100644 index 000000000..edf71fccd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/ct-test-srv.json @@ -0,0 +1,64 @@ +{ + "Personalities": [ + { + "UserAgent": "boulder/1.0", + "Addr": ":4600", + "PrivKey": "MHcCAQEEIArwh8VhAPXaUocPILwSJrQF1E2OXtY7O2aJyjGIR7UPoAoGCCqGSM49AwEHoUQDQgAExhriVaEwBOtdNzg5EOtJBHl/u+ua1FtCR/CBXQ1kvpFelcP3gozLNXyxV/UexuifpmzTN31CdfdHv1kK3KDIxQ==", + "FlakinessRate": 1 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4601", + "PrivKey": "MHcCAQEEINk7TLYXyJznFl32p62xfZZTarZJTWZe+8u1HF3xmn2doAoGCCqGSM49AwEHoUQDQgAE7uzW0zXQpWIk7MZUBdTu1muNzekMCIv/kn16+ifndQ584DElobOJ0ZlcACz9WdFyGTjOCfAqBmFybX2OJKfFVg==", + "FlakinessRate": 1 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4602", + "PrivKey": "MHcCAQEEIFJD5JlN30x8i3EkSHF8UuB4fG2WEqXrDD4NiswocRseoAoGCCqGSM49AwEHoUQDQgAE/s5W5OHfowdLA7KerJ+mOizfHJE6Snfib8ueoBYl8Y12lpOoJTtCmmrx4m9KAb9AptInWpGrIaLY+5Y29l2eGw==", + "FlakinessRate": 1 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4603", + "PrivKey": "MHcCAQEEIDrGahcizJgStF+Zf9h29wLZhNKyasQ2TMieIdHNn3ZBoAoGCCqGSM49AwEHoUQDQgAE2EFdA2UBfbJ2Sw1413hBN9YESyABmTGbdgcMh0l/GyV3eFrFjcVS0laNphkfRZ+qkcMbeF+IIHqVzxHAM/2mQQ==", + "FlakinessRate": 1 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4604", + "PrivKey": "MHcCAQEEIH6JmZXVRq2KDWJinKsDxv7gDzw0WEepfXu5s1VQvAHfoAoGCCqGSM49AwEHoUQDQgAEAMSHwrzvr/KvNmUT55+uQo7CXQLPx1X+qEdKGekUg1q/InN/E37bCY/x45wC00qgiE0D3xoxnUJbKaCQcAX39w==", + "FlakinessRate": 2 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4605", + "PrivKey": "MHcCAQEEIOkBiM7jy65TfsJTMxDwIcv3TD/FVTe/aXG4QUUXiQ98oAoGCCqGSM49AwEHoUQDQgAEzmpksKS/mHgJZ821po3ldwonsz3K19jwsZgNSGYvEuzAVtWbGfY+6aUXua7f8WK8l2amHETISOY4JTRwk5QFyw==", + "FlakinessRate": 98 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4606", + "PrivKey": "MHcCAQEEIHIAfD/dxvjxSLAW22Pz8xZR7eCJp2VcVgMID+VmhHtNoAoGCCqGSM49AwEHoUQDQgAE31BxBVCdehxOC35jJzvAPNrU4ZjNXbmxS+zSN5DSkpJWQUp5wUHPGnXiSCtx7jXnTYLVzslIyXWpNN8m8BiKjQ==", + "FlakinessRate": 2 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4607", + "PrivKey": "MHcCAQEEIMly7UpXClsaVP1Con6jTgiL6ZTuarj0kWxdo3NqNJWVoAoGCCqGSM49AwEHoUQDQgAEAjRx6Mhc/U4Ye7NzsZ7bbKMGhKVpGZHpZJMzLzNIveBAPh5OBDHpSdn9RY58t4diH8YLjqCi9o+k1T5RwiFbfQ==", + "FlakinessRate": 2 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4608", + "PrivKey": "MHcCAQEEIJF8W76HJanaUjvSX/mnjwwtBZ0yq1YD/PPvbWJuLhESoAoGCCqGSM49AwEHoUQDQgAEsHFSkgrlrwIY0PG79tOZhPvBzrnrpbrWa3pG2FfkLeEJQ2Uvgw1oTZZ+oXcrm4Yb3khWDbpkzDbupI+e8xloeA==", + "FlakinessRate": 20 + }, + { + "UserAgent": "boulder/1.0", + "Addr": ":4609", + "PrivKey": "MHcCAQEEIIazaamUIxkn+ie+qfDAnO9Fmnrm11rGeE+3fFTHjYNdoAoGCCqGSM49AwEHoUQDQgAEMVjHUOxzh2flagPhuEYy/AhAlpD9qqACg4fGcCxOhLU35r21CQXzKDdCHMu69QDFd6EAe8iGFsybg+Yn4/njtA==", + "FlakinessRate": 100 + } + ] +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/log_list.json b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/log_list.json new file mode 100644 index 000000000..5a8af2d76 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/log_list.json @@ -0,0 +1,221 @@ +{ + "version": "0.1", + "log_list_timestamp": "1970-01-01T00:00:01Z", + "operators": [ + { + "name": "Operator A", + "email": ["fake@example.org"], + "logs": [ + { + "description": "A1 Current", + "log_id": "OJiMlNA1mMOTLd/pI7q68npCDrlsQeFaqAwasPwEvQM=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExhriVaEwBOtdNzg5EOtJBHl/u+ua1FtCR/CBXQ1kvpFelcP3gozLNXyxV/UexuifpmzTN31CdfdHv1kK3KDIxQ==", + "url": "http://boulder.service.consul:4600", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + }, + { + "description": "A1 Future", + "log_id": "2OHE0zamM5iS1NRFWJf9N6CWxdJ93je+leBX371vC+k=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7uzW0zXQpWIk7MZUBdTu1muNzekMCIv/kn16+ifndQ584DElobOJ0ZlcACz9WdFyGTjOCfAqBmFybX2OJKfFVg==", + "url": "http://boulder.service.consul:4601", + "temporal_interval": { + "start_inclusive": "2070-01-01T00:00:00Z", + "end_exclusive": "3070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + }, + { + "description": "A2 Past", + "log_id": "z7banNzwEtmRiittSviBYKjWmVltXNBhLfudmDXIcoU=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/s5W5OHfowdLA7KerJ+mOizfHJE6Snfib8ueoBYl8Y12lpOoJTtCmmrx4m9KAb9AptInWpGrIaLY+5Y29l2eGw==", + "url": "http://boulder.service.consul:4602", + "temporal_interval": { + "start_inclusive": "1870-01-01T00:00:00Z", + "end_exclusive": "1970-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + }, + { + "description": "A2 Current", + "log_id": "HRrTQca8iy14Qbrw6/itgVzVWTcaENF3tWnJP743pq8=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2EFdA2UBfbJ2Sw1413hBN9YESyABmTGbdgcMh0l/GyV3eFrFjcVS0laNphkfRZ+qkcMbeF+IIHqVzxHAM/2mQQ==", + "url": "http://boulder.service.consul:4603", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + } + ] + }, + { + "name": "Operator B", + "email": ["fake@example.org"], + "logs": [ + { + "description": "B1", + "log_id": "UtToynGEyMkkXDMQei8Ll54oMwWHI0IieDEKs12/Td4=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMSHwrzvr/KvNmUT55+uQo7CXQLPx1X+qEdKGekUg1q/InN/E37bCY/x45wC00qgiE0D3xoxnUJbKaCQcAX39w==", + "url": "http://boulder.service.consul:4604", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + }, + { + "description": "B2", + "log_id": "EOPWVkKfDlS3lQe5brFUMsEYAJ8I7uZr7z55geKzv7c=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzmpksKS/mHgJZ821po3ldwonsz3K19jwsZgNSGYvEuzAVtWbGfY+6aUXua7f8WK8l2amHETISOY4JTRwk5QFyw==", + "url": "http://boulder.service.consul:4605", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + } + ] + }, + { + "name": "Operator C", + "email": ["fake@example.org"], + "logs": [ + { + "description": "C1", + "log_id": "Oqk/Tv0cUSnEJ4bZa0eprm3IQQ4XgNcv20/bXixlxnQ=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE31BxBVCdehxOC35jJzvAPNrU4ZjNXbmxS+zSN5DSkpJWQUp5wUHPGnXiSCtx7jXnTYLVzslIyXWpNN8m8BiKjQ==", + "url": "http://boulder.service.consul:4606", + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + } + ] + }, + { + "name": "Operator D", + "email": ["fake@example.org"], + "logs": [ + { + "description": "D1", + "log_id": "e90gTyc4KkZpHv2pgeSOS224Md6/21UmWIxRF9mXveI=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAjRx6Mhc/U4Ye7NzsZ7bbKMGhKVpGZHpZJMzLzNIveBAPh5OBDHpSdn9RY58t4diH8YLjqCi9o+k1T5RwiFbfQ==", + "url": "http://boulder.service.consul:4607", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "usable": { + "timestamp": "2000-00-00T00:00:00Z" + } + } + } + ] + }, + { + "name": "Operator E", + "email": ["fake@example.org"], + "logs": [ + { + "description": "E1", + "log_id": "ck+wYNY31I+5XBC7htsdNdYVjOSm4YgnDxlzO9PouwQ=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsHFSkgrlrwIY0PG79tOZhPvBzrnrpbrWa3pG2FfkLeEJQ2Uvgw1oTZZ+oXcrm4Yb3khWDbpkzDbupI+e8xloeA==", + "url": "http://boulder.service.consul:4608", + "state": { + "retired": { + "timestamp": "2000-01-01T00:00:00Z" + } + } + } + ] + }, + { + "name": "Operator F", + "email": ["fake@example.org"], + "logs": [ + { + "description": "F1", + "log_id": "FWPcPPStmIK3l/jogz7yLYUtafS44cpLs6hQ3HrjdUQ=", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMVjHUOxzh2flagPhuEYy/AhAlpD9qqACg4fGcCxOhLU35r21CQXzKDdCHMu69QDFd6EAe8iGFsybg+Yn4/njtA==", + "url": "http://boulder.service.consul:4609", + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:00Z", + "end_exclusive": "2070-01-01T00:00:00Z" + }, + "state": { + "pending": { + "timestamp": "2000-01-01T00:00:00Z" + } + } + } + ] + }, + { + "name": "Unused", + "email": ["fake@example.org"], + "logs": [ + { + "description": "This Log Has Every Field To Ensure We Can Parse It", + "log_id": "BaseSixtyFourEncodingOfSHA256HashOfPublicKey=", + "key": "BaseSixtyFourEncodingOfDEREncodingOfPublicKey=", + "url": "https://example.com/ct/", + "mmd": 86400, + "state": { + "readonly": { + "timestamp": "2020-01-01T00:00:01Z", + "final_tree_head": { + "sha256_root_hash": "D1H4wAJmq0MRCeLfeOtrsZ9Am015anO5MkeasNhnQWI=", + "tree_size": 123456789 + } + } + }, + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:01Z", + "end_exclusive": "2070-01-01T00:00:01Z" + } + }, + { + "description": "This Log Is Missing State To Ensure We Can Handle It", + "log_id": "SomeOtherFakeLogID=", + "key": "SomeOtherFakeKey=", + "url": "https://example.net/ct/", + "mmd": 86400, + "temporal_interval": { + "start_inclusive": "1970-01-01T00:00:01Z", + "end_exclusive": "2070-01-01T00:00:01Z" + } + } + ] + } + ] +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/main.go b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/main.go new file mode 100644 index 000000000..564ad85f7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ct-test-srv/main.go @@ -0,0 +1,261 @@ +// This is a test server that implements the subset of RFC6962 APIs needed to +// run Boulder's CT log submission code. Currently it only implements add-chain. +// This is used by startservers.py. +package main + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/publisher" +) + +type ctSubmissionRequest struct { + Chain []string `json:"chain"` +} + +type integrationSrv struct { + sync.Mutex + submissions map[string]int64 + // Hostnames where we refuse to provide an SCT. This is to exercise the code + // path where all CT servers fail. + rejectHosts map[string]bool + // A list of entries that we rejected based on rejectHosts. + rejected []string + key *ecdsa.PrivateKey + flakinessRate int + userAgent string +} + +func readJSON(r *http.Request, output interface{}) error { + if r.Method != "POST" { + return fmt.Errorf("incorrect method; only POST allowed") + } + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + return err + } + + err = json.Unmarshal(bodyBytes, output) + if err != nil { + return err + } + return nil +} + +func (is *integrationSrv) addChain(w http.ResponseWriter, r *http.Request) { + is.addChainOrPre(w, r, false) +} + +// addRejectHost takes a JSON POST with a "host" field; any subsequent +// submissions for that host will get a 400 error. +func (is *integrationSrv) addRejectHost(w http.ResponseWriter, r *http.Request) { + var rejectHostReq struct { + Host string + } + err := readJSON(r, &rejectHostReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + is.Lock() + defer is.Unlock() + is.rejectHosts[rejectHostReq.Host] = true + w.Write([]byte{}) +} + +// getRejections returns a JSON array containing strings; those strings are +// base64 encodings of certificates or precertificates that were rejected due to +// the rejectHosts mechanism. +func (is *integrationSrv) getRejections(w http.ResponseWriter, r *http.Request) { + is.Lock() + defer is.Unlock() + output, err := json.Marshal(is.rejected) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(output) +} + +// shouldReject checks if the given host is in the rejectHosts list for the +// integrationSrv. If it is, then the chain is appended to the integrationSrv +// rejected list and true is returned indicating the request should be rejected. +func (is *integrationSrv) shouldReject(host, chain string) bool { + is.Lock() + defer is.Unlock() + if is.rejectHosts[host] { + is.rejected = append(is.rejected, chain) + return true + } + return false +} + +func (is *integrationSrv) addPreChain(w http.ResponseWriter, r *http.Request) { + is.addChainOrPre(w, r, true) +} + +func (is *integrationSrv) addChainOrPre(w http.ResponseWriter, r *http.Request, precert bool) { + if is.userAgent != "" && r.UserAgent() != is.userAgent { + http.Error(w, "invalid user-agent", http.StatusBadRequest) + return + } + if r.Method != "POST" { + http.NotFound(w, r) + return + } + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var addChainReq ctSubmissionRequest + err = json.Unmarshal(bodyBytes, &addChainReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(addChainReq.Chain) == 0 { + w.WriteHeader(400) + return + } + + b, err := base64.StdEncoding.DecodeString(addChainReq.Chain[0]) + if err != nil { + w.WriteHeader(400) + return + } + cert, err := x509.ParseCertificate(b) + if err != nil { + w.WriteHeader(400) + return + } + hostnames := strings.Join(cert.DNSNames, ",") + + for _, h := range cert.DNSNames { + if is.shouldReject(h, addChainReq.Chain[0]) { + w.WriteHeader(400) + return + } + } + + is.Lock() + is.submissions[hostnames]++ + is.Unlock() + + if is.flakinessRate != 0 && rand.Intn(100) < is.flakinessRate { + time.Sleep(10 * time.Second) + } + + w.WriteHeader(http.StatusOK) + w.Write(publisher.CreateTestingSignedSCT(addChainReq.Chain, is.key, precert, time.Now())) +} + +func (is *integrationSrv) getSubmissions(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.NotFound(w, r) + return + } + + is.Lock() + hostnames := r.URL.Query().Get("hostnames") + submissions := is.submissions[hostnames] + is.Unlock() + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%d", submissions) +} + +type config struct { + Personalities []Personality +} + +type Personality struct { + // If present, the expected UserAgent of the reporter to this test CT log. + UserAgent string + // Port (and optionally IP) to listen on + Addr string + // Private key for signing SCTs + // Generate your own with: + // openssl ecparam -name prime256v1 -genkey -outform der -noout | base64 -w 0 + PrivKey string + // FlakinessRate is an integer between 0-100 that controls how often the log + // "flakes", i.e. fails to respond in a reasonable time frame. + FlakinessRate int +} + +func runPersonality(p Personality) { + keyDER, err := base64.StdEncoding.DecodeString(p.PrivKey) + if err != nil { + log.Fatal(err) + } + key, err := x509.ParseECPrivateKey(keyDER) + if err != nil { + log.Fatal(err) + } + pubKeyBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + log.Fatal(err) + } + is := integrationSrv{ + key: key, + flakinessRate: p.FlakinessRate, + submissions: make(map[string]int64), + rejectHosts: make(map[string]bool), + userAgent: p.UserAgent, + } + m := http.NewServeMux() + m.HandleFunc("/submissions", is.getSubmissions) + m.HandleFunc("/ct/v1/add-pre-chain", is.addPreChain) + m.HandleFunc("/ct/v1/add-chain", is.addChain) + m.HandleFunc("/add-reject-host", is.addRejectHost) + m.HandleFunc("/get-rejections", is.getRejections) + // The gosec linter complains that ReadHeaderTimeout is not set. That's fine, + // because this is test-only code. + ////nolint:gosec + srv := &http.Server{ + Addr: p.Addr, + Handler: m, + } + logID := sha256.Sum256(pubKeyBytes) + log.Printf("ct-test-srv on %s with pubkey %s and log ID %s", p.Addr, + base64.StdEncoding.EncodeToString(pubKeyBytes), base64.StdEncoding.EncodeToString(logID[:])) + log.Fatal(srv.ListenAndServe()) +} + +func main() { + configFile := flag.String("config", "", "Path to config file.") + flag.Parse() + data, err := os.ReadFile(*configFile) + if err != nil { + log.Fatal(err) + } + var c config + err = json.Unmarshal(data, &c) + if err != nil { + log.Fatal(err) + } + + for _, p := range c.Personalities { + go runPersonality(p) + } + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/db.go b/third-party/github.com/letsencrypt/boulder/test/db.go new file mode 100644 index 000000000..26212133f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/db.go @@ -0,0 +1,126 @@ +package test + +import ( + "context" + "database/sql" + "fmt" + "io" + "testing" +) + +var ( + _ CleanUpDB = &sql.DB{} +) + +// CleanUpDB is an interface with only what is needed to delete all +// rows in all tables in a database plus close the database +// connection. It is satisfied by *sql.DB. +type CleanUpDB interface { + BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error) + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + + io.Closer +} + +// ResetBoulderTestDatabase returns a cleanup function which deletes all rows in +// all tables of the 'boulder_sa_test' database. Omits the 'gorp_migrations' +// table as this is used by sql-migrate (https://github.com/rubenv/sql-migrate) +// to track migrations. If it encounters an error it fails the tests. +func ResetBoulderTestDatabase(t testing.TB) func() { + return resetTestDatabase(t, context.Background(), "boulder") +} + +// ResetIncidentsTestDatabase returns a cleanup function which deletes all rows +// in all tables of the 'incidents_sa_test' database. Omits the +// 'gorp_migrations' table as this is used by sql-migrate +// (https://github.com/rubenv/sql-migrate) to track migrations. If it encounters +// an error it fails the tests. +func ResetIncidentsTestDatabase(t testing.TB) func() { + return resetTestDatabase(t, context.Background(), "incidents") +} + +func resetTestDatabase(t testing.TB, ctx context.Context, dbPrefix string) func() { + db, err := sql.Open("mysql", fmt.Sprintf("test_setup@tcp(boulder-proxysql:6033)/%s_sa_test", dbPrefix)) + if err != nil { + t.Fatalf("Couldn't create db: %s", err) + } + err = deleteEverythingInAllTables(ctx, db) + if err != nil { + t.Fatalf("Failed to delete everything: %s", err) + } + return func() { + err := deleteEverythingInAllTables(ctx, db) + if err != nil { + t.Fatalf("Failed to truncate tables after the test: %s", err) + } + _ = db.Close() + } +} + +// clearEverythingInAllTables deletes all rows in the tables +// available to the CleanUpDB passed in and resets the autoincrement +// counters. See allTableNamesInDB for what is meant by "all tables +// available". To be used only in test code. +func deleteEverythingInAllTables(ctx context.Context, db CleanUpDB) error { + ts, err := allTableNamesInDB(ctx, db) + if err != nil { + return err + } + for _, tn := range ts { + // We do this in a transaction to make sure that the foreign + // key checks remain disabled even if the db object chooses + // another connection to make the deletion on. Note that + // `alter table` statements will silently cause transactions + // to commit, so we do them outside of the transaction. + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("unable to start transaction to delete all rows from table %#v: %s", tn, err) + } + _, err = tx.ExecContext(ctx, "set FOREIGN_KEY_CHECKS = 0") + if err != nil { + return fmt.Errorf("unable to disable FOREIGN_KEY_CHECKS to delete all rows from table %#v: %s", tn, err) + } + // 1 = 1 here prevents the MariaDB i_am_a_dummy setting from + // rejecting the DELETE for not having a WHERE clause. + + _, err = tx.ExecContext(ctx, "delete from `"+tn+"` where 1 = 1") + if err != nil { + return fmt.Errorf("unable to delete all rows from table %#v: %s", tn, err) + } + _, err = tx.ExecContext(ctx, "set FOREIGN_KEY_CHECKS = 1") + if err != nil { + return fmt.Errorf("unable to re-enable FOREIGN_KEY_CHECKS to delete all rows from table %#v: %s", tn, err) + } + err = tx.Commit() + if err != nil { + return fmt.Errorf("unable to commit transaction to delete all rows from table %#v: %s", tn, err) + } + + _, err = db.ExecContext(ctx, "alter table `"+tn+"` AUTO_INCREMENT = 1") + if err != nil { + return fmt.Errorf("unable to reset autoincrement on table %#v: %s", tn, err) + } + } + return err +} + +// allTableNamesInDB returns the names of the tables available to the passed +// CleanUpDB. Omits the 'gorp_migrations' table as this is used by sql-migrate +// (https://github.com/rubenv/sql-migrate) to track migrations. +func allTableNamesInDB(ctx context.Context, db CleanUpDB) ([]string, error) { + r, err := db.QueryContext(ctx, "select table_name from information_schema.tables t where t.table_schema = DATABASE() and t.table_name != 'gorp_migrations';") + if err != nil { + return nil, err + } + var ts []string + for r.Next() { + tableName := "" + err = r.Scan(&tableName) + if err != nil { + return nil, err + } + ts = append(ts, tableName) + } + return ts, r.Err() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/entrypoint.sh b/third-party/github.com/letsencrypt/boulder/test/entrypoint.sh new file mode 100644 index 000000000..12d0397c4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/entrypoint.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -e -u + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Start rsyslog. Note: Sometimes for unknown reasons /var/run/rsyslogd.pid is +# already present, which prevents the whole container from starting. We remove +# it just in case it's there. +rm -f /var/run/rsyslogd.pid +service rsyslog start + +# make sure we can reach the mysqldb. +./test/wait-for-it.sh boulder-mysql 3306 + +# make sure we can reach the proxysql. +./test/wait-for-it.sh bproxysql 6032 + +# create the database +MYSQL_CONTAINER=1 $DIR/create_db.sh + +if [[ $# -eq 0 ]]; then + exec python3 ./start.py +fi + +exec "$@" diff --git a/third-party/github.com/letsencrypt/boulder/test/example-bad-key-revoker-template b/third-party/github.com/letsencrypt/boulder/test/example-bad-key-revoker-template new file mode 100644 index 000000000..51833fa30 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/example-bad-key-revoker-template @@ -0,0 +1,8 @@ +Hello, + +The public key associated with certificates which you have issued has been marked as compromised. As such we are required to revoke any certificates which contain this public key. + +The following currently unexpired certificates that you've issued contain this public key and have been revoked: +{{range . -}} +{{.}} +{{end}} diff --git a/third-party/github.com/letsencrypt/boulder/test/example-blocked-keys.yaml b/third-party/github.com/letsencrypt/boulder/test/example-blocked-keys.yaml new file mode 100644 index 000000000..2c0c3a47e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/example-blocked-keys.yaml @@ -0,0 +1,26 @@ +# +# List of blocked keys +# +# Each blocked entry is a Base64 encoded SHA256 hash of a SubjectPublicKeyInfo. +# +# Use the test/block-a-key utility to generate new additions. +# +# NOTE: This list is loaded all-at-once in-memory by Boulder and is intended +# to be used infrequently. Alternative mechanisms should be explored if +# large scale blocks are required. +# +blocked: + # test/block-a-key/test/test.ecdsa.cert.pem + - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + # test/block-a-key/test/test.rsa.cert.pem + - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= + # test/block-a-key/test/test.ecdsa.jwk.json + - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + # test/block-a-key/test/test.rsa.jwk.json + - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= + # test/hierarchy/int-r4.cert.pem + - +//lPMatuGvtf7yesXNv6FSf0UovKbP3BKdQZ23L4BY= +blockedHashesHex: + - 41e6dcd55dd2917de2ce461118d262966f4172ebdfd28a31e14d919fe6f824e1 + + diff --git a/third-party/github.com/letsencrypt/boulder/test/example-weak-keys.json b/third-party/github.com/letsencrypt/boulder/test/example-weak-keys.json new file mode 100644 index 000000000..bf6548988 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/example-weak-keys.json @@ -0,0 +1,16 @@ +[ + "0002a4226a4043426396", + "0002beb9288f6c0140cf", + "00006aa0ce2cd60e6660", + "00015b6662ff95aefa3f", + "00015e77627966ce16e7", + "000220bb2bcbc060b8da", + "00024ac71844e42b0fa6", + "00026532237f74a48943", + "00029956ea9997f257e1", + "0002a4ba3cf408927759", + "00008be7025d9f1a9088", + "0001313db46d8945bba0", + "000169a60c9eb82a558b", + "00008f7e6a29aea0b430" +] \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/test/format-configs.py b/third-party/github.com/letsencrypt/boulder/test/format-configs.py new file mode 100644 index 000000000..a3d37a536 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/format-configs.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +import argparse +import glob +import json +import sys + +parser = argparse.ArgumentParser() +parser.add_argument('globs', nargs='+', help='List of JSON file globs') +parser.add_argument('--write', action='store_true', help='Write out formatted files') +args = parser.parse_args() + +needs_format = [] + +for pattern in args.globs: + for cfg in glob.glob(pattern): + with open(cfg, "r") as fr: + existing = fr.read() + j = json.loads(existing) + new = json.dumps(j, indent="\t") + new += "\n" + if new != existing: + if args.write: + with open(cfg, "w") as fw: + fw.write(new) + else: + needs_format.append(cfg) + +if len(needs_format) > 0: + print("Files need reformatting:") + for file in needs_format: + print(f"\t{file}") + print("Run ./test/format-configs.py --write 'test/config*/*.json'") + sys.exit(1) diff --git a/third-party/github.com/letsencrypt/boulder/test/grafana/boulderdash.json b/third-party/github.com/letsencrypt/boulder/test/grafana/boulderdash.json new file mode 100644 index 000000000..15d78c9a7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/grafana/boulderdash.json @@ -0,0 +1,2140 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "4.5.2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [], + "refresh": "1m", + "rows": [ + { + "collapse": false, + "height": 256, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (instance) (rate(response_time_count{code!~\"50.\",instance=~\".*wfe.*\"}[$interval])) / sum by (instance) (rate(response_time_count{}[$interval]))", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "API request success rate", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "label": "", + "logBase": 1, + "max": "1.1", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 19, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (instance) (rate(response_time_count[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Request volume", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "Validation count for DNS-01", + "fill": 1, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (result) (rate(validation_time_count{type=\"dns-01\"}[$interval]))", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{result}}", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "DNS-01", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "Validation count for HTTP-01", + "fill": 1, + "id": 7, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (result) (rate(validation_time_count{type=\"http-01\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{result}}", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "HTTP-01", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "Validation count for TLS-SNI-01", + "fill": 1, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (result) (rate(validation_time_count{type=\"tls-sni-01\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{result}}", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "TLS-SNI-01", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Validations", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code, instance) (rate(response_time_count{method=\"GET\",instance=~\".*wfe.*\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "metric": "response_", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "GETs per second by response code (WFE)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code) (rate(response_time_count{method=\"POST\",instance=~\".*wfe.*\"}[$interval]))", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "POSTs per second by response code (WFE)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum by (le, endpoint) (rate(response_time_bucket{method=\"GET\",code!~\"^4.*\"}[$interval])))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{endpoint}}", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "GET median latency by endpoint", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum by (le, endpoint) (rate(response_time_bucket{method=\"POST\"}[$interval])))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{endpoint}}", + "metric": "response_", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "POST median latency by endpoint", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 14, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code, endpoint) (irate(response_time_count{code=~\"^5.*\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{code}} {{endpoint}}", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "errors per second by endpoint", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "none", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code) (rate(response_time_count{endpoint=\"/acme/new-reg\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "refId": "A", + "step": 600 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "new-reg by response code", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 10, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code) (rate(response_time_count{endpoint=\"/acme/new-authz\"}[$interval]))", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "refId": "A", + "step": 600 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "new-authz by response code", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 11, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code) (rate(response_time_count{endpoint=\"/acme/new-cert\"}[$interval]))", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "refId": "A", + "step": 600 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "new-cert by response code", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (code) (rate(response_time_count{method=\"POST\",endpoint=\"/acme/challenge/\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{code}}", + "metric": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "POST challenge by response code", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 275, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 13, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 7, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (instance) (irate(process_cpu_seconds_total{job=~\"boulder_.*\"}[$interval]))", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "metric": "process_cpu_seconds_total", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Boulder CPU", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "Shows expiry-mailer stats. Missing data for an extended period of time means that mail is not being sent.", + "fill": 1, + "id": 24, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 5, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(Mailer_SendMail_Attempts[5m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Attempts", + "refId": "A", + "step": 240 + }, + { + "expr": "sum(rate(Mailer_SendMail_Successes[5m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Success", + "refId": "B", + "step": 240 + }, + { + "expr": "sum(rate(Mailer_SendMail_Errors_EOF[5m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Error: EOF", + "refId": "C", + "step": 240 + }, + { + "expr": "sum(rate(Mailer_SendMail_Errors_SMTP_421[5m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Error: 421", + "refId": "D", + "step": 240 + }, + { + "expr": "sum(rate(Mailer_SendMail_Reconnects[5m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Reconnects", + "refId": "E", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Expiry-mailer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 3, + "id": 16, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (method, code) (rate(response_time_count{instance=~\".*ocsp.*\",code!=\"405\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{method}}, {{code}}", + "metric": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "OCSP response volume", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "rps", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum by (le, endpoint) (rate(response_time_bucket{instance=~\".*ocsp.*\"}[$interval])))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "median", + "refId": "A", + "step": 240 + }, + { + "expr": "histogram_quantile(0.99, sum by (le, endpoint) (rate(response_time_bucket{instance=~\".*ocsp.*\"}[$interval])))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "99th percentile", + "refId": "B", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "OCSP latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (job) (rate(response_time_count{code!~\"[45]0.\",instance=~\".*ocsp.*\"}[$interval])) / sum by (job) (rate(response_time_count{instance=~\".*ocsp.*\"}[$interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "success rate", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "OCSP success rate (excluding 400s)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 20, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (purpose) (rate(signatures[$interval]))", + "intervalFactor": 2, + "refId": "A", + "step": 120 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "HSM signatures", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 21, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(mysql_info_schema_innodb_metrics_index_index_page_splits_total[$interval])", + "intervalFactor": 2, + "metric": "e", + "refId": "A", + "step": 120 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "InnoDB page splits", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 22, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(ct_googleapis_com_icarus_Submits[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "ct_googleapis_com_icarus", + "metric": "ct_googleapis_com_icarus_Submits", + "refId": "A", + "step": 120 + }, + { + "expr": "irate(ctlog_gen2_api_venafi_com__Submits[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "ctlog_gen2_api_venafi_com", + "metric": "ctlog_gen2_api_venafi_com__Submits", + "refId": "B", + "step": 120 + }, + { + "expr": "irate(sabre_ct_comodo_com__Submits[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "sabre_ct_comodo_com", + "metric": "sabre_ct_comodo_com__Submits", + "refId": "C", + "step": 120 + }, + { + "expr": "irate(mammoth_ct_comodo_com__Submits[5m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "mammoth_ct_comodo_com", + "metric": "mammoth_ct_comodo_com__Submits", + "refId": "D", + "step": 120 + }, + { + "expr": "sum by (log, status) (irate(ct_submission_time_seconds_count[5m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{status}} {{log}}", + "refId": "E", + "step": 120 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CT submissions", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "id": 23, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(IsSafeDomain_IsSafeDomain_Status_Bad{job=\"boulder_va\"}[$interval])", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "GSB status: bad", + "metric": "IsSafeDomain_IsSafeDomain_Status_Bad", + "refId": "A", + "step": 120 + }, + { + "expr": "irate(IsSafeDomain_IsSafeDomain_Status_Good{job=\"boulder_va\"}[$interval])", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "GSB status: good", + "refId": "B", + "step": 120 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Safe Browsing", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "tags": [], + "text": "default", + "value": "default" + }, + "hide": 0, + "label": null, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "auto": true, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "auto", + "value": "$__auto_interval" + }, + "hide": 0, + "label": null, + "name": "interval", + "options": [ + { + "selected": true, + "text": "auto", + "value": "$__auto_interval" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "utc", + "title": "Boulderdash", + "version": 51 +} \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/test/grafana/lint.py b/third-party/github.com/letsencrypt/boulder/test/grafana/lint.py new file mode 100644 index 000000000..cab1aefb1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/grafana/lint.py @@ -0,0 +1,26 @@ +# Check dashboard JSON files for common errors, like forgetting to templatize a +# datasource. +import json +import os +with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), + "boulderdash.json")) as f: + dashboard = json.load(f) + +# When exporting, the current value of templated variables is saved. We don't +# want to save a specific value for datasource, since that's +# deployment-specific, so we ensure that the dashboard was exported with the +# datasource template variable set to "Default." +for li in dashboard["templating"]["list"]: + if li["type"] == "datasource": + assert(li["current"]["value"] == "default") + +# Additionally, ensure each panel's datasource is using the template variable +# rather than a hardcoded datasource. Grafana will choose a hardcoded +# datasource on new panels by default, so this is an easy mistake to make. +for ro in dashboard["rows"]: + for pa in ro["panels"]: + assert(pa["datasource"] == "$datasource") + +# It seems that __inputs is non-empty when template variables at the top of the +# dashboard have been modified from the defaults; check for that. +assert(len(dashboard["__inputs"]) == 0) diff --git a/third-party/github.com/letsencrypt/boulder/test/health-checker/main.go b/third-party/github.com/letsencrypt/boulder/test/health-checker/main.go new file mode 100644 index 000000000..0331d59e5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/health-checker/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + healthpb "google.golang.org/grpc/health/grpc_health_v1" + + "github.com/letsencrypt/boulder/cmd" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/metrics" +) + +type config struct { + GRPC *cmd.GRPCClientConfig + TLS *cmd.TLSConfig +} + +func main() { + defer cmd.AuditPanic() + + // Flag and config parsing and validation. + configFile := flag.String("config", "", "Path to the TLS configuration file") + serverAddr := flag.String("addr", "", "Address of the gRPC server to check") + hostOverride := flag.String("host-override", "", "Hostname to use for TLS certificate validation") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "failed to read json config") + + if c.GRPC.ServerAddress == "" && *serverAddr == "" { + cmd.Fail("must specify either -addr flag or client.ServerAddress config") + } else if c.GRPC.ServerAddress != "" && *serverAddr != "" { + cmd.Fail("cannot specify both -addr flag and client.ServerAddress config") + } else if c.GRPC.ServerAddress == "" { + c.GRPC.ServerAddress = *serverAddr + } + + tlsConfig, err := c.TLS.Load(metrics.NoopRegisterer) + cmd.FailOnError(err, "failed to load TLS credentials") + + if *hostOverride != "" { + c.GRPC.HostOverride = *hostOverride + } + + // GRPC connection prerequisites. + clk := cmd.Clock() + + // Health check retry and timeout. + ticker := time.NewTicker(100 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 10*c.GRPC.Timeout.Duration) + defer cancel() + + for { + select { + case <-ticker.C: + _, hostOverride, err := c.GRPC.MakeTargetAndHostOverride() + cmd.FailOnError(err, "") + + // Set the hostOverride to match the dNSName in the server certificate. + c.GRPC.HostOverride = strings.Replace(hostOverride, ".service.consul", ".boulder", 1) + fmt.Fprintf(os.Stderr, "health checking %s (%s)\n", c.GRPC.HostOverride, *serverAddr) + + // Set up the GRPC connection. + conn, err := bgrpc.ClientSetup(c.GRPC, tlsConfig, metrics.NoopRegisterer, clk) + cmd.FailOnError(err, "failed to connect to service") + client := healthpb.NewHealthClient(conn) + ctx2, cancel2 := context.WithTimeout(ctx, c.GRPC.Timeout.Duration) + defer cancel2() + + // Make the health check. + req := &healthpb.HealthCheckRequest{ + Service: "", + } + resp, err := client.Check(ctx2, req) + if err != nil { + if strings.Contains(err.Error(), "authentication handshake failed") { + cmd.Fail(fmt.Sprintf("health checking %s (%s): %s\n", c.GRPC.HostOverride, *serverAddr, err)) + } + fmt.Fprintf(os.Stderr, "health checking %s (%s): %s\n", c.GRPC.HostOverride, *serverAddr, err) + } else if resp.Status == healthpb.HealthCheckResponse_SERVING { + return + } else { + cmd.Fail(fmt.Sprintf("service %s failed health check with status %s", *serverAddr, resp.Status)) + } + + case <-ctx.Done(): + cmd.Fail(fmt.Sprintf("timed out waiting for %s health check", *serverAddr)) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/helpers.py b/third-party/github.com/letsencrypt/boulder/test/helpers.py new file mode 100644 index 000000000..3a7e38615 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/helpers.py @@ -0,0 +1,203 @@ +import atexit +import base64 +import errno +import glob +import os +import random +import re +import requests +import shutil +import socket +import subprocess +import tempfile +import time +import urllib + +import challtestsrv + +challSrv = challtestsrv.ChallTestServer() +tempdir = tempfile.mkdtemp() + +@atexit.register +def stop(): + shutil.rmtree(tempdir) + +config_dir = os.environ.get('BOULDER_CONFIG_DIR', '') +if config_dir == '': + raise Exception("BOULDER_CONFIG_DIR was not set") +CONFIG_NEXT = config_dir.startswith("test/config-next") + +def temppath(name): + """Creates and returns a closed file inside the tempdir.""" + f = tempfile.NamedTemporaryFile( + dir=tempdir, + suffix='.{0}'.format(name), + mode='w+', + delete=False + ) + f.close() + return f + +def fakeclock(date): + return date.strftime("%a %b %d %H:%M:%S UTC %Y") + +def get_future_output(cmd, date): + return subprocess.check_output(cmd, stderr=subprocess.STDOUT, + env={'FAKECLOCK': fakeclock(date)}).decode() + +def random_domain(): + """Generate a random domain for testing (to avoid rate limiting).""" + return "rand.%x.xyz" % random.randrange(2**32) + +def run(cmd, **kwargs): + return subprocess.check_call(cmd, stderr=subprocess.STDOUT, **kwargs) + +def fetch_ocsp(request_bytes, url): + """Fetch an OCSP response using POST, GET, and GET with URL encoding. + + Returns a tuple of the responses. + """ + ocsp_req_b64 = base64.b64encode(request_bytes).decode() + + # Make the OCSP request three different ways: by POST, by GET, and by GET with + # URL-encoded parameters. All three should have an identical response. + get_response = requests.get("%s/%s" % (url, ocsp_req_b64)).content + get_encoded_response = requests.get("%s/%s" % (url, urllib.parse.quote(ocsp_req_b64, safe = ""))).content + post_response = requests.post("%s/" % (url), data=request_bytes).content + + return (post_response, get_response, get_encoded_response) + +def make_ocsp_req(cert_file, issuer_file): + """Return the bytes of an OCSP request for the given certificate file.""" + with tempfile.NamedTemporaryFile(dir=tempdir) as f: + run(["openssl", "ocsp", "-no_nonce", + "-issuer", issuer_file, + "-cert", cert_file, + "-reqout", f.name]) + ocsp_req = f.read() + return ocsp_req + +def ocsp_verify(cert_file, issuer_file, ocsp_response): + with tempfile.NamedTemporaryFile(dir=tempdir, delete=False) as f: + f.write(ocsp_response) + f.close() + output = subprocess.check_output([ + 'openssl', 'ocsp', '-no_nonce', + '-issuer', issuer_file, + '-cert', cert_file, + '-verify_other', issuer_file, + '-CAfile', 'test/certs/webpki/root-rsa.cert.pem', + '-respin', f.name], stderr=subprocess.STDOUT).decode() + # OpenSSL doesn't always return non-zero when response verify fails, so we + # also look for the string "Response Verify Failure" + verify_failure = "Response Verify Failure" + if re.search(verify_failure, output): + print(output) + raise(Exception("OCSP verify failure")) + return output + +def verify_ocsp(cert_file, issuer_glob, url, status="revoked", reason=None): + # Try to verify the OCSP response using every issuer identified by the glob. + # If one works, great. If none work, re-raise the exception produced by the + # last attempt + lastException = None + for issuer_file in glob.glob(issuer_glob): + try: + output = try_verify_ocsp(cert_file, issuer_file, url, status, reason) + return output + except Exception as e: + lastException = e + continue + raise(lastException) + +def try_verify_ocsp(cert_file, issuer_file, url, status="revoked", reason=None): + ocsp_request = make_ocsp_req(cert_file, issuer_file) + responses = fetch_ocsp(ocsp_request, url) + + # Verify all responses are the same + for resp in responses: + if resp != responses[0]: + raise(Exception("OCSP responses differed: %s vs %s" %( + base64.b64encode(responses[0]), base64.b64encode(resp)))) + + # Check response is for the correct certificate and is correct + # status + resp = responses[0] + verify_output = ocsp_verify(cert_file, issuer_file, resp) + if status is not None: + if not re.search("%s: %s" % (cert_file, status), verify_output): + print(verify_output) + raise(Exception("OCSP response wasn't '%s'" % status)) + if reason == "unspecified": + if re.search("Reason:", verify_output): + print(verify_output) + raise(Exception("OCSP response contained unexpected reason")) + elif reason is not None: + if not re.search("Reason: %s" % reason, verify_output): + print(verify_output) + raise(Exception("OCSP response wasn't '%s'" % reason)) + return verify_output + +def reset_akamai_purges(): + requests.post("http://localhost:6789/debug/reset-purges", data="{}") + +def verify_akamai_purge(): + deadline = time.time() + .4 + while True: + time.sleep(0.05) + if time.time() > deadline: + raise(Exception("Timed out waiting for Akamai purge")) + response = requests.get("http://localhost:6789/debug/get-purges") + purgeData = response.json() + if len(purgeData["V3"]) == 0: + continue + break + reset_akamai_purges() + +twenty_days_ago_functions = [ ] + +def register_twenty_days_ago(f): + """Register a function to be run during "setup_twenty_days_ago." This allows + test cases to define their own custom setup. + """ + twenty_days_ago_functions.append(f) + +def setup_twenty_days_ago(): + """Do any setup that needs to happen 20 day in the past, for tests that + will run in the 'present'. + """ + for f in twenty_days_ago_functions: + f() + +six_months_ago_functions = [] + +def register_six_months_ago(f): + six_months_ago_functions.append(f) + +def setup_six_months_ago(): + [f() for f in six_months_ago_functions] + +def waitport(port, prog, perTickCheck=None): + """Wait until a port on localhost is open.""" + for _ in range(1000): + try: + time.sleep(0.1) + if perTickCheck is not None and not perTickCheck(): + return False + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(('localhost', port)) + s.close() + return True + except socket.error as e: + if e.errno == errno.ECONNREFUSED: + print("Waiting for debug port %d (%s)" % (port, prog)) + else: + raise + raise(Exception("timed out waiting for debug port %d (%s)" % (port, prog))) + +def waithealth(prog, port, host_override): + subprocess.check_call([ + './bin/health-checker', + '-addr', ("localhost:%d" % (port)), + '-host-override', host_override, + '-config', os.path.join(config_dir, 'health-checker.json')]) diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/README.md b/third-party/github.com/letsencrypt/boulder/test/hierarchy/README.md new file mode 100644 index 000000000..690f707fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/README.md @@ -0,0 +1,27 @@ +# Boulder Test Hierarchy + +This directory contains certificates which are analogues of Let's Encrypt's +active hierarchy. These are useful for ensuring that our tests cover all of +our actual situations, such as cross-signed intermediates, cross-signed roots, +both RSA and ECDSA roots and intermediates, and having issuance chains with +more than one intermediate in them. Also included are a selection of fake +end-entity certificates, issued from each of the intermediates. This directory +does not include private keys for the roots, as Boulder should never perform +any operations which require access to root private keys. + +## Usage + +These certificates (particularly their subject info and public key info) are +subject to change at any time. Values derived from these certificates, such as +their `Serial`, `IssuerID`, `Fingerprint`, or `IssuerNameID` should never be +hard-coded in tests or mocks. If you need to assert facts about those values +in a test, load the cert from disk and compute those values dynamically. + +In general, loading and using one of these certificates for a test might +look like: + +```go +ee, _ := CA.IssuePrecertificate(...) +cert, _ := issuance.LoadCertificate("test/hierarchy/int-e1.cert.pem") +test.AssertEqual(t, issuance.GetIssuerNameID(ee), issuer.NameID()) +``` diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.cert.pem new file mode 100644 index 000000000..24eddcaf9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0TCCAlagAwIBAgIIA65R21EVWjwwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMC +WFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdh +bnQgRWxlcGhhbnQgRTEwHhcNMjEwMjA0MDAxMTMyWhcNMjMwMzA2MDAxMTMyWjAh +MR8wHQYDVQQDExZlZS5pbnQtZTEuYm91bGRlci50ZXN0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAuwGj8QiyNhEgRRYVxFRi+5HeGQk7+7KUP4Ky3SX4 +gyErddykJFpR+wfcOZy5f5QHb/lWopoPhBRmKLCJBWgNKR4WKeGODufALlej2eti +iGAh8rNNjM75xRWCKIQdFITP+062wP2mXYlj58XETbZditm//0rdW5i3Og7gRrSR +25brJkK6LK2OQaxuMI/0Uof1nlIg2LuNLazZBgZxl6ZJXtSMQNGarejAja1GBqG9 +9/ZCzRatr75oKph8jyocjrJFod/36rEyBBSIPCsJEKPVDuhS4vYe8P4iyP43+Jtt +3q6rCDQ5TvW6zzjP59eZjgOPnCqobNnqOjXYKmox1uOVowIDAQABo4GEMIGBMA4G +A1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYD +VR0TAQH/BAIwADAfBgNVHSMEGDAWgBQB2rt6yyUgjl551vmWQi8CQSkHvjAhBgNV +HREEGjAYghZlZS5pbnQtZTEuYm91bGRlci50ZXN0MAoGCCqGSM49BAMDA2kAMGYC +MQCwKc9EQTAmi0EerjMg/hxUeVdrWc8m+1bKNGT3lwoG7mPyj11O/+XLsFw0J8ms +J7kCMQDILNmDBkI3/O09h9cy64CXlWFU5VAfNGGCZkq3pzL/wQvfAn4D1irS2lS7 +fJp8N4M= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.key.pem new file mode 100644 index 000000000..d18d659f8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e1.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuwGj8QiyNhEgRRYVxFRi+5HeGQk7+7KUP4Ky3SX4gyErddyk +JFpR+wfcOZy5f5QHb/lWopoPhBRmKLCJBWgNKR4WKeGODufALlej2etiiGAh8rNN +jM75xRWCKIQdFITP+062wP2mXYlj58XETbZditm//0rdW5i3Og7gRrSR25brJkK6 +LK2OQaxuMI/0Uof1nlIg2LuNLazZBgZxl6ZJXtSMQNGarejAja1GBqG99/ZCzRat +r75oKph8jyocjrJFod/36rEyBBSIPCsJEKPVDuhS4vYe8P4iyP43+Jtt3q6rCDQ5 +TvW6zzjP59eZjgOPnCqobNnqOjXYKmox1uOVowIDAQABAoIBAEZSvpjUoVetpwnz +3SmgZjyeRPv7OgBTzmX3u1VogwSkw5gl6d/1yyBwe0N7CVLNkuJrzEWHU9Bib2xb +vps23sQYmVMUi/xU8DM9J9O6LaqFJB8FiGMsLkcL6I9d5yWhMCkcF6OJfzdrhBNT +jpd+vbyKWCYjvAxG6Jg/od1U6AjAGjo4gsJ/Z3267yVjrhf1bOk4CIKn5qL8kzIx ++VF7Q0+ilyAg2a992MnnOQIE+Q2I/tD0jCbNLC5qDIV+4pdOcTZ699THpaa3jL8s +HvM5T+1ovjBRDXXLrKOTmeyMhYm1VaNhQV3ElWAIaLVbstjdyuNyM34RwZXx8OOJ +vVNd0hECgYEA6N1AtOsIiqHYsbyCWRaQc3pXA3XafWFEBU8zyRTckHGxrjJiaKZ4 +UiFRJ+ur+7SN38jL6ZQM0AzAq69KQ1BJe7kro/84vWColmyJHjWbJ7x61OCmrww3 +8IXphpjGBPqzCSH2kjfyM/M5xkq1+PA4sRs7AQY1fhvtnASZaZ0rTkkCgYEAzZYG +B8J9TYeun3VdIMpFLVSNr21oZb7vR+vpYXn2g/N75rOjL9LHhxNOwpwTrJGlW5cY +SBvjQcz7/GHRXhZqxxhEU5cL3DVX3FfNuwnRBOw1LOR0QLHFKdh2BMDWf+AutER3 +i310snhXPZMScFGqi7khsO8Rs9OJ4rzNQHWvdIsCgYEA19oHmexnrYHayN4xgX0u +Byz3LWj4T9JyZ+2D1jf1QBtzlUJ1AAaXb6IchUGq2RYDkNWjVu/6dHvtuPcygnUQ +uJPrhQgWQ00u2MjgzVTpbosC3QMk3wwXamfnEPHaVFFC1gtacS1U4JzsCAfG6Gtc +UacpKYjk2vHubfnBbynWM6kCgYEAgf0f5vwkekcWNKDit37tapIR3CATaHHnndQe +hpG1Ow1TBDYFMpHVsySUIhzJm82jflv08HMhqFNR6Ox4k0MdVLGVUj0pNJ1N5nZm +EKNOVAx+OtpgXx+ICMNjK/I6LjSzkyvPYpV6mfXZQ4egmwAoE5yFHviqeseAYar7 +JIzE2a0CgYACsJJ8APZWkJIpPCPBpDthaX8oedl5OM6uMn/C26qG7hlQNTt9Pxhh +gteAsVG2LKaTECTqP+XSMH/Gv9FCqjKfSHbg3gkfFM51qZPylwG6EDMmDO4NvMDh +jsv+hRL+/KPyMHphW4OB5kDa+d2Eu6vUGBi2lGq3+MblU0iTo0uFIQ== +-----END RSA PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.cert.pem new file mode 100644 index 000000000..46a5e7570 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICzDCCAlKgAwIBAgIIBgOX92IAEs4wCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMC +WFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEfMB0GA1UEAxMWKFRFU1QpIEVzb3Rl +cmljIEVtdSBFMjAeFw0yMTAyMDQwMDExMzJaFw0yMzAzMDYwMDExMzJaMCExHzAd +BgNVBAMTFmVlLmludC1lMi5ib3VsZGVyLnRlc3QwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQCzvIZgIbnZKusM6YRvqVQwTlO5d/Hu8v+U51FgXrtUuHmF +BvwIlsZGaiKi8UTxd6YvzX+dYcb/UPzSI91xBLj4xt4TWXmYPo9QoTqbJbY4djOR +lrkxIg5hCKAObIte/o+h5v85/QTAWhckT1TLjwb7AS5M1zSJIcRcV+YC7nKR+5Eq +VafLVe0gtPRV2P+zoJeE9VUjz63lMrlv/COgg3oyxoVsbHsWLEqqgTgLoAovlt5T +D6oKuV9pwRTEoGu6Xj9RBBmIA6Mf7N7/2eX6d5gRJJ8BlbOgWDOIv3W/owXeNMkt +MMdtnnKUX534IQaDfp6/5kvdfphNmUN0TW7g6/KlAgMBAAGjgYQwgYEwDgYDVR0P +AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMB +Af8EAjAAMB8GA1UdIwQYMBaAFJI1guafAH22PqUMp0pfnQLtQGkgMCEGA1UdEQQa +MBiCFmVlLmludC1lMi5ib3VsZGVyLnRlc3QwCgYIKoZIzj0EAwMDaAAwZQIxAP+V +QA21/1IPmMPtcpnDCvYPyQipJLytv+/tqtnsoqVWtsiTzbzQX9zxuwjoLyt2awIw +XDmR/S0uXG3XHez1LdhAUqxftzoZvjm9rINoyLlevG/HSw7UWZGxBdIsdzPkgFJP +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.key.pem new file mode 100644 index 000000000..a3d634cef --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-e2.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAs7yGYCG52SrrDOmEb6lUME5TuXfx7vL/lOdRYF67VLh5hQb8 +CJbGRmoiovFE8XemL81/nWHG/1D80iPdcQS4+MbeE1l5mD6PUKE6myW2OHYzkZa5 +MSIOYQigDmyLXv6Poeb/Of0EwFoXJE9Uy48G+wEuTNc0iSHEXFfmAu5ykfuRKlWn +y1XtILT0Vdj/s6CXhPVVI8+t5TK5b/wjoIN6MsaFbGx7FixKqoE4C6AKL5beUw+q +CrlfacEUxKBrul4/UQQZiAOjH+ze/9nl+neYESSfAZWzoFgziL91v6MF3jTJLTDH +bZ5ylF+d+CEGg36ev+ZL3X6YTZlDdE1u4OvypQIDAQABAoIBAHxwZCCeeQuwOTih +XH3qoE0pjbH1J12mg+lWFfNA4zYO2qONaGWR7gjGZuClZnQ/wKGMB3SxQ5N1QPVE +u4YKHP6wwQRoiFUtyw+p8OeFvplszNtZnTI1P/tSe25BHGVSnaMcSUyervF17lvH +SQ/+IHkcIjA1NzxSUp8UhD03Vb9XYaCbB3XwPTgnXgqA3czkyzBRyTGN/QekruvK +P760Rgv11bqcGK7MDcK1QPX3fwQsBN5+xq5XinyO7lfDFKasi1P+75jBYLSKDBwQ +dlwmI3/vnFikA6YutviAgARTnLFvrNVr5f1Gf8SCllXY2rdZmSL0I8ya0pf3rsVj +q4CDj+kCgYEA4Fsx01pHDR1zaQTwrMl/fms5oT/QuDgT8yAR4nRp7dVcqZNAM7Iq +kvtpYbJBQmz3wtdW3NgBg1H6hwOZhm70NlRAsOa2IimuwWpvPDslyNsbnqj/S1HN +jKk/Mja9EGJ1o/8tSPQUS3/9wgyea3N1J+lshRm74aGU2UazusFPwEsCgYEAzRY/ +fI5xfZpPwszYhkq9UC7FucMS6A786IAAN0JxJyswaelMfMUrRfiI8EufriiiX7QT +fuRlDZhfDUVBjCzLQgwnN8txZrRZ5KH49pC5oqw3q5z0iXJgpXSn5sxYRXVbm4rs +A+9ruaFldxVvTpE5xKvzTQGVBJVeY6CaubVAos8CgYBUPsox4+dkLFfm6nz5VNxz ++w1z2EOmuR/8nmE42J/iN8kIwAtOnitQb+l9TvMkX0iVuEicutuulPzu79IZYdaA +BBkalDd2EpLVfALy6f7hMi1n4Wuju77kf7UERPuviFlGUI6Po19vjksaL6TZEky+ +xO8D98rOCd+byum4SdiJiwKBgDEUlfT1EewBNf1kkJzy3gOGbgNaz/eBPr1VhLe0 +yueYymlOT+O8O/Lu27bGIlzHlLRaoB/KAPUT9gty+5DUV4Bi8C/GHEl799dje/Vm +BUcM9/W2Bj+ug7qVBGmTlbxprZa31GvMrHcsTOAG3TBsSOrsS7muGz+Rj5lAIkc3 +PVS5AoGBAL0gKWUHDTqzJtO5K+xlceQ8rUMR3mkD0GiU86TYl5gIOlZIPDFk9L5/ +BdMjlDcMAFQi+xmlGVwrfMtZt9SVsDNWwl/Ef3pF1nDJP8iMjJu6O/Qoujx7MFMc +kepraEs1eFmtnoHJb3Dr42JU++p3SbNuQdR1ijiLMYO2DLOylNjD +-----END RSA PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.cert.pem new file mode 100644 index 000000000..cd50fa29b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDbjCCAlagAwIBAgIIHXJEPbUYmCEwDQYJKoZIhvcNAQELBQAwRjELMAkGA1UE +BhMCWFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEgMB4GA1UEAxMXKFRFU1QpIFJh +ZGljYWwgUmhpbm8gUjMwHhcNMjEwMjA0MDAxMTMyWhcNMjMwMzA2MDAxMTMyWjAh +MR8wHQYDVQQDExZlZS5pbnQtcjMuYm91bGRlci50ZXN0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAwM0mcX8w4fRiAvPVlLUy1cdnLxOuMcsf7A6Ui+Kj +SyMsDu6x1d67YaYSdghUmFxY7viMeHEItE0i77GyBtOwj9HvNRFRAeP5V8HDJ7LA +THGrpY5pmebLdWq/hiK9fCbxEsu6BlapCfKvEI8QFeFrPb+e7YoRA2F+F5bJh0ns +lMCzvpx13fgtcxc8BEGU3TbaeT9nH7Gnl81sHmk9LnKCS7ZrH51EDU/xcvbczo/9 +NIkOLONYgpMLNJRwiIbizTJFf009mlxs8uYhgQF4kMqYUR2vpqm1hZSqgaLds+iQ +ag61Tvp+W3dZC4fDHWijiEellffT9WLR3cMydUczDn9nbQIDAQABo4GEMIGBMA4G +A1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYD +VR0TAQH/BAIwADAfBgNVHSMEGDAWgBSKYLCGNk1dzO93c+p6K6Ku23ZjSTAhBgNV +HREEGjAYghZlZS5pbnQtcjMuYm91bGRlci50ZXN0MA0GCSqGSIb3DQEBCwUAA4IB +AQBsE21bs6SKXK99ReuwvvINFuogdTfCBsB3+zNp5PyAKGlW8BdZEY50euTe8A2x +D9yXMJ46+wkm2m4TkyflaxKh52441XzHf4cfBQr3Lyk9PX7kvUpe8rWlAxvzilD0 +IwciW5/Pz2XB0e3P1feDNEA+W3+IINGJJlcKLYnvn/PL6oZRXcVLtZV6iIxtrIBu +gJ7bczkLPgAIedb9a1KZw6uP3q6sQU2UK3+yjAExq1TfHBXbvnDK2bYcbxQFHFkQ +MU48Ji8KFX9Q1EQwYEYE3y3NLZeYdU5ho2Sc4xMYm0DEPHEd9wROqAWIQGyb3ncc +IH5Dwzf8WjDRd8P4GR6dh9Tl +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.key.pem new file mode 100644 index 000000000..412bd2b55 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r3.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAwM0mcX8w4fRiAvPVlLUy1cdnLxOuMcsf7A6Ui+KjSyMsDu6x +1d67YaYSdghUmFxY7viMeHEItE0i77GyBtOwj9HvNRFRAeP5V8HDJ7LATHGrpY5p +mebLdWq/hiK9fCbxEsu6BlapCfKvEI8QFeFrPb+e7YoRA2F+F5bJh0nslMCzvpx1 +3fgtcxc8BEGU3TbaeT9nH7Gnl81sHmk9LnKCS7ZrH51EDU/xcvbczo/9NIkOLONY +gpMLNJRwiIbizTJFf009mlxs8uYhgQF4kMqYUR2vpqm1hZSqgaLds+iQag61Tvp+ +W3dZC4fDHWijiEellffT9WLR3cMydUczDn9nbQIDAQABAoIBACJ/ElfQWCC1pyu8 +EQTwfs39RZsIvGwwWd/UpAN7Y5g4DNQHJU6M8Z4BJuBzkR9JtqfbVNbf8pzACHY2 +pxHNopO5DkHCfWoHLj/jbVWXCA0lcX7HwmFFCDZUCTyozpp+JTglt30W2FvtTiF6 +V3hywstjk74QrAac1QDHe/t24Jukp3LRnQ1XZbCNaseBkSovBUynMwAGHExgA96u +plwbIgYZwRQQ3To0UV8EF+akqsmhJHkV0LkhJB1WaJuipSPY5LJHkwj9PHVZZy4j +eMUuEjBdEPTGVORY+eUH64C5M8PLae8I2C9rae+P/rdRPRNkZBt+Igt8QjJkYERk +2r8IUEkCgYEA1TDSCRlCjJfAM+SxweRvTTCpcCvRUYOhQMaiaOsHQrBNr1XR3C0g +Nr8eLIRwDyhUJMhHlhbfTdM7Cal6e9d5qRLVuygNB50CFIkGXAb2iCG0+NBsv4n0 +W+9vDoA3o7Jh2WhdhwL5mQQL03ItGhyHhnBv6d+ORs8hpAw27zK5k5cCgYEA54Q0 +h8Z9aSSgiEGGSnDk57fUdHV9TM++p9APBq2p5ylG9K+Y/VtT74zSpJ2CiOqLJSnf +4QIgWQfe+FuMwaN1RRIe7lO9Wlz3pOJww+MO+rF0pjRZTky2ZTt9i68Rt2Hk4qiu +XIIy4YqmuHMJ1sd0ropjgk4bJ31krL1lsKCKrZsCgYAKvPPHW4tbk4Ut1/YQIxZs +F+hg6wQXC/9CSP8DM9tgw4qWK0dvxKIbv9KgQWd3i/t5AtGAQNSskdgma2/s7vSE +zJsRWzoUyRbCvAgi+ILQZoo8AhuIJkW1n8DDRTgIOcLt9XDIjSDPUUHbO6QD7a3x +2pX4fLco3+P85FScBb0NLwKBgDGStHDSRq5J4nnqlefArrMTQNHDCpZ08V0bhuwm +KXhO9VuVcgvmD13+6GfJNlc86ZiGk+KpQuXtcof5inU4G/czPx5HHgeIWpqaxgyb +xOxXLSQdl3XVpUSd7W8IiKGcu5bxCYzTcDOtLa/XKicsREbPaSlQsi3Ngs4eK/Ub +Gza7AoGAPWVMBHsVx+Gey1aoS9KVpbL5BIIRkiQsWXYIQmXIM1DOCBJSvwiOCe3m +zeRa9MqUfMIZsBM4UKu02VqA8dFl6YwqZ9eYFS9Z3QuuM27mOE7iWRA8RugcSXm0 +wQoOjbfhKG0YEIXCZLgqFtPBaVaxhPsUI0wdTL/frN35NU5d6Sk= +-----END RSA PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.cert.pem new file mode 100644 index 000000000..7aa208f52 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDcDCCAligAwIBAgIID4VhX15UXkAwDQYJKoZIhvcNAQELBQAwSDELMAkGA1UE +BhMCWFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEiMCAGA1UEAxMZKFRFU1QpIFJl +c2lsaWVudCBSYXZlbiBSNDAeFw0yMTAyMDQwMDExMzJaFw0yMzAzMDYwMDExMzJa +MCExHzAdBgNVBAMTFmVlLmludC1yNC5ib3VsZGVyLnRlc3QwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCn1htXlVxjt3wrSJ2en4U38+hemAgc248607NM +y4mXaDH4KVOBLZX6vAX1BVLXtoLxg3gPM4/Gq6IZ02QiXV0llnozrUq8fACpOZMG +VerYGoM1w7d2k4rIw/l0FIaQZ+ciNcNyunWkohllS3H+aHvM2Qx6pokiy++h1pSy +xANKRaC1QsBGarhZSyJsVQddXarG+cB3F+cjFZGFKjTUHVFyVtxwE+vds/TCMfcS +ppShs1bkHuX6A11MN/owwmHFgtsY8JfnpgqcYISCBxaTzGjc/YNdvkjSS8Lgdzct +6vR1QqKmaboT5x5ego2iDcwkGuyxOe2ZAesgOYHeWeamukBDAgMBAAGjgYQwgYEw +DgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAM +BgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFJwEDm+6022jWylljJDC/oidg9gsMCEG +A1UdEQQaMBiCFmVlLmludC1yNC5ib3VsZGVyLnRlc3QwDQYJKoZIhvcNAQELBQAD +ggEBAJvgWoSLu5zY107xD4RFQBplx9sKnF5E0bFZewdXD8LVMAiAm10gbLe3dLzZ +/5ee8pCXexPuBjRkSSXMYfUCijomQgYqjeSO/t+70PZg4mwd+6tfrBX/G5HRvOiT +CaFjoC+6gh1tucvoseNh70SCFvI2kEIHh/0ZD6S+i7oQX1YBvD4i+8R2yX9CU9a2 +EfPsZUX2VvFTk5Q6amaX/JXeyj/8ZXknSQNR4icuvSpx1Kp+k2DQvF2wWw/jQp18 +NMhmD6KPwYudPc1M1OXtglYS6NokXazdKglR8h04AxinPIcsZsWaUsxSWPwfVqAW +ISTdK/SKiXhXxgJ3tBoWzpOThn8= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.key.pem new file mode 100644 index 000000000..ca6d54a31 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/ee-r4.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAp9YbV5VcY7d8K0idnp+FN/PoXpgIHNuPOtOzTMuJl2gx+ClT +gS2V+rwF9QVS17aC8YN4DzOPxquiGdNkIl1dJZZ6M61KvHwAqTmTBlXq2BqDNcO3 +dpOKyMP5dBSGkGfnIjXDcrp1pKIZZUtx/mh7zNkMeqaJIsvvodaUssQDSkWgtULA +Rmq4WUsibFUHXV2qxvnAdxfnIxWRhSo01B1RclbccBPr3bP0wjH3EqaUobNW5B7l ++gNdTDf6MMJhxYLbGPCX56YKnGCEggcWk8xo3P2DXb5I0kvC4Hc3Ler0dUKipmm6 +E+ceXoKNog3MJBrssTntmQHrIDmB3lnmprpAQwIDAQABAoIBAQCFIg+aT/5zkw7J +/tYZB4zTL4U50/tLeNaK4XcvCZ1hHuPUaGO26oQ32oIXNFvchQglsBXCaTI5c9go +CEk8ATdsI4tYBrRsAyk7E1KPCgQ52/4M3e1f//VtABeWftmnHuR3fJJHJaVALN1c +PpZ0KklZ2ypM+GF72q2BgQd/LoE1nfCiuUrSmheKOFHERUkNS+AE8qTiiPp9Sn3C +zMA+fAbE6CZfGiGinxXe2j0k+KCkM3m5ObzfWgrMYp/82j7tIlmPPJtC7Km48QIX +O6wcTWhN/VRyYhsyniyS9nifEjcq+dJFZ+/AD7VTHf9f2I/3WzJ0n0ADALXGUThV +UhWhJzihAoGBANcnBx/duR7fTRioPxFVzC/DdENG/pgLU1ikoDUNfMl21uZnaRwN +YuC2UXdcvwEGmxzoQ7xvS0DaOmHrZWmnFaz+S0zXqSNu2TJC6ngyDcAx0sYqko3j +s7JRnNaCqpjL9efAb+AasXsJKhPm8kPOEoeeXUme/Dopy//eaPiGCh1JAoGBAMez +ZI96uO+pcc+YlBQOQsq8XE1Yr7wMfyWJnGlHscAlQ5xz6xJUMEyPJCuG4K8wMfOz +BFl4fArh+/VEFOgWiok1I12FfAm/xRkGFp+9txyXj02VtJTX1iVLQ/Bso2+UYEEN +f4sVpUwFCCz/5torkaEGNSYMb5n69AyUY970Va0rAoGARqKdiCy29hfBq/KwofRV +EOlOZjgMpcYyGswRfNlsuoe1jfctXvRWHgg9Pr7IRoHwstDeTCMNxcDfof4yUTl1 +uFHUTuoOsX9W91VYvRVRxmOVG1Imw0aaXFTG9PX5JCjyFp/rGtwooIgltFsB9pjV +JIktf1oe3MmUG/Dc7Zqz/2ECgYAHjDs3xQ6qWEAp9X1bSLKzkOz4K2rw85P2qj3U +KNaKCZ6FkkgHOFFfA2X9kyp41Jx+tnxqmUgu7R2lxn33y6pOx0hf54SppargaD+A +qB38oanT592cZo/8dtzJgIGo3PXKX6U7b4UA24vUj5N9GXp2mJJ3rq6lJjwFIbKo +oZl/YwKBgAZJgJGv8Aqtcq5bfov4vE3DsyzZSB7S9OtX8d7jZuALtOYGeYADMNBK +/8TPvVW4WcIIN1O+VLGp4wAMJwlNDV9PanoXBbEDZSDOt8y6ag7LipeFGxBOx3IH +/qDUZgzwznUc+U+JMHYQcu8cFeBVJSUboDnvcm1JKsGoKOeI4Ejs +-----END RSA PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.cert.pem new file mode 100644 index 000000000..6943a2a8b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC1zCCAl2gAwIBAgIRAKEKMTHhmcPVLqCw0WNZeaUwCgYIKoZIzj0EAwMwSDEL +MAkGA1UEBhMCWFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEiMCAGA1UEAxMZKFRF +U1QpIElyaWRlc2NlbnQgSXJpcyBYMjAeFw0yMDA5MDQwMDAwMDBaFw0yNTA5MTUx +NjAwMDBaMEkxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIzAh +BgNVBAMTGihURVNUKSBFbGVnYW50IEVsZXBoYW50IEUxMHYwEAYHKoZIzj0CAQYF +K4EEACIDYgAExW7wTIngu6HQoRbp2OdTPw3vZY+nDOtazlM3GqNk7BTbpjYqX4ck +gp2unGQoLmQs6np1PDlPFUAGsmW5UMik088vRutd19eUKBDRFRRP3Wu+olMq050Y +0b5zfjvrzgA2o4IBCDCCAQQwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG +AQUFBwMCBggrBgEFBQcDATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQB +2rt6yyUgjl551vmWQi8CQSkHvjAfBgNVHSMEGDAWgBRzP5+/l/ViqS7jourE1Xr5 +paFTVjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94Mi5pLmxl +bmNyLm9yZy8wJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gyLmMubGVuY3Iub3Jn +LzAiBgNVHSAEGzAZMAgGBmeBDAECATANBgsrBgEEAYLfEwEBATAKBggqhkjOPQQD +AwNoADBlAjEAi7Q0STnZ1frkUOD6s7xIZ81S0wDuvJBcb/6Q5DUom1etMcMt0PvI +VsaAN9Pww4TrAjAU72jytj7ULm64MosmKpNBS9TGzpzPEDqPY0tzU38/2aheZmMN +dP+fYeZH872n0zQ= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.crl.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.crl.pem new file mode 100644 index 000000000..8b383d0a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.crl.pem @@ -0,0 +1,11 @@ +-----BEGIN X509 CRL----- +MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg +RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN +MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6 +yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw +NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w +LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+ +OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva +tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw== +-----END X509 CRL----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.key.pem new file mode 100644 index 000000000..08e572765 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e1.key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDA7b+7NLS4oi3jI5XMy +rSe5LnC1xixOQrij+tMGjHMR8WpIKyHc+aaevr1DxSW1ggmhZANiAATFbvBMieC7 +odChFunY51M/De9lj6cM61rOUzcao2TsFNumNipfhySCna6cZCguZCzqenU8OU8V +QAayZblQyKTTzy9G613X15QoENEVFE/da76iUyrTnRjRvnN+O+vOADY= +-----END PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.cert.pem new file mode 100644 index 000000000..cf77aecf3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0zCCAligAwIBAgIQYWYcHcOHBZprayi5n0huzTAKBggqhkjOPQQDAzBIMQsw +CQYDVQQGEwJYWDEVMBMGA1UEChMMQm91bGRlciBUZXN0MSIwIAYDVQQDExkoVEVT +VCkgSXJpZGVzY2VudCBJcmlzIFgyMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2 +MDAwMFowRTELMAkGA1UEBhMCWFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEfMB0G +A1UEAxMWKFRFU1QpIEVzb3RlcmljIEVtdSBFMjB2MBAGByqGSM49AgEGBSuBBAAi +A2IABO6nJy6raRyPH9ZcXYbnkPIS/r/9W134KlnfgDRWw4jqoNU+T5i0xliWu0o5 +4VlwasQmKe+LWpKvlIS6ZW0Kbu1eqNBU5hVXXl9LpqYxI+t6/HjQiZuT33CMyCBn +SR81BqOCAQgwggEEMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcD +AgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUkjWC5p8A +fbY+pQynSl+dAu1AaSAwHwYDVR0jBBgwFoAUcz+fv5f1Yqku46LqxNV6+aWhU1Yw +MgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8veDIuaS5sZW5jci5v +cmcvMCcGA1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94Mi5jLmxlbmNyLm9yZy8wIgYD +VR0gBBswGTAIBgZngQwBAgEwDQYLKwYBBAGC3xMBAQEwCgYIKoZIzj0EAwMDaQAw +ZgIxAOGjfngXtNcnjperk3xdHRuM72wwjxtUyWhMGc6uwPGE4YFEI0DrhsHvxldA +n8ngCAIxAODGvwRDv6MJnyPxao0XMgdHSahqXWY1Itgn5Ng1O3vMIvgXDhgdazCc +Hvopt14c8Q== +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.key.pem new file mode 100644 index 000000000..b25f32581 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-e2.key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBWIM3FNps4vLbGRx6U +NZi6loX3QhPDSYBoMdRVFRPL+s77ecnjqIcu5RlNLULZ8P2hZANiAATupycuq2kc +jx/WXF2G55DyEv6//Vtd+CpZ34A0VsOI6qDVPk+YtMZYlrtKOeFZcGrEJinvi1qS +r5SEumVtCm7tXqjQVOYVV15fS6amMSPrevx40Imbk99wjMggZ0kfNQY= +-----END PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3-cross.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3-cross.cert.pem new file mode 100644 index 000000000..7b5d63400 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3-cross.cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEMTCCAxmgAwIBAgIQWMoTtGAjA/DvOIUjng6FvTANBgkqhkiG9w0BAQsFADBX +MQswCQYDVQQGEwJYWDEZMBcGA1UEChMQKFRFU1QpIElkZW5UcnVzdDEtMCsGA1UE +AxMkKFRFU1QpIERpYXBoYW5vdXMgRGlhbW9uZCBSb290IENBIFgzMB4XDTIwMTAw +NzE5MjE0MFoXDTIxMDkyOTE5MjE0MFowRjELMAkGA1UEBhMCWFgxFTATBgNVBAoT +DEJvdWxkZXIgVGVzdDEgMB4GA1UEAxMXKFRFU1QpIFJhZGljYWwgUmhpbm8gUjMw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIWoAFGWbqRxP0cJJQ3DIo +JQaOSI5kEIWPA3XZ28uXlwiQ8b4Jmr2F/zhQWQ03OlSIWOPeS+2GefQNuDbZclLv +0/ssiUlNimlSvx3H1cvyvUSAPVu/Dfyglfqevxd7SAPL5SKQ/mIaKBo7LpHzn4hi +kC9TG09qQn4wgpkX6fEU6fMPW8PITPELpoiODJw3RMGMacaiHztT4u5FV4wDkEzO +nR92XxDLNZzIzoop/WXpYrGOVM7sx0KeOwosDtOriMWkNpL3rNHnwcbpzaNs6tbB +x3/UDHh2tWoNfc3d3suApbJzgD0ZQDs7CNM38+za0EOlnsI44A7zcB6qWI6hkWP5 +AgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUH +AwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFIpgsIY2 +TV3M73dz6noroq7bdmNJMB8GA1UdIwQYMBaAFBk7wtJhQcogCFYs8mRLNeZtqM4K +MDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iu +b3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMCIG +A1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUA +A4IBAQBxOJe0OWwtJgxL6mOjuTTSwx+QEqDGO9As/tkExAFLCg7o5Ou+Nf9BVm/a +FPRS3gYOSnZ9+gOACH5tDLh5uZY1uhzEgkstwZQhCODw9iIyGQjvGVmAxNV+Mhwc +PozAaxZMPriQHu1YlCuq3UEq4xHuzswEWp9YAGptHL5mbIJ3M2FGzfPpR1o7U2Gb +r1FNYqLiNacT+DSITPAykB+rrSR2NQkgb3HuygBh6mao7yB7BEpWmsb0fMdtukVk +JfX7Xx/1pdgCbY+FFidwuwrcztfEF9uZab/rW6xgKr+FuteIrcq9NDFv+xm9EPI2 +jRPSWXv4B1Tmuo+Azi9aG9oOXg7L +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.cert.pem new file mode 100644 index 000000000..2242dcbc0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.cert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFIDCCAwigAwIBAgIQOMM6fFS4BsgdmM1bqD3mtTANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJYWDEVMBMGA1UEChMMQm91bGRlciBUZXN0MSAwHgYDVQQDExco +VEVTVCkgSW5lZmZhYmxlIEljZSBYMTAeFw0yMDA5MDQwMDAwMDBaFw0yNTA5MTUx +NjAwMDBaMEYxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIDAe +BgNVBAMTFyhURVNUKSBSYWRpY2FsIFJoaW5vIFIzMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAyFqABRlm6kcT9HCSUNwyKCUGjkiOZBCFjwN12dvLl5cI +kPG+CZq9hf84UFkNNzpUiFjj3kvthnn0Dbg22XJS79P7LIlJTYppUr8dx9XL8r1E +gD1bvw38oJX6nr8Xe0gDy+UikP5iGigaOy6R85+IYpAvUxtPakJ+MIKZF+nxFOnz +D1vDyEzxC6aIjgycN0TBjGnGoh87U+LuRVeMA5BMzp0fdl8QyzWcyM6KKf1l6WKx +jlTO7MdCnjsKLA7Tq4jFpDaS96zR58HG6c2jbOrWwcd/1Ax4drVqDX3N3d7LgKWy +c4A9GUA7OwjTN/Ps2tBDpZ7COOAO83AeqliOoZFj+QIDAQABo4IBCDCCAQQwDgYD +VR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATASBgNV +HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSKYLCGNk1dzO93c+p6K6Ku23ZjSTAf +BgNVHSMEGDAWgBTsAG5kwCQWsvVti8sNSotsstfBjTAyBggrBgEFBQcBAQQmMCQw +IgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wJwYDVR0fBCAwHjAc +oBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzAiBgNVHSAEGzAZMAgGBmeBDAEC +ATANBgsrBgEEAYLfEwEBATANBgkqhkiG9w0BAQsFAAOCAgEAtnRyLKD/Zo/JrNzy +8XDpfJ2td0I8KssWQpVM+Szdb92ebXUsQ3uFsSsc00X31D9eJLQ/tHEueUT+pHRA +qRT0Iw2A2tZpZhLj36xULC6ofQkKMUCbP6ZSsucygwGP4UTOfIZ6+dtGApsh63hi +hECa7sllJxltPvRr2Pmz1IlemgihosBGTZWCnsTdA55VYPQa7aYlJ1Y2mwKDct90 +Jol2fKuHdSN8EXt1FJUtmZ/iMWkPSE3/r8PLGS9m7rwiYb88oLb0tw3DUnp4FXHc +hQqS3m0bBkiPkPP6Ls7Nz/LkNNUuK1OJaa6qtzuhomzgSXWiXNIigxzCTZjq+Fhb +3H9PD0F719uCpv65E1iUumfU80r/JxIO33KcFnF3RZw3fgWcQVMEp5Ad7tChNSyc +3nJzIJ+my3ZASNv1N0TZfAzzfGXFJlZQ6Nf8PccmcUa9xc/0W1J9blvw6BMAe6CX +E0nhHaefo1nsx43UdimYejgufIRgqPDsPPBsF15G00UvZusBzFttw/ub2N2MM56f +YDCVCQQNqAHuT6ehx4y1bNYTHbM2OIEo2jNno0Sy2dQvxfUlgwlQIICh+7rF5FIy +/vhclA4MF1vo3FLfZeKWayL65yhI8ANuYonsCUqqrEqRJc/GWlL6a6qm8lxyNmDB +cJ0X3oAVQ2f9t6TKvq3QDsFHiPI= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.key.pem new file mode 100644 index 000000000..2e4ef6282 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r3.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDIWoAFGWbqRxP0 +cJJQ3DIoJQaOSI5kEIWPA3XZ28uXlwiQ8b4Jmr2F/zhQWQ03OlSIWOPeS+2GefQN +uDbZclLv0/ssiUlNimlSvx3H1cvyvUSAPVu/Dfyglfqevxd7SAPL5SKQ/mIaKBo7 +LpHzn4hikC9TG09qQn4wgpkX6fEU6fMPW8PITPELpoiODJw3RMGMacaiHztT4u5F +V4wDkEzOnR92XxDLNZzIzoop/WXpYrGOVM7sx0KeOwosDtOriMWkNpL3rNHnwcbp +zaNs6tbBx3/UDHh2tWoNfc3d3suApbJzgD0ZQDs7CNM38+za0EOlnsI44A7zcB6q +WI6hkWP5AgMBAAECggEASLzad392Zp2xd/AanrKinwJ6M9PRpjB9XKOD+LkcXAeg +O4cYWEJOhkRXPIxoCOHraKjk6YKlVEoYOZbkiuM/iwRpzwx0iWszu6/Y7wEGjzT5 +lpkwItfAHMj2eQWlT8OgZTjl6MAB+78NbukEYe9MQ4RXOhPTLB/B0njHffAX72Av +PmyYI5MQxiL7A63ewzksB+CMGExEHypvt89lZkG/gPhWs0tvvHImwESW5CH+oQOc +bIaB0flJq0+xQCGhZkDR0YlAlWOGQSwpcCMzKG1+zjr6L1nopcQysA2aWI9jkMAy +kx2u3e7kf3TkrOqx/yvEEdVp+qsT+azGzcuwsnUwkQKBgQD1W+Hgp+An2FCbtMh9 +H9UPHt/HcxUq76qsu07BsfnJjSjRx88z3iLCOv6HeLUR688TEztN+mC+CcRaji+i +DeRe3j6ooc+a3XxxtcxE2d9z+xtTRLt8HfSosFH76ZR2pJ/soicf/cP96XttM0Z8 +cs9CBvpiPBboTCuOSvcr2s2VhQKBgQDRCvEK0QdUx+pvvcQiAaCpHBKp8uwa65xi +hmr9XYeEP6M9Yp7Dt8iA8dFwl7ri/pig1fFKtsf1n4q/EbWlUwigCAfhkf9kEl5E +Rnj1LbywR4qA0w6UDEP70UM//VTHDxbrePeRjOoJBheMdABwRJCWvU0mZCE7WLRy +TfHb0Z9U5QKBgDqBNUQHY5i8qMPoAKJtU7VuTDfXxiVdzpmvdCEVmhUoNqKG/W5F +uo4L2SNecfaa/t5yiIKYgDbwR0S8gLkojNreLZyyMLmhtIm8qr+EIBccujBJxFbd +IbiTiokB8mez63pWU/P546EI6mhogJcuHSOGXG/OGjw75WrhjzyCyOCtAoGAY3rl +gtQ+vOX2dv7D27sSjefCKgZkvdrqLSjyuWhNGW5/bLMGAvXvAQ4TMZXDZkrqr3+g +uIGLXyRxjsQKwYZmUGIB/iLQevsSyUMQRP1jEjC5hNzrzyCXKbtIWadhNOnFaoHC +rw10Qp8XjcuWedbnSBUGJgL4nZl1JgBZ3NZBENECgYAJuTUvD2yE31U1LY69OnYC +fpzX15Vi79rznj8xNZo86U5l/JDZnwD+gvxwDq6sQMbRO2Pav8GO+eutZ7btpAUi +CrC8OCSFVr0+XPPizPx0FS11PS/T1ETYvKU8TC0FA6gRycvq1OqmMQbW3gaZNY3E +Dd2nXB2sx4ZoalvqJylmEQ== +-----END PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4-cross.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4-cross.cert.pem new file mode 100644 index 000000000..f91d91575 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4-cross.cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIENDCCAxygAwIBAgIRAKpe9os9nQK+J6uq1C4bxrcwDQYJKoZIhvcNAQELBQAw +VzELMAkGA1UEBhMCWFgxGTAXBgNVBAoTEChURVNUKSBJZGVuVHJ1c3QxLTArBgNV +BAMTJChURVNUKSBEaWFwaGFub3VzIERpYW1vbmQgUm9vdCBDQSBYMzAeFw0yMDEw +MDcxOTIxNDVaFw0yMTA5MjkxOTIxNDVaMEgxCzAJBgNVBAYTAlhYMRUwEwYDVQQK +EwxCb3VsZGVyIFRlc3QxIjAgBgNVBAMTGShURVNUKSBSZXNpbGllbnQgUmF2ZW4g +UjQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3j6Qtr5/kaJ26ANPl +VGOtKIUH5ICvxoPmOKkZQTDdw/Lk56XaQ8M87wXlNz+bVTh4uDcDE2B3sIaEKnhS +dso1DjqbCKvhLC6Vl/YgqdnlRrueVTCFmt0V2pDs7qAvyyNfsSQEi8n0ZXp7FGuZ +EyOnFBdHYP7Y4OPtevXri7031HhtvKfN0IfA98o5CF6KLZm5c1QqqCLyHK21tC4k +G4PK4k1K2wHfzHrk7josYQvOAWny3uD9896z+ijNh0cr2eJHsJf9aXbQfw5bqVo8 +o+5rG4EogVRLcXdQmFA2xQIuLHS+Mz4JgTknTMq/FxaHx6TYLtlkT+mT/tHE48BE +/I/zAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYB +BQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJwE +Dm+6022jWylljJDC/oidg9gsMB8GA1UdIwQYMBaAFBk7wtJhQcogCFYs8mRLNeZt +qM4KMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVu +Y3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcv +MCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEB +CwUAA4IBAQBe0zod66+cPB06/7sstow5vA6L/8E+IBwWDH9jM/LQyBCV6K28QE5b +Y7v6akxVTxCjN8dyuHA/7GgUWG3eWan/blefn5dSWReTQLERCUCLJCql9ekqzI9J +AZsWzIB3obUusf1l/PX6tENmYOrqsJDomUzUg8h7dGXtk/csJhf55dgwt2GQNxWS +ah8AG8Uhdb5fdGSgKk/0297r3uO5MFcjlu6nax7o1usmA7nZFbfyRUrP74Q2n0h5 +04sgAc5ZqByD1ZOyGZfv0vdaRfGYxuzsa3MRN4dO4Ccqti98XDk7wKuAG4td3mhx +BeNAmKEUHoIMPTrI5bakvHDokO9wvh0o +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.cert.pem new file mode 100644 index 000000000..bb7ecfbd0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.cert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFIjCCAwqgAwIBAgIQRxLfKYKxwUMlVEJNV0W9yjANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJYWDEVMBMGA1UEChMMQm91bGRlciBUZXN0MSAwHgYDVQQDExco +VEVTVCkgSW5lZmZhYmxlIEljZSBYMTAeFw0yMDA5MDQwMDAwMDBaFw0yNTA5MTUx +NjAwMDBaMEgxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIjAg +BgNVBAMTGShURVNUKSBSZXNpbGllbnQgUmF2ZW4gUjQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC3j6Qtr5/kaJ26ANPlVGOtKIUH5ICvxoPmOKkZQTDd +w/Lk56XaQ8M87wXlNz+bVTh4uDcDE2B3sIaEKnhSdso1DjqbCKvhLC6Vl/Ygqdnl +RrueVTCFmt0V2pDs7qAvyyNfsSQEi8n0ZXp7FGuZEyOnFBdHYP7Y4OPtevXri703 +1HhtvKfN0IfA98o5CF6KLZm5c1QqqCLyHK21tC4kG4PK4k1K2wHfzHrk7josYQvO +AWny3uD9896z+ijNh0cr2eJHsJf9aXbQfw5bqVo8o+5rG4EogVRLcXdQmFA2xQIu +LHS+Mz4JgTknTMq/FxaHx6TYLtlkT+mT/tHE48BE/I/zAgMBAAGjggEIMIIBBDAO +BgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJwEDm+6022jWylljJDC/oidg9gs +MB8GA1UdIwQYMBaAFOwAbmTAJBay9W2Lyw1Ki2yy18GNMDIGCCsGAQUFBwEBBCYw +JDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAe +MBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EM +AQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQBwUN+8DSF/tA+F +gwxsx8vr7fVuCA9cM2CuN/iIlTKcGoL5VJYdM4eYyoBhF+TfStnJLEZ3LxdqEzlo +zFuV4GY5RJBSamJEzH3wrtd9whnQgGLl+L44utUegbIvj8pSxz1ONxj8Sf9U/i/Y +FSmw7jtHP4oQvqpTJUquD1hmS9FbVjQNuHdYMaiKdIJCP4i3SQN+2EczBac8JQxK +ZmyrW71n99MFfhGbHBPQR35bAYnTSpu7WFda91gb3LEnYedCHyLrslLd9VP+44Qt +Z2NCUysRo9Wu2i7GJvW8YlecxOPjoFNjX5jr0E/3SHvcn77pUTspEi1StWYJj9el +WcsiVRoEkniHi8qAbk/j5cv/uYPpmWHDzIsXc8tvrlPXBRudokBSo+8LzFHj/0uL +IP7PajlnwSoEDIT4yppbq7JuPXZ/0ukpMZqDv/fatZVUYOSDvGU8fOzkz+5tw4Xk +9QbjRhf4A9hxRQAEWRiSRZnZAV/w/ExFD/JG9uagmHJIqr+m27RMs3CsERdIOWTx +Q2NKVxPG2/OtMZD0klKaUF3aVpQm7njG8PR4A86S0k8s6u0CLZj4DhExWeHu14xS +/8q0JO/T6OvbA0dOkvLJ8o7Pj+KXyHWENcbWhTdETezVAQYtVNvYEzZxyKd79c+A +csfZyG9nwwW9sDh3o1XinwqDDmVECQ== +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.key.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.key.pem new file mode 100644 index 000000000..0514d64bb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/int-r4.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC3j6Qtr5/kaJ26 +ANPlVGOtKIUH5ICvxoPmOKkZQTDdw/Lk56XaQ8M87wXlNz+bVTh4uDcDE2B3sIaE +KnhSdso1DjqbCKvhLC6Vl/YgqdnlRrueVTCFmt0V2pDs7qAvyyNfsSQEi8n0ZXp7 +FGuZEyOnFBdHYP7Y4OPtevXri7031HhtvKfN0IfA98o5CF6KLZm5c1QqqCLyHK21 +tC4kG4PK4k1K2wHfzHrk7josYQvOAWny3uD9896z+ijNh0cr2eJHsJf9aXbQfw5b +qVo8o+5rG4EogVRLcXdQmFA2xQIuLHS+Mz4JgTknTMq/FxaHx6TYLtlkT+mT/tHE +48BE/I/zAgMBAAECggEBAKwPic6FPDRG1+n9UqI5a0FppOEUEIgzZXnMjL0ufVay +kSB9/tnMANtCFd2Y3xeEV23ZBz/rztYCcuS6RpTN4pa+4rJl+28TEguJKN3POH8Q +wVcV9WiXFDui54wf1alXGa5eBiv4uHJNGPT73CvdY+L+EyAGTHwQubXmN1P2ZYRJ +Hbgl1SitBn7O6PNkbw3cozJFgcaJKKHVFpLHn6ZGl5x5qaXR56XHHo71rzFd1X+A +VQt3XwQyHHbK6FkO94dcILc6YoQWKq0z/fFS+Zeez94fWtHzaTVOHTxwd2yTr9Tg +KSTMxCHiNwf3OrzF35W89GtEPdSYb8Ud8VscirYcT1ECgYEA75Foqm4ekLzyUQOg +ZwHN6SY/N0adop5PjpXB9jQJEQBaMuunkjyC69KmzXHMExreDwjNTxrobv1knuOc +vfeevkiHMVRwkdwTEbwMCeTqGIF1iGIDAUy8YUaPXcpdcfyCI+LvqTOJXmBzPKyl +I8xms6A7lwGKTXI+TRN4eeqNmLkCgYEAxCbNqRN1Tzmj2hJQ6FM4sS8Oil1vH6em +9txxnEHO2wsrTSkfwIjK8n+F7dfnG+yifghmh8IPZY7W2hRs3GXvgRRn9QMkFLGN +CF+3zjmtnGZ+rtR2EXLzxJzKgDo1kARCOKpmHJpdyEo7APnzlALoYd0BRxMfI12z +Ep4mZQafAAsCgYEAmgt5LuXiN5WXhup7EOFDI2FZktSQdkmvxHKdpw+sqMb+OPH4 +7XqFgNgSM9axr7M+CJLTWcNmpD/BnL2lQy3fYGHItLqkK9ZEWMn/P7l3ocxU5B6J +6iMKms5BT8DZN3tzv1mkW7ts4EfKscAd7Bf6DhTBXIc8BDKqxur3NAXTiNkCgYBv +Huhtezd+3VGErdGl+9dnERh0rD/SuABvYyz9b46HKsmqGb0CLryCKlouBpzHhgP7 +0Dh9eiOMziHLQ7z0Es9e2beW5uOe0YLrFoajTquaqbnkwznr4qpUXNqfT9qeLrtx +LJ9SXuT4HY1VnUQvOoJ5RmF96Ug/mcpjprJrkxeqRwJ/GfgCxltOkdJCZWIhAs2X +ylyHigDh/0gCoOwivSVdFm695G2w78jUVgfC6DD/KnyCuMu++vvtjkVxTmKOIZGZ +vQlfOjkF+IsF0oOL/yRBqgCtm18jstBnAe0M+yKWyZt1PlDSbg+ronhVIdzI6+Ds +xaAN6/bWHmWEwVGB3MrhSQ== +-----END PRIVATE KEY----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-dst.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-dst.cert.pem new file mode 100644 index 000000000..f58d7562a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-dst.cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIQUn3q9pSKHAHwO+HOtDNkLjANBgkqhkiG9w0BAQsFADBX +MQswCQYDVQQGEwJYWDEZMBcGA1UEChMQKFRFU1QpIElkZW5UcnVzdDEtMCsGA1UE +AxMkKFRFU1QpIERpYXBoYW5vdXMgRGlhbW9uZCBSb290IENBIFgzMB4XDTAwMDkz +MDIxMTIxOVoXDTIxMDEzMDE0MDExNVowVzELMAkGA1UEBhMCWFgxGTAXBgNVBAoT +EChURVNUKSBJZGVuVHJ1c3QxLTArBgNVBAMTJChURVNUKSBEaWFwaGFub3VzIERp +YW1vbmQgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ANMSIROypaPK+0us0pDb8NPtVOLtCgjcJRiYh6xp0xz5C0qq3+Vt709a70mF1w5+ +4MlcE/6YPtDn0wPFuvKV7toHY0YIEMlo1xXvOT/pLkefTEgWm7aIz/32JpbYXimX +DjTRef4YopM+zMEbj8RACekZw6NiU/cS2Sm5k+v7PDc/MxLoENRMrvTJZ9E8i4Qg +4vafYMjMMX0fFsz1HWQ4HsXAMMHKCWVDIVJ77kz5j/rfTr+HiWyG7/wJzYIoecek +bi7pDX1PolP1tHdEs2aRzUhhelDCOsE5gZLJcLDXjieglZ3W4Vq5wCoAApRDKCMO +hYVZpIixiOqnSk/aMK20hFMCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFBk7wtJhQcogCFYs8mRLNeZtqM4KMA0GCSqG +SIb3DQEBCwUAA4IBAQA2BTzE5rGBb3RoU1+bc7eMOon66mRQSYoFUnP/LqEeSNYG +gLE2Wdr70b5I8vvGs9fJcSAQe6Hoqdvd9eSv+nhnOD/Nfu5dftkXQyEfDm61yTX0 +A1eLQ1cNtDTMFpbfemXBMoDgWKkY140U4daqN+yf9QpSoyqR2Cr1HmzEGeUahHaM +/0I+RP2oEyvDnp8HqI5lQOsN/U2z5NBKhb2kCrjfrxQs4EMnqihqX6hlkRO14Fg+ +2/LL17d0ZF83I/QGmZ3KVGjIp1I/x8DK5BJexpst9un9NewEwfJZZ8yHtexqU03u +iaMhgikV56g1BVlsQ5FjgTDrcGU+HlrEAFxEQoS8 +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1-cross.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1-cross.cert.pem new file mode 100644 index 000000000..189ffa90e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1-cross.cert.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFPjCCBCagAwIBAgIRAJa/oQus/hdayzfOeeepyXkwDQYJKoZIhvcNAQELBQAw +VzELMAkGA1UEBhMCWFgxGTAXBgNVBAoTEChURVNUKSBJZGVuVHJ1c3QxLTArBgNV +BAMTJChURVNUKSBEaWFwaGFub3VzIERpYW1vbmQgUm9vdCBDQSBYMzAeFw0yMTAx +MjAxOTE0MDNaFw0yNDA5MzAxODE0MDNaMEYxCzAJBgNVBAYTAlhYMRUwEwYDVQQK +EwxCb3VsZGVyIFRlc3QxIDAeBgNVBAMTFyhURVNUKSBJbmVmZmFibGUgSWNlIFgx +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxH5IxkmHnnk+a4AFNGaS +1jRu9Vry8itwgkd39wgeqNGuBzQhSf+QdfCX/dVv254ak/ULkemyoHotDhEmYQgC +9f2jR6sT9HIA8requNtx7ATpxhiZRpkszMMJq1MEvbdyQUasJQZa9IrQeLnyMJfo +wqq4ecBevkw+aNN7Sw2ISqa0KpF91M9a6f1H+9zbcYLIJyG28+SxUe9qLYG8yMy5 +mBh9J5CflGX4jASWjwoaQSpOApIXxnA2taA7txi1cNyixqpqTs48v+fvPLilQ1vk +rMuFfTUv3BjHis4vk8QzbBvr939qbol4ZP5mVGhfyNtU1AFnM8yEq9RsC38x5aLc +HOVRYiLBmteLDdwlag9f8KuO/CfeoWRo1LthoG7KJlEY+ohxwRVNf4/P+C7VZXD/ +CWl3C6PeuXvXldmNRCLzn3PjuSMQcLTsA+XcIaKAJAEkGy2DXBJfUd/u+4qFg5tP +VFzMh4bm9ZsadsXaW0VFpoLwSUUsdqt5VpEFXGr6b6pNs3anKZGgAYP7jrsJ/5VG +SPvrRw5MxXcxFwzdlcRk5L76ZBlsTiXuGT5txeHOCFIG2SweKzlFMqMjTWTwV8QO +QBIuhjHYybYwG0FCFeKNFmwCKBpbddurJMGv4WVVnE7dBmvZZm7zealpxr3VvbDC +N3O6J+7RlqtNpEiWgjoTgocCAwEAAaOCARQwggEQMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTsAG5kwCQWsvVti8sNSotsstfBjTAf +BgNVHSMEGDAWgBQZO8LSYUHKIAhWLPJkSzXmbajOCjBLBggrBgEFBQcBAQQ/MD0w +OwYIKwYBBQUHMAKGL2h0dHA6Ly9hcHBzLmlkZW50cnVzdC5jb20vcm9vdHMvZHN0 +cm9vdGNheDMucDdjMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRy +dXN0LmNvbS9EU1RST09UQ0FYM0NSTC5jcmwwIgYDVR0gBBswGTAIBgZngQwBAgEw +DQYLKwYBBAGC3xMBAQEwDQYJKoZIhvcNAQELBQADggEBAC4sk0zDpMGG+kXCN7O7 +RundAdmgLwJKg3BsWYCqhQtgnKYnj5RA8Zwl5M8IxZFiopxtB+toE3AI2tO8J99u +QSD5FaB9Gh3bcuApkOHoz9cndDdjFSrqaWGFIxLTKTifjpdzvamRKB2KUsCDCanH +Mj0SuHHQNK9pGR6hh7TO9vTlYcay5eCsXMon/zi6c2Tb8/QtGvTG/ryszTtZRnGK +Md8jM/A7B4kFiY4Rah63lZOO4jRu6NjOqBHzbGLy7OHHrVaO8zfHIKtR1vjAeKV9 +im4bSnm0qmysw3KDon26x1RL7BSas+WBdYsXCUwbrRkDIstmNOmf3K786U09nszM +MNY= +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1.cert.pem new file mode 100644 index 000000000..c5af09340 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x1.cert.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFWDCCA0CgAwIBAgIQIscgxTJeigIe7M/ES3JpNTANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJYWDEVMBMGA1UEChMMQm91bGRlciBUZXN0MSAwHgYDVQQDExco +VEVTVCkgSW5lZmZhYmxlIEljZSBYMTAeFw0xNTA2MDQxMTA0MzhaFw0zNTA2MDQx +MTA0MzhaMEYxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIDAe +BgNVBAMTFyhURVNUKSBJbmVmZmFibGUgSWNlIFgxMIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAxH5IxkmHnnk+a4AFNGaS1jRu9Vry8itwgkd39wgeqNGu +BzQhSf+QdfCX/dVv254ak/ULkemyoHotDhEmYQgC9f2jR6sT9HIA8requNtx7ATp +xhiZRpkszMMJq1MEvbdyQUasJQZa9IrQeLnyMJfowqq4ecBevkw+aNN7Sw2ISqa0 +KpF91M9a6f1H+9zbcYLIJyG28+SxUe9qLYG8yMy5mBh9J5CflGX4jASWjwoaQSpO +ApIXxnA2taA7txi1cNyixqpqTs48v+fvPLilQ1vkrMuFfTUv3BjHis4vk8QzbBvr +939qbol4ZP5mVGhfyNtU1AFnM8yEq9RsC38x5aLcHOVRYiLBmteLDdwlag9f8KuO +/CfeoWRo1LthoG7KJlEY+ohxwRVNf4/P+C7VZXD/CWl3C6PeuXvXldmNRCLzn3Pj +uSMQcLTsA+XcIaKAJAEkGy2DXBJfUd/u+4qFg5tPVFzMh4bm9ZsadsXaW0VFpoLw +SUUsdqt5VpEFXGr6b6pNs3anKZGgAYP7jrsJ/5VGSPvrRw5MxXcxFwzdlcRk5L76 +ZBlsTiXuGT5txeHOCFIG2SweKzlFMqMjTWTwV8QOQBIuhjHYybYwG0FCFeKNFmwC +KBpbddurJMGv4WVVnE7dBmvZZm7zealpxr3VvbDCN3O6J+7RlqtNpEiWgjoTgocC +AwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFOwAbmTAJBay9W2Lyw1Ki2yy18GNMA0GCSqGSIb3DQEBCwUAA4ICAQBf9Ub2 +QuJfmonVLmEhy5sA6zBIMavO0HpVE0DpwtnLoLRW3UdgCzvZw1o/FOK4pv7BNJX5 +3PImqEIg4UMPCiC7X9lhj823srxw3zfL9YxrXNX/ROQ7NHgrM+CvyycSDo23J1dR +5mUsqP5JLGOPmjQWjOreKBGttO6U/IwxAOVaohVmAPktBSx0/XX8TS3765h38eLS +snHHFU/gerZXlfmnADhSwIaoMGT5ucZB5y4Mkb3i82w1y0mCnhbrGoXrASPCu++C +9dBN/fs9rHd8NW4RE8PR2C6lJIllPA98Q0GRSUrDiUKnXArHSx2ZlGp0Mtatqc0/ +lU81rtr3serKdcqbMO/aD+ampX335d5HEx2cXL2f6bBn9EjWQbWBM2YFPWdUHd8Q +unSsVy+MXSDh+8w+q7Y7EQlXpNd0ADOpOXb3zf+ekYsSIHI/pUlwUJWF/CM8Ysm3 +hmbt5Qow05FJTUSTKeNGh4t8WI6rHDGtHery2V5zZsAZ0EGGB1sQQL+IMKbVzl0U +3ek7RVPJKuSyurGOAEhjqo/1gfDmnrevPS7GRU/7dTzB6X4dJIia+WKBcq43QvfG +qUqQtmtTylJUIWLueeGgWMr+JoRgio5UkYRbpJnVBlBIq2sRkfZ1kP/1WciW/HIM +jgCRFTBWwxK9NuJEXsPmentvELy5A/D4uP6gfQ== +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2-cross.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2-cross.cert.pem new file mode 100644 index 000000000..3a5a49518 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2-cross.cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEUDCCAjigAwIBAgIQMoNIizVFHRPRHf3+bcA7LTANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJYWDEVMBMGA1UEChMMQm91bGRlciBUZXN0MSAwHgYDVQQDExco +VEVTVCkgSW5lZmZhYmxlIEljZSBYMTAeFw0yMDA5MDQwMDAwMDBaFw0yNTA5MTUx +NjAwMDBaMEgxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIjAg +BgNVBAMTGShURVNUKSBJcmlkZXNjZW50IElyaXMgWDIwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAATVXC/BnBdkaS7EhZPa3177GOn6jdMhoA99KwDk1WYQ1P891U6F2ZSJ +qFVDSbBJPz/LXjrXKTIvLTyFKpFzXesr0TFawRibJJkUPgMY6ohuMwGNJ8U0PWAU +oM6Wq//s/RajgeUwgeIwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFHM/n7+X9WKpLuOi6sTVevmloVNWMB8GA1UdIwQYMBaAFOwAbmTA +JBay9W2Lyw1Ki2yy18GNMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0 +cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEu +Yy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEB +MA0GCSqGSIb3DQEBCwUAA4ICAQDDy1vIFa+mxymBHQvI99OoEQNy8OFQ6MRfnVHB +tHUJ8gV395gv7ukMUoM7DoBJGRFemlQp+RPeDOr17qXOQlbpgURzOhiKA0dtfLWE +hOm6ENXbCSzHphlFOlqBVgnWa8fD97mf6lKt6TOBrj1PGPgyq+anzbeEC3YhelB1 +UIPQ72OtYWHi69pJfsUkscDjl4QnozxSWHoxgsVe4nKWnW1Xws+lwhBDZTbgT4zI +jtZ2Z9vhJiqsQvaxaTg+LRQvuktJ8GSA99FCBZfmRkLcvkm/dieo+bLJLncoKlX5 +3gwtl35kQj0E0UquChdWdcKcDmaAT+VdYRPSX4HLaENqsgckwkaAKODiz7a9uNQK +qIDBdCj16WbTlwYo9J+yqcYxM2fv4YBIvQ/SkGZoQJ2BMlCKR3pHANZNa7622n/2 +RE14wj80CNt10a1hX1qEV8iJOHjiy4hZSYkvb9FVgLbGPLTdYGGSdFtIoEAlt25f +EVhCAr20xx4kdD6Z8avrXe11c945XsE3TJ1veYwPQiWMTjWv/TTb+bFo/6AxZbQ/ +Pbe2inH/AyaSr2C36UjSRK/4brI97lu9GSUvEOOePT3QyuUzdi6Ke4V5E/Qzp3Yk +TeVBcj3FK+bSazKjB9ndFa7c31ggmCj1IVXHkmwV+KS7uosmuT1JJU7+fImFtEKB +K1yBhg== +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2.cert.pem b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2.cert.pem new file mode 100644 index 000000000..df6823968 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hierarchy/root-x2.cert.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICDzCCAZSgAwIBAgIRAJZSYAs5uRSI65L/L/drYDIwCgYIKoZIzj0EAwMwSDEL +MAkGA1UEBhMCWFgxFTATBgNVBAoTDEJvdWxkZXIgVGVzdDEiMCAGA1UEAxMZKFRF +U1QpIElyaWRlc2NlbnQgSXJpcyBYMjAeFw0yMDA5MDQwMDAwMDBaFw00MDA5MTcx +NjAwMDBaMEgxCzAJBgNVBAYTAlhYMRUwEwYDVQQKEwxCb3VsZGVyIFRlc3QxIjAg +BgNVBAMTGShURVNUKSBJcmlkZXNjZW50IElyaXMgWDIwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAATVXC/BnBdkaS7EhZPa3177GOn6jdMhoA99KwDk1WYQ1P891U6F2ZSJ +qFVDSbBJPz/LXjrXKTIvLTyFKpFzXesr0TFawRibJJkUPgMY6ohuMwGNJ8U0PWAU +oM6Wq//s/RajQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBRzP5+/l/ViqS7jourE1Xr5paFTVjAKBggqhkjOPQQDAwNpADBmAjEA +2Y4+7QDv6mN7Bg28fK/hlzAzz1Bi+zcr2v5aOTXXPrQZxUGu9X3ojuVTO8mfoZgU +AjEAzFZmf002M+ltm3JwSJjShu8aIoD47ymSiNdMiXcf6lTJ6ytKgyImV+frOpjx +0/Ev +-----END CERTIFICATE----- diff --git a/third-party/github.com/letsencrypt/boulder/test/hostname-policy.yaml b/third-party/github.com/letsencrypt/boulder/test/hostname-policy.yaml new file mode 100644 index 000000000..88730260f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/hostname-policy.yaml @@ -0,0 +1,33 @@ +# +# Example YAML Boulder hostname policy +# +# This is *not* a production ready policy file and not reflective of Let's +# Encrypt's policies! It is just an example. + +# ExactBlockedNames prevent issuance for the exact names listed, as well as +# their wildcard form. +ExactBlockedNames: + - "highrisk.le-test.hoffman-andrews.com" + - "exactblacklist.letsencrypt.org" + +# HighRiskBlockedNames prevent issuance for the exact names listed as well as +# all subdomains/wildcards. +HighRiskBlockedNames: + # See RFC 3152 + - "ipv6.arpa" + # See RFC 2317 + - "in-addr.arpa" + # Etc etc etc + - "example" + - "example.net" + - "example.org" + - "invalid" + - "local" + - "localhost" + - "test" + +# AdminBlockedNames are treated the same as HighRiskBlockedNames by Boulder but +# since they change more frequently based on administrative action over time +# they are separated into their own list. +AdminBlockedNames: + - "sealand" diff --git a/third-party/github.com/letsencrypt/boulder/test/inmem/nonce/nonce.go b/third-party/github.com/letsencrypt/boulder/test/inmem/nonce/nonce.go new file mode 100644 index 000000000..bdebdae3a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/inmem/nonce/nonce.go @@ -0,0 +1,58 @@ +package inmemnonce + +import ( + "context" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/nonce" + noncepb "github.com/letsencrypt/boulder/nonce/proto" +) + +// Service implements noncepb.NonceServiceClient for tests. +type Service struct { + *nonce.NonceService +} + +var _ noncepb.NonceServiceClient = &Service{} + +// Nonce implements proto.NonceServiceClient +func (imns *Service) Nonce(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*noncepb.NonceMessage, error) { + n, err := imns.NonceService.Nonce() + if err != nil { + return nil, err + } + return &noncepb.NonceMessage{Nonce: n}, nil +} + +// Redeem implements proto.NonceServiceClient +func (imns *Service) Redeem(ctx context.Context, in *noncepb.NonceMessage, opts ...grpc.CallOption) (*noncepb.ValidMessage, error) { + valid := imns.NonceService.Valid(in.Nonce) + return &noncepb.ValidMessage{Valid: valid}, nil +} + +// AsSource returns a wrapper type that implements jose.NonceSource using this +// inmemory service. This is useful so that tests can get nonces for signing +// their JWS that will be accepted by the test WFE configured using this service. +func (imns *Service) AsSource() jose.NonceSource { + return nonceServiceAdapter{imns} +} + +// nonceServiceAdapter changes the gRPC nonce service interface to the one +// required by jose. Used only for tests. +type nonceServiceAdapter struct { + noncepb.NonceServiceClient +} + +// Nonce returns a nonce, implementing the jose.NonceSource interface +func (nsa nonceServiceAdapter) Nonce() (string, error) { + resp, err := nsa.NonceServiceClient.Nonce(context.Background(), &emptypb.Empty{}) + if err != nil { + return "", err + } + return resp.Nonce, nil +} + +var _ jose.NonceSource = nonceServiceAdapter{} diff --git a/third-party/github.com/letsencrypt/boulder/test/inmem/ra/ra.go b/third-party/github.com/letsencrypt/boulder/test/inmem/ra/ra.go new file mode 100644 index 000000000..b6ed5d891 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/inmem/ra/ra.go @@ -0,0 +1,25 @@ +package ra + +import ( + "context" + + "github.com/letsencrypt/boulder/ra" + rapb "github.com/letsencrypt/boulder/ra/proto" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" +) + +// RA meets the `rapb.RegistrationAuthorityClient` interface and acts as a +// wrapper for an inner `*ra.RegistrationAuthorityImpl` (which in turn meets +// the `rapb.RegistrationAuthorityServer` interface). Only methods used by +// unit tests need to be implemented. +type RA struct { + rapb.RegistrationAuthorityClient + Impl *ra.RegistrationAuthorityImpl +} + +// AdministrativelyRevokeCertificate is a wrapper for `*ra.RegistrationAuthorityImpl.AdministrativelyRevokeCertificate`. +func (ra RA) AdministrativelyRevokeCertificate(ctx context.Context, req *rapb.AdministrativelyRevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return ra.Impl.AdministrativelyRevokeCertificate(ctx, req) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/inmem/sa/sa.go b/third-party/github.com/letsencrypt/boulder/test/inmem/sa/sa.go new file mode 100644 index 000000000..4df3017b9 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/inmem/sa/sa.go @@ -0,0 +1,179 @@ +package sa + +import ( + "context" + "io" + + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/sa" + sapb "github.com/letsencrypt/boulder/sa/proto" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" +) + +// SA meets the `sapb.StorageAuthorityClient` interface and acts as a +// wrapper for an inner `sa.SQLStorageAuthority` (which in turn meets +// the `sapb.StorageAuthorityServer` interface). Only methods used by +// unit tests need to be implemented. +type SA struct { + sapb.StorageAuthorityClient + Impl *sa.SQLStorageAuthority +} + +func (sa SA) NewRegistration(ctx context.Context, req *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) { + return sa.Impl.NewRegistration(ctx, req) +} + +func (sa SA) GetRegistration(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { + return sa.Impl.GetRegistration(ctx, req) +} + +func (sa SA) CountRegistrationsByIP(ctx context.Context, req *sapb.CountRegistrationsByIPRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return sa.Impl.CountRegistrationsByIP(ctx, req) +} + +func (sa SA) CountRegistrationsByIPRange(ctx context.Context, req *sapb.CountRegistrationsByIPRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return sa.Impl.CountRegistrationsByIPRange(ctx, req) +} + +func (sa SA) DeactivateRegistration(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.DeactivateRegistration(ctx, req) +} + +func (sa SA) GetAuthorization2(ctx context.Context, req *sapb.AuthorizationID2, _ ...grpc.CallOption) (*corepb.Authorization, error) { + return sa.Impl.GetAuthorization2(ctx, req) +} + +func (sa SA) GetAuthorizations2(ctx context.Context, req *sapb.GetAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + return sa.Impl.GetAuthorizations2(ctx, req) +} + +func (sa SA) GetPendingAuthorization2(ctx context.Context, req *sapb.GetPendingAuthorizationRequest, _ ...grpc.CallOption) (*corepb.Authorization, error) { + return sa.Impl.GetPendingAuthorization2(ctx, req) +} + +func (sa SA) GetValidAuthorizations2(ctx context.Context, req *sapb.GetValidAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + return sa.Impl.GetValidAuthorizations2(ctx, req) +} + +func (sa SA) GetValidOrderAuthorizations2(ctx context.Context, req *sapb.GetValidOrderAuthorizationsRequest, _ ...grpc.CallOption) (*sapb.Authorizations, error) { + return sa.Impl.GetValidOrderAuthorizations2(ctx, req) +} + +func (sa SA) CountPendingAuthorizations2(ctx context.Context, req *sapb.RegistrationID, _ ...grpc.CallOption) (*sapb.Count, error) { + return sa.Impl.CountPendingAuthorizations2(ctx, req) +} + +func (sa SA) DeactivateAuthorization2(ctx context.Context, req *sapb.AuthorizationID2, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.DeactivateAuthorization2(ctx, req) +} + +func (sa SA) FinalizeAuthorization2(ctx context.Context, req *sapb.FinalizeAuthorizationRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.FinalizeAuthorization2(ctx, req) +} + +func (sa SA) NewOrderAndAuthzs(ctx context.Context, req *sapb.NewOrderAndAuthzsRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + return sa.Impl.NewOrderAndAuthzs(ctx, req) +} + +func (sa SA) GetOrder(ctx context.Context, req *sapb.OrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + return sa.Impl.GetOrder(ctx, req) +} + +func (sa SA) GetOrderForNames(ctx context.Context, req *sapb.GetOrderForNamesRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + return sa.Impl.GetOrderForNames(ctx, req) +} + +func (sa SA) CountOrders(ctx context.Context, req *sapb.CountOrdersRequest, _ ...grpc.CallOption) (*sapb.Count, error) { + return sa.Impl.CountOrders(ctx, req) +} + +func (sa SA) SetOrderError(ctx context.Context, req *sapb.SetOrderErrorRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.SetOrderError(ctx, req) +} + +func (sa SA) SetOrderProcessing(ctx context.Context, req *sapb.OrderRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.SetOrderProcessing(ctx, req) +} + +func (sa SA) FinalizeOrder(ctx context.Context, req *sapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.FinalizeOrder(ctx, req) +} + +func (sa SA) AddPrecertificate(ctx context.Context, req *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.AddPrecertificate(ctx, req) +} + +func (sa SA) AddCertificate(ctx context.Context, req *sapb.AddCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.AddCertificate(ctx, req) +} + +func (sa SA) CountCertificatesByNames(ctx context.Context, req *sapb.CountCertificatesByNamesRequest, _ ...grpc.CallOption) (*sapb.CountByNames, error) { + return sa.Impl.CountCertificatesByNames(ctx, req) +} + +func (sa SA) RevokeCertificate(ctx context.Context, req *sapb.RevokeCertificateRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.RevokeCertificate(ctx, req) +} + +func (sa SA) GetLintPrecertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return sa.Impl.GetLintPrecertificate(ctx, req) +} + +func (sa SA) GetCertificateStatus(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + return sa.Impl.GetCertificateStatus(ctx, req) +} + +func (sa SA) AddBlockedKey(ctx context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + return sa.Impl.AddBlockedKey(ctx, req) +} + +func (sa SA) FQDNSetExists(ctx context.Context, req *sapb.FQDNSetExistsRequest, _ ...grpc.CallOption) (*sapb.Exists, error) { + return sa.Impl.FQDNSetExists(ctx, req) +} + +type mockStreamResult[T any] struct { + val T + err error +} + +type mockClientStream[T any] struct { + grpc.ClientStream + stream <-chan mockStreamResult[T] +} + +func (c mockClientStream[T]) Recv() (T, error) { + result := <-c.stream + return result.val, result.err +} + +type mockServerStream[T any] struct { + grpc.ServerStream + context context.Context + stream chan<- mockStreamResult[T] +} + +func (s mockServerStream[T]) Send(val T) error { + s.stream <- mockStreamResult[T]{val: val, err: nil} + return nil +} + +func (s mockServerStream[T]) Context() context.Context { + return s.context +} + +func (sa SA) SerialsForIncident(ctx context.Context, req *sapb.SerialsForIncidentRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[sapb.IncidentSerial], error) { + streamChan := make(chan mockStreamResult[*sapb.IncidentSerial]) + client := mockClientStream[*sapb.IncidentSerial]{stream: streamChan} + server := mockServerStream[*sapb.IncidentSerial]{context: ctx, stream: streamChan} + go func() { + err := sa.Impl.SerialsForIncident(req, server) + if err != nil { + streamChan <- mockStreamResult[*sapb.IncidentSerial]{nil, err} + } + streamChan <- mockStreamResult[*sapb.IncidentSerial]{nil, io.EOF} + close(streamChan) + }() + return client, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration-test.py b/third-party/github.com/letsencrypt/boulder/test/integration-test.py new file mode 100644 index 000000000..af4aa3860 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration-test.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +This file contains basic infrastructure for running the integration test cases. +Most test cases are in v2_integration.py. There are a few exceptions: Test cases +that don't test either the v1 or v2 API are in this file, and test cases that +have to run at a specific point in the cycle (e.g. after all other test cases) +are also in this file. +""" +import argparse +import datetime +import inspect +import json +import os +import random +import re +import requests +import subprocess +import shlex +import signal +import time + +import startservers + +import v2_integration +from helpers import * + +from acme import challenges + +# Set the environment variable RACE to anything other than 'true' to disable +# race detection. This significantly speeds up integration testing cycles +# locally. +race_detection = True +if os.environ.get('RACE', 'true') != 'true': + race_detection = False + +def run_go_tests(filterPattern=None): + """ + run_go_tests launches the Go integration tests. The go test command must + return zero or an exception will be raised. If the filterPattern is provided + it is used as the value of the `--test.run` argument to the go test command. + """ + cmdLine = ["go", "test"] + if filterPattern is not None and filterPattern != "": + cmdLine = cmdLine + ["--test.run", filterPattern] + cmdLine = cmdLine + ["-tags", "integration", "-count=1", "-race", "./test/integration"] + subprocess.check_call(cmdLine, stderr=subprocess.STDOUT) + +exit_status = 1 + +def main(): + parser = argparse.ArgumentParser(description='Run integration tests') + parser.add_argument('--chisel', dest="run_chisel", action="store_true", + help="run integration tests using chisel") + parser.add_argument('--gotest', dest="run_go", action="store_true", + help="run Go integration tests") + parser.add_argument('--filter', dest="test_case_filter", action="store", + help="Regex filter for test cases") + # allow any ACME client to run custom command for integration + # testing (without having to implement its own busy-wait loop) + parser.add_argument('--custom', metavar="CMD", help="run custom command") + parser.set_defaults(run_chisel=False, test_case_filter="", skip_setup=False) + args = parser.parse_args() + + if not (args.run_chisel or args.custom or args.run_go is not None): + raise(Exception("must run at least one of the letsencrypt or chisel tests with --chisel, --gotest, or --custom")) + + if not startservers.install(race_detection=race_detection): + raise(Exception("failed to build")) + + if not args.test_case_filter: + now = datetime.datetime.utcnow() + + six_months_ago = now+datetime.timedelta(days=-30*6) + if not startservers.start(fakeclock=fakeclock(six_months_ago)): + raise(Exception("startservers failed (mocking six months ago)")) + setup_six_months_ago() + startservers.stop() + + twenty_days_ago = now+datetime.timedelta(days=-20) + if not startservers.start(fakeclock=fakeclock(twenty_days_ago)): + raise(Exception("startservers failed (mocking twenty days ago)")) + setup_twenty_days_ago() + startservers.stop() + + if not startservers.start(fakeclock=None): + raise(Exception("startservers failed")) + + if args.run_chisel: + run_chisel(args.test_case_filter) + + if args.run_go: + run_go_tests(args.test_case_filter) + + if args.custom: + run(args.custom.split()) + + # Skip the last-phase checks when the test case filter is one, because that + # means we want to quickly iterate on a single test case. + if not args.test_case_filter: + run_cert_checker() + check_balance() + + if not startservers.check(): + raise(Exception("startservers.check failed")) + + global exit_status + exit_status = 0 + +def run_chisel(test_case_filter): + for key, value in inspect.getmembers(v2_integration): + if callable(value) and key.startswith('test_') and re.search(test_case_filter, key): + value() + for key, value in globals().items(): + if callable(value) and key.startswith('test_') and re.search(test_case_filter, key): + value() + +def check_balance(): + """Verify that gRPC load balancing across backends is working correctly. + + Fetch metrics from each backend and ensure the grpc_server_handled_total + metric is present, which means that backend handled at least one request. + """ + addresses = [ + "localhost:8003", # SA + "localhost:8103", # SA + "localhost:8009", # publisher + "localhost:8109", # publisher + "localhost:8004", # VA + "localhost:8104", # VA + "localhost:8001", # CA + "localhost:8101", # CA + "localhost:8002", # RA + "localhost:8102", # RA + ] + for address in addresses: + metrics = requests.get("http://%s/metrics" % address) + if not "grpc_server_handled_total" in metrics.text: + raise(Exception("no gRPC traffic processed by %s; load balancing problem?") + % address) + +def run_cert_checker(): + run(["./bin/boulder", "cert-checker", "-config", "%s/cert-checker.json" % config_dir]) + +if __name__ == "__main__": + main() diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/admin_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/admin_test.go new file mode 100644 index 000000000..9313f8197 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/admin_test.go @@ -0,0 +1,60 @@ +//go:build integration + +package integration + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/eggsampler/acme/v3" + _ "github.com/go-sql-driver/mysql" + + "github.com/letsencrypt/boulder/test" +) + +func TestAdminClearEmail(t *testing.T) { + t.Parallel() + os.Setenv("DIRECTORY", "http://boulder.service.consul:4001/directory") + + // Note that `example@mail.example.letsencrypt.org` is a substring of `long-example@mail.example.letsencrypt.org`. + // We specifically want to test that the superstring does not get removed, even though we use substring matching + // as an initial filter. + client1, err := makeClient("mailto:example@mail.example.letsencrypt.org", "mailto:long-example@mail.example.letsencrypt.org", "mailto:third-example@mail.example.letsencrypt.org") + test.AssertNotError(t, err, "creating first acme client") + + client2, err := makeClient("mailto:example@mail.example.letsencrypt.org") + test.AssertNotError(t, err, "creating second acme client") + + client3, err := makeClient("mailto:other@mail.example.letsencrypt.org") + test.AssertNotError(t, err, "creating second acme client") + + deleteMe := "example@mail.example.letsencrypt.org" + config := fmt.Sprintf("%s/%s", os.Getenv("BOULDER_CONFIG_DIR"), "admin.json") + cmd := exec.Command( + "./bin/admin", + "-config", config, + "-dry-run=false", + "update-email", + "-address", deleteMe, + "-clear") + output, err := cmd.CombinedOutput() + test.AssertNotError(t, err, fmt.Sprintf("clearing email via admin tool (%s): %s", cmd, string(output))) + t.Logf("clear-email output: %s\n", string(output)) + + updatedAccount1, err := client1.NewAccountOptions(client1.PrivateKey, acme.NewAcctOptOnlyReturnExisting()) + test.AssertNotError(t, err, "fetching updated account for first client") + + t.Log(updatedAccount1.Contact) + test.AssertDeepEquals(t, updatedAccount1.Contact, + []string{"mailto:long-example@mail.example.letsencrypt.org", "mailto:third-example@mail.example.letsencrypt.org"}) + + updatedAccount2, err := client2.NewAccountOptions(client2.PrivateKey, acme.NewAcctOptOnlyReturnExisting()) + test.AssertNotError(t, err, "fetching updated account for second client") + test.AssertDeepEquals(t, updatedAccount2.Contact, []string(nil)) + + updatedAccount3, err := client3.NewAccountOptions(client3.PrivateKey, acme.NewAcctOptOnlyReturnExisting()) + test.AssertNotError(t, err, "fetching updated account for third client") + test.AssertDeepEquals(t, updatedAccount3.Contact, []string{"mailto:other@mail.example.letsencrypt.org"}) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/akamai_purger_drain_queue_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/akamai_purger_drain_queue_test.go new file mode 100644 index 000000000..3c885cd1a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/akamai_purger_drain_queue_test.go @@ -0,0 +1,134 @@ +//go:build integration + +package integration + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "syscall" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/balancer/roundrobin" + "google.golang.org/grpc/connectivity" + + akamaipb "github.com/letsencrypt/boulder/akamai/proto" + "github.com/letsencrypt/boulder/cmd" + bcreds "github.com/letsencrypt/boulder/grpc/creds" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/test" +) + +func setup() (*exec.Cmd, *bytes.Buffer, akamaipb.AkamaiPurgerClient, error) { + purgerCmd := exec.Command("./bin/boulder", "akamai-purger", "--config", "test/integration/testdata/akamai-purger-queue-drain-config.json") + var outputBuffer bytes.Buffer + purgerCmd.Stdout = &outputBuffer + purgerCmd.Stderr = &outputBuffer + purgerCmd.Start() + + // If we error, we need to kill the process we started or the test command + // will never exit. + sigterm := func() { + purgerCmd.Process.Signal(syscall.SIGTERM) + purgerCmd.Wait() + } + + tlsConfig, err := (&cmd.TLSConfig{ + CACertFile: "test/certs/ipki/minica.pem", + CertFile: "test/certs/ipki/ra.boulder/cert.pem", + KeyFile: "test/certs/ipki/ra.boulder/key.pem", + }).Load(metrics.NoopRegisterer) + if err != nil { + sigterm() + return nil, nil, nil, err + } + creds := bcreds.NewClientCredentials(tlsConfig.RootCAs, tlsConfig.Certificates, "akamai-purger.boulder") + conn, err := grpc.Dial( + "dns:///akamai-purger.service.consul:9199", + grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]}`, roundrobin.Name)), + grpc.WithTransportCredentials(creds), + ) + if err != nil { + sigterm() + return nil, nil, nil, err + } + for i := range 42 { + if conn.GetState() == connectivity.Ready { + break + } + if i > 40 { + sigterm() + return nil, nil, nil, fmt.Errorf("timed out waiting for akamai-purger to come up: %s", outputBuffer.String()) + } + time.Sleep(50 * time.Millisecond) + } + purgerClient := akamaipb.NewAkamaiPurgerClient(conn) + return purgerCmd, &outputBuffer, purgerClient, nil +} + +func TestAkamaiPurgerDrainQueueFails(t *testing.T) { + purgerCmd, outputBuffer, purgerClient, err := setup() + if err != nil { + t.Fatal(err) + } + + // We know that the purger is configured to only process two items per batch, + // so submitting 10 items should give it enough of a backlog to guarantee + // that our SIGTERM reaches the process before it's fully cleared the queue. + for i := range 10 { + _, err = purgerClient.Purge(context.Background(), &akamaipb.PurgeRequest{ + Urls: []string{fmt.Sprintf("http://example%d.com/", i)}, + }) + if err != nil { + // Don't use t.Fatal here because we need to get as far as the SIGTERM or + // we'll hang on exit. + t.Error(err) + } + } + + purgerCmd.Process.Signal(syscall.SIGTERM) + err = purgerCmd.Wait() + if err == nil { + t.Error("expected error shutting down akamai-purger that could not reach backend") + } + + // Use two asserts because we're not sure what integer (10? 8?) will come in + // the middle of the error message. + test.AssertContains(t, outputBuffer.String(), "failed to purge OCSP responses for") + test.AssertContains(t, outputBuffer.String(), "certificates before exit: all attempts to submit purge request failed") +} + +func TestAkamaiPurgerDrainQueueSucceeds(t *testing.T) { + purgerCmd, outputBuffer, purgerClient, err := setup() + if err != nil { + t.Fatal(err) + } + for range 10 { + _, err := purgerClient.Purge(context.Background(), &akamaipb.PurgeRequest{ + Urls: []string{"http://example.com/"}, + }) + if err != nil { + t.Error(err) + } + } + time.Sleep(200 * time.Millisecond) + purgerCmd.Process.Signal(syscall.SIGTERM) + + akamaiTestSrvCmd := exec.Command("./bin/akamai-test-srv", "--listen", "localhost:6889", + "--secret", "its-a-secret") + akamaiTestSrvCmd.Stdout = os.Stdout + akamaiTestSrvCmd.Stderr = os.Stderr + akamaiTestSrvCmd.Start() + + err = purgerCmd.Wait() + if err != nil { + t.Errorf("unexpected error shutting down akamai-purger: %s. Output was:\n%s", err, outputBuffer.String()) + } + test.AssertContains(t, outputBuffer.String(), "Shutting down; finished purging OCSP responses") + akamaiTestSrvCmd.Process.Signal(syscall.SIGTERM) + _ = akamaiTestSrvCmd.Wait() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/ari_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/ari_test.go new file mode 100644 index 000000000..70fb1c4a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/ari_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509/pkix" + "math/big" + "os" + "testing" + "time" + + "github.com/eggsampler/acme/v3" + + "github.com/letsencrypt/boulder/test" +) + +// certID matches the ASN.1 structure of the CertID sequence defined by RFC6960. +type certID struct { + HashAlgorithm pkix.AlgorithmIdentifier + IssuerNameHash []byte + IssuerKeyHash []byte + SerialNumber *big.Int +} + +func TestARI(t *testing.T) { + t.Parallel() + + // Create an account. + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + // Create a private key. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Issue a cert, request ARI, and check that both the suggested window and + // the retry-after header are approximately the right amount of time in the + // future. + name := random_domain() + ir, err := authAndIssue(client, key, []string{name}, true) + test.AssertNotError(t, err, "failed to issue test cert") + + cert := ir.certs[0] + ari, err := client.GetRenewalInfo(cert) + test.AssertNotError(t, err, "ARI request should have succeeded") + test.AssertEquals(t, ari.SuggestedWindow.Start.Sub(time.Now()).Round(time.Hour), 1415*time.Hour) + test.AssertEquals(t, ari.SuggestedWindow.End.Sub(time.Now()).Round(time.Hour), 1463*time.Hour) + test.AssertEquals(t, ari.RetryAfter.Sub(time.Now()).Round(time.Hour), 6*time.Hour) + + // TODO(@pgporada): Clean this up when 'test/config/{sa,wfe2}.json' sets + // TrackReplacementCertificatesARI=true. + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config-next" { + // Make a new order which indicates that it replaces the cert issued above. + _, order, err := makeClientAndOrder(client, key, []string{name}, true, cert) + test.AssertNotError(t, err, "failed to issue test cert") + replaceID, err := acme.GenerateARICertID(cert) + test.AssertNotError(t, err, "failed to generate ARI certID") + test.AssertEquals(t, order.Replaces, replaceID) + test.AssertNotEquals(t, order.Replaces, "") + + // Try it again and verify it fails + _, order, err = makeClientAndOrder(client, key, []string{name}, true, cert) + test.AssertError(t, err, "subsequent ARI replacements for a replaced cert should fail, but didn't") + } else { + // ARI is disabled so we only use the client to POST the replacement + // order, but we never finalize it. + replacementOrder, err := client.ReplacementOrder(client.Account, cert, []acme.Identifier{{Type: "dns", Value: name}}) + test.AssertNotError(t, err, "ARI replacement request should have succeeded") + test.AssertNotEquals(t, replacementOrder.Replaces, "") + } + + // Revoke the cert and re-request ARI. The renewal window should now be in + // the past indicating to the client that a renewal should happen + // immediately. + err = client.RevokeCertificate(client.Account, cert, client.PrivateKey, 0) + test.AssertNotError(t, err, "failed to revoke cert") + + ari, err = client.GetRenewalInfo(cert) + test.AssertNotError(t, err, "ARI request should have succeeded") + test.Assert(t, ari.SuggestedWindow.End.Before(time.Now()), "suggested window should end in the past") + test.Assert(t, ari.SuggestedWindow.Start.Before(ari.SuggestedWindow.End), "suggested window should start before it ends") + + // Try to make a new cert for a new domain, but sabotage the CT logs so + // issuance fails. Recover the precert from CT, then request ARI and check + // that it fails, because we don't serve ARI for non-issued certs. + name = random_domain() + err = ctAddRejectHost(name) + test.AssertNotError(t, err, "failed to add ct-test-srv reject host") + _, err = authAndIssue(client, key, []string{name}, true) + test.AssertError(t, err, "expected error from authAndIssue, was nil") + + cert, err = ctFindRejection([]string{name}) + test.AssertNotError(t, err, "failed to find rejected precert") + + ari, err = client.GetRenewalInfo(cert) + test.AssertError(t, err, "ARI request should have failed") + test.AssertEquals(t, err.(acme.Problem).Status, 404) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/authz_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/authz_test.go new file mode 100644 index 000000000..b8783b83a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/authz_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package integration + +import ( + "testing" + "time" + + "github.com/letsencrypt/boulder/test" +) + +const ( + // validAuthorizationLifetime is the expected valid authorization lifetime. It + // should match the value in the RA config's "authorizationLifetimeDays" + // configuration field. + validAuthorizationLifetime = 30 +) + +// TestValidAuthzExpires checks that a valid authorization has the expected +// expires time. +func TestValidAuthzExpires(t *testing.T) { + t.Parallel() + c, err := makeClient() + test.AssertNotError(t, err, "makeClient failed") + + // Issue for a random domain + domains := []string{random_domain()} + result, err := authAndIssue(c, nil, domains, true) + // There should be no error + test.AssertNotError(t, err, "authAndIssue failed") + // The order should be valid + test.AssertEquals(t, result.Order.Status, "valid") + // There should be one authorization URL + test.AssertEquals(t, len(result.Order.Authorizations), 1) + + // Fetching the authz by URL shouldn't fail + authzURL := result.Order.Authorizations[0] + authzOb, err := c.FetchAuthorization(c.Account, authzURL) + test.AssertNotError(t, err, "FetchAuthorization failed") + + // The authz should be valid and for the correct identifier + test.AssertEquals(t, authzOb.Status, "valid") + test.AssertEquals(t, authzOb.Identifier.Value, domains[0]) + + // The authz should have the expected expiry date, plus or minus a minute + expectedExpiresMin := time.Now().AddDate(0, 0, validAuthorizationLifetime).Add(-time.Minute) + expectedExpiresMax := expectedExpiresMin.Add(2 * time.Minute) + actualExpires := authzOb.Expires + if actualExpires.Before(expectedExpiresMin) || actualExpires.After(expectedExpiresMax) { + t.Errorf("Wrong expiry. Got %s, expected it to be between %s and %s", + actualExpires, expectedExpiresMin, expectedExpiresMax) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/bad_key_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/bad_key_test.go new file mode 100644 index 000000000..482c04dee --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/bad_key_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package integration + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + + "github.com/eggsampler/acme/v3" + + "github.com/letsencrypt/boulder/test" +) + +// TestFermat ensures that a certificate public key which can be factored using +// less than 100 rounds of Fermat's Algorithm is rejected. +func TestFermat(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + p string + q string + } + + testCases := []testCase{ + { + name: "canon printer (2048 bit, 1 round)", + p: "155536235030272749691472293262418471207550926406427515178205576891522284497518443889075039382254334975506248481615035474816604875321501901699955105345417152355947783063521554077194367454070647740704883461064399268622437721385112646454393005862535727615809073410746393326688230040267160616554768771412289114449", + q: "155536235030272749691472293262418471207550926406427515178205576891522284497518443889075039382254334975506248481615035474816604875321501901699955105345417152355947783063521554077194367454070647740704883461064399268622437721385112646454393005862535727615809073410746393326688230040267160616554768771412289114113", + }, + { + name: "innsbruck printer (4096 bit, 1 round)", + p: "25868808535211632564072019392873831934145242707953960515208595626279836366691068618582894100813803673421320899654654938470888358089618966238341690624345530870988951109006149164192566967552401505863871260691612081236189439839963332690997129144163260418447718577834226720411404568398865166471102885763673744513186211985402019037772108416694793355840983833695882936201196462579254234744648546792097397517107797153785052856301942321429858537224127598198913168345965493941246097657533085617002572245972336841716321849601971924830462771411171570422802773095537171762650402420866468579928479284978914972383512240254605625661", + q: "25868808535211632564072019392873831934145242707953960515208595626279836366691068618582894100813803673421320899654654938470888358089618966238341690624345530870988951109006149164192566967552401505863871260691612081236189439839963332690997129144163260418447718577834226720411404568398865166471102885763673744513186211985402019037772108416694793355840983833695882936201196462579254234744648546792097397517107797153785052856301942321429858537224127598198913168345965493941246097657533085617002572245972336841716321849601971924830462771411171570422802773095537171762650402420866468579928479284978914972383512240254605624819", + }, + // Ideally we'd have a 2408-bit, nearly-100-rounds test case, but it turns + // out purposefully generating keys that require 1 < N < 100 rounds to be + // factored is surprisingly tricky. + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create a client and complete an HTTP-01 challenge for a fake domain. + c, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + + domain := random_domain() + + order, err := c.Client.NewOrder( + c.Account, []acme.Identifier{{Type: "dns", Value: domain}}) + test.AssertNotError(t, err, "creating new order") + test.AssertEquals(t, len(order.Authorizations), 1) + + authUrl := order.Authorizations[0] + + auth, err := c.Client.FetchAuthorization(c.Account, authUrl) + test.AssertNotError(t, err, "fetching authorization") + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeHTTP01] + test.Assert(t, ok, "getting HTTP-01 challenge") + + err = addHTTP01Response(chal.Token, chal.KeyAuthorization) + defer delHTTP01Response(chal.Token) + test.AssertNotError(t, err, "adding HTTP-01 response") + + chal, err = c.Client.UpdateChallenge(c.Account, chal) + test.AssertNotError(t, err, "updating HTTP-01 challenge") + + // Reconstruct the public modulus N from the test case's prime factors. + p, ok := new(big.Int).SetString(tc.p, 10) + test.Assert(t, ok, "failed to create large prime") + q, ok := new(big.Int).SetString(tc.q, 10) + test.Assert(t, ok, "failed to create large prime") + n := new(big.Int).Mul(p, q) + + // Reconstruct the private exponent D from the test case's prime factors. + p_1 := new(big.Int).Sub(p, big.NewInt(1)) + q_1 := new(big.Int).Sub(q, big.NewInt(1)) + field := new(big.Int).Mul(p_1, q_1) + d := new(big.Int).ModInverse(big.NewInt(65537), field) + + // Create a CSR containing the reconstructed pubkey and signed with the + // reconstructed private key. + pubkey := rsa.PublicKey{ + N: n, + E: 65537, + } + + privkey := rsa.PrivateKey{ + PublicKey: pubkey, + D: d, + Primes: []*big.Int{p, q}, + } + + csrDer, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + SignatureAlgorithm: x509.SHA256WithRSA, + PublicKeyAlgorithm: x509.RSA, + PublicKey: &pubkey, + Subject: pkix.Name{CommonName: domain}, + DNSNames: []string{domain}, + }, &privkey) + test.AssertNotError(t, err, "creating CSR") + + csr, err := x509.ParseCertificateRequest(csrDer) + test.AssertNotError(t, err, "parsing CSR") + + // Finalizing the order should fail as we reject the public key. + _, err = c.Client.FinalizeOrder(c.Account, order, csr) + test.AssertError(t, err, "finalizing order") + test.AssertContains(t, err.Error(), "urn:ietf:params:acme:error:badCSR") + test.AssertContains(t, err.Error(), "key generated with factors too close together") + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/cert_storage_failed_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/cert_storage_failed_test.go new file mode 100644 index 000000000..207b15039 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/cert_storage_failed_test.go @@ -0,0 +1,214 @@ +//go:build integration + +package integration + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "database/sql" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test" + ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper" + "github.com/letsencrypt/boulder/test/vars" +) + +// getPrecertByName finds and parses a precertificate using the given hostname. +// It returns the most recent one. +func getPrecertByName(db *sql.DB, name string) (*x509.Certificate, error) { + name = sa.ReverseName(name) + // Find the certificate from the precertificates table. We don't know the serial so + // we have to look it up by name. + var der []byte + rows, err := db.Query(` + SELECT der + FROM issuedNames JOIN precertificates + USING (serial) + WHERE reversedName = ? + ORDER BY issuedNames.id DESC + LIMIT 1 + `, name) + for rows.Next() { + err = rows.Scan(&der) + if err != nil { + return nil, err + } + } + if der == nil { + return nil, fmt.Errorf("no precertificate found for %q", name) + } + + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + + return cert, nil +} + +// expectOCSP500 queries OCSP for the given certificate and expects a 500 error. +func expectOCSP500(cert *x509.Certificate) error { + _, err := ocsp_helper.Req(cert, ocsp_helper.DefaultConfig) + if err == nil { + return errors.New("Expected error getting OCSP for certificate that failed status storage") + } + + var statusCodeError ocsp_helper.StatusCodeError + if !errors.As(err, &statusCodeError) { + return fmt.Errorf("Got wrong kind of error for OCSP. Expected status code error, got %s", err) + } else if statusCodeError.Code != 500 { + return fmt.Errorf("Got wrong error status for OCSP. Expected 500, got %d", statusCodeError.Code) + } + return nil +} + +// TestIssuanceCertStorageFailed tests what happens when a storage RPC fails +// during issuance. Specifically, it tests that case where we successfully +// prepared and stored a linting certificate plus metadata, but after +// issuing the precertificate we failed to mark the certificate as "ready" +// to serve an OCSP "good" response. +// +// To do this, we need to mess with the database, because we want to cause +// a failure in one specific query, without control ever returning to the +// client. Fortunately we can do this with MySQL triggers. +// +// We also want to make sure we can revoke the precertificate, which we will +// assume exists (note that this different from the root program assumption +// that a final certificate exists for any precertificate, though it is +// similar in spirit). +func TestIssuanceCertStorageFailed(t *testing.T) { + t.Parallel() + os.Setenv("DIRECTORY", "http://boulder.service.consul:4001/directory") + + ctx := context.Background() + + // This test is gated on the StoreLintingCertificateInsteadOfPrecertificate + // feature flag. + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Skipping test because it requires the StoreLintingCertificateInsteadOfPrecertificate feature flag") + } + + db, err := sql.Open("mysql", vars.DBConnSAIntegrationFullPerms) + test.AssertNotError(t, err, "failed to open db connection") + + _, err = db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`) + test.AssertNotError(t, err, "failed to drop trigger") + + // Make a specific update to certificateStatus fail, for this test but not others. + // To limit the effect to this one test, we make the trigger aware of a specific + // hostname used in this test. Since the UPDATE to the certificateStatus table + // doesn't include the hostname, we look it up in the issuedNames table, keyed + // off of the serial being updated. + // We limit this to UPDATEs that set the status to "good" because otherwise we + // would fail to revoke the certificate later. + // NOTE: CREATE and DROP TRIGGER do not work in prepared statements. Go's + // database/sql will automatically try to use a prepared statement if you pass + // any arguments to Exec besides the query itself, so don't do that. + _, err = db.ExecContext(ctx, ` + CREATE TRIGGER fail_ready + BEFORE UPDATE ON certificateStatus + FOR EACH ROW BEGIN + DECLARE reversedName1 VARCHAR(255); + SELECT reversedName + INTO reversedName1 + FROM issuedNames + WHERE serial = NEW.serial + AND reversedName LIKE "com.wantserror.%"; + IF NEW.status = "good" AND reversedName1 != "" THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Pretend there was an error updating the certificateStatus'; + END IF; + END + `) + test.AssertNotError(t, err, "failed to create trigger") + + defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`) + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // ---- Test revocation by serial ---- + revokeMeDomain := "revokeme.wantserror.com" + // This should fail because the trigger prevented setting the certificate status to "ready" + _, err = authAndIssue(nil, certKey, []string{revokeMeDomain}, true) + test.AssertError(t, err, "expected authAndIssue to fail") + + cert, err := getPrecertByName(db, revokeMeDomain) + test.AssertNotError(t, err, "failed to get certificate by name") + + err = expectOCSP500(cert) + test.AssertNotError(t, err, "expected 500 error from OCSP") + + // Revoke by invoking admin-revoker + config := fmt.Sprintf("%s/%s", os.Getenv("BOULDER_CONFIG_DIR"), "admin.json") + output, err := exec.Command( + "./bin/admin", + "-config", config, + "-dry-run=false", + "revoke-cert", + "-serial", core.SerialToString(cert.SerialNumber), + "-reason", "unspecified", + ).CombinedOutput() + test.AssertNotError(t, err, fmt.Sprintf("revoking via admin-revoker: %s", string(output))) + + _, err = ocsp_helper.Req(cert, + ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.Unspecified)) + + // ---- Test revocation by key ---- + blockMyKeyDomain := "blockmykey.wantserror.com" + // This should fail because the trigger prevented setting the certificate status to "ready" + _, err = authAndIssue(nil, certKey, []string{blockMyKeyDomain}, true) + test.AssertError(t, err, "expected authAndIssue to fail") + + cert, err = getPrecertByName(db, blockMyKeyDomain) + test.AssertNotError(t, err, "failed to get certificate by name") + + err = expectOCSP500(cert) + test.AssertNotError(t, err, "expected 500 error from OCSP") + + // Time to revoke! We'll do it by creating a different, successful certificate + // with the same key, then revoking that certificate for keyCompromise. + revokeClient, err := makeClient() + test.AssertNotError(t, err, "creating second acme client") + res, err := authAndIssue(nil, certKey, []string{random_domain()}, true) + test.AssertNotError(t, err, "issuing second cert") + + successfulCert := res.certs[0] + err = revokeClient.RevokeCertificate( + revokeClient.Account, + successfulCert, + certKey, + 1, + ) + test.AssertNotError(t, err, "revoking second certificate") + + for range 300 { + _, err = ocsp_helper.Req(successfulCert, + ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)) + if err == nil { + break + } + time.Sleep(15 * time.Millisecond) + } + test.AssertNotError(t, err, "expected status to eventually become revoked") + + // Try to issue again with the same key, expecting an error because of the key is blocked. + _, err = authAndIssue(nil, certKey, []string{"123.example.com"}, true) + test.AssertError(t, err, "expected authAndIssue to fail") + if !strings.Contains(err.Error(), "public key is forbidden") { + t.Errorf("expected issuance to be rejected with a bad pubkey") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/common_mock.go b/third-party/github.com/letsencrypt/boulder/test/integration/common_mock.go new file mode 100644 index 000000000..87fe6e42b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/common_mock.go @@ -0,0 +1,101 @@ +//go:build integration + +package integration + +import ( + "bytes" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + berrors "github.com/letsencrypt/boulder/errors" +) + +var ctSrvPorts = []int{4600, 4601, 4602, 4603, 4604, 4605, 4606, 4607, 4608, 4609} + +// ctAddRejectHost adds a domain to all of the CT test server's reject-host +// lists. If this fails the test is aborted with a fatal error. +func ctAddRejectHost(domain string) error { + for _, port := range ctSrvPorts { + url := fmt.Sprintf("http://boulder.service.consul:%d/add-reject-host", port) + body := []byte(fmt.Sprintf(`{"host": %q}`, domain)) + resp, err := http.Post(url, "", bytes.NewBuffer(body)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("adding reject host: %d", resp.StatusCode) + } + resp.Body.Close() + } + return nil +} + +// ctGetRejections returns a slice of base64 encoded certificates that were +// rejected by the CT test server at the specified port or an error. +func ctGetRejections(port int) ([]string, error) { + url := fmt.Sprintf("http://boulder.service.consul:%d/get-rejections", port) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "getting rejections: status %d", resp.StatusCode) + } + var rejections []string + err = json.NewDecoder(resp.Body).Decode(&rejections) + if err != nil { + return nil, err + } + return rejections, nil +} + +// ctFindRejection returns a parsed x509.Certificate matching the given domains +// from the base64 certificates any CT test server rejected. If no rejected +// certificate matching the provided domains is found an error is returned. +func ctFindRejection(domains []string) (*x509.Certificate, error) { + // Collect up rejections from all of the ctSrvPorts + var rejections []string + for _, port := range ctSrvPorts { + r, err := ctGetRejections(port) + if err != nil { + continue + } + rejections = append(rejections, r...) + } + + // Parse each rejection cert + var cert *x509.Certificate +RejectionLoop: + for _, r := range rejections { + precertDER, err := base64.StdEncoding.DecodeString(r) + if err != nil { + return nil, err + } + c, err := x509.ParseCertificate(precertDER) + if err != nil { + return nil, err + } + // If the cert doesn't have the right number of names it won't be a match. + if len(c.DNSNames) != len(domains) { + continue + } + // If any names don't match, it isn't a match + for i, name := range c.DNSNames { + if name != domains[i] { + continue RejectionLoop + } + } + // It's a match! + cert = c + break + } + if cert == nil { + return nil, berrors.NotFoundError("no matching ct-test-srv rejection found") + } + return cert, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/common_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/common_test.go new file mode 100644 index 000000000..8b78a9fbf --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/common_test.go @@ -0,0 +1,219 @@ +//go:build integration + +package integration + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "fmt" + "net/http" + "os" + + "github.com/eggsampler/acme/v3" +) + +func init() { + // Go tests get run in the directory their source code lives in. For these + // test cases, that would be "test/integration." However, it's easier to + // reference test data and config files for integration tests relative to the + // root of the Boulder repo, so we run all of these tests from there instead. + os.Chdir("../../") +} + +var ( + OIDExtensionCTPoison = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} +) + +func random_domain() string { + var bytes [3]byte + rand.Read(bytes[:]) + return hex.EncodeToString(bytes[:]) + ".com" +} + +type client struct { + acme.Account + acme.Client +} + +func makeClient(contacts ...string) (*client, error) { + c, err := acme.NewClient("http://boulder.service.consul:4001/directory") + if err != nil { + return nil, fmt.Errorf("Error connecting to acme directory: %v", err) + } + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("error creating private key: %v", err) + } + account, err := c.NewAccount(privKey, false, true, contacts...) + if err != nil { + return nil, err + } + return &client{account, c}, nil +} + +func addHTTP01Response(token, keyAuthorization string) error { + resp, err := http.Post("http://boulder.service.consul:8055/add-http01", "", + bytes.NewBufferString(fmt.Sprintf(`{ + "token": "%s", + "content": "%s" + }`, token, keyAuthorization))) + if err != nil { + return fmt.Errorf("adding http-01 response: %s", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("adding http-01 response: status %d", resp.StatusCode) + } + resp.Body.Close() + return nil +} + +func delHTTP01Response(token string) error { + resp, err := http.Post("http://boulder.service.consul:8055/del-http01", "", + bytes.NewBufferString(fmt.Sprintf(`{ + "token": "%s" + }`, token))) + if err != nil { + return fmt.Errorf("deleting http-01 response: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("deleting http-01 response: status %d", resp.StatusCode) + } + return nil +} + +func makeClientAndOrder(c *client, csrKey *ecdsa.PrivateKey, domains []string, cn bool, certToReplace *x509.Certificate) (*client, *acme.Order, error) { + var err error + if c == nil { + c, err = makeClient() + if err != nil { + return nil, nil, err + } + } + + var ids []acme.Identifier + for _, domain := range domains { + ids = append(ids, acme.Identifier{Type: "dns", Value: domain}) + } + var order acme.Order + if certToReplace != nil { + order, err = c.Client.ReplacementOrder(c.Account, certToReplace, ids) + } else { + order, err = c.Client.NewOrder(c.Account, ids) + } + if err != nil { + return nil, nil, err + } + + for _, authUrl := range order.Authorizations { + auth, err := c.Client.FetchAuthorization(c.Account, authUrl) + if err != nil { + return nil, nil, fmt.Errorf("fetching authorization at %s: %s", authUrl, err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeHTTP01] + if !ok { + return nil, nil, fmt.Errorf("no HTTP challenge at %s", authUrl) + } + + err = addHTTP01Response(chal.Token, chal.KeyAuthorization) + if err != nil { + return nil, nil, fmt.Errorf("adding HTTP-01 response: %s", err) + } + chal, err = c.Client.UpdateChallenge(c.Account, chal) + if err != nil { + delHTTP01Response(chal.Token) + return nil, nil, fmt.Errorf("updating challenge: %s", err) + } + delHTTP01Response(chal.Token) + } + + csr, err := makeCSR(csrKey, domains, cn) + if err != nil { + return nil, nil, err + } + + order, err = c.Client.FinalizeOrder(c.Account, order, csr) + if err != nil { + return nil, nil, fmt.Errorf("finalizing order: %s", err) + } + + return c, &order, nil +} + +type issuanceResult struct { + acme.Order + certs []*x509.Certificate +} + +func authAndIssue(c *client, csrKey *ecdsa.PrivateKey, domains []string, cn bool) (*issuanceResult, error) { + var err error + + c, order, err := makeClientAndOrder(c, csrKey, domains, cn, nil) + if err != nil { + return nil, err + } + + certs, err := c.Client.FetchCertificates(c.Account, order.Certificate) + if err != nil { + return nil, fmt.Errorf("fetching certificates: %s", err) + } + return &issuanceResult{*order, certs}, nil +} + +type issuanceResultAllChains struct { + acme.Order + certs map[string][]*x509.Certificate +} + +func authAndIssueFetchAllChains(c *client, csrKey *ecdsa.PrivateKey, domains []string, cn bool) (*issuanceResultAllChains, error) { + c, order, err := makeClientAndOrder(c, csrKey, domains, cn, nil) + if err != nil { + return nil, err + } + + // Retrieve all the certificate chains served by the WFE2. + certs, err := c.Client.FetchAllCertificates(c.Account, order.Certificate) + if err != nil { + return nil, fmt.Errorf("fetching certificates: %s", err) + } + + return &issuanceResultAllChains{*order, certs}, nil +} + +func makeCSR(k *ecdsa.PrivateKey, domains []string, cn bool) (*x509.CertificateRequest, error) { + var err error + if k == nil { + k, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating certificate key: %s", err) + } + } + + tmpl := &x509.CertificateRequest{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + PublicKeyAlgorithm: x509.ECDSA, + PublicKey: k.Public(), + DNSNames: domains, + } + if cn { + tmpl.Subject = pkix.Name{CommonName: domains[0]} + } + + csrDer, err := x509.CreateCertificateRequest(rand.Reader, tmpl, k) + if err != nil { + return nil, fmt.Errorf("making csr: %s", err) + } + csr, err := x509.ParseCertificateRequest(csrDer) + if err != nil { + return nil, fmt.Errorf("parsing csr: %s", err) + } + return csr, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/crl_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/crl_test.go new file mode 100644 index 000000000..fc7cc28a0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/crl_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package integration + +import ( + "database/sql" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/test/vars" +) + +// runUpdater executes the crl-updater binary with the -runOnce flag, and +// returns when it completes. +func runUpdater(t *testing.T, configFile string) { + t.Helper() + + binPath, err := filepath.Abs("bin/boulder") + test.AssertNotError(t, err, "computing boulder binary path") + + c := exec.Command(binPath, "crl-updater", "-config", configFile, "-debug-addr", ":8022", "-runOnce") + out, err := c.CombinedOutput() + for _, line := range strings.Split(string(out), "\n") { + // Print the updater's stdout for debugging, but only if the test fails. + t.Log(line) + } + test.AssertNotError(t, err, "crl-updater failed") +} + +// TestCRLPipeline runs an end-to-end test of the crl issuance process, ensuring +// that the correct number of properly-formed and validly-signed CRLs are sent +// to our fake S3 service. +func TestCRLPipeline(t *testing.T) { + // Basic setup. + fc := clock.NewFake() + configDir, ok := os.LookupEnv("BOULDER_CONFIG_DIR") + test.Assert(t, ok, "failed to look up test config directory") + configFile := path.Join(configDir, "crl-updater.json") + + // Reset the "leasedUntil" column so that this test isn't dependent on state + // like priors runs of this test. + db, err := sql.Open("mysql", vars.DBConnSAIntegrationFullPerms) + test.AssertNotError(t, err, "opening database connection") + _, err = db.Exec(`UPDATE crlShards SET leasedUntil = ?`, fc.Now().Add(-time.Minute)) + test.AssertNotError(t, err, "resetting leasedUntil column") + + // Issue a test certificate and save its serial number. + client, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + res, err := authAndIssue(client, nil, []string{random_domain()}, true) + test.AssertNotError(t, err, "failed to create test certificate") + cert := res.certs[0] + serial := core.SerialToString(cert.SerialNumber) + + // Confirm that the cert does not yet show up as revoked in the CRLs. + runUpdater(t, configFile) + resp, err := http.Get("http://localhost:4501/query?serial=" + serial) + test.AssertNotError(t, err, "s3-test-srv GET /query failed") + test.AssertEquals(t, resp.StatusCode, 404) + resp.Body.Close() + + // Revoke the certificate. + err = client.RevokeCertificate(client.Account, cert, client.PrivateKey, 5) + test.AssertNotError(t, err, "failed to revoke test certificate") + + // Reset the "leasedUntil" column to prepare for another round of CRLs. + _, err = db.Exec(`UPDATE crlShards SET leasedUntil = ?`, fc.Now().Add(-time.Minute)) + test.AssertNotError(t, err, "resetting leasedUntil column") + + // Confirm that the cert now *does* show up in the CRLs. + runUpdater(t, configFile) + resp, err = http.Get("http://localhost:4501/query?serial=" + serial) + test.AssertNotError(t, err, "s3-test-srv GET /query failed") + test.AssertEquals(t, resp.StatusCode, 200) + + // Confirm that the revoked certificate entry has the correct reason. + reason, err := io.ReadAll(resp.Body) + test.AssertNotError(t, err, "reading revocation reason") + test.AssertEquals(t, string(reason), "5") + resp.Body.Close() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/errors_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/errors_test.go new file mode 100644 index 000000000..0c71bdb72 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/errors_test.go @@ -0,0 +1,185 @@ +//go:build integration + +package integration + +import ( + "fmt" + "strings" + "testing" + + "github.com/eggsampler/acme/v3" + + "github.com/letsencrypt/boulder/test" +) + +// TestTooBigOrderError tests that submitting an order with more than 100 names +// produces the expected problem result. +func TestTooBigOrderError(t *testing.T) { + t.Parallel() + + var domains []string + for i := range 101 { + domains = append(domains, fmt.Sprintf("%d.example.com", i)) + } + + _, err := authAndIssue(nil, nil, domains, true) + test.AssertError(t, err, "authAndIssue failed") + + var prob acme.Problem + test.AssertErrorWraps(t, err, &prob) + test.AssertEquals(t, prob.Type, "urn:ietf:params:acme:error:malformed") + test.AssertEquals(t, prob.Detail, "Order cannot contain more than 100 DNS names") +} + +// TestAccountEmailError tests that registering a new account, or updating an +// account, with invalid contact information produces the expected problem +// result to ACME clients. +func TestAccountEmailError(t *testing.T) { + t.Parallel() + + // The registrations.contact field is VARCHAR(191). 175 'a' characters plus + // the prefix "mailto:" and the suffix "@a.com" makes exactly 191 bytes of + // encoded JSON. The correct size to hit our maximum DB field length. + var longStringBuf strings.Builder + longStringBuf.WriteString("mailto:") + for range 175 { + longStringBuf.WriteRune('a') + } + longStringBuf.WriteString("@a.com") + + createErrorPrefix := "Error creating new account :: " + updateErrorPrefix := "Unable to update account :: " + + testCases := []struct { + name string + contacts []string + expectedProbType string + expectedProbDetail string + }{ + { + name: "empty contact", + contacts: []string{"mailto:valid@valid.com", ""}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `empty contact`, + }, + { + name: "empty proto", + contacts: []string{"mailto:valid@valid.com", " "}, + expectedProbType: "urn:ietf:params:acme:error:unsupportedContact", + expectedProbDetail: `contact method "" is not supported`, + }, + { + name: "empty mailto", + contacts: []string{"mailto:valid@valid.com", "mailto:"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `"" is not a valid e-mail address`, + }, + { + name: "non-ascii mailto", + contacts: []string{"mailto:valid@valid.com", "mailto:cpu@l̴etsencrypt.org"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `contact email ["mailto:cpu@l̴etsencrypt.org"] contains non-ASCII characters`, + }, + { + name: "too many contacts", + contacts: []string{"a", "b", "c", "d"}, + expectedProbType: "urn:ietf:params:acme:error:malformed", + expectedProbDetail: `too many contacts provided: 4 > 3`, + }, + { + name: "invalid contact", + contacts: []string{"mailto:valid@valid.com", "mailto:a@"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `"a@" is not a valid e-mail address`, + }, + { + name: "forbidden contact domain", + contacts: []string{"mailto:valid@valid.com", "mailto:a@example.com"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: "invalid contact domain. Contact emails @example.com are forbidden", + }, + { + name: "contact domain invalid TLD", + contacts: []string{"mailto:valid@valid.com", "mailto:a@example.cpu"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `contact email "a@example.cpu" has invalid domain : Domain name does not end with a valid public suffix (TLD)`, + }, + { + name: "contact domain invalid", + contacts: []string{"mailto:valid@valid.com", "mailto:a@example./.com"}, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: "contact email \"a@example./.com\" has invalid domain : Domain name contains an invalid character", + }, + { + name: "too long contact", + contacts: []string{ + longStringBuf.String(), + }, + expectedProbType: "urn:ietf:params:acme:error:invalidContact", + expectedProbDetail: `too many/too long contact(s). Please use shorter or fewer email addresses`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // First try registering a new account and ensuring the expected problem occurs + var prob acme.Problem + _, err := makeClient(tc.contacts...) + if err != nil { + test.AssertErrorWraps(t, err, &prob) + test.AssertEquals(t, prob.Type, tc.expectedProbType) + test.AssertEquals(t, prob.Detail, createErrorPrefix+tc.expectedProbDetail) + } else { + t.Errorf("expected %s type problem for %q, got nil", + tc.expectedProbType, strings.Join(tc.contacts, ",")) + } + + // Next try making a client with a good contact and updating with the test + // case contact info. The same problem should occur. + c, err := makeClient("mailto:valid@valid.com") + test.AssertNotError(t, err, "failed to create account with valid contact") + _, err = c.UpdateAccount(c.Account, tc.contacts...) + if err != nil { + test.AssertErrorWraps(t, err, &prob) + test.AssertEquals(t, prob.Type, tc.expectedProbType) + test.AssertEquals(t, prob.Detail, updateErrorPrefix+tc.expectedProbDetail) + } else { + t.Errorf("expected %s type problem after updating account to %q, got nil", + tc.expectedProbType, strings.Join(tc.contacts, ",")) + } + }) + } +} + +func TestRejectedIdentifier(t *testing.T) { + t.Parallel() + + // When a single malformed name is provided, we correctly reject it. + domains := []string{ + "яџ–Х6яяdь}", + } + _, err := authAndIssue(nil, nil, domains, true) + test.AssertError(t, err, "issuance should fail for one malformed name") + var prob acme.Problem + test.AssertErrorWraps(t, err, &prob) + test.AssertEquals(t, prob.Type, "urn:ietf:params:acme:error:rejectedIdentifier") + test.AssertContains(t, prob.Detail, "Domain name contains an invalid character") + + // When multiple malformed names are provided, we correctly reject all of + // them and reflect this in suberrors. This test ensures that the way we + // encode these errors across the gRPC boundary is resilient to non-ascii + // characters. + domains = []string{ + "˜o-", + "ш№Ў", + "р±y", + "яџ–Х6яя", + "яџ–Х6яя`ь", + } + _, err = authAndIssue(nil, nil, domains, true) + test.AssertError(t, err, "issuance should fail for multiple malformed names") + test.AssertErrorWraps(t, err, &prob) + test.AssertEquals(t, prob.Type, "urn:ietf:params:acme:error:rejectedIdentifier") + test.AssertContains(t, prob.Detail, "Domain name contains an invalid character") + test.AssertContains(t, prob.Detail, "and 4 more problems") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/issuance_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/issuance_test.go new file mode 100644 index 000000000..4eb93d7e1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/issuance_test.go @@ -0,0 +1,106 @@ +//go:build integration + +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +// TestCommonNameInCSR ensures that CSRs which have a CN set result in certs +// with the same CN set. +func TestCommonNameInCSR(t *testing.T) { + t.Parallel() + + // Create an account. + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + // Create a private key. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Put together some names. + cn := random_domain() + san1 := random_domain() + san2 := random_domain() + + // Issue a cert. authAndIssue includes the 0th name as the CN by default. + ir, err := authAndIssue(client, key, []string{cn, san1, san2}, true) + test.AssertNotError(t, err, "failed to issue test cert") + cert := ir.certs[0] + + // Ensure that the CN is incorporated into the SANs. + test.AssertSliceContains(t, cert.DNSNames, cn) + test.AssertSliceContains(t, cert.DNSNames, san1) + test.AssertSliceContains(t, cert.DNSNames, san2) + + // Ensure that the CN is preserved as the CN. + test.AssertEquals(t, cert.Subject.CommonName, cn) +} + +// TestFirstCSRSANHoistedToCN ensures that CSRs which have no CN set result in +// certs with the first CSR SAN hoisted into the CN field. +func TestFirstCSRSANHoistedToCN(t *testing.T) { + t.Parallel() + + // Create an account. + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + // Create a private key. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Create some names that we can sort. + san1 := "a" + random_domain() + san2 := "b" + random_domain() + + // Issue a cert using a CSR with no CN set, and the SANs in *non*-alpha order. + ir, err := authAndIssue(client, key, []string{san2, san1}, false) + test.AssertNotError(t, err, "failed to issue test cert") + cert := ir.certs[0] + + // Ensure that the SANs are correct, and sorted alphabetically. + test.AssertEquals(t, cert.DNSNames[0], san1) + test.AssertEquals(t, cert.DNSNames[1], san2) + + // Ensure that the first SAN from the CSR is the CN. + test.Assert(t, cert.Subject.CommonName == san2, "first SAN should have been hoisted") +} + +// TestCommonNameSANsTooLong tests that, when the names in an order and CSR are +// too long to be hoisted into the CN, the correct behavior results (depending +// on the state of the AllowNoCommonName feature flag). +func TestCommonNameSANsTooLong(t *testing.T) { + t.Parallel() + + // Create an account. + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + // Create a private key. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Put together some names. + san1 := fmt.Sprintf("thisdomainnameis.morethan64characterslong.forthesakeoftesting.%s", random_domain()) + san2 := fmt.Sprintf("thisdomainnameis.morethan64characterslong.forthesakeoftesting.%s", random_domain()) + + // Issue a cert using a CSR with no CN set. + ir, err := authAndIssue(client, key, []string{san1, san2}, false) + test.AssertNotError(t, err, "failed to issue test cert") + cert := ir.certs[0] + + // Ensure that the SANs are correct. + test.AssertSliceContains(t, cert.DNSNames, san1) + test.AssertSliceContains(t, cert.DNSNames, san2) + + // Ensure that the CN is empty. + test.AssertEquals(t, cert.Subject.CommonName, "") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/key_rollover_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/key_rollover_test.go new file mode 100644 index 000000000..1873864e3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/key_rollover_test.go @@ -0,0 +1,47 @@ +//go:build integration + +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/eggsampler/acme/v3" + "github.com/letsencrypt/boulder/test" +) + +// TestAccountKeyChange tests that the whole account key rollover process works, +// including between different kinds of keys. +func TestAccountKeyChange(t *testing.T) { + t.Parallel() + + c, err := acme.NewClient("http://boulder.service.consul:4001/directory") + test.AssertNotError(t, err, "creating client") + + // We could test all five key types (RSA 2048, 3072, and 4096, and ECDSA P-256 + // and P-384) supported by go-jose and goodkey, but doing so results in a very + // slow integration test. Instead, just test rollover once in each direction, + // ECDSA->RSA and vice versa. + key1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating P-256 account key") + + acct1, err := c.NewAccount(key1, false, true) + test.AssertNotError(t, err, "creating account") + + key2, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "creating RSA 2048 account key") + + acct2, err := c.AccountKeyChange(acct1, key2) + test.AssertNotError(t, err, "rolling over account key") + test.AssertEquals(t, acct2.URL, acct1.URL) + + key3, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + test.AssertNotError(t, err, "creating P-384 account key") + + acct3, err := c.AccountKeyChange(acct1, key3) + test.AssertNotError(t, err, "rolling over account key") + test.AssertEquals(t, acct3.URL, acct1.URL) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/nonce_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/nonce_test.go new file mode 100644 index 000000000..58a576f58 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/nonce_test.go @@ -0,0 +1,68 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + bgrpc "github.com/letsencrypt/boulder/grpc" + nb "github.com/letsencrypt/boulder/grpc/noncebalancer" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/nonce" + noncepb "github.com/letsencrypt/boulder/nonce/proto" + "github.com/letsencrypt/boulder/test" + "google.golang.org/grpc/status" +) + +type nonceBalancerTestConfig struct { + NotWFE struct { + TLS cmd.TLSConfig + GetNonceService *cmd.GRPCClientConfig + RedeemNonceService *cmd.GRPCClientConfig + NoncePrefixKey cmd.PasswordConfig + } +} + +func TestNonceBalancer_NoBackendMatchingPrefix(t *testing.T) { + t.Parallel() + + if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + t.Skip("Derived nonce prefixes are only configured in config-next") + } + + // We're going to use a minimal nonce service client called "notwfe" which + // masquerades as a wfe for the purpose of redeeming nonces. + + // Load the test config. + var c nonceBalancerTestConfig + err := cmd.ReadConfigFile("test/integration/testdata/nonce-client.json", &c) + test.AssertNotError(t, err, "Could not read config file") + + tlsConfig, err := c.NotWFE.TLS.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Could not load TLS config") + + rncKey, err := c.NotWFE.NoncePrefixKey.Pass() + test.AssertNotError(t, err, "Failed to load noncePrefixKey") + + clk := clock.New() + + redeemNonceConn, err := bgrpc.ClientSetup(c.NotWFE.RedeemNonceService, tlsConfig, metrics.NoopRegisterer, clk) + test.AssertNotError(t, err, "Failed to load credentials and create gRPC connection to redeem nonce service") + rnc := nonce.NewRedeemer(redeemNonceConn) + + // Attempt to redeem a nonce with a prefix that doesn't match any backends. + ctx := context.WithValue(context.Background(), nonce.PrefixCtxKey{}, "12345678") + ctx = context.WithValue(ctx, nonce.HMACKeyCtxKey{}, rncKey) + _, err = rnc.Redeem(ctx, &noncepb.NonceMessage{Nonce: "0123456789"}) + + // We expect to get a specific gRPC status error with code NotFound. + gotRPCStatus, ok := status.FromError(err) + test.Assert(t, ok, "Failed to convert error to status") + test.AssertEquals(t, gotRPCStatus, nb.ErrNoBackendsMatchPrefix) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/ocsp_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/ocsp_test.go new file mode 100644 index 000000000..8da548b30 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/ocsp_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package integration + +import ( + "strings" + "testing" + + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +// TODO(#5172): Fill out these test stubs. +func TestOCSPBadRequestMethod(t *testing.T) { + return +} + +func TestOCSPBadGetUrl(t *testing.T) { + return +} + +func TestOCSPBadGetBody(t *testing.T) { + return +} + +func TestOCSPBadPostBody(t *testing.T) { + return +} + +func TestOCSPBadHashAlgorithm(t *testing.T) { + return +} + +func TestOCSPBadIssuerCert(t *testing.T) { + return +} + +func TestOCSPBadSerialPrefix(t *testing.T) { + t.Parallel() + domain := random_domain() + res, err := authAndIssue(nil, nil, []string{domain}, true) + if err != nil || len(res.certs) < 1 { + t.Fatal("Failed to issue dummy cert for OCSP testing") + } + cert := res.certs[0] + // Increment the first byte of the cert's serial number by 1, making the + // prefix invalid. This works because ocsp_helper.Req (and the underlying + // ocsp.CreateRequest) completely ignore the cert's .Raw value. + serialStr := []byte(core.SerialToString(cert.SerialNumber)) + serialStr[0] = serialStr[0] + 1 + cert.SerialNumber.SetString(string(serialStr), 16) + _, err = ocsp_helper.Req(cert, ocsp_helper.DefaultConfig) + if err == nil { + t.Fatal("Expected error getting OCSP for request with invalid serial") + } +} + +func TestOCSPNonexistentSerial(t *testing.T) { + return +} + +func TestOCSPExpiredCert(t *testing.T) { + return +} + +func TestOCSPRejectedPrecertificate(t *testing.T) { + t.Parallel() + domain := random_domain() + err := ctAddRejectHost(domain) + if err != nil { + t.Fatalf("adding ct-test-srv reject host: %s", err) + } + + _, err = authAndIssue(nil, nil, []string{domain}, true) + if err != nil { + if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:serverInternal") || + !strings.Contains(err.Error(), "SCT embedding") { + t.Fatal(err) + } + } + if err == nil { + t.Fatal("expected error issuing for domain rejected by CT servers; got none") + } + + // Try to find a precertificate matching the domain from one of the + // configured ct-test-srv instances. + cert, err := ctFindRejection([]string{domain}) + if err != nil || cert == nil { + t.Fatalf("couldn't find rejected precert for %q", domain) + } + + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + if err != nil { + t.Errorf("requesting OCSP for rejected precertificate: %s", err) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/otel_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/otel_test.go new file mode 100644 index 000000000..b0d020c59 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/otel_test.go @@ -0,0 +1,309 @@ +//go:build integration + +package integration + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/eggsampler/acme/v3" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" +) + +// TraceResponse is the list of traces returned from Jaeger's trace search API +// We always search for a single trace by ID, so this should be length 1. +// This is a specialization of Jaeger's structuredResponse type which +// uses []interface{} upstream. +type TraceResponse struct { + Data []Trace +} + +// Trace represents a single trace in Jaeger's API +// See https://pkg.go.dev/github.com/jaegertracing/jaeger/model/json#Trace +type Trace struct { + TraceID string + Spans []Span + Processes map[string]struct { + ServiceName string + } + Warnings []string +} + +// Span represents a single span in Jaeger's API +// See https://pkg.go.dev/github.com/jaegertracing/jaeger/model/json#Span +type Span struct { + SpanID string + OperationName string + Warnings []string + ProcessID string + References []struct { + RefType string + TraceID string + SpanID string + } +} + +func getTraceFromJaeger(t *testing.T, traceID trace.TraceID) Trace { + t.Helper() + traceURL := "http://bjaeger:16686/api/traces/" + traceID.String() + resp, err := http.Get(traceURL) + test.AssertNotError(t, err, "failed to trace from jaeger: "+traceID.String()) + if resp.StatusCode == http.StatusNotFound { + t.Fatalf("jaeger returned 404 for trace %s", traceID) + } + test.AssertEquals(t, resp.StatusCode, http.StatusOK) + + body, err := io.ReadAll(resp.Body) + test.AssertNotError(t, err, "failed to read trace body") + + var parsed TraceResponse + err = json.Unmarshal(body, &parsed) + test.AssertNotError(t, err, "failed to decode traces body") + + if len(parsed.Data) != 1 { + t.Fatalf("expected to get exactly one trace from jaeger for %s: %v", traceID, parsed) + } + + return parsed.Data[0] +} + +type expectedSpans struct { + Operation string + Service string + Children []expectedSpans +} + +// isParent returns true if the given span has a parent of ParentID +// The empty string means no ParentID +func isParent(parentID string, span Span) bool { + if len(span.References) == 0 { + return parentID == "" + } + for _, ref := range span.References { + // In OpenTelemetry, CHILD_OF is the only reference, but Jaeger supports other systems. + if ref.RefType == "CHILD_OF" { + return ref.SpanID == parentID + } + } + return false +} + +func missingChildren(trace Trace, spanID string, children []expectedSpans) bool { + for _, child := range children { + if !findSpans(trace, spanID, child) { + // Missing Child + return true + } + } + return false +} + +// findSpans checks if the expectedSpan and its expected children are found in trace +func findSpans(trace Trace, parentSpan string, expectedSpan expectedSpans) bool { + for _, span := range trace.Spans { + if !isParent(parentSpan, span) { + continue + } + if trace.Processes[span.ProcessID].ServiceName != expectedSpan.Service { + continue + } + if span.OperationName != expectedSpan.Operation { + continue + } + if missingChildren(trace, span.SpanID, expectedSpan.Children) { + continue + } + + // This span has the correct parent, service, operation, and children + return true + } + fmt.Printf("did not find span %s::%s with parent '%s'\n", expectedSpan.Service, expectedSpan.Operation, parentSpan) + return false +} + +// ContextInjectingRoundTripper holds a context that is added to every request +// sent through this RoundTripper, propagating the OpenTelemetry trace through +// the requests made with it. +// +// This is useful for tracing HTTP clients which don't pass through a context, +// notably including the eggsampler ACME client used in this test. +// +// This test uses a trace started in the test to connect all the outgoing +// requests into a trace that is retrieved from Jaeger's API to make assertions +// about the spans from Boulder. +type ContextInjectingRoundTripper struct { + ctx context.Context +} + +// RoundTrip implements http.RoundTripper, injecting c.ctx and the OpenTelemetry +// propagation headers into the request. This ensures all requests are traced. +func (c *ContextInjectingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + // RoundTrip is not permitted to modify the request, so we clone with this context + r := request.Clone(c.ctx) + // Inject the otel propagation headers + otel.GetTextMapPropagator().Inject(c.ctx, propagation.HeaderCarrier(r.Header)) + return http.DefaultTransport.RoundTrip(r) +} + +// rpcSpan is a helper for constructing an RPC span where we have both a client and server rpc operation +func rpcSpan(op, client, server string, children ...expectedSpans) expectedSpans { + return expectedSpans{ + Operation: op, + Service: client, + Children: []expectedSpans{ + { + Operation: op, + Service: server, + Children: children, + }, + }, + } +} + +func httpSpan(endpoint string, children ...expectedSpans) expectedSpans { + return expectedSpans{ + Operation: endpoint, + Service: "boulder-wfe2", + Children: append(children, + rpcSpan("nonce.NonceService/Nonce", "boulder-wfe2", "nonce-service"), + rpcSpan("nonce.NonceService/Redeem", "boulder-wfe2", "nonce-service"), + ), + } +} + +// TestTraces tests that all the expected spans are present and properly connected +func TestTraces(t *testing.T) { + t.Parallel() + if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + t.Skip("OpenTelemetry is only configured in config-next") + } + + traceID := traceIssuingTestCert(t) + + wfe := "boulder-wfe2" + sa := "boulder-sa" + ra := "boulder-ra" + ca := "boulder-ca" + + expectedSpans := expectedSpans{ + Operation: "TraceTest", + Service: "integration.test", + Children: []expectedSpans{ + {Operation: "/directory", Service: wfe}, + {Operation: "/acme/new-nonce", Service: wfe, Children: []expectedSpans{ + rpcSpan("nonce.NonceService/Nonce", wfe, "nonce-service")}}, + httpSpan("/acme/new-acct", + rpcSpan("sa.StorageAuthorityReadOnly/KeyBlocked", wfe, sa), + rpcSpan("sa.StorageAuthorityReadOnly/GetRegistrationByKey", wfe, sa), + rpcSpan("ra.RegistrationAuthority/NewRegistration", wfe, ra, + rpcSpan("sa.StorageAuthority/KeyBlocked", ra, sa), + rpcSpan("sa.StorageAuthority/CountRegistrationsByIP", ra, sa), + rpcSpan("sa.StorageAuthority/NewRegistration", ra, sa))), + httpSpan("/acme/new-order", + rpcSpan("sa.StorageAuthorityReadOnly/GetRegistration", wfe, sa), + rpcSpan("ra.RegistrationAuthority/NewOrder", wfe, ra, + rpcSpan("sa.StorageAuthority/GetOrderForNames", ra, sa), + // 8 ra -> sa rate limit spans omitted here + rpcSpan("sa.StorageAuthority/NewOrderAndAuthzs", ra, sa))), + httpSpan("/acme/authz-v3/", + rpcSpan("sa.StorageAuthorityReadOnly/GetAuthorization2", wfe, sa)), + httpSpan("/acme/chall-v3/", + rpcSpan("sa.StorageAuthorityReadOnly/GetAuthorization2", wfe, sa), + rpcSpan("ra.RegistrationAuthority/PerformValidation", wfe, ra, + rpcSpan("sa.StorageAuthority/GetRegistration", ra, sa))), + httpSpan("/acme/finalize/", + rpcSpan("sa.StorageAuthorityReadOnly/GetOrder", wfe, sa), + rpcSpan("ra.RegistrationAuthority/FinalizeOrder", wfe, ra, + rpcSpan("sa.StorageAuthority/KeyBlocked", ra, sa), + rpcSpan("sa.StorageAuthority/GetRegistration", ra, sa), + rpcSpan("sa.StorageAuthority/GetValidOrderAuthorizations2", ra, sa), + rpcSpan("sa.StorageAuthority/SetOrderProcessing", ra, sa), + rpcSpan("ca.CertificateAuthority/IssuePrecertificate", ra, ca), + rpcSpan("Publisher/SubmitToSingleCTWithResult", ra, "boulder-publisher"), + rpcSpan("ca.CertificateAuthority/IssueCertificateForPrecertificate", ra, ca), + rpcSpan("sa.StorageAuthority/FinalizeOrder", ra, sa))), + httpSpan("/acme/order/", rpcSpan("sa.StorageAuthorityReadOnly/GetOrder", wfe, sa)), + httpSpan("/acme/cert/", rpcSpan("sa.StorageAuthorityReadOnly/GetCertificate", wfe, sa)), + }, + } + + // Retry checking for spans. Span submission is batched asynchronously, so we + // may have to wait for the DefaultScheduleDelay (5 seconds) for results to + // be available. Rather than always waiting, we retry a few times. + // Empirically, this test passes on the second or third try. + var trace Trace + found := false + const retries = 10 + for range retries { + trace := getTraceFromJaeger(t, traceID) + if findSpans(trace, "", expectedSpans) { + found = true + break + } + time.Sleep(sdktrace.DefaultScheduleDelay / 5 * time.Millisecond) + } + test.Assert(t, found, fmt.Sprintf("Failed to find expected spans in Jaeger for trace %s", traceID)) + + test.AssertEquals(t, len(trace.Warnings), 0) + for _, span := range trace.Spans { + for _, warning := range span.Warnings { + if strings.Contains(warning, "clock skew adjustment disabled; not applying calculated delta") { + continue + } + t.Errorf("Span %s (%s) warning: %v", span.SpanID, span.OperationName, warning) + } + } +} + +func traceIssuingTestCert(t *testing.T) trace.TraceID { + domains := []string{random_domain()} + + // Configure this integration test to trace to jaeger:4317 like Boulder will + shutdown := cmd.NewOpenTelemetry(cmd.OpenTelemetryConfig{ + Endpoint: "bjaeger:4317", + SampleRatio: 1, + }, blog.Get()) + defer shutdown(context.Background()) + + tracer := otel.GetTracerProvider().Tracer("TraceTest") + ctx, span := tracer.Start(context.Background(), "TraceTest") + defer span.End() + + // Provide an HTTP client with otel spans. + // The acme client doesn't pass contexts through, so we inject one. + option := acme.WithHTTPClient(&http.Client{ + Timeout: 60 * time.Second, + Transport: &ContextInjectingRoundTripper{ctx}, + }) + + c, err := acme.NewClient("http://boulder.service.consul:4001/directory", option) + test.AssertNotError(t, err, "acme.NewClient failed") + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Generating ECDSA key failed") + + account, err := c.NewAccount(privKey, false, true) + test.AssertNotError(t, err, "newAccount failed") + + _, err = authAndIssue(&client{account, c}, nil, domains, true) + test.AssertNotError(t, err, "authAndIssue failed") + + return span.SpanContext().TraceID() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/ratelimit_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/ratelimit_test.go new file mode 100644 index 000000000..88050b6b2 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/ratelimit_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/ratelimits" + bredis "github.com/letsencrypt/boulder/redis" + "github.com/letsencrypt/boulder/test" +) + +func TestDuplicateFQDNRateLimit(t *testing.T) { + t.Parallel() + domain := random_domain() + + _, err := authAndIssue(nil, nil, []string{domain}, true) + test.AssertNotError(t, err, "Failed to issue first certificate") + + _, err = authAndIssue(nil, nil, []string{domain}, true) + test.AssertNotError(t, err, "Failed to issue second certificate") + + _, err = authAndIssue(nil, nil, []string{domain}, true) + test.AssertError(t, err, "Somehow managed to issue third certificate") + + if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + // Setup rate limiting. + rc := bredis.Config{ + Username: "unittest-rw", + TLS: cmd.TLSConfig{ + CACertFile: "test/certs/ipki/minica.pem", + CertFile: "test/certs/ipki/localhost/cert.pem", + KeyFile: "test/certs/ipki/localhost/key.pem", + }, + Lookups: []cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + LookupDNSAuthority: "consul.service.consul", + } + rc.PasswordConfig = cmd.PasswordConfig{ + PasswordFile: "test/secrets/ratelimits_redis_password", + } + + fc := clock.NewFake() + stats := metrics.NoopRegisterer + log := blog.NewMock() + ring, err := bredis.NewRingFromConfig(rc, stats, log) + test.AssertNotError(t, err, "making redis ring client") + source := ratelimits.NewRedisSource(ring.Ring, fc, stats) + test.AssertNotNil(t, source, "source should not be nil") + limiter, err := ratelimits.NewLimiter(fc, source, stats) + test.AssertNotError(t, err, "making limiter") + txnBuilder, err := ratelimits.NewTransactionBuilder("test/config-next/wfe2-ratelimit-defaults.yml", "") + test.AssertNotError(t, err, "making transaction composer") + + // Check that the CertificatesPerFQDNSet limit is reached. + txn, err := txnBuilder.CertificatesPerFQDNSetTransaction([]string{domain}) + test.AssertNotError(t, err, "making transaction") + result, err := limiter.Check(context.Background(), txn) + test.AssertNotError(t, err, "checking transaction") + test.Assert(t, !result.Allowed, "should not be allowed") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/revocation_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/revocation_test.go new file mode 100644 index 000000000..c6ae66d73 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/revocation_test.go @@ -0,0 +1,538 @@ +//go:build integration + +package integration + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/eggsampler/acme/v3" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/test" + ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +// isPrecert returns true if the provided cert has an extension with the OID +// equal to OIDExtensionCTPoison. +func isPrecert(cert *x509.Certificate) bool { + for _, ext := range cert.Extensions { + if ext.Id.Equal(OIDExtensionCTPoison) { + return true + } + } + return false +} + +// TestRevocation tests that a certificate can be revoked using all of the +// RFC 8555 revocation authentication mechanisms. It does so for both certs and +// precerts (with no corresponding final cert), and for both the Unspecified and +// keyCompromise revocation reasons. +func TestRevocation(t *testing.T) { + t.Parallel() + + type authMethod string + var ( + byAccount authMethod = "byAccount" + byAuth authMethod = "byAuth" + byKey authMethod = "byKey" + ) + + type certKind string + var ( + finalcert certKind = "cert" + precert certKind = "precert" + ) + + type testCase struct { + method authMethod + reason int + kind certKind + } + + var testCases []testCase + for _, kind := range []certKind{precert, finalcert} { + for _, reason := range []int{ocsp.Unspecified, ocsp.KeyCompromise} { + for _, method := range []authMethod{byAccount, byAuth, byKey} { + testCases = append(testCases, testCase{ + method: method, + reason: reason, + kind: kind, + // We do not expect any of these revocation requests to error. + // The ones done byAccount will succeed as requested, but will not + // result in the key being blocked for future issuance. + // The ones done byAuth will succeed, but will be overwritten to have + // reason code 5 (cessationOfOperation). + // The ones done byKey will succeed, but will be overwritten to have + // reason code 1 (keyCompromise), and will block the key. + }) + } + } + } + + for _, tc := range testCases { + name := fmt.Sprintf("%s_%d_%s", tc.kind, tc.reason, tc.method) + t.Run(name, func(t *testing.T) { + issueClient, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + domain := random_domain() + + // Try to issue a certificate for the name. + var cert *x509.Certificate + switch tc.kind { + case finalcert: + res, err := authAndIssue(issueClient, certKey, []string{domain}, true) + test.AssertNotError(t, err, "authAndIssue failed") + cert = res.certs[0] + + case precert: + // Make sure the ct-test-srv will reject generating SCTs for the domain, + // so we only get a precert and no final cert. + err := ctAddRejectHost(domain) + test.AssertNotError(t, err, "adding ct-test-srv reject host") + + _, err = authAndIssue(issueClient, certKey, []string{domain}, true) + test.AssertError(t, err, "expected error from authAndIssue, was nil") + if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:serverInternal") || + !strings.Contains(err.Error(), "SCT embedding") { + t.Fatal(err) + } + + // Instead recover the precertificate from CT. + cert, err = ctFindRejection([]string{domain}) + if err != nil || cert == nil { + t.Fatalf("couldn't find rejected precert for %q", domain) + } + // And make sure the cert we found is in fact a precert. + if !isPrecert(cert) { + t.Fatal("precert was missing poison extension") + } + + default: + t.Fatalf("unrecognized cert kind %q", tc.kind) + } + + // Initially, the cert should have a Good OCSP response. + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for precert") + + // Set up the account and key that we'll use to revoke the cert. + var revokeClient *client + var revokeKey crypto.Signer + switch tc.method { + case byAccount: + // When revoking by account, use the same client and key as were used + // for the original issuance. + revokeClient = issueClient + revokeKey = revokeClient.PrivateKey + + case byAuth: + // When revoking by auth, create a brand new client, authorize it for + // the same domain, and use that account and key for revocation. Ignore + // errors from authAndIssue because all we need is the auth, not the + // issuance. + revokeClient, err = makeClient() + test.AssertNotError(t, err, "creating second acme client") + _, _ = authAndIssue(revokeClient, certKey, []string{domain}, true) + revokeKey = revokeClient.PrivateKey + + case byKey: + // When revoking by key, create a brand new client and use it with + // the cert's key for revocation. + revokeClient, err = makeClient() + test.AssertNotError(t, err, "creating second acme client") + revokeKey = certKey + + default: + t.Fatalf("unrecognized revocation method %q", tc.method) + } + + // Revoke the cert using the specified key and client. + err = revokeClient.RevokeCertificate( + revokeClient.Account, + cert, + revokeKey, + tc.reason, + ) + + test.AssertNotError(t, err, "revocation should have succeeded") + + // Check the OCSP response for the certificate again. It should now be + // revoked. If the request was made by demonstrating control over the + // names, the reason should be overwritten to CessationOfOperation (5), + // and if the request was made by key, then the reason should be set to + // KeyCompromise (1). + ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked) + switch tc.method { + case byAuth: + ocspConfig = ocspConfig.WithExpectReason(ocsp.CessationOfOperation) + case byKey: + ocspConfig = ocspConfig.WithExpectReason(ocsp.KeyCompromise) + default: + ocspConfig = ocspConfig.WithExpectReason(tc.reason) + } + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for revoked cert") + }) + } +} + +// TestReRevocation verifies that a certificate can have its revocation +// information updated only when both of the following are true: +// a) The certificate was not initially revoked for reason keyCompromise; and +// b) The second request is authenticated using the cert's keypair. +// In which case the revocation reason (but not revocation date) will be +// updated to be keyCompromise. +func TestReRevocation(t *testing.T) { + t.Parallel() + + type authMethod string + var ( + byAccount authMethod = "byAccount" + byKey authMethod = "byKey" + ) + + type testCase struct { + method1 authMethod + reason1 int + method2 authMethod + reason2 int + expectError bool + } + + testCases := []testCase{ + {method1: byAccount, reason1: 0, method2: byAccount, reason2: 0, expectError: true}, + {method1: byAccount, reason1: 1, method2: byAccount, reason2: 1, expectError: true}, + {method1: byAccount, reason1: 0, method2: byKey, reason2: 1, expectError: false}, + {method1: byAccount, reason1: 1, method2: byKey, reason2: 1, expectError: true}, + {method1: byKey, reason1: 1, method2: byKey, reason2: 1, expectError: true}, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + issueClient, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Try to issue a certificate for the name. + domain := random_domain() + res, err := authAndIssue(issueClient, certKey, []string{domain}, true) + test.AssertNotError(t, err, "authAndIssue failed") + cert := res.certs[0] + + // Initially, the cert should have a Good OCSP response. + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for precert") + + // Set up the account and key that we'll use to revoke the cert. + var revokeClient *client + var revokeKey crypto.Signer + switch tc.method1 { + case byAccount: + // When revoking by account, use the same client and key as were used + // for the original issuance. + revokeClient = issueClient + revokeKey = revokeClient.PrivateKey + + case byKey: + // When revoking by key, create a brand new client and use it with + // the cert's key for revocation. + revokeClient, err = makeClient() + test.AssertNotError(t, err, "creating second acme client") + revokeKey = certKey + + default: + t.Fatalf("unrecognized revocation method %q", tc.method1) + } + + // Revoke the cert using the specified key and client. + err = revokeClient.RevokeCertificate( + revokeClient.Account, + cert, + revokeKey, + tc.reason1, + ) + test.AssertNotError(t, err, "initial revocation should have succeeded") + + // Check the OCSP response for the certificate again. It should now be + // revoked. + ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason1) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for revoked cert") + + // Set up the account and key that we'll use to *re*-revoke the cert. + switch tc.method2 { + case byAccount: + // When revoking by account, use the same client and key as were used + // for the original issuance. + revokeClient = issueClient + revokeKey = revokeClient.PrivateKey + + case byKey: + // When revoking by key, create a brand new client and use it with + // the cert's key for revocation. + revokeClient, err = makeClient() + test.AssertNotError(t, err, "creating second acme client") + revokeKey = certKey + + default: + t.Fatalf("unrecognized revocation method %q", tc.method2) + } + + // Re-revoke the cert using the specified key and client. + err = revokeClient.RevokeCertificate( + revokeClient.Account, + cert, + revokeKey, + tc.reason2, + ) + + switch tc.expectError { + case true: + test.AssertError(t, err, "second revocation should have failed") + + // Check the OCSP response for the certificate again. It should still be + // revoked, with the same reason. + ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason1) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for revoked cert") + + case false: + test.AssertNotError(t, err, "second revocation should have succeeded") + + // Check the OCSP response for the certificate again. It should now be + // revoked with reason keyCompromise. + ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectStatus(tc.reason2) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for revoked cert") + } + }) + } +} + +func TestRevokeWithKeyCompromiseBlocksKey(t *testing.T) { + t.Parallel() + + type authMethod string + var ( + byAccount authMethod = "byAccount" + byKey authMethod = "byKey" + ) + + // Test keyCompromise revocation both when revoking by certificate key and + // revoking by subscriber key. Both should work, although with slightly + // different behavior. + for _, method := range []authMethod{byKey, byAccount} { + c, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate cert key") + + res, err := authAndIssue(c, certKey, []string{random_domain()}, true) + test.AssertNotError(t, err, "authAndIssue failed") + cert := res.certs[0] + + // Revoke the cert with reason keyCompromise, either authenticated via the + // issuing account, or via the certificate key itself. + switch method { + case byAccount: + err = c.RevokeCertificate(c.Account, cert, c.PrivateKey, ocsp.KeyCompromise) + case byKey: + err = c.RevokeCertificate(acme.Account{}, cert, certKey, ocsp.KeyCompromise) + } + test.AssertNotError(t, err, "failed to revoke certificate") + + // Check the OCSP response. It should be revoked with reason = 1 (keyCompromise). + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise) + _, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig) + test.AssertNotError(t, err, "requesting OCSP for revoked cert") + + // Attempt to create a new account using the compromised key. This should + // work when the key was just *reported* as compromised, but fail when + // the compromise was demonstrated/proven. + _, err = c.NewAccount(certKey, false, true) + switch method { + case byAccount: + test.AssertNotError(t, err, "NewAccount failed with a non-blocklisted key") + case byKey: + test.AssertError(t, err, "NewAccount didn't fail with a blocklisted key") + test.AssertEquals(t, err.Error(), `acme: error code 400 "urn:ietf:params:acme:error:badPublicKey": public key is forbidden`) + } + } +} + +func TestBadKeyRevoker(t *testing.T) { + // Both accounts have two email addresses, one of which is shared between + // them. All three addresses should receive mail, because the revocation + // request is signed by the certificate key, not an account key, so we don't + // know who requested the revocation. Finally, a third account with no address + // to ensure the bad-key-revoker handles that gracefully. + revokerClient, err := makeClient("mailto:revoker@letsencrypt.org", "mailto:shared@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + revokeeClient, err := makeClient("mailto:shared@letsencrypt.org", "mailto:revokee@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + noContactClient, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate cert key") + + res, err := authAndIssue(revokerClient, certKey, []string{random_domain()}, true) + test.AssertNotError(t, err, "authAndIssue failed") + badCert := res.certs[0] + t.Logf("Generated to-be-revoked cert with serial %x", badCert.SerialNumber) + + certs := []*x509.Certificate{} + for _, c := range []*client{revokerClient, revokeeClient, noContactClient} { + cert, err := authAndIssue(c, certKey, []string{random_domain()}, true) + t.Logf("TestBadKeyRevoker: Issued cert with serial %x", cert.certs[0].SerialNumber) + test.AssertNotError(t, err, "authAndIssue failed") + certs = append(certs, cert.certs[0]) + } + + err = revokerClient.RevokeCertificate( + acme.Account{}, + badCert, + certKey, + ocsp.KeyCompromise, + ) + test.AssertNotError(t, err, "failed to revoke certificate") + + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise) + _, err = ocsp_helper.ReqDER(badCert.Raw, ocspConfig) + test.AssertNotError(t, err, "ReqDER failed") + + for _, cert := range certs { + for i := range 5 { + t.Logf("TestBadKeyRevoker: Requesting OCSP for cert with serial %x (attempt %d)", cert.SerialNumber, i) + _, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig) + if err != nil { + t.Logf("TestBadKeyRevoker: Got bad response: %s", err.Error()) + if i >= 4 { + t.Fatal("timed out waiting for correct OCSP status") + } + time.Sleep(time.Second) + continue + } + break + } + } + + revokeeCount, err := http.Get("http://boulder.service.consul:9381/count?to=revokee@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = revokeeCount.Body.Close() }() + body, err := io.ReadAll(revokeeCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "1\n") + + revokerCount, err := http.Get("http://boulder.service.consul:9381/count?to=revoker@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = revokerCount.Body.Close() }() + body, err = io.ReadAll(revokerCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "1\n") + + sharedCount, err := http.Get("http://boulder.service.consul:9381/count?to=shared@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = sharedCount.Body.Close() }() + body, err = io.ReadAll(sharedCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "1\n") +} + +func TestBadKeyRevokerByAccount(t *testing.T) { + // Both accounts have two email addresses, one of which is shared between + // them. No accounts should receive any mail, because the revocation request + // is signed by the account key (not the cert key) and so will not be + // propagated to other certs sharing the same key. + revokerClient, err := makeClient("mailto:revoker-moz@letsencrypt.org", "mailto:shared-moz@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + revokeeClient, err := makeClient("mailto:shared-moz@letsencrypt.org", "mailto:revokee-moz@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + noContactClient, err := makeClient() + test.AssertNotError(t, err, "creating acme client") + + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "failed to generate cert key") + + res, err := authAndIssue(revokerClient, certKey, []string{random_domain()}, true) + test.AssertNotError(t, err, "authAndIssue failed") + badCert := res.certs[0] + t.Logf("Generated to-be-revoked cert with serial %x", badCert.SerialNumber) + + certs := []*x509.Certificate{} + for _, c := range []*client{revokerClient, revokeeClient, noContactClient} { + cert, err := authAndIssue(c, certKey, []string{random_domain()}, true) + t.Logf("TestBadKeyRevokerByAccount: Issued cert with serial %x", cert.certs[0].SerialNumber) + test.AssertNotError(t, err, "authAndIssue failed") + certs = append(certs, cert.certs[0]) + } + + err = revokerClient.RevokeCertificate( + revokerClient.Account, + badCert, + revokerClient.PrivateKey, + ocsp.KeyCompromise, + ) + test.AssertNotError(t, err, "failed to revoke certificate") + + ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise) + _, err = ocsp_helper.ReqDER(badCert.Raw, ocspConfig) + test.AssertNotError(t, err, "ReqDER failed") + + ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good) + for _, cert := range certs { + for i := range 5 { + t.Logf("TestBadKeyRevoker: Requesting OCSP for cert with serial %x (attempt %d)", cert.SerialNumber, i) + _, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig) + if err != nil { + t.Logf("TestBadKeyRevoker: Got bad response: %s", err.Error()) + if i >= 4 { + t.Fatal("timed out waiting for correct OCSP status") + } + time.Sleep(time.Second) + continue + } + break + } + } + + revokeeCount, err := http.Get("http://boulder.service.consul:9381/count?to=revokee-moz@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = revokeeCount.Body.Close() }() + body, err := io.ReadAll(revokeeCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "0\n") + + revokerCount, err := http.Get("http://boulder.service.consul:9381/count?to=revoker-moz@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = revokerCount.Body.Close() }() + body, err = io.ReadAll(revokerCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "0\n") + + sharedCount, err := http.Get("http://boulder.service.consul:9381/count?to=shared-moz@letsencrypt.org&from=bad-key-revoker@test.org") + test.AssertNotError(t, err, "mail-test-srv GET /count failed") + defer func() { _ = sharedCount.Body.Close() }() + body, err = io.ReadAll(sharedCount.Body) + test.AssertNotError(t, err, "failed to read body") + test.AssertEquals(t, string(body), "0\n") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/srv_resolver_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/srv_resolver_test.go new file mode 100644 index 000000000..c92575bfb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/srv_resolver_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package integration + +import ( + "context" + "testing" + + "github.com/jmhodges/clock" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/cmd" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/nonce" + "github.com/letsencrypt/boulder/test" +) + +type srvResolverTestConfig struct { + WebFooEnd struct { + TLS cmd.TLSConfig + // CaseOne config will have 2 SRV records. The first will have 0 + // backends, the second will have 1. + CaseOne *cmd.GRPCClientConfig + + // CaseTwo config will have 2 SRV records. The first will not be + // configured in Consul, the second will have 1 backend. + CaseTwo *cmd.GRPCClientConfig + + // CaseThree config will have 2 SRV records. Neither will be configured + // in Consul. + CaseThree *cmd.GRPCClientConfig + + // CaseFour config will have 2 SRV records. Neither will have backends. + CaseFour *cmd.GRPCClientConfig + } +} + +func TestSRVResolver_CaseOne(t *testing.T) { + t.Parallel() + + var c srvResolverTestConfig + err := cmd.ReadConfigFile("test/integration/testdata/srv-resolver-config.json", &c) + test.AssertNotError(t, err, "Could not read config file") + + tlsConfig, err := c.WebFooEnd.TLS.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Could not load TLS config") + clk := clock.New() + + getNonceConn, err := bgrpc.ClientSetup(c.WebFooEnd.CaseOne, tlsConfig, metrics.NoopRegisterer, clk) + test.AssertNotError(t, err, "Could not set up gRPC client") + + // This should succeed, even though the first SRV record has no backends. + gnc := nonce.NewGetter(getNonceConn) + _, err = gnc.Nonce(context.Background(), &emptypb.Empty{}) + test.AssertNotError(t, err, "Unexpected error getting nonce") +} + +func TestSRVResolver_CaseTwo(t *testing.T) { + t.Parallel() + + var c srvResolverTestConfig + err := cmd.ReadConfigFile("test/integration/testdata/srv-resolver-config.json", &c) + test.AssertNotError(t, err, "Could not read config file") + + tlsConfig, err := c.WebFooEnd.TLS.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Could not load TLS config") + clk := clock.New() + + getNonceConn, err := bgrpc.ClientSetup(c.WebFooEnd.CaseTwo, tlsConfig, metrics.NoopRegisterer, clk) + test.AssertNotError(t, err, "Could not set up gRPC client") + + // This should succeed, even though the first SRV record is not configured + // in Consul. + gnc := nonce.NewGetter(getNonceConn) + _, err = gnc.Nonce(context.Background(), &emptypb.Empty{}) + test.AssertNotError(t, err, "Unexpected error getting nonce") +} + +func TestSRVResolver_CaseThree(t *testing.T) { + t.Parallel() + + var c srvResolverTestConfig + err := cmd.ReadConfigFile("test/integration/testdata/srv-resolver-config.json", &c) + test.AssertNotError(t, err, "Could not read config file") + + tlsConfig, err := c.WebFooEnd.TLS.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Could not load TLS config") + clk := clock.New() + + getNonceConn, err := bgrpc.ClientSetup(c.WebFooEnd.CaseThree, tlsConfig, metrics.NoopRegisterer, clk) + test.AssertNotError(t, err, "Could not set up gRPC client") + + // This should fail, neither SRV record is configured in Consul and the + // resolver will not return any backends. + gnc := nonce.NewGetter(getNonceConn) + _, err = gnc.Nonce(context.Background(), &emptypb.Empty{}) + test.AssertError(t, err, "Expected error getting nonce") + test.AssertContains(t, err.Error(), "last resolver error: produced zero addresses") +} + +func TestSRVResolver_CaseFour(t *testing.T) { + t.Parallel() + + var c srvResolverTestConfig + err := cmd.ReadConfigFile("test/integration/testdata/srv-resolver-config.json", &c) + test.AssertNotError(t, err, "Could not read config file") + + tlsConfig, err := c.WebFooEnd.TLS.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Could not load TLS config") + clk := clock.New() + + getNonceConn4, err := bgrpc.ClientSetup(c.WebFooEnd.CaseFour, tlsConfig, metrics.NoopRegisterer, clk) + test.AssertNotError(t, err, "Could not set up gRPC client") + + // This should fail, neither SRV record resolves to backends. + gnc := nonce.NewGetter(getNonceConn4) + _, err = gnc.Nonce(context.Background(), &emptypb.Empty{}) + test.AssertError(t, err, "Expected error getting nonce") + test.AssertContains(t, err.Error(), "last resolver error: produced zero addresses") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/subordinate_ca_chains_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/subordinate_ca_chains_test.go new file mode 100644 index 000000000..0aceb6a3e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/subordinate_ca_chains_test.go @@ -0,0 +1,50 @@ +//go:build integration + +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "os" + "strings" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +func TestSubordinateCAChainsServedByWFE(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { + t.Skip("Skipping test in config") + } + + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + name := random_domain() + chains, err := authAndIssueFetchAllChains(client, key, []string{name}, true) + test.AssertNotError(t, err, "failed to issue test cert") + + // An ECDSA intermediate signed by an ECDSA root, and an ECDSA cross-signed by an RSA root. + test.AssertEquals(t, len(chains.certs), 2) + + seenECDSAIntermediate := false + seenECDSACrossSignedIntermediate := false + for _, certUrl := range chains.certs { + for _, cert := range certUrl { + if strings.Contains(cert.Subject.CommonName, "int ecdsa") && cert.Issuer.CommonName == "root ecdsa" { + seenECDSAIntermediate = true + } + if strings.Contains(cert.Subject.CommonName, "int ecdsa") && cert.Issuer.CommonName == "root rsa" { + seenECDSACrossSignedIntermediate = true + } + } + } + test.Assert(t, seenECDSAIntermediate, "did not see ECDSA intermediate and should have") + test.Assert(t, seenECDSACrossSignedIntermediate, "did not see ECDSA by RSA cross-signed intermediate and should have") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/testdata/akamai-purger-queue-drain-config.json b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/akamai-purger-queue-drain-config.json new file mode 100644 index 000000000..0a09d857e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/akamai-purger-queue-drain-config.json @@ -0,0 +1,41 @@ +{ + "akamaiPurger": { + "debugAddr": ":9766", + "purgeRetries": 10, + "purgeRetryBackoff": "50ms", + "throughput": { + "queueEntriesPerBatch": 2, + "purgeBatchInterval": "32ms" + }, + "baseURL": "http://localhost:6889", + "clientToken": "its-a-token", + "clientSecret": "its-a-secret", + "accessToken": "idk-how-this-is-different-from-client-token-but-okay", + "v3Network": "staging", + "tls": { + "caCertfile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/akamai-purger.boulder/cert.pem", + "keyFile": "test/certs/ipki/akamai-purger.boulder/key.pem" + }, + "grpc": { + "address": ":9199", + "maxConnectionAge": "30s", + "services": { + "akamai.AkamaiPurger": { + "clientNames": [ + "ra.boulder" + ] + }, + "grpc.health.v1.Health": { + "clientNames": [ + "health-checker.boulder" + ] + } + } + } + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": -1 + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/testdata/nonce-client.json b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/nonce-client.json new file mode 100644 index 000000000..90e84706b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/nonce-client.json @@ -0,0 +1,39 @@ +{ + "notwfe": { + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/wfe.boulder/cert.pem", + "keyFile": "test/certs/ipki/wfe.boulder/key.pem" + }, + "getNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "nonce-taro", + "domain": "service.consul" + }, + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "redeemNonceService": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "nonce-taro", + "domain": "service.consul" + }, + { + "service": "nonce-zinc", + "domain": "service.consul" + } + ], + "srvResolver": "nonce-srv", + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "noncePrefixKey": { + "passwordFile": "test/secrets/nonce_prefix_key" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/testdata/srv-resolver-config.json b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/srv-resolver-config.json new file mode 100644 index 000000000..fa312514d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/testdata/srv-resolver-config.json @@ -0,0 +1,73 @@ +{ + "webFooEnd": { + "tls": { + "caCertFile": "test/certs/ipki/minica.pem", + "certFile": "test/certs/ipki/wfe.boulder/cert.pem", + "keyFile": "test/certs/ipki/wfe.boulder/key.pem" + }, + "caseOne": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "case1a", + "domain": "service.consul" + }, + { + "service": "case1b", + "domain": "service.consul" + } + ], + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "caseTwo": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "case2a", + "domain": "service.consul" + }, + { + "service": "case2b", + "domain": "service.consul" + } + ], + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "caseThree": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "case3a", + "domain": "service.consul" + }, + { + "service": "case3b", + "domain": "service.consul" + } + ], + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + }, + "caseFour": { + "dnsAuthority": "consul.service.consul", + "srvLookups": [ + { + "service": "case4a", + "domain": "service.consul" + }, + { + "service": "case4b", + "domain": "service.consul" + } + ], + "timeout": "15s", + "noWaitForReady": true, + "hostOverride": "nonce.boulder" + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/integration/wfe_test.go b/third-party/github.com/letsencrypt/boulder/test/integration/wfe_test.go new file mode 100644 index 000000000..7bbe4fece --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/integration/wfe_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package integration + +import ( + "io" + "net/http" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +// TestWFECORS is a small integration test that checks that the +// Access-Control-Allow-Origin header is returned for a GET request to the +// directory endpoint that has an Origin request header of "*". +func TestWFECORS(t *testing.T) { + // Construct a GET request with an Origin header to sollicit an + // Access-Control-Allow-Origin response header. + getReq, _ := http.NewRequest("GET", "http://boulder.service.consul:4001/directory", nil) + getReq.Header.Set("Origin", "*") + + // Performing the GET should return status 200. + client := &http.Client{} + resp, err := client.Do(getReq) + test.AssertNotError(t, err, "GET directory") + test.AssertEquals(t, resp.StatusCode, http.StatusOK) + + // We expect that the response has the correct Access-Control-Allow-Origin + // header. + corsAllowOrigin := resp.Header.Get("Access-Control-Allow-Origin") + test.AssertEquals(t, corsAllowOrigin, "*") +} + +// TestWFEHTTPMetrics verifies that the measured_http metrics we collect +// for boulder-wfe and boulder-wfe2 are being properly collected. In order +// to initialize the prometheus metrics we make a call to the /directory +// endpoint before checking the /metrics endpoint. +func TestWFEHTTPMetrics(t *testing.T) { + // Check boulder-wfe2 + resp, err := http.Get("http://boulder.service.consul:4001/directory") + test.AssertNotError(t, err, "GET boulder-wfe2 directory") + test.AssertEquals(t, resp.StatusCode, http.StatusOK) + resp.Body.Close() + + resp, err = http.Get("http://boulder.service.consul:8013/metrics") + test.AssertNotError(t, err, "GET boulder-wfe2 metrics") + test.AssertEquals(t, resp.StatusCode, http.StatusOK) + body, err := io.ReadAll(resp.Body) + test.AssertNotError(t, err, "Reading boulder-wfe2 metrics response") + test.AssertContains(t, string(body), `response_time_count{code="200",endpoint="/directory",method="GET"}`) + resp.Body.Close() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/list-features/list-features.go b/third-party/github.com/letsencrypt/boulder/test/list-features/list-features.go new file mode 100644 index 000000000..66813a45f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/list-features/list-features.go @@ -0,0 +1,14 @@ +package main + +import ( + "fmt" + "reflect" + + "github.com/letsencrypt/boulder/features" +) + +func main() { + for _, flag := range reflect.VisibleFields(reflect.TypeOf(features.Config{})) { + fmt.Println(flag.Name) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/README.md b/third-party/github.com/letsencrypt/boulder/test/load-generator/README.md new file mode 100644 index 000000000..6a67e1f29 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/README.md @@ -0,0 +1,5 @@ +# `load-generator` + +![](https://i.imgur.com/58ZQjyH.gif) + +`load-generator` is a load generator for RFC 8555 which emulates user workflows. diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge.go new file mode 100644 index 000000000..47e8d861d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge.go @@ -0,0 +1,98 @@ +package acme + +import ( + "errors" + "fmt" + mrand "math/rand" + "strings" + + "github.com/letsencrypt/boulder/core" +) + +// ChallengeStrategy is an interface describing a strategy for picking +// a challenge from a given authorization. +type ChallengeStrategy interface { + PickChallenge(*core.Authorization) (*core.Challenge, error) +} + +const ( + // RandomChallengeStrategy is the name for a random challenge selection + // strategy that will choose one of the authorization's challenges at random. + RandomChallengeStrategy = "RANDOM" + // The following challenge strategies will always pick the named challenge + // type or return an error if there isn't a challenge of that type to pick. + HTTP01ChallengeStrategy = "HTTP-01" + DNS01ChallengeStrategy = "DNS-01" + TLSALPN01ChallengeStrategy = "TLS-ALPN-01" +) + +// NewChallengeStrategy returns the ChallengeStrategy for the given +// ChallengeStrategyName, or an error if it is unknown. +func NewChallengeStrategy(rawName string) (ChallengeStrategy, error) { + var preferredType core.AcmeChallenge + switch name := strings.ToUpper(rawName); name { + case RandomChallengeStrategy: + return &randomChallengeStrategy{}, nil + case HTTP01ChallengeStrategy: + preferredType = core.ChallengeTypeHTTP01 + case DNS01ChallengeStrategy: + preferredType = core.ChallengeTypeDNS01 + case TLSALPN01ChallengeStrategy: + preferredType = core.ChallengeTypeTLSALPN01 + default: + return nil, fmt.Errorf("ChallengeStrategy %q unknown", name) + } + + return &preferredTypeChallengeStrategy{ + preferredType: preferredType, + }, nil +} + +var ( + ErrPickChallengeNilAuthz = errors.New("PickChallenge: provided authorization can not be nil") + ErrPickChallengeAuthzMissingChallenges = errors.New("PickChallenge: provided authorization had no challenges") +) + +// randomChallengeStrategy is a ChallengeStrategy implementation that always +// returns a random challenge from the given authorization. +type randomChallengeStrategy struct { +} + +// PickChallenge for a randomChallengeStrategy returns a random challenge from +// the authorization. +func (strategy randomChallengeStrategy) PickChallenge(authz *core.Authorization) (*core.Challenge, error) { + if authz == nil { + return nil, ErrPickChallengeNilAuthz + } + if len(authz.Challenges) == 0 { + return nil, ErrPickChallengeAuthzMissingChallenges + } + return &authz.Challenges[mrand.Intn(len(authz.Challenges))], nil +} + +// preferredTypeChallengeStrategy is a ChallengeStrategy implementation that +// always returns the authorization's challenge with type matching the +// preferredType. +type preferredTypeChallengeStrategy struct { + preferredType core.AcmeChallenge +} + +// PickChallenge for a preferredTypeChallengeStrategy returns the authorization +// challenge that has Type equal the preferredType. An error is returned if the +// challenge doesn't have an authorization matching the preferredType. +func (strategy preferredTypeChallengeStrategy) PickChallenge(authz *core.Authorization) (*core.Challenge, error) { + if authz == nil { + return nil, ErrPickChallengeNilAuthz + } + if len(authz.Challenges) == 0 { + return nil, ErrPickChallengeAuthzMissingChallenges + } + for _, chall := range authz.Challenges { + if chall.Type == strategy.preferredType { + return &chall, nil + } + } + return nil, fmt.Errorf("authorization (ID %q) had no %q type challenge", + authz.ID, + strategy.preferredType) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge_test.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge_test.go new file mode 100644 index 000000000..68b713866 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/challenge_test.go @@ -0,0 +1,138 @@ +package acme + +import ( + "fmt" + "testing" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" +) + +func TestNewChallengeStrategy(t *testing.T) { + testCases := []struct { + Name string + InputName string + ExpectedError string + ExpectedStratType string + }{ + { + Name: "unknown name", + InputName: "hyper-quauntum-math-mesh-challenge", + ExpectedError: `ChallengeStrategy "HYPER-QUAUNTUM-MATH-MESH-CHALLENGE" unknown`, + }, + { + Name: "known name, HTTP-01", + InputName: "HTTP-01", + ExpectedStratType: "*acme.preferredTypeChallengeStrategy", + }, + { + Name: "known name, DNS-01", + InputName: "DNS-01", + ExpectedStratType: "*acme.preferredTypeChallengeStrategy", + }, + { + Name: "known name, TLS-ALPN-01", + InputName: "TLS-ALPN-01", + ExpectedStratType: "*acme.preferredTypeChallengeStrategy", + }, + { + Name: "known name, RANDOM", + InputName: "RANDOM", + ExpectedStratType: "*acme.randomChallengeStrategy", + }, + { + Name: "known name, mixed case", + InputName: "rAnDoM", + ExpectedStratType: "*acme.randomChallengeStrategy", + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + strategy, err := NewChallengeStrategy(tc.InputName) + if err == nil && tc.ExpectedError == "" { + test.AssertEquals(t, fmt.Sprintf("%T", strategy), tc.ExpectedStratType) + } else if err == nil && tc.ExpectedError != "" { + t.Errorf("Expected %q got no error\n", tc.ExpectedError) + } else if err != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedError) + } + }) + } +} + +func TestPickChallenge(t *testing.T) { + exampleDNSChall := core.Challenge{ + Type: "dns-01", + } + exampleAuthz := &core.Authorization{ + ID: "1234", + Challenges: []core.Challenge{ + { + Type: "arm-wrestling", + }, + exampleDNSChall, + { + Type: "http-01", + }, + }, + } + + testCases := []struct { + Name string + StratName string + InputAuthz *core.Authorization + ExpectedError string + ExpectedChallenge *core.Challenge + }{ + { + Name: "Preferred type strategy, nil input authz", + StratName: "http-01", + ExpectedError: ErrPickChallengeNilAuthz.Error(), + }, + { + Name: "Random type strategy, nil input authz", + StratName: "random", + ExpectedError: ErrPickChallengeNilAuthz.Error(), + }, + { + Name: "Preferred type strategy, nil input authz challenges", + StratName: "http-01", + InputAuthz: &core.Authorization{}, + ExpectedError: ErrPickChallengeAuthzMissingChallenges.Error(), + }, + { + Name: "Random type strategy, nil input authz challenges", + StratName: "random", + InputAuthz: &core.Authorization{}, + ExpectedError: ErrPickChallengeAuthzMissingChallenges.Error(), + }, + { + Name: "Preferred type strategy, no challenge of type", + StratName: "tls-alpn-01", + InputAuthz: exampleAuthz, + ExpectedError: `authorization (ID "1234") had no "tls-alpn-01" type challenge`, + }, + { + Name: "Preferred type strategy, challenge of type present", + StratName: "dns-01", + InputAuthz: exampleAuthz, + ExpectedChallenge: &exampleDNSChall, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + strategy, err := NewChallengeStrategy(tc.StratName) + test.AssertNotError(t, err, "Failed to create challenge strategy") + chall, err := strategy.PickChallenge(tc.InputAuthz) + if err == nil && tc.ExpectedError == "" { + test.AssertDeepEquals(t, chall, tc.ExpectedChallenge) + } else if err == nil && tc.ExpectedError != "" { + t.Errorf("Expected %q got no error\n", tc.ExpectedError) + } else if err != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedError) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory.go new file mode 100644 index 000000000..e473e5072 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory.go @@ -0,0 +1,249 @@ +// Package acme provides ACME client functionality tailored to the needs of the +// load-generator. It is not a general purpose ACME client library. +package acme + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" +) + +const ( + // NewNonceEndpoint is the directory key for the newNonce endpoint. + NewNonceEndpoint Endpoint = "newNonce" + // NewAccountEndpoint is the directory key for the newAccount endpoint. + NewAccountEndpoint Endpoint = "newAccount" + // NewOrderEndpoint is the directory key for the newOrder endpoint. + NewOrderEndpoint Endpoint = "newOrder" + // RevokeCertEndpoint is the directory key for the revokeCert endpoint. + RevokeCertEndpoint Endpoint = "revokeCert" + // KeyChangeEndpoint is the directory key for the keyChange endpoint. + KeyChangeEndpoint Endpoint = "keyChange" +) + +var ( + // ErrEmptyDirectory is returned if NewDirectory is provided and empty directory URL. + ErrEmptyDirectory = errors.New("directoryURL must not be empty") + // ErrInvalidDirectoryURL is returned if NewDirectory is provided an invalid directory URL. + ErrInvalidDirectoryURL = errors.New("directoryURL is not a valid URL") + // ErrInvalidDirectoryHTTPCode is returned if NewDirectory is provided a directory URL + // that returns something other than HTTP Status OK to a GET request. + ErrInvalidDirectoryHTTPCode = errors.New("GET request to directoryURL did not result in HTTP Status 200") + // ErrInvalidDirectoryJSON is returned if NewDirectory is provided a directory URL + // that returns invalid JSON. + ErrInvalidDirectoryJSON = errors.New("GET request to directoryURL returned invalid JSON") + // ErrInvalidDirectoryMeta is returned if NewDirectory is provided a directory + // URL that returns a directory resource with an invalid or missing "meta" key. + ErrInvalidDirectoryMeta = errors.New(`server's directory resource had invalid or missing "meta" key`) + // ErrInvalidTermsOfService is returned if NewDirectory is provided + // a directory URL that returns a directory resource with an invalid or + // missing "termsOfService" key in the "meta" map. + ErrInvalidTermsOfService = errors.New(`server's directory resource had invalid or missing "meta.termsOfService" key`) + + // RequiredEndpoints is a slice of Endpoint keys that must be present in the + // ACME server's directory. The load-generator uses each of these endpoints + // and expects to be able to find a URL for each in the server's directory + // resource. + RequiredEndpoints = []Endpoint{ + NewNonceEndpoint, NewAccountEndpoint, + NewOrderEndpoint, RevokeCertEndpoint, + } +) + +// Endpoint represents a string key used for looking up an endpoint URL in an ACME +// server directory resource. +// +// E.g. NewOrderEndpoint -> "newOrder" -> "https://acme.example.com/acme/v1/new-order-plz" +// +// See "ACME Resource Types" registry - RFC 8555 Section 9.7.5. +type Endpoint string + +// ErrMissingEndpoint is an error returned if NewDirectory is provided an ACME +// server directory URL that is missing a key for a required endpoint in the +// response JSON. See also RequiredEndpoints. +type ErrMissingEndpoint struct { + endpoint Endpoint +} + +// Error returns the error message for an ErrMissingEndpoint error. +func (e ErrMissingEndpoint) Error() string { + return fmt.Sprintf( + "directoryURL JSON was missing required key for %q endpoint", + e.endpoint, + ) +} + +// ErrInvalidEndpointURL is an error returned if NewDirectory is provided an +// ACME server directory URL that has an invalid URL for a required endpoint. +// See also RequiredEndpoints. +type ErrInvalidEndpointURL struct { + endpoint Endpoint + value string +} + +// Error returns the error message for an ErrInvalidEndpointURL error. +func (e ErrInvalidEndpointURL) Error() string { + return fmt.Sprintf( + "directoryURL JSON had invalid URL value (%q) for %q endpoint", + e.value, e.endpoint) +} + +// Directory is a type for holding URLs extracted from the ACME server's +// Directory resource. +// +// See RFC 8555 Section 7.1.1 "Directory". +// +// Its public API is read-only and therefore it is safe for concurrent access. +type Directory struct { + // TermsOfService is the URL identifying the current terms of service found in + // the ACME server's directory resource's "meta" field. + TermsOfService string + // endpointURLs is a map from endpoint name to URL. + endpointURLs map[Endpoint]string +} + +// getRawDirectory validates the provided directoryURL and makes a GET request +// to fetch the raw bytes of the server's directory resource. If the URL is +// invalid, if there is an error getting the directory bytes, or if the HTTP +// response code is not 200 an error is returned. +func getRawDirectory(directoryURL string) ([]byte, error) { + if directoryURL == "" { + return nil, ErrEmptyDirectory + } + + if _, err := url.Parse(directoryURL); err != nil { + return nil, ErrInvalidDirectoryURL + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{ + // Bypassing CDN or testing against Pebble instances can cause + // validation failures. For a **test-only** tool its acceptable to skip + // cert verification of the ACME server's HTTPs certificate. + InsecureSkipVerify: true, + }, + MaxIdleConns: 1, + IdleConnTimeout: 15 * time.Second, + }, + Timeout: 10 * time.Second, + } + + resp, err := httpClient.Get(directoryURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, ErrInvalidDirectoryHTTPCode + } + + rawDirectory, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return rawDirectory, nil +} + +// termsOfService reads the termsOfService key from the meta key of the raw +// directory resource. +func termsOfService(rawDirectory map[string]interface{}) (string, error) { + var directoryMeta map[string]interface{} + + if rawDirectoryMeta, ok := rawDirectory["meta"]; !ok { + return "", ErrInvalidDirectoryMeta + } else if directoryMetaMap, ok := rawDirectoryMeta.(map[string]interface{}); !ok { + return "", ErrInvalidDirectoryMeta + } else { + directoryMeta = directoryMetaMap + } + + rawToSURL, ok := directoryMeta["termsOfService"] + if !ok { + return "", ErrInvalidTermsOfService + } + + tosURL, ok := rawToSURL.(string) + if !ok { + return "", ErrInvalidTermsOfService + } + return tosURL, nil +} + +// NewDirectory creates a Directory populated from the ACME directory resource +// returned by a GET request to the provided directoryURL. It also checks that +// the fetched directory contains each of the RequiredEndpoints. +func NewDirectory(directoryURL string) (*Directory, error) { + // Fetch the raw directory JSON + dirContents, err := getRawDirectory(directoryURL) + if err != nil { + return nil, err + } + + // Unmarshal the directory + var dirResource map[string]interface{} + err = json.Unmarshal(dirContents, &dirResource) + if err != nil { + return nil, ErrInvalidDirectoryJSON + } + + // serverURL tries to find a valid url.URL for the provided endpoint in + // the unmarshaled directory resource. + serverURL := func(name Endpoint) (*url.URL, error) { + if rawURL, ok := dirResource[string(name)]; !ok { + return nil, ErrMissingEndpoint{endpoint: name} + } else if urlString, ok := rawURL.(string); !ok { + return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} + } else if url, err := url.Parse(urlString); err != nil { + return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} + } else { + return url, nil + } + } + + // Create an empty directory to populate + directory := &Directory{ + endpointURLs: make(map[Endpoint]string), + } + + // Every required endpoint must have a valid URL populated from the directory + for _, endpointName := range RequiredEndpoints { + url, err := serverURL(endpointName) + if err != nil { + return nil, err + } + directory.endpointURLs[endpointName] = url.String() + } + + // Populate the terms-of-service + tos, err := termsOfService(dirResource) + if err != nil { + return nil, err + } + directory.TermsOfService = tos + return directory, nil +} + +// EndpointURL returns the string representation of the ACME server's URL for +// the provided endpoint. If the Endpoint is not known an empty string is +// returned. +func (d *Directory) EndpointURL(ep Endpoint) string { + if url, ok := d.endpointURLs[ep]; ok { + return url + } + + return "" +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory_test.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory_test.go new file mode 100644 index 000000000..3ee286a10 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/acme/directory_test.go @@ -0,0 +1,186 @@ +package acme + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +// Path constants for test cases and mockDirectoryServer handlers. +const ( + wrongStatusCodePath = "/dir-wrong-status" + invalidJSONPath = "/dir-bad-json" + missingEndpointPath = "/dir-missing-endpoint" + invalidEndpointURLPath = "/dir-invalid-endpoint" + validDirectoryPath = "/dir-valid" + invalidMetaDirectoryPath = "/dir-valid-meta-invalid" + invalidMetaDirectoryToSPath = "/dir-valid-meta-valid-tos-invalid" +) + +// mockDirectoryServer is an httptest.Server that returns mock data for ACME +// directory GET requests based on the requested path. +type mockDirectoryServer struct { + *httptest.Server +} + +// newMockDirectoryServer creates a mockDirectoryServer that returns mock data +// based on the requested path. The returned server will not be started +// automatically. +func newMockDirectoryServer() *mockDirectoryServer { + m := http.NewServeMux() + + m.HandleFunc(wrongStatusCodePath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnavailableForLegalReasons) + }) + + m.HandleFunc(invalidJSONPath, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{`) + }) + + m.HandleFunc(missingEndpointPath, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + }) + + m.HandleFunc(invalidEndpointURLPath, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{ + "newAccount": "", + "newNonce": "ht\ntp://bad-scheme", + "newOrder": "", + "revokeCert": "" + }`) + }) + + m.HandleFunc(invalidMetaDirectoryPath, func(w http.ResponseWriter, r *http.Request) { + noMetaDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, noMetaDir) + }) + + m.HandleFunc(invalidMetaDirectoryToSPath, func(w http.ResponseWriter, r *http.Request) { + noToSDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "meta": { + "chaos": "reigns" + }, + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, noToSDir) + }) + + m.HandleFunc(validDirectoryPath, func(w http.ResponseWriter, r *http.Request) { + validDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "meta": { + "termsOfService": "data:text/plain,Do%20what%20thou%20wilt" + }, + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, validDir) + }) + + srv := &mockDirectoryServer{ + Server: httptest.NewUnstartedServer(m), + } + + return srv +} + +// TestNew tests that creating a new Client and populating the endpoint map +// works correctly. +func TestNew(t *testing.T) { + srv := newMockDirectoryServer() + srv.Start() + defer srv.Close() + + srvUrl, _ := url.Parse(srv.URL) + _, port, _ := net.SplitHostPort(srvUrl.Host) + + testURL := func(path string) string { + return fmt.Sprintf("http://localhost:%s%s", port, path) + } + + testCases := []struct { + Name string + DirectoryURL string + ExpectedError string + }{ + { + Name: "empty directory URL", + ExpectedError: ErrEmptyDirectory.Error(), + }, + { + Name: "invalid directory URL", + DirectoryURL: "http://" + string([]byte{0x1, 0x7F}), + ExpectedError: ErrInvalidDirectoryURL.Error(), + }, + { + Name: "unreachable directory URL", + DirectoryURL: "http://localhost:1987", + ExpectedError: "connect: connection refused", + }, + { + Name: "wrong directory HTTP status code", + DirectoryURL: testURL(wrongStatusCodePath), + ExpectedError: ErrInvalidDirectoryHTTPCode.Error(), + }, + { + Name: "invalid directory JSON", + DirectoryURL: testURL(invalidJSONPath), + ExpectedError: ErrInvalidDirectoryJSON.Error(), + }, + { + Name: "directory JSON missing required endpoint", + DirectoryURL: testURL(missingEndpointPath), + ExpectedError: ErrMissingEndpoint{endpoint: NewNonceEndpoint}.Error(), + }, + { + Name: "directory JSON with invalid endpoint URL", + DirectoryURL: testURL(invalidEndpointURLPath), + ExpectedError: ErrInvalidEndpointURL{ + endpoint: NewNonceEndpoint, + value: "ht\ntp://bad-scheme", + }.Error(), + }, + { + Name: "directory JSON missing meta key", + DirectoryURL: testURL(invalidMetaDirectoryPath), + ExpectedError: ErrInvalidDirectoryMeta.Error(), + }, + { + Name: "directory JSON missing meta TermsOfService key", + DirectoryURL: testURL(invalidMetaDirectoryToSPath), + ExpectedError: ErrInvalidTermsOfService.Error(), + }, + { + Name: "valid directory", + DirectoryURL: testURL(validDirectoryPath), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + _, err := NewDirectory(tc.DirectoryURL) + if err == nil && tc.ExpectedError != "" { + t.Errorf("expected error %q got nil", tc.ExpectedError) + } else if err != nil { + test.AssertContains(t, err.Error(), tc.ExpectedError) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/boulder-calls.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/boulder-calls.go new file mode 100644 index 000000000..8f98cade3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/boulder-calls.go @@ -0,0 +1,658 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + mrand "math/rand" + "net/http" + "time" + + "github.com/go-jose/go-jose/v4" + "golang.org/x/crypto/ocsp" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test/load-generator/acme" +) + +var ( + // stringToOperation maps a configured plan action to a function that can + // operate on a state/context. + stringToOperation = map[string]func(*State, *acmeCache) error{ + "newAccount": newAccount, + "getAccount": getAccount, + "newOrder": newOrder, + "fulfillOrder": fulfillOrder, + "finalizeOrder": finalizeOrder, + "revokeCertificate": revokeCertificate, + } +) + +// OrderJSON is used because it's awkward to work with core.Order or corepb.Order +// when the API returns a different object than either of these types can represent without +// converting field values. The WFE uses an unexported `orderJSON` type for the +// API results that contain an order. We duplicate it here instead of moving it +// somewhere exported for this one utility. +type OrderJSON struct { + // The URL field isn't returned by the API, we populate it manually with the + // `Location` header. + URL string + Status core.AcmeStatus `json:"status"` + Expires time.Time `json:"expires"` + Identifiers []identifier.ACMEIdentifier `json:"identifiers"` + Authorizations []string `json:"authorizations"` + Finalize string `json:"finalize"` + Certificate string `json:"certificate,omitempty"` + Error *probs.ProblemDetails `json:"error,omitempty"` +} + +// getAccount takes a randomly selected v2 account from `state.accts` and puts it +// into `c.acct`. The context `nonceSource` is also populated as convenience. +func getAccount(s *State, c *acmeCache) error { + s.rMu.RLock() + defer s.rMu.RUnlock() + + // There must be an existing v2 account in the state + if len(s.accts) == 0 { + return errors.New("no accounts to return") + } + + // Select a random account from the state and put it into the context + c.acct = s.accts[mrand.Intn(len(s.accts))] + c.ns = &nonceSource{s: s} + return nil +} + +// newAccount puts a V2 account into the provided context. If the state provided +// has too many accounts already (based on `state.NumAccts` and `state.maxRegs`) +// then `newAccount` puts an existing account from the state into the context, +// otherwise it creates a new account and puts it into both the state and the +// context. +func newAccount(s *State, c *acmeCache) error { + // Check the max regs and if exceeded, just return an existing account instead + // of creating a new one. + if s.maxRegs != 0 && s.numAccts() >= s.maxRegs { + return getAccount(s, c) + } + + // Create a random signing key + signKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + c.acct = &account{ + key: signKey, + } + c.ns = &nonceSource{s: s} + + // Prepare an account registration message body + reqBody := struct { + ToSAgreed bool `json:"termsOfServiceAgreed"` + Contact []string + }{ + ToSAgreed: true, + } + // Set the account contact email if configured + if s.email != "" { + reqBody.Contact = []string{fmt.Sprintf("mailto:%s", s.email)} + } + reqBodyStr, err := json.Marshal(&reqBody) + if err != nil { + return err + } + + // Sign the new account registration body using a JWS with an embedded JWK + // because we do not have a key ID from the server yet. + newAccountURL := s.directory.EndpointURL(acme.NewAccountEndpoint) + jws, err := c.signEmbeddedV2Request(reqBodyStr, newAccountURL) + if err != nil { + return err + } + bodyBuf := []byte(jws.FullSerialize()) + + resp, err := s.post( + newAccountURL, + bodyBuf, + c.ns, + string(acme.NewAccountEndpoint), + http.StatusCreated) + if err != nil { + return fmt.Errorf("%s, post failed: %s", newAccountURL, err) + } + defer resp.Body.Close() + + // Populate the context account's key ID with the Location header returned by + // the server + locHeader := resp.Header.Get("Location") + if locHeader == "" { + return fmt.Errorf("%s, bad response - no Location header with account ID", newAccountURL) + } + c.acct.id = locHeader + + // Add the account to the state + s.addAccount(c.acct) + return nil +} + +// randDomain generates a random(-ish) domain name as a subdomain of the +// provided base domain. +func randDomain(base string) string { + // This approach will cause some repeat domains but not enough to make rate + // limits annoying! + n := time.Now().UnixNano() + b := new(bytes.Buffer) + binary.Write(b, binary.LittleEndian, n) + return fmt.Sprintf("%x.%s", sha1.Sum(b.Bytes()), base) +} + +// newOrder creates a new pending order object for a random set of domains using +// the context's account. +func newOrder(s *State, c *acmeCache) error { + // Pick a random number of names within the constraints of the maxNamesPerCert + // parameter + orderSize := 1 + mrand.Intn(s.maxNamesPerCert-1) + // Generate that many random domain names. There may be some duplicates, we + // don't care. The ACME server will collapse those down for us, how handy! + dnsNames := []identifier.ACMEIdentifier{} + for range orderSize { + dnsNames = append(dnsNames, identifier.ACMEIdentifier{ + Type: identifier.DNS, + Value: randDomain(s.domainBase), + }) + } + + // create the new order request object + initOrder := struct { + Identifiers []identifier.ACMEIdentifier + }{ + Identifiers: dnsNames, + } + initOrderStr, err := json.Marshal(&initOrder) + if err != nil { + return err + } + + // Sign the new order request with the context account's key/key ID + newOrderURL := s.directory.EndpointURL(acme.NewOrderEndpoint) + jws, err := c.signKeyIDV2Request(initOrderStr, newOrderURL) + if err != nil { + return err + } + bodyBuf := []byte(jws.FullSerialize()) + + resp, err := s.post( + newOrderURL, + bodyBuf, + c.ns, + string(acme.NewOrderEndpoint), + http.StatusCreated) + if err != nil { + return fmt.Errorf("%s, post failed: %s", newOrderURL, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%s, bad response: %s", newOrderURL, body) + } + + // Unmarshal the Order object + var orderJSON OrderJSON + err = json.Unmarshal(body, &orderJSON) + if err != nil { + return err + } + + // Populate the URL of the order from the Location header + orderURL := resp.Header.Get("Location") + if orderURL == "" { + return fmt.Errorf("%s, bad response - no Location header with order ID", newOrderURL) + } + orderJSON.URL = orderURL + + // Store the pending order in the context + c.pendingOrders = append(c.pendingOrders, &orderJSON) + return nil +} + +// popPendingOrder *removes* a random pendingOrder from the context, returning +// it. +func popPendingOrder(c *acmeCache) *OrderJSON { + orderIndex := mrand.Intn(len(c.pendingOrders)) + order := c.pendingOrders[orderIndex] + c.pendingOrders = append(c.pendingOrders[:orderIndex], c.pendingOrders[orderIndex+1:]...) + return order +} + +// getAuthorization fetches an authorization by GET-ing the provided URL. It +// records the latency and result of the GET operation in the state. +func getAuthorization(s *State, c *acmeCache, url string) (*core.Authorization, error) { + latencyTag := "/acme/authz/{ID}" + resp, err := postAsGet(s, c, url, latencyTag) + // If there was an error, note the state and return + if err != nil { + return nil, fmt.Errorf("%s bad response: %s", url, err) + } + + // Read the response body + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Unmarshal an authorization from the HTTP response body + var authz core.Authorization + err = json.Unmarshal(body, &authz) + if err != nil { + return nil, fmt.Errorf("%s response: %s", url, body) + } + // The Authorization ID is not set in the response so we populate it using the + // URL + authz.ID = url + return &authz, nil +} + +// completeAuthorization processes a provided authorization by solving its +// HTTP-01 challenge using the context's account and the state's challenge +// server. Aftering POSTing the authorization's HTTP-01 challenge the +// authorization will be polled waiting for a state change. +func completeAuthorization(authz *core.Authorization, s *State, c *acmeCache) error { + // Skip if the authz isn't pending + if authz.Status != core.StatusPending { + return nil + } + + // Find a challenge to solve from the pending authorization using the + // challenge selection strategy from the load-generator state. + chalToSolve, err := s.challStrat.PickChallenge(authz) + if err != nil { + return err + } + + // Compute the key authorization from the context account's key + jwk := &jose.JSONWebKey{Key: &c.acct.key.PublicKey} + thumbprint, err := jwk.Thumbprint(crypto.SHA256) + if err != nil { + return err + } + authStr := fmt.Sprintf("%s.%s", chalToSolve.Token, base64.RawURLEncoding.EncodeToString(thumbprint)) + + // Add the challenge response to the state's test server and defer a clean-up. + switch chalToSolve.Type { + case core.ChallengeTypeHTTP01: + s.challSrv.AddHTTPOneChallenge(chalToSolve.Token, authStr) + defer s.challSrv.DeleteHTTPOneChallenge(chalToSolve.Token) + case core.ChallengeTypeDNS01: + // Compute the digest of the key authorization + h := sha256.New() + h.Write([]byte(authStr)) + authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + domain := "_acme-challenge." + authz.Identifier.Value + "." + s.challSrv.AddDNSOneChallenge(domain, authorizedKeysDigest) + defer s.challSrv.DeleteDNSOneChallenge(domain) + case core.ChallengeTypeTLSALPN01: + s.challSrv.AddTLSALPNChallenge(authz.Identifier.Value, authStr) + defer s.challSrv.DeleteTLSALPNChallenge(authz.Identifier.Value) + default: + return fmt.Errorf("challenge strategy picked challenge with unknown type: %q", chalToSolve.Type) + } + + // Prepare the Challenge POST body + jws, err := c.signKeyIDV2Request([]byte(`{}`), chalToSolve.URL) + if err != nil { + return err + } + requestPayload := []byte(jws.FullSerialize()) + + resp, err := s.post( + chalToSolve.URL, + requestPayload, + c.ns, + "/acme/challenge/{ID}", // We want all challenge POST latencies to be grouped + http.StatusOK, + ) + if err != nil { + return err + } + + // Read the response body and cleanup when finished + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + if err != nil { + return err + } + + // Poll the authorization waiting for the challenge response to be recorded in + // a change of state. The polling may sleep and retry a few times if required + err = pollAuthorization(authz, s, c) + if err != nil { + return err + } + + // The challenge is completed, the authz is valid + return nil +} + +// pollAuthorization GETs a provided authorization up to three times, sleeping +// in between attempts, waiting for the status of the returned authorization to +// be valid. If the status is invalid, or if three GETs do not produce the +// correct authorization state an error is returned. If no error is returned +// then the authorization is valid and ready. +func pollAuthorization(authz *core.Authorization, s *State, c *acmeCache) error { + authzURL := authz.ID + for range 3 { + // Fetch the authz by its URL + authz, err := getAuthorization(s, c, authzURL) + if err != nil { + return nil + } + // If the authz is invalid, abort with an error + if authz.Status == "invalid" { + return fmt.Errorf("Authorization %q failed challenge and is status invalid", authzURL) + } + // If the authz is valid, return with no error - the authz is ready to go! + if authz.Status == "valid" { + return nil + } + // Otherwise sleep and try again + time.Sleep(3 * time.Second) + } + return fmt.Errorf("Timed out polling authorization %q", authzURL) +} + +// fulfillOrder processes a pending order from the context, completing each +// authorization's HTTP-01 challenge using the context's account, and finally +// placing the now-ready-to-be-finalized order into the context's list of +// fulfilled orders. +func fulfillOrder(s *State, c *acmeCache) error { + // There must be at least one pending order in the context to fulfill + if len(c.pendingOrders) == 0 { + return errors.New("no pending orders to fulfill") + } + + // Get an order to fulfill from the context + order := popPendingOrder(c) + + // Each of its authorizations need to be processed + for _, url := range order.Authorizations { + // Fetch the authz by its URL + authz, err := getAuthorization(s, c, url) + if err != nil { + return err + } + + // Complete the authorization by solving a challenge + err = completeAuthorization(authz, s, c) + if err != nil { + return err + } + } + + // Once all of the authorizations have been fulfilled the order is fulfilled + // and ready for future finalization. + c.fulfilledOrders = append(c.fulfilledOrders, order.URL) + return nil +} + +// getOrder GETs an order by URL, returning an OrderJSON object. It tracks the +// latency of the GET operation in the provided state. +func getOrder(s *State, c *acmeCache, url string) (*OrderJSON, error) { + latencyTag := "/acme/order/{ID}" + // POST-as-GET the order URL + resp, err := postAsGet(s, c, url, latencyTag) + // If there was an error, track that result + if err != nil { + return nil, fmt.Errorf("%s bad response: %s", url, err) + } + // Read the response body + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%s, bad response: %s", url, body) + } + + // Unmarshal the Order object from the response body + var orderJSON OrderJSON + err = json.Unmarshal(body, &orderJSON) + if err != nil { + return nil, err + } + + // Populate the order's URL based on the URL we fetched it from + orderJSON.URL = url + return &orderJSON, nil +} + +// pollOrderForCert polls a provided order, waiting for the status to change to +// valid such that a certificate URL for the order is known. Three attempts are +// made to check the order status, sleeping 3s between each. If these attempts +// expire without the status becoming valid an error is returned. +func pollOrderForCert(order *OrderJSON, s *State, c *acmeCache) (*OrderJSON, error) { + for range 3 { + // Fetch the order by its URL + order, err := getOrder(s, c, order.URL) + if err != nil { + return nil, err + } + // If the order is invalid, fail + if order.Status == "invalid" { + return nil, fmt.Errorf("Order %q failed and is status invalid", order.URL) + } + // If the order is valid, return with no error - the authz is ready to go! + if order.Status == "valid" { + return order, nil + } + // Otherwise sleep and try again + time.Sleep(3 * time.Second) + } + return nil, fmt.Errorf("Timed out polling order %q", order.URL) +} + +// popFulfilledOrder **removes** a fulfilled order from the context, returning +// it. Fulfilled orders have all of their authorizations satisfied. +func popFulfilledOrder(c *acmeCache) string { + orderIndex := mrand.Intn(len(c.fulfilledOrders)) + order := c.fulfilledOrders[orderIndex] + c.fulfilledOrders = append(c.fulfilledOrders[:orderIndex], c.fulfilledOrders[orderIndex+1:]...) + return order +} + +// finalizeOrder removes a fulfilled order from the context and POSTs a CSR to +// the order's finalization URL. The CSR's key is set from the state's +// `certKey`. The order is then polled for the status to change to valid so that +// the certificate URL can be added to the context. The context's `certs` list +// is updated with the URL for the order's certificate. +func finalizeOrder(s *State, c *acmeCache) error { + // There must be at least one fulfilled order in the context + if len(c.fulfilledOrders) < 1 { + return errors.New("No fulfilled orders in the context ready to be finalized") + } + + // Pop a fulfilled order to process, and then GET its contents + orderID := popFulfilledOrder(c) + order, err := getOrder(s, c, orderID) + if err != nil { + return err + } + + if order.Status != core.StatusReady { + return fmt.Errorf("order %s was status %q, expected %q", + orderID, order.Status, core.StatusReady) + } + + // Mark down the finalization URL for the order + finalizeURL := order.Finalize + + // Pull the values from the order identifiers for use in the CSR + dnsNames := make([]string, len(order.Identifiers)) + for i, ident := range order.Identifiers { + dnsNames[i] = ident.Value + } + + // Create a CSR using the state's certKey + csr, err := x509.CreateCertificateRequest( + rand.Reader, + &x509.CertificateRequest{DNSNames: dnsNames}, + s.certKey, + ) + if err != nil { + return err + } + + // Create the finalization request body with the encoded CSR + request := fmt.Sprintf( + `{"csr":"%s"}`, + base64.RawURLEncoding.EncodeToString(csr), + ) + + // Sign the request body with the context's account key/keyID + jws, err := c.signKeyIDV2Request([]byte(request), finalizeURL) + if err != nil { + return err + } + requestPayload := []byte(jws.FullSerialize()) + + resp, err := s.post( + finalizeURL, + requestPayload, + c.ns, + "/acme/order/finalize", // We want all order finalizations to be grouped. + http.StatusOK, + ) + if err != nil { + return err + } + defer resp.Body.Close() + // Read the body to ensure there isn't an error. We don't need the actual + // contents. + _, err = io.ReadAll(resp.Body) + if err != nil { + return err + } + + // Poll the order waiting for the certificate to be ready + completedOrder, err := pollOrderForCert(order, s, c) + if err != nil { + return err + } + + // The valid order should have a certificate URL + certURL := completedOrder.Certificate + if certURL == "" { + return fmt.Errorf("Order %q was finalized but has no cert URL", order.URL) + } + + // Append the certificate URL into the context's list of certificates + c.certs = append(c.certs, certURL) + c.finalizedOrders = append(c.finalizedOrders, order.URL) + return nil +} + +// postAsGet performs a POST-as-GET request to the provided URL authenticated by +// the context's account. A HTTP status code other than StatusOK (200) +// in response to a POST-as-GET request is considered an error. The caller is +// responsible for closing the HTTP response body. +// +// See RFC 8555 Section 6.3 for more information on POST-as-GET requests. +func postAsGet(s *State, c *acmeCache, url string, latencyTag string) (*http.Response, error) { + // Create the POST-as-GET request JWS + jws, err := c.signKeyIDV2Request([]byte(""), url) + if err != nil { + return nil, err + } + requestPayload := []byte(jws.FullSerialize()) + + return s.post(url, requestPayload, c.ns, latencyTag, http.StatusOK) +} + +func popCertificate(c *acmeCache) string { + certIndex := mrand.Intn(len(c.certs)) + certURL := c.certs[certIndex] + c.certs = append(c.certs[:certIndex], c.certs[certIndex+1:]...) + return certURL +} + +func getCert(s *State, c *acmeCache, url string) ([]byte, error) { + latencyTag := "/acme/cert/{serial}" + resp, err := postAsGet(s, c, url, latencyTag) + if err != nil { + return nil, fmt.Errorf("%s bad response: %s", url, err) + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// revokeCertificate removes a certificate url from the context, retrieves it, +// and sends a revocation request for the certificate to the ACME server. +// The revocation request is signed with the account key rather than the certificate +// key. +func revokeCertificate(s *State, c *acmeCache) error { + if len(c.certs) < 1 { + return errors.New("No certificates in the context that can be revoked") + } + + if r := mrand.Float32(); r > s.revokeChance { + return nil + } + + certURL := popCertificate(c) + certPEM, err := getCert(s, c, certURL) + if err != nil { + return err + } + + pemBlock, _ := pem.Decode(certPEM) + revokeObj := struct { + Certificate string + Reason int + }{ + Certificate: base64.URLEncoding.EncodeToString(pemBlock.Bytes), + Reason: ocsp.Unspecified, + } + + revokeJSON, err := json.Marshal(revokeObj) + if err != nil { + return err + } + revokeURL := s.directory.EndpointURL(acme.RevokeCertEndpoint) + // TODO(roland): randomly use the certificate key to sign the request instead of + // the account key + jws, err := c.signKeyIDV2Request(revokeJSON, revokeURL) + if err != nil { + return err + } + requestPayload := []byte(jws.FullSerialize()) + + resp, err := s.post( + revokeURL, + requestPayload, + c.ns, + "/acme/revoke-cert", + http.StatusOK, + ) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = io.ReadAll(resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/config/integration-test-config.json b/third-party/github.com/letsencrypt/boulder/test/load-generator/config/integration-test-config.json new file mode 100644 index 000000000..50d868568 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/config/integration-test-config.json @@ -0,0 +1,27 @@ +{ + "plan": { + "actions": [ + "newAccount", + "newOrder", + "fulfillOrder", + "finalizeOrder", + "revokeCertificate" + ], + "rate": 1, + "runtime": "10s", + "rateDelta": "5/1m" + }, + "directoryURL": "http://boulder.service.consul:4001/directory", + "domainBase": "com", + "challengeStrategy": "random", + "httpOneAddrs": [":80"], + "tlsAlpnOneAddrs": [":443"], + "dnsAddrs": [":8053", ":8054"], + "fakeDNS": "10.77.77.77", + "regKeySize": 2048, + "regEmail": "loadtesting@letsencrypt.org", + "maxRegs": 20, + "maxNamesPerCert": 20, + "dontSaveState": true, + "revokeChance": 0.5 +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/example-config.json b/third-party/github.com/letsencrypt/boulder/test/load-generator/example-config.json new file mode 100644 index 000000000..4802a985e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/example-config.json @@ -0,0 +1,22 @@ +{ + "plan": { + "actions": [ + "newAccount", + "newOrder", + "fulfillOrder", + "finalizeOrder" + ], + "rate": 5, + "runtime": "5m", + "rateDelta": "5/1m" + }, + "apiBase": "http://localhost:4001", + "domainBase": "com", + "httpOneAddr": "localhost:80", + "regKeySize": 2048, + "regEmail": "loadtesting@letsencrypt.org", + "maxRegs": 20, + "maxNamesPerCert": 20, + "dontSaveState": true, + "results": "v2-example-latency.json" +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/latency-charter.py b/third-party/github.com/letsencrypt/boulder/test/load-generator/latency-charter.py new file mode 100644 index 000000000..189eaeeeb --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/latency-charter.py @@ -0,0 +1,137 @@ +#!/usr/bin/python + +import matplotlib +import matplotlib.pyplot as plt +from matplotlib import gridspec +import numpy as np +import datetime +import json +import pandas +import argparse +import os +matplotlib.style.use('ggplot') + +# sacrificial plot for single legend +matplotlib.rcParams['figure.figsize'] = 1, 1 +randFig = plt.figure() +randAx = plt.subplot() +randAx.plot(0, 0, color='green', label='good', marker='+') +randAx.plot(0, 0, color='red', label='failed', marker='x') +randAx.plot(0, 0, color='black', label='sent', linestyle='--') +randAx.plot(0, 0, color='green', label='50th quantile') +randAx.plot(0, 0, color='orange', label='90th quantile') +randAx.plot(0, 0, color='red', label='99th quantile') +handles, labels = randAx.get_legend_handles_labels() + +# big ol' plotting method +def plot_section(all_data, title, outputPath): + # group calls by the endpoint/method + actions = all_data.groupby('action') + h = len(actions.groups.keys()) + matplotlib.rcParams['figure.figsize'] = 20, 3 * h + + fig = plt.figure() + fig.legend(handles, labels, ncol=6, fontsize=16, framealpha=0, loc='upper center') + if title is not None: + fig.suptitle(title, fontsize=20, y=0.93) + gs = gridspec.GridSpec(h, 3) + + # figure out left and right datetime bounds + started = all_data['sent'].min() + stopped = all_data['finished'].max() + + i = 0 + # plot one row of charts for each endpoint/method combination + for section in actions.groups.keys(): + # setup the tree charts + ax = fig.add_subplot(gs[i, 0]) + ax.set_title(section) + ax.set_xlim(started, stopped) + ax2 = fig.add_subplot(gs[i, 2]) + ax2.set_xlim(started, stopped) + ax3 = fig.add_subplot(gs[i, 1]) + ax3.set_xlim(started, stopped) + + # find the maximum y value and set it across all three charts + calls = actions.get_group(section) + tookMax = calls['took'].max() + ax.set_ylim(0, tookMax+tookMax*0.1) + ax2.set_ylim(0, tookMax+tookMax*0.1) + ax3.set_ylim(0, tookMax+tookMax*0.1) + + groups = calls.groupby('type') + if groups.groups.get('error', False) is not False: + bad = groups.get_group('error') + ax.plot_date(bad['finished'], bad['took'], color='red', marker='x', label='error') + + bad_rate = bad.set_index('finished') + bad_rate['rate'] = [0] * len(bad_rate.index) + bad_rate = bad_rate.resample('5S').count() + bad_rate['rate'] = bad_rate['rate'].divide(5) + rateMax = bad_rate['rate'].max() + ax2.plot_date(bad_rate.index, bad_rate['rate'], linestyle='-', marker='', color='red', label='error') + if groups.groups.get('good', False) is not False: + good = groups.get_group('good') + ax.plot_date(good['finished'], good['took'], color='green', marker='+', label='good') + + good_rate = good.set_index('finished') + good_rate['rate'] = [0] * len(good_rate.index) + good_rate = good_rate.resample('5S').count() + good_rate['rate'] = good_rate['rate'].divide(5) + rateMax = good_rate['rate'].max() + ax2.plot_date(good_rate.index, good_rate['rate'], linestyle='-', marker='', color='green', label='good') + ax.set_ylabel('Latency (ms)') + + # calculate the request rate + sent_rate = pandas.DataFrame(calls['sent']) + sent_rate = sent_rate.set_index('sent') + sent_rate['rate'] = [0] * len(sent_rate.index) + sent_rate = sent_rate.resample('5S').count() + sent_rate['rate'] = sent_rate['rate'].divide(5) + if sent_rate['rate'].max() > rateMax: + rateMax = sent_rate['rate'].max() + ax2.plot_date(sent_rate.index, sent_rate['rate'], linestyle='--', marker='', color='black', label='sent') + ax2.set_ylim(0, rateMax+rateMax*0.1) + ax2.set_ylabel('Rate (per second)') + + # calculate and plot latency quantiles + calls = calls.set_index('finished') + calls = calls.sort_index() + quan = pandas.DataFrame(calls['took']) + for q, c in [[.5, 'green'], [.9, 'orange'], [.99, 'red']]: + quanN = quan.rolling(500, center=True).quantile(q) + ax3.plot(quanN['took'].index, quanN['took'], color=c) + + ax3.set_ylabel('Latency quantiles (ms)') + + i += 1 + + # format x axes + for ax in fig.axes: + matplotlib.pyplot.sca(ax) + plt.xticks(rotation=30, ha='right') + majorFormatter = matplotlib.dates.DateFormatter('%H:%M:%S') + ax.xaxis.set_major_formatter(majorFormatter) + + # save image + gs.update(wspace=0.275, hspace=0.5) + fig.savefig(outputPath, bbox_inches='tight') + +# and the main event +parser = argparse.ArgumentParser() +parser.add_argument('chartData', type=str, help='Path to file containing JSON chart output from load-generator') +parser.add_argument('--output', type=str, help='Path to save output to', default='latency-chart.png') +parser.add_argument('--title', type=str, help='Chart title') +args = parser.parse_args() + +with open(args.chartData) as data_file: + stuff = [] + for l in data_file.readlines(): + stuff.append(json.loads(l)) + +df = pandas.DataFrame(stuff) +df['finished'] = pandas.to_datetime(df['finished']).astype(datetime.datetime) +df['sent'] = pandas.to_datetime(df['sent']).astype(datetime.datetime) +df['took'] = df['took'].divide(1000000) + +plot_section(df, args.title, args.output) diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/latency.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/latency.go new file mode 100644 index 000000000..234835d68 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/latency.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type point struct { + Sent time.Time `json:"sent"` + Finished time.Time `json:"finished"` + Took int64 `json:"took"` + PType string `json:"type"` + Action string `json:"action"` +} + +type latencyWriter interface { + Add(action string, sent, finished time.Time, pType string) + Close() +} + +type latencyNoop struct{} + +func (ln *latencyNoop) Add(_ string, _, _ time.Time, _ string) {} + +func (ln *latencyNoop) Close() {} + +type latencyFile struct { + metrics chan *point + output *os.File + stop chan struct{} +} + +func newLatencyFile(filename string) (latencyWriter, error) { + if filename == "" { + return &latencyNoop{}, nil + } + fmt.Printf("[+] Opening results file %s\n", filename) + file, err := os.OpenFile(filename, os.O_RDWR|os.O_APPEND|os.O_CREATE, os.ModePerm) + if err != nil { + return nil, err + } + f := &latencyFile{ + metrics: make(chan *point, 2048), + stop: make(chan struct{}, 1), + output: file, + } + go f.write() + return f, nil +} + +func (f *latencyFile) write() { + for { + select { + case p := <-f.metrics: + data, err := json.Marshal(p) + if err != nil { + panic(err) + } + _, err = f.output.Write(append(data, []byte("\n")...)) + if err != nil { + panic(err) + } + case <-f.stop: + return + } + } +} + +// Add writes a point to the file +func (f *latencyFile) Add(action string, sent, finished time.Time, pType string) { + f.metrics <- &point{ + Sent: sent, + Finished: finished, + Took: finished.Sub(sent).Nanoseconds(), + PType: pType, + Action: action, + } +} + +// Close stops f.write() and closes the file, any remaining metrics will be discarded +func (f *latencyFile) Close() { + f.stop <- struct{}{} + _ = f.output.Close() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/main.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/main.go new file mode 100644 index 000000000..1baed0673 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/letsencrypt/boulder/cmd" +) + +type Config struct { + // Execution plan parameters + Plan struct { + Actions []string // things to do + Rate int64 // requests / s + RateDelta string // requests / s^2 + Runtime string // how long to run for + } + ExternalState string // path to file to load/save registrations etc to/from + DontSaveState bool // don't save changes to external state + DirectoryURL string // ACME server directory URL + DomainBase string // base domain name to create authorizations for + HTTPOneAddrs []string // addresses to listen for http-01 validation requests on + TLSALPNOneAddrs []string // addresses to listen for tls-alpn-01 validation requests on + DNSAddrs []string // addresses to listen for DNS requests on + FakeDNS string // IPv6 address to use for all DNS A requests + RealIP string // value of the Real-IP header to use when bypassing CDN + RegEmail string // email to use in registrations + Results string // path to save metrics to + MaxRegs int // maximum number of registrations to create + MaxNamesPerCert int // maximum number of names on one certificate/order + ChallengeStrategy string // challenge selection strategy ("random", "http-01", "dns-01", "tls-alpn-01") + RevokeChance float32 // chance of revoking certificate after issuance, between 0.0 and 1.0 +} + +func main() { + configPath := flag.String("config", "", "Path to configuration file for load-generator") + resultsPath := flag.String("results", "", "Path to latency results file") + rateArg := flag.Int("rate", 0, "") + runtimeArg := flag.String("runtime", "", "") + deltaArg := flag.String("delta", "", "") + flag.Parse() + + if *configPath == "" { + fmt.Fprintf(os.Stderr, "-config argument must not be empty\n") + os.Exit(1) + } + + configBytes, err := os.ReadFile(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read load-generator config file %q: %s\n", *configPath, err) + os.Exit(1) + } + var config Config + err = json.Unmarshal(configBytes, &config) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse load-generator config file: %s\n", err) + os.Exit(1) + } + + if *resultsPath != "" { + config.Results = *resultsPath + } + if *rateArg != 0 { + config.Plan.Rate = int64(*rateArg) + } + if *runtimeArg != "" { + config.Plan.Runtime = *runtimeArg + } + if *deltaArg != "" { + config.Plan.RateDelta = *deltaArg + } + + s, err := New( + config.DirectoryURL, + config.DomainBase, + config.RealIP, + config.MaxRegs, + config.MaxNamesPerCert, + config.Results, + config.RegEmail, + config.Plan.Actions, + config.ChallengeStrategy, + config.RevokeChance, + ) + cmd.FailOnError(err, "Failed to create load generator") + + if config.ExternalState != "" { + err = s.Restore(config.ExternalState) + cmd.FailOnError(err, "Failed to load registration snapshot") + } + + runtime, err := time.ParseDuration(config.Plan.Runtime) + cmd.FailOnError(err, "Failed to parse plan runtime") + + var delta *RateDelta + if config.Plan.RateDelta != "" { + parts := strings.Split(config.Plan.RateDelta, "/") + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "RateDelta is malformed") + os.Exit(1) + } + rate, err := strconv.Atoi(parts[0]) + cmd.FailOnError(err, "Failed to parse increase portion of RateDelta") + period, err := time.ParseDuration(parts[1]) + cmd.FailOnError(err, "Failed to parse period portion of RateDelta") + delta = &RateDelta{Inc: int64(rate), Period: period} + } + + if len(config.HTTPOneAddrs) == 0 && + len(config.TLSALPNOneAddrs) == 0 && + len(config.DNSAddrs) == 0 { + cmd.Fail("There must be at least one bind address in " + + "HTTPOneAddrs, TLSALPNOneAddrs or DNSAddrs\n") + } + + ctx, cancel := context.WithCancel(context.Background()) + go cmd.CatchSignals(cancel) + + err = s.Run( + ctx, + config.HTTPOneAddrs, + config.TLSALPNOneAddrs, + config.DNSAddrs, + config.FakeDNS, + Plan{ + Runtime: runtime, + Rate: config.Plan.Rate, + Delta: delta, + }) + cmd.FailOnError(err, "Failed to run load generator") + + if config.ExternalState != "" && !config.DontSaveState { + err = s.Snapshot(config.ExternalState) + cmd.FailOnError(err, "Failed to save registration snapshot") + } + + fmt.Println("[+] All done, bye bye ^_^") +} diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/requirements.txt b/third-party/github.com/letsencrypt/boulder/test/load-generator/requirements.txt new file mode 100644 index 000000000..46c38e1fd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/requirements.txt @@ -0,0 +1,3 @@ +matplotlib +numpy +pandas diff --git a/third-party/github.com/letsencrypt/boulder/test/load-generator/state.go b/third-party/github.com/letsencrypt/boulder/test/load-generator/state.go new file mode 100644 index 000000000..db6f80640 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/load-generator/state.go @@ -0,0 +1,599 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "reflect" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-jose/go-jose/v4" + + "github.com/letsencrypt/boulder/test/load-generator/acme" + "github.com/letsencrypt/challtestsrv" +) + +// account is an ACME v2 account resource. It does not have a `jose.Signer` +// because we need to set the Signer options per-request with the URL being +// POSTed and must construct it on the fly from the `key`. Accounts are +// protected by a `sync.Mutex` that must be held for updates (see +// `account.Update`). +type account struct { + key *ecdsa.PrivateKey + id string + finalizedOrders []string + certs []string + mu sync.Mutex +} + +// update locks an account resource's mutex and sets the `finalizedOrders` and +// `certs` fields to the provided values. +func (acct *account) update(finalizedOrders, certs []string) { + acct.mu.Lock() + defer acct.mu.Unlock() + + acct.finalizedOrders = append(acct.finalizedOrders, finalizedOrders...) + acct.certs = append(acct.certs, certs...) +} + +type acmeCache struct { + // The current V2 account (may be nil for legacy load generation) + acct *account + // Pending orders waiting for authorization challenge validation + pendingOrders []*OrderJSON + // Fulfilled orders in a valid status waiting for finalization + fulfilledOrders []string + // Finalized orders that have certificates + finalizedOrders []string + + // A list of URLs for issued certificates + certs []string + // The nonce source for JWS signature nonce headers + ns *nonceSource +} + +// signEmbeddedV2Request signs the provided request data using the acmeCache's +// account's private key. The provided URL is set as a protected header per ACME +// v2 JWS standards. The resulting JWS contains an **embedded** JWK - this makes +// this function primarily applicable to new account requests where no key ID is +// known. +func (c *acmeCache) signEmbeddedV2Request(data []byte, url string) (*jose.JSONWebSignature, error) { + // Create a signing key for the account's private key + signingKey := jose.SigningKey{ + Key: c.acct.key, + Algorithm: jose.ES256, + } + // Create a signer, setting the URL protected header + signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{ + NonceSource: c.ns, + EmbedJWK: true, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": url, + }, + }) + if err != nil { + return nil, err + } + + // Sign the data with the signer + signed, err := signer.Sign(data) + if err != nil { + return nil, err + } + return signed, nil +} + +// signKeyIDV2Request signs the provided request data using the acmeCache's +// account's private key. The provided URL is set as a protected header per ACME +// v2 JWS standards. The resulting JWS contains a Key ID header that is +// populated using the acmeCache's account's ID. This is the default JWS signing +// style for ACME v2 requests and should be used everywhere but where the key ID +// is unknown (e.g. new-account requests where an account doesn't exist yet). +func (c *acmeCache) signKeyIDV2Request(data []byte, url string) (*jose.JSONWebSignature, error) { + // Create a JWK with the account's private key and key ID + jwk := &jose.JSONWebKey{ + Key: c.acct.key, + Algorithm: "ECDSA", + KeyID: c.acct.id, + } + + // Create a signing key with the JWK + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.ES256, + } + + // Ensure the signer's nonce source and URL header will be set + opts := &jose.SignerOptions{ + NonceSource: c.ns, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": url, + }, + } + + // Construct the signer with the configured options + signer, err := jose.NewSigner(signerKey, opts) + if err != nil { + return nil, err + } + + // Sign the data with the signer + signed, err := signer.Sign(data) + if err != nil { + return nil, err + } + return signed, nil +} + +type RateDelta struct { + Inc int64 + Period time.Duration +} + +type Plan struct { + Runtime time.Duration + Rate int64 + Delta *RateDelta +} + +type respCode struct { + code int + num int +} + +// State holds *all* the stuff +type State struct { + domainBase string + email string + maxRegs int + maxNamesPerCert int + realIP string + certKey *ecdsa.PrivateKey + + operations []func(*State, *acmeCache) error + + rMu sync.RWMutex + + // accts holds V2 account objects + accts []*account + + challSrv *challtestsrv.ChallSrv + callLatency latencyWriter + + directory *acme.Directory + challStrat acme.ChallengeStrategy + httpClient *http.Client + + revokeChance float32 + + reqTotal int64 + respCodes map[int]*respCode + cMu sync.Mutex + + wg *sync.WaitGroup +} + +type rawAccount struct { + FinalizedOrders []string `json:"finalizedOrders"` + Certs []string `json:"certs"` + ID string `json:"id"` + RawKey []byte `json:"rawKey"` +} + +type snapshot struct { + Accounts []rawAccount +} + +func (s *State) numAccts() int { + s.rMu.RLock() + defer s.rMu.RUnlock() + return len(s.accts) +} + +// Snapshot will save out generated accounts +func (s *State) Snapshot(filename string) error { + fmt.Printf("[+] Saving accounts to %s\n", filename) + snap := snapshot{} + for _, acct := range s.accts { + k, err := x509.MarshalECPrivateKey(acct.key) + if err != nil { + return err + } + snap.Accounts = append(snap.Accounts, rawAccount{ + Certs: acct.certs, + FinalizedOrders: acct.finalizedOrders, + ID: acct.id, + RawKey: k, + }) + } + cont, err := json.Marshal(snap) + if err != nil { + return err + } + return os.WriteFile(filename, cont, os.ModePerm) +} + +// Restore previously generated accounts +func (s *State) Restore(filename string) error { + fmt.Printf("[+] Loading accounts from %q\n", filename) + // NOTE(@cpu): Using os.O_CREATE here explicitly to create the file if it does + // not exist. + f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return err + } + + content, err := io.ReadAll(f) + if err != nil { + return err + } + // If the file's content is the empty string it was probably just created. + // Avoid an unmarshaling error by assuming an empty file is an empty snapshot. + if string(content) == "" { + content = []byte("{}") + } + + snap := snapshot{} + err = json.Unmarshal(content, &snap) + if err != nil { + return err + } + for _, a := range snap.Accounts { + key, err := x509.ParseECPrivateKey(a.RawKey) + if err != nil { + continue + } + s.accts = append(s.accts, &account{ + key: key, + id: a.ID, + finalizedOrders: a.FinalizedOrders, + certs: a.Certs, + }) + } + return nil +} + +// New returns a pointer to a new State struct or an error +func New( + directoryURL string, + domainBase string, + realIP string, + maxRegs, maxNamesPerCert int, + latencyPath string, + userEmail string, + operations []string, + challStrat string, + revokeChance float32) (*State, error) { + certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + directory, err := acme.NewDirectory(directoryURL) + if err != nil { + return nil, err + } + strategy, err := acme.NewChallengeStrategy(challStrat) + if err != nil { + return nil, err + } + if revokeChance > 1 { + return nil, errors.New("revokeChance must be between 0.0 and 1.0") + } + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // CDN bypass can cause validation failures + }, + MaxIdleConns: 500, + IdleConnTimeout: 90 * time.Second, + }, + Timeout: 10 * time.Second, + } + latencyFile, err := newLatencyFile(latencyPath) + if err != nil { + return nil, err + } + s := &State{ + httpClient: httpClient, + directory: directory, + challStrat: strategy, + certKey: certKey, + domainBase: domainBase, + callLatency: latencyFile, + wg: new(sync.WaitGroup), + realIP: realIP, + maxRegs: maxRegs, + maxNamesPerCert: maxNamesPerCert, + email: userEmail, + respCodes: make(map[int]*respCode), + revokeChance: revokeChance, + } + + // convert operations strings to methods + for _, opName := range operations { + op, present := stringToOperation[opName] + if !present { + return nil, fmt.Errorf("unknown operation %q", opName) + } + s.operations = append(s.operations, op) + } + + return s, nil +} + +// Run runs the WFE load-generator +func (s *State) Run( + ctx context.Context, + httpOneAddrs []string, + tlsALPNOneAddrs []string, + dnsAddrs []string, + fakeDNS string, + p Plan) error { + // Create a new challenge server binding the requested addrs. + challSrv, err := challtestsrv.New(challtestsrv.Config{ + HTTPOneAddrs: httpOneAddrs, + TLSALPNOneAddrs: tlsALPNOneAddrs, + DNSOneAddrs: dnsAddrs, + // Use a logger that has a load-generator prefix + Log: log.New(os.Stdout, "load-generator challsrv - ", log.LstdFlags), + }) + // Setup the challenge server to return the mock "fake DNS" IP address + challSrv.SetDefaultDNSIPv4(fakeDNS) + // Disable returning any AAAA records. + challSrv.SetDefaultDNSIPv6("") + + if err != nil { + return err + } + // Save the challenge server in the state + s.challSrv = challSrv + + // Start the Challenge server in its own Go routine + go s.challSrv.Run() + + if p.Delta != nil { + go func() { + for { + time.Sleep(p.Delta.Period) + atomic.AddInt64(&p.Rate, p.Delta.Inc) + } + }() + } + + // Run sending loop + stop := make(chan bool, 1) + fmt.Println("[+] Beginning execution plan") + i := int64(0) + go func() { + for { + start := time.Now() + select { + case <-stop: + return + default: + s.wg.Add(1) + go s.sendCall() + atomic.AddInt64(&i, 1) + } + sf := time.Duration(time.Second.Nanoseconds()/atomic.LoadInt64(&p.Rate)) - time.Since(start) + time.Sleep(sf) + } + }() + go func() { + lastTotal := int64(0) + lastReqTotal := int64(0) + for { + time.Sleep(time.Second) + curTotal := atomic.LoadInt64(&i) + curReqTotal := atomic.LoadInt64(&s.reqTotal) + fmt.Printf( + "%s Action rate: %d/s [expected: %d/s], Request rate: %d/s, Responses: [%s]\n", + time.Now().Format(time.DateTime), + curTotal-lastTotal, + atomic.LoadInt64(&p.Rate), + curReqTotal-lastReqTotal, + s.respCodeString(), + ) + lastTotal = curTotal + lastReqTotal = curReqTotal + } + }() + + select { + case <-time.After(p.Runtime): + fmt.Println("[+] Execution plan finished") + case <-ctx.Done(): + fmt.Println("[!] Execution plan cancelled") + } + stop <- true + fmt.Println("[+] Waiting for pending flows to finish before killing challenge server") + s.wg.Wait() + fmt.Println("[+] Shutting down challenge server") + s.challSrv.Shutdown() + return nil +} + +// HTTP utils + +func (s *State) addRespCode(code int) { + s.cMu.Lock() + defer s.cMu.Unlock() + code = code / 100 + if e, ok := s.respCodes[code]; ok { + e.num++ + } else if !ok { + s.respCodes[code] = &respCode{code, 1} + } +} + +// codes is a convenience type for holding copies of the state object's +// `respCodes` field of `map[int]*respCode`. Unlike the state object the +// respCodes are copied by value and not held as pointers. The codes type allows +// sorting the response codes for output. +type codes []respCode + +func (c codes) Len() int { + return len(c) +} + +func (c codes) Less(i, j int) bool { + return c[i].code < c[j].code +} + +func (c codes) Swap(i, j int) { + c[i], c[j] = c[j], c[i] +} + +func (s *State) respCodeString() string { + s.cMu.Lock() + list := codes{} + for _, v := range s.respCodes { + list = append(list, *v) + } + s.cMu.Unlock() + sort.Sort(list) + counts := []string{} + for _, v := range list { + counts = append(counts, fmt.Sprintf("%dxx: %d", v.code, v.num)) + } + return strings.Join(counts, ", ") +} + +var userAgent = "boulder load-generator -- heyo ^_^" + +func (s *State) post( + url string, + payload []byte, + ns *nonceSource, + latencyTag string, + expectedCode int) (*http.Response, error) { + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Add("X-Real-IP", s.realIP) + req.Header.Add("User-Agent", userAgent) + req.Header.Add("Content-Type", "application/jose+json") + atomic.AddInt64(&s.reqTotal, 1) + started := time.Now() + resp, err := s.httpClient.Do(req) + finished := time.Now() + state := "error" + // Defer logging the latency and result + defer func() { + s.callLatency.Add(latencyTag, started, finished, state) + }() + if err != nil { + return nil, err + } + go s.addRespCode(resp.StatusCode) + if newNonce := resp.Header.Get("Replay-Nonce"); newNonce != "" { + ns.addNonce(newNonce) + } + if resp.StatusCode != expectedCode { + return nil, fmt.Errorf("POST %q returned HTTP status %d, expected %d", + url, resp.StatusCode, expectedCode) + } + state = "good" + return resp, nil +} + +type nonceSource struct { + mu sync.Mutex + noncePool []string + s *State +} + +func (ns *nonceSource) getNonce() (string, error) { + nonceURL := ns.s.directory.EndpointURL(acme.NewNonceEndpoint) + latencyTag := string(acme.NewNonceEndpoint) + started := time.Now() + resp, err := ns.s.httpClient.Head(nonceURL) + finished := time.Now() + state := "error" + defer func() { + ns.s.callLatency.Add(fmt.Sprintf("HEAD %s", latencyTag), + started, finished, state) + }() + if err != nil { + return "", err + } + defer resp.Body.Close() + if nonce := resp.Header.Get("Replay-Nonce"); nonce != "" { + state = "good" + return nonce, nil + } + return "", errors.New("'Replay-Nonce' header not supplied") +} + +// Nonce satisfies the interface jose.NonceSource, should probably actually be per context but ¯\_(ツ)_/¯ for now +func (ns *nonceSource) Nonce() (string, error) { + ns.mu.Lock() + if len(ns.noncePool) == 0 { + ns.mu.Unlock() + return ns.getNonce() + } + defer ns.mu.Unlock() + nonce := ns.noncePool[0] + if len(ns.noncePool) > 1 { + ns.noncePool = ns.noncePool[1:] + } else { + ns.noncePool = []string{} + } + return nonce, nil +} + +func (ns *nonceSource) addNonce(nonce string) { + ns.mu.Lock() + defer ns.mu.Unlock() + ns.noncePool = append(ns.noncePool, nonce) +} + +// addAccount adds the provided account to the state's list of accts +func (s *State) addAccount(acct *account) { + s.rMu.Lock() + defer s.rMu.Unlock() + + s.accts = append(s.accts, acct) +} + +func (s *State) sendCall() { + defer s.wg.Done() + c := &acmeCache{} + + for _, op := range s.operations { + err := op(s, c) + if err != nil { + method := runtime.FuncForPC(reflect.ValueOf(op).Pointer()).Name() + fmt.Printf("[FAILED] %s: %s\n", method, err) + break + } + } + // If the acmeCache's V2 account isn't nil, update it based on the cache's + // finalizedOrders and certs. + if c.acct != nil { + c.acct.update(c.finalizedOrders, c.certs) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http.go b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http.go new file mode 100644 index 000000000..3b6fd916b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" +) + +// filter filters mails based on the To: and From: fields. +// The zero value matches all mails. +type filter struct { + To string + From string +} + +func (f *filter) Match(m rcvdMail) bool { + if f.To != "" && f.To != m.To { + return false + } + if f.From != "" && f.From != m.From { + return false + } + return true +} + +/* +/count - number of mails +/count?to=foo@bar.com - number of mails for foo@bar.com +/count?from=service@test.org - number of mails sent by service@test.org +/clear - clear the mail list +/mail/0 - first mail +/mail/1 - second mail +/mail/0?to=foo@bar.com - first mail for foo@bar.com +/mail/1?to=foo@bar.com - second mail for foo@bar.com +/mail/1?to=foo@bar.com&from=service@test.org - second mail for foo@bar.com from service@test.org +*/ + +func (srv *mailSrv) setupHTTP(serveMux *http.ServeMux) { + serveMux.HandleFunc("/count", srv.httpCount) + serveMux.HandleFunc("/clear", srv.httpClear) + serveMux.Handle("/mail/", http.StripPrefix("/mail/", http.HandlerFunc(srv.httpGetMail))) +} + +func (srv *mailSrv) httpClear(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + srv.allMailMutex.Lock() + srv.allReceivedMail = nil + srv.allMailMutex.Unlock() + w.WriteHeader(200) + } else { + w.WriteHeader(405) + } +} + +func (srv *mailSrv) httpCount(w http.ResponseWriter, r *http.Request) { + count := 0 + srv.iterMail(extractFilter(r), func(m rcvdMail) bool { + count++ + return false + }) + fmt.Fprintf(w, "%d\n", count) +} + +func (srv *mailSrv) httpGetMail(w http.ResponseWriter, r *http.Request) { + mailNum, err := strconv.Atoi(strings.Trim(r.URL.Path, "/")) + if err != nil { + w.WriteHeader(400) + log.Println("mail-test-srv: bad request:", r.URL.Path, "-", err) + return + } + idx := 0 + found := srv.iterMail(extractFilter(r), func(m rcvdMail) bool { + if mailNum == idx { + printMail(w, m) + return true + } + idx++ + return false + }) + if !found { + w.WriteHeader(404) + } +} + +func extractFilter(r *http.Request) filter { + values := r.URL.Query() + return filter{To: values.Get("to"), From: values.Get("from")} +} + +func (srv *mailSrv) iterMail(f filter, cb func(rcvdMail) bool) bool { + srv.allMailMutex.Lock() + defer srv.allMailMutex.Unlock() + for _, v := range srv.allReceivedMail { + if !f.Match(v) { + continue + } + if cb(v) { + return true + } + } + return false +} + +func printMail(w io.Writer, mail rcvdMail) { + fmt.Fprintf(w, "FROM %s\n", mail.From) + fmt.Fprintf(w, "TO %s\n", mail.To) + fmt.Fprintf(w, "\n%s\n", mail.Mail) +} diff --git a/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http_test.go b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http_test.go new file mode 100644 index 000000000..9bfb67742 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/http_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func reqAndRecorder(t testing.TB, method, relativeUrl string, body io.Reader) (*httptest.ResponseRecorder, *http.Request) { + endURL := fmt.Sprintf("http://localhost:9381%s", relativeUrl) + r, err := http.NewRequest(method, endURL, body) + if err != nil { + t.Fatalf("could not construct request: %v", err) + } + return httptest.NewRecorder(), r +} + +func TestHTTPClear(t *testing.T) { + srv := mailSrv{} + w, r := reqAndRecorder(t, "POST", "/clear", nil) + srv.allReceivedMail = []rcvdMail{{}} + srv.httpClear(w, r) + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + if len(srv.allReceivedMail) != 0 { + t.Error("/clear failed to clear mail buffer") + } + + w, r = reqAndRecorder(t, "GET", "/clear", nil) + srv.allReceivedMail = []rcvdMail{{}} + srv.httpClear(w, r) + if w.Code != 405 { + t.Errorf("expected 405, got %d", w.Code) + } + if len(srv.allReceivedMail) != 1 { + t.Error("GET /clear cleared the mail buffer") + } +} + +func TestHTTPCount(t *testing.T) { + srv := mailSrv{} + srv.allReceivedMail = []rcvdMail{ + {From: "a", To: "b"}, + {From: "a", To: "b"}, + {From: "a", To: "c"}, + {From: "c", To: "a"}, + {From: "c", To: "b"}, + } + + tests := []struct { + URL string + Count int + }{ + {URL: "/count", Count: 5}, + {URL: "/count?to=b", Count: 3}, + {URL: "/count?to=c", Count: 1}, + } + + var buf bytes.Buffer + for _, test := range tests { + w, r := reqAndRecorder(t, "GET", test.URL, nil) + buf.Reset() + w.Body = &buf + + srv.httpCount(w, r) + if w.Code != 200 { + t.Errorf("%s: expected 200, got %d", test.URL, w.Code) + } + n, err := strconv.Atoi(strings.TrimSpace(buf.String())) + if err != nil { + t.Errorf("%s: expected a number, got '%s'", test.URL, buf.String()) + } else if n != test.Count { + t.Errorf("%s: expected %d, got %d", test.URL, test.Count, n) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/main.go b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/main.go new file mode 100644 index 000000000..3d13532a5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/mail-test-srv/main.go @@ -0,0 +1,251 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/mail" + "regexp" + "strings" + "sync" + + "github.com/letsencrypt/boulder/cmd" + blog "github.com/letsencrypt/boulder/log" +) + +type mailSrv struct { + closeFirst uint + allReceivedMail []rcvdMail + allMailMutex sync.Mutex + connNumber uint + connNumberMutex sync.RWMutex + logger blog.Logger +} + +type rcvdMail struct { + From string + To string + Mail string +} + +func expectLine(buf *bufio.Reader, expected string) error { + line, _, err := buf.ReadLine() + if err != nil { + return fmt.Errorf("readline: %v", err) + } + if string(line) != expected { + return fmt.Errorf("Expected %s, got %s", expected, line) + } + return nil +} + +var mailFromRegex = regexp.MustCompile(`^MAIL FROM:<(.*)>\s*BODY=8BITMIME\s*$`) +var rcptToRegex = regexp.MustCompile(`^RCPT TO:<(.*)>\s*$`) +var smtpErr501 = []byte("501 syntax error in parameters or arguments \r\n") +var smtpOk250 = []byte("250 OK \r\n") + +func (srv *mailSrv) handleConn(conn net.Conn) { + defer conn.Close() + srv.connNumberMutex.Lock() + srv.connNumber++ + srv.connNumberMutex.Unlock() + srv.logger.Infof("mail-test-srv: Got connection from %s", conn.RemoteAddr()) + + readBuf := bufio.NewReader(conn) + conn.Write([]byte("220 smtp.example.com ESMTP\r\n")) + err := expectLine(readBuf, "EHLO localhost") + if err != nil { + log.Printf("mail-test-srv: %s: %v\n", conn.RemoteAddr(), err) + return + } + conn.Write([]byte("250-PIPELINING\r\n")) + conn.Write([]byte("250-AUTH PLAIN LOGIN\r\n")) + conn.Write([]byte("250 8BITMIME\r\n")) + // This AUTH PLAIN is the output of: echo -en '\0cert-manager@example.com\0password' | base64 + // Must match the mail configs for integration tests. + err = expectLine(readBuf, "AUTH PLAIN AGNlcnQtbWFuYWdlckBleGFtcGxlLmNvbQBwYXNzd29yZA==") + if err != nil { + log.Printf("mail-test-srv: %s: %v\n", conn.RemoteAddr(), err) + return + } + conn.Write([]byte("235 2.7.0 Authentication successful\r\n")) + srv.logger.Infof("mail-test-srv: Successful auth from %s", conn.RemoteAddr()) + + // necessary commands: + // MAIL RCPT DATA QUIT + + var fromAddr string + var toAddr []string + + clearState := func() { + fromAddr = "" + toAddr = nil + } + + reader := bufio.NewScanner(readBuf) +scan: + for reader.Scan() { + line := reader.Text() + cmdSplit := strings.SplitN(line, " ", 2) + cmd := cmdSplit[0] + switch cmd { + case "QUIT": + conn.Write([]byte("221 Bye \r\n")) + break scan + case "RSET": + clearState() + conn.Write(smtpOk250) + case "NOOP": + conn.Write(smtpOk250) + case "MAIL": + srv.connNumberMutex.RLock() + if srv.connNumber <= srv.closeFirst { + // Half of the time, close cleanly to simulate the server side closing + // unexpectedly. + if srv.connNumber%2 == 0 { + log.Printf( + "mail-test-srv: connection # %d < -closeFirst parameter %d, disconnecting client. Bye!\n", + srv.connNumber, srv.closeFirst) + clearState() + conn.Close() + } else { + // The rest of the time, simulate a stale connection timeout by sending + // a SMTP 421 message. This replicates the timeout/close from issue + // 2249 - https://github.com/letsencrypt/boulder/issues/2249 + log.Printf( + "mail-test-srv: connection # %d < -closeFirst parameter %d, disconnecting with 421. Bye!\n", + srv.connNumber, srv.closeFirst) + clearState() + conn.Write([]byte("421 1.2.3 foo.bar.baz Error: timeout exceeded \r\n")) + conn.Close() + } + } + srv.connNumberMutex.RUnlock() + clearState() + matches := mailFromRegex.FindStringSubmatch(line) + if matches == nil { + log.Panicf("mail-test-srv: %s: MAIL FROM parse error\n", conn.RemoteAddr()) + } + addr, err := mail.ParseAddress(matches[1]) + if err != nil { + log.Panicf("mail-test-srv: %s: addr parse error: %v\n", conn.RemoteAddr(), err) + } + fromAddr = addr.Address + conn.Write(smtpOk250) + case "RCPT": + matches := rcptToRegex.FindStringSubmatch(line) + if matches == nil { + conn.Write(smtpErr501) + continue + } + addr, err := mail.ParseAddress(matches[1]) + if err != nil { + log.Panicf("mail-test-srv: %s: addr parse error: %v\n", conn.RemoteAddr(), err) + } + toAddr = append(toAddr, addr.Address) + conn.Write(smtpOk250) + case "DATA": + conn.Write([]byte("354 Start mail input \r\n")) + var msgBuf bytes.Buffer + + for reader.Scan() { + line := reader.Text() + msgBuf.WriteString(line) + msgBuf.WriteString("\r\n") + if strings.HasSuffix(msgBuf.String(), "\r\n.\r\n") { + break + } + } + if reader.Err() != nil { + log.Printf("mail-test-srv: read from %s: %v\n", conn.RemoteAddr(), reader.Err()) + return + } + + mailResult := rcvdMail{ + From: fromAddr, + Mail: msgBuf.String(), + } + srv.allMailMutex.Lock() + for _, rcpt := range toAddr { + mailResult.To = rcpt + srv.allReceivedMail = append(srv.allReceivedMail, mailResult) + log.Printf("mail-test-srv: Got mail: %s -> %s\n", fromAddr, rcpt) + } + srv.allMailMutex.Unlock() + conn.Write([]byte("250 Got mail \r\n")) + clearState() + } + } + if reader.Err() != nil { + log.Printf("mail-test-srv: read from %s: %s\n", conn.RemoteAddr(), reader.Err()) + } +} + +func (srv *mailSrv) serveSMTP(ctx context.Context, l net.Listener) error { + for { + conn, err := l.Accept() + if err != nil { + // If the accept call returned an error because the listener has been + // closed, then the context should have been canceled too. In that case, + // ignore the error. + select { + case <-ctx.Done(): + return nil + default: + return err + } + } + go srv.handleConn(conn) + } +} + +func main() { + var listenAPI = flag.String("http", "0.0.0.0:9381", "http port to listen on") + var listenSMTP = flag.String("smtp", "0.0.0.0:9380", "smtp port to listen on") + var certFilename = flag.String("cert", "", "certificate to serve") + var privKeyFilename = flag.String("key", "", "private key for certificate") + var closeFirst = flag.Uint("closeFirst", 0, "close first n connections after MAIL for reconnection tests") + + flag.Parse() + + cert, err := tls.LoadX509KeyPair(*certFilename, *privKeyFilename) + if err != nil { + log.Fatal(err) + } + l, err := tls.Listen("tcp", *listenSMTP, &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + if err != nil { + log.Fatalf("Couldn't bind %q for SMTP: %s", *listenSMTP, err) + } + defer l.Close() + + srv := mailSrv{ + closeFirst: *closeFirst, + logger: cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7}), + } + + srv.setupHTTP(http.DefaultServeMux) + go func() { + // The gosec linter complains that timeouts cannot be set here. That's fine, + // because this is test-only code. + ////nolint:gosec + err := http.ListenAndServe(*listenAPI, http.DefaultServeMux) + if err != nil { + log.Fatalln("Couldn't start HTTP server", err) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go cmd.FailOnError(srv.serveSMTP(ctx, l), "Failed to accept connection") + + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ocsp/README.md b/third-party/github.com/letsencrypt/boulder/test/ocsp/README.md new file mode 100644 index 000000000..b96bf9f01 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ocsp/README.md @@ -0,0 +1,10 @@ +This directory contains two utilities for checking ocsp. + +"checkocsp" is a command-line tool to check the OCSP response for a certificate +or a list of certificates. + +"ocsp_forever" is a similar tool that runs as a daemon and continually checks +OCSP for a list of certificates, and exports Prometheus stats. + +Both of these are useful for monitoring a Boulder instance. "checkocsp" is also +useful for debugging. diff --git a/third-party/github.com/letsencrypt/boulder/test/ocsp/checkari/main.go b/third-party/github.com/letsencrypt/boulder/test/ocsp/checkari/main.go new file mode 100644 index 000000000..dafbf5052 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ocsp/checkari/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "crypto" + _ "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "math/big" + "net/http" + "os" + + "github.com/letsencrypt/boulder/core" +) + +// certID matches the ASN.1 structure of the CertID sequence defined by RFC6960. +type certID struct { + HashAlgorithm pkix.AlgorithmIdentifier + IssuerNameHash []byte + IssuerKeyHash []byte + SerialNumber *big.Int +} + +func createRequest(cert *x509.Certificate) ([]byte, error) { + if !crypto.SHA256.Available() { + return nil, x509.ErrUnsupportedAlgorithm + } + h := crypto.SHA256.New() + + h.Write(cert.RawIssuer) + issuerNameHash := h.Sum(nil) + + req := certID{ + pkix.AlgorithmIdentifier{ // SHA256 + Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, + Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, + }, + issuerNameHash, + cert.AuthorityKeyId, + cert.SerialNumber, + } + + return asn1.Marshal(req) +} + +func parseResponse(resp *http.Response) (*core.RenewalInfo, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var res core.RenewalInfo + err = json.Unmarshal(body, &res) + if err != nil { + return nil, err + } + + return &res, nil +} + +func checkARI(baseURL string, certPath string) (*core.RenewalInfo, error) { + cert, err := core.LoadCert(certPath) + if err != nil { + return nil, err + } + + req, err := createRequest(cert) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/%s", baseURL, base64.RawURLEncoding.EncodeToString(req)) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + ri, err := parseResponse(resp) + if err != nil { + return nil, err + } + + return ri, nil +} + +func getARIURL(directory string) (string, error) { + resp, err := http.Get(directory) + if err != nil { + return "", err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var dir struct { + RenewalInfo string `json:"renewalInfo"` + } + err = json.Unmarshal(body, &dir) + if err != nil { + return "", err + } + + return dir.RenewalInfo, nil +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, ` +checkari [-url https://acme.api/directory] FILE [FILE]... + +Tool for querying ARI. Provide a list of filenames for certificates in PEM +format, and this tool will query for and output the suggested renewal window +for each certificate. + +`) + flag.PrintDefaults() + } + directory := flag.String("url", "https://acme-v02.api.letsencrypt.org/directory", "ACME server's Directory URL") + flag.Parse() + if len(flag.Args()) == 0 { + flag.Usage() + os.Exit(1) + } + + ariPath, err := getARIURL(*directory) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + + for _, cert := range flag.Args() { + fmt.Printf("%s:\n", cert) + window, err := checkARI(ariPath, cert) + if err != nil { + fmt.Printf("\t%s\n", err) + } else { + fmt.Printf("\tRenew after : %s\n", window.SuggestedWindow.Start) + fmt.Printf("\tRenew before: %s\n", window.SuggestedWindow.End) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ocsp/checkocsp/checkocsp.go b/third-party/github.com/letsencrypt/boulder/test/ocsp/checkocsp/checkocsp.go new file mode 100644 index 000000000..52a52f9b4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ocsp/checkocsp/checkocsp.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/hex" + "flag" + "fmt" + "log" + "math/big" + "os" + "strings" + + "github.com/letsencrypt/boulder/test/ocsp/helper" +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, ` +checkocsp [OPTION]... FILE [FILE]... + +OCSP-checking tool. Provide a list of filenames for certificates in PEM format, +and this tool will check OCSP for each certificate based on its AIA field. +It will return an error if the OCSP server fails to respond for any request, +if any response is invalid or has a bad signature, or if any response is too +stale. + +`) + flag.PrintDefaults() + } + helper.RegisterFlags() + serials := flag.Bool("serials", false, "Parameters are hex-encoded serial numbers instead of filenames. Requires --issuer-file and --url.") + flag.Parse() + var errors bool + if len(flag.Args()) == 0 { + flag.Usage() + os.Exit(0) + } + config, err := helper.ConfigFromFlags() + if err != nil { + log.Fatal(err) + } + for _, a := range flag.Args() { + var err error + var bytes []byte + if *serials { + bytes, err = hex.DecodeString(strings.Replace(a, ":", "", -1)) + if err != nil { + log.Printf("error for %s: %s\n", a, err) + } + serialNumber := big.NewInt(0).SetBytes(bytes) + _, err = helper.ReqSerial(serialNumber, config) + + } else { + _, err = helper.ReqFile(a, config) + } + if err != nil { + log.Printf("error for %s: %s\n", a, err) + errors = true + } + } + if errors { + os.Exit(1) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ocsp/helper/helper.go b/third-party/github.com/letsencrypt/boulder/test/ocsp/helper/helper.go new file mode 100644 index 000000000..a223f5fa6 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ocsp/helper/helper.go @@ -0,0 +1,468 @@ +package helper + +import ( + "bytes" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ocsp" +) + +var ( + method *string + urlOverride *string + hostOverride *string + tooSoon *int + ignoreExpiredCerts *bool + expectStatus *int + expectReason *int + issuerFile *string +) + +// Config contains fields which control various behaviors of the +// checker's behavior. +type Config struct { + method string + urlOverride string + hostOverride string + tooSoon int + ignoreExpiredCerts bool + expectStatus int + expectReason int + output io.Writer + issuerFile string +} + +// DefaultConfig is a Config populated with a set of curated default values +// intended for library test usage of this package. +var DefaultConfig = Config{ + method: "GET", + urlOverride: "", + hostOverride: "", + tooSoon: 76, + ignoreExpiredCerts: false, + expectStatus: -1, + expectReason: -1, + output: io.Discard, + issuerFile: "", +} + +var parseFlagsOnce sync.Once + +// RegisterFlags registers command-line flags that affect OCSP checking. +func RegisterFlags() { + method = flag.String("method", DefaultConfig.method, "Method to use for fetching OCSP") + urlOverride = flag.String("url", DefaultConfig.urlOverride, "URL of OCSP responder to override") + hostOverride = flag.String("host", DefaultConfig.hostOverride, "Host header to override in HTTP request") + tooSoon = flag.Int("too-soon", DefaultConfig.tooSoon, "If NextUpdate is fewer than this many hours in future, warn.") + ignoreExpiredCerts = flag.Bool("ignore-expired-certs", DefaultConfig.ignoreExpiredCerts, "If a cert is expired, don't bother requesting OCSP.") + expectStatus = flag.Int("expect-status", DefaultConfig.expectStatus, "Expect response to have this numeric status (0=Good, 1=Revoked, 2=Unknown); or -1 for no enforcement.") + expectReason = flag.Int("expect-reason", DefaultConfig.expectReason, "Expect response to have this numeric revocation reason (0=Unspecified, 1=KeyCompromise, etc); or -1 for no enforcement.") + issuerFile = flag.String("issuer-file", DefaultConfig.issuerFile, "Path to issuer file. Use as an alternative to automatic fetch of issuer from the certificate.") +} + +// ConfigFromFlags returns a Config whose values are populated from any command +// line flags passed by the user, or default values if not passed. However, it +// replaces io.Discard with os.Stdout so that CLI usages of this package +// will produce output on stdout by default. +func ConfigFromFlags() (Config, error) { + parseFlagsOnce.Do(func() { + flag.Parse() + }) + if method == nil || urlOverride == nil || hostOverride == nil || tooSoon == nil || ignoreExpiredCerts == nil || expectStatus == nil || expectReason == nil || issuerFile == nil { + return DefaultConfig, errors.New("ConfigFromFlags was called without registering flags. Call RegisterFlags before flag.Parse()") + } + return Config{ + method: *method, + urlOverride: *urlOverride, + hostOverride: *hostOverride, + tooSoon: *tooSoon, + ignoreExpiredCerts: *ignoreExpiredCerts, + expectStatus: *expectStatus, + expectReason: *expectReason, + output: os.Stdout, + issuerFile: *issuerFile, + }, nil +} + +// WithExpectStatus returns a new Config with the given expectStatus, +// and all other fields the same as the receiver. +func (template Config) WithExpectStatus(status int) Config { + ret := template + ret.expectStatus = status + return ret +} + +// WithExpectReason returns a new Config with the given expectReason, +// and all other fields the same as the receiver. +func (template Config) WithExpectReason(reason int) Config { + ret := template + ret.expectReason = reason + return ret +} + +// WithOutput returns a new Config with the given output, +// and all other fields the same as the receiver. +func (template Config) WithOutput(w io.Writer) Config { + ret := template + ret.output = w + return ret +} + +func GetIssuerFile(f string) (*x509.Certificate, error) { + certFileBytes, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading issuer file: %w", err) + } + block, _ := pem.Decode(certFileBytes) + if block == nil { + return nil, fmt.Errorf("no pem data found in issuer file") + } + issuer, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing issuer certificate: %w", err) + } + return issuer, nil +} + +func GetIssuer(cert *x509.Certificate) (*x509.Certificate, error) { + if cert == nil { + return nil, fmt.Errorf("nil certificate") + } + if len(cert.IssuingCertificateURL) == 0 { + return nil, fmt.Errorf("No AIA information available, can't get issuer") + } + issuerURL := cert.IssuingCertificateURL[0] + resp, err := http.Get(issuerURL) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("got http status code %d from AIA issuer url %q", resp.StatusCode, resp.Request.URL) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var issuer *x509.Certificate + contentType := resp.Header.Get("Content-Type") + if contentType == "application/x-pkcs7-mime" || contentType == "application/pkcs7-mime" { + issuer, err = parseCMS(body) + } else { + issuer, err = parse(body) + } + if err != nil { + return nil, fmt.Errorf("from %s: %w", issuerURL, err) + } + return issuer, nil +} + +// parse tries to parse the bytes as a PEM or DER-encoded certificate. +func parse(body []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(body) + var der []byte + if block == nil { + der = body + } else { + der = block.Bytes + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + return cert, nil +} + +// parseCMS parses certificates from CMS messages of type SignedData. +func parseCMS(body []byte) (*x509.Certificate, error) { + type signedData struct { + Version int + Digests asn1.RawValue + EncapContentInfo asn1.RawValue + Certificates asn1.RawValue + } + type cms struct { + ContentType asn1.ObjectIdentifier + SignedData signedData `asn1:"explicit,tag:0"` + } + var msg cms + _, err := asn1.Unmarshal(body, &msg) + if err != nil { + return nil, fmt.Errorf("parsing CMS: %s", err) + } + cert, err := x509.ParseCertificate(msg.SignedData.Certificates.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing CMS: %s", err) + } + return cert, nil +} + +// ReqFile makes an OCSP request using the given config for the PEM-encoded +// certificate in fileName, and returns the response. +func ReqFile(fileName string, config Config) (*ocsp.Response, error) { + contents, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + return ReqDER(contents, config) +} + +// ReqDER makes an OCSP request using the given config for the given DER-encoded +// certificate, and returns the response. +func ReqDER(der []byte, config Config) (*ocsp.Response, error) { + cert, err := parse(der) + if err != nil { + return nil, fmt.Errorf("parsing certificate: %s", err) + } + if time.Now().After(cert.NotAfter) { + if config.ignoreExpiredCerts { + return nil, nil + } + return nil, fmt.Errorf("certificate expired %s ago: %s", time.Since(cert.NotAfter), cert.NotAfter) + } + return Req(cert, config) +} + +// ReqSerial makes an OCSP request using the given config for a certificate only identified by +// serial number. It requires that the Config have issuerFile set. +func ReqSerial(serialNumber *big.Int, config Config) (*ocsp.Response, error) { + if config.issuerFile == "" { + return nil, errors.New("checking OCSP by serial number requires --issuer-file") + } + return Req(&x509.Certificate{SerialNumber: serialNumber}, config) +} + +// Req makes an OCSP request using the given config for the given in-memory +// certificate, and returns the response. +func Req(cert *x509.Certificate, config Config) (*ocsp.Response, error) { + var issuer *x509.Certificate + var err error + if config.issuerFile == "" { + issuer, err = GetIssuer(cert) + if err != nil { + return nil, fmt.Errorf("problem getting issuer (try --issuer-file flag instead): %w", err) + } + } else { + issuer, err = GetIssuerFile(config.issuerFile) + } + if err != nil { + return nil, fmt.Errorf("getting issuer: %s", err) + } + req, err := ocsp.CreateRequest(cert, issuer, nil) + if err != nil { + return nil, fmt.Errorf("creating OCSP request: %s", err) + } + + ocspURL, err := getOCSPURL(cert, config.urlOverride) + if err != nil { + return nil, err + } + + httpResp, err := sendHTTPRequest(req, ocspURL, config.method, config.hostOverride, config.output) + if err != nil { + return nil, err + } + respBytes, err := io.ReadAll(httpResp.Body) + defer httpResp.Body.Close() + if err != nil { + return nil, err + } + fmt.Fprintf(config.output, "HTTP %d\n", httpResp.StatusCode) + for k, v := range httpResp.Header { + for _, vv := range v { + fmt.Fprintf(config.output, "%s: %s\n", k, vv) + } + } + if httpResp.StatusCode != 200 { + return nil, StatusCodeError{httpResp.StatusCode, respBytes} + } + if len(respBytes) == 0 { + return nil, fmt.Errorf("empty response body") + } + return parseAndPrint(respBytes, cert, issuer, config) +} + +type StatusCodeError struct { + Code int + Body []byte +} + +func (e StatusCodeError) Error() string { + return fmt.Sprintf("HTTP status code %d, body: %s", e.Code, e.Body) +} + +func sendHTTPRequest( + req []byte, + ocspURL *url.URL, + method string, + host string, + output io.Writer, +) (*http.Response, error) { + encodedReq := base64.StdEncoding.EncodeToString(req) + var httpRequest *http.Request + var err error + if method == "GET" { + ocspURL.Path = encodedReq + fmt.Fprintf(output, "Fetching %s\n", ocspURL.String()) + httpRequest, err = http.NewRequest("GET", ocspURL.String(), http.NoBody) + } else if method == "POST" { + fmt.Fprintf(output, "POSTing request, reproduce with: curl -i --data-binary @- %s < <(base64 -d <<<%s)\n", + ocspURL, encodedReq) + httpRequest, err = http.NewRequest("POST", ocspURL.String(), bytes.NewBuffer(req)) + } else { + return nil, fmt.Errorf("invalid method %s, expected GET or POST", method) + } + if err != nil { + return nil, err + } + httpRequest.Header.Add("Content-Type", "application/ocsp-request") + if host != "" { + httpRequest.Host = host + } + client := http.Client{ + Timeout: 5 * time.Second, + } + + return client.Do(httpRequest) +} + +func getOCSPURL(cert *x509.Certificate, urlOverride string) (*url.URL, error) { + var ocspServer string + if urlOverride != "" { + ocspServer = urlOverride + } else if len(cert.OCSPServer) > 0 { + ocspServer = cert.OCSPServer[0] + } else { + return nil, fmt.Errorf("no ocsp servers in cert") + } + ocspURL, err := url.Parse(ocspServer) + if err != nil { + return nil, fmt.Errorf("parsing URL: %s", err) + } + return ocspURL, nil +} + +// checkSignerTimes checks that the OCSP response is within the +// validity window of whichever certificate signed it, and that that +// certificate is currently valid. +func checkSignerTimes(resp *ocsp.Response, issuer *x509.Certificate, output io.Writer) error { + var ocspSigner = issuer + if delegatedSigner := resp.Certificate; delegatedSigner != nil { + ocspSigner = delegatedSigner + + fmt.Fprintf(output, "Using delegated OCSP signer from response: %s\n", + base64.StdEncoding.EncodeToString(ocspSigner.Raw)) + } + + if resp.NextUpdate.After(ocspSigner.NotAfter) { + return fmt.Errorf("OCSP response is valid longer than OCSP signer (%s): %s is after %s", + ocspSigner.Subject, resp.NextUpdate, ocspSigner.NotAfter) + } + if resp.ThisUpdate.Before(ocspSigner.NotBefore) { + return fmt.Errorf("OCSP response's validity begins before the OCSP signer's (%s): %s is before %s", + ocspSigner.Subject, resp.ThisUpdate, ocspSigner.NotBefore) + } + + if time.Now().After(ocspSigner.NotAfter) { + return fmt.Errorf("OCSP signer (%s) expired at %s", ocspSigner.Subject, ocspSigner.NotAfter) + } + if time.Now().Before(ocspSigner.NotBefore) { + return fmt.Errorf("OCSP signer (%s) not valid until %s", ocspSigner.Subject, ocspSigner.NotBefore) + } + return nil +} + +func parseAndPrint(respBytes []byte, cert, issuer *x509.Certificate, config Config) (*ocsp.Response, error) { + fmt.Fprintf(config.output, "\nDecoding body: %s\n", base64.StdEncoding.EncodeToString(respBytes)) + resp, err := ocsp.ParseResponseForCert(respBytes, cert, issuer) + if err != nil { + return nil, fmt.Errorf("parsing response: %s", err) + } + + var errs []error + if config.expectStatus != -1 && resp.Status != config.expectStatus { + errs = append(errs, fmt.Errorf("wrong CertStatus %d, expected %d", resp.Status, config.expectStatus)) + } + if config.expectReason != -1 && resp.RevocationReason != config.expectReason { + errs = append(errs, fmt.Errorf("wrong RevocationReason %d, expected %d", resp.RevocationReason, config.expectReason)) + } + timeTilExpiry := time.Until(resp.NextUpdate) + tooSoonDuration := time.Duration(config.tooSoon) * time.Hour + if timeTilExpiry < tooSoonDuration { + errs = append(errs, fmt.Errorf("NextUpdate is too soon: %s", timeTilExpiry)) + } + + err = checkSignerTimes(resp, issuer, config.output) + if err != nil { + errs = append(errs, fmt.Errorf("checking signature on delegated signer: %s", err)) + } + + fmt.Fprint(config.output, PrettyResponse(resp)) + + if len(errs) > 0 { + fmt.Fprint(config.output, "Errors:\n") + err := errs[0] + fmt.Fprintf(config.output, " %v\n", err.Error()) + for _, e := range errs[1:] { + err = fmt.Errorf("%w; %v", err, e) + fmt.Fprintf(config.output, " %v\n", e.Error()) + } + return nil, err + } + fmt.Fprint(config.output, "No errors found.\n") + return resp, nil +} + +func PrettyResponse(resp *ocsp.Response) string { + var builder strings.Builder + pr := func(s string, v ...interface{}) { + fmt.Fprintf(&builder, s, v...) + } + + pr("\n") + pr("Response:\n") + pr(" SerialNumber %036x\n", resp.SerialNumber) + pr(" CertStatus %d\n", resp.Status) + pr(" RevocationReason %d\n", resp.RevocationReason) + pr(" RevokedAt %s\n", resp.RevokedAt) + pr(" ProducedAt %s\n", resp.ProducedAt) + pr(" ThisUpdate %s\n", resp.ThisUpdate) + pr(" NextUpdate %s\n", resp.NextUpdate) + pr(" SignatureAlgorithm %s\n", resp.SignatureAlgorithm) + pr(" IssuerHash %s\n", resp.IssuerHash) + if resp.Extensions != nil { + pr(" Extensions %#v\n", resp.Extensions) + } + if resp.Certificate != nil { + pr(" Certificate:\n") + pr(" Subject: %s\n", resp.Certificate.Subject) + pr(" Issuer: %s\n", resp.Certificate.Issuer) + pr(" NotBefore: %s\n", resp.Certificate.NotBefore) + pr(" NotAfter: %s\n", resp.Certificate.NotAfter) + } + + var responder pkix.RDNSequence + _, err := asn1.Unmarshal(resp.RawResponderName, &responder) + if err != nil { + pr(" Responder: error (%s)\n", err) + } else { + pr(" Responder: %s\n", responder) + } + + return builder.String() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/ocsp/ocsp_forever/main.go b/third-party/github.com/letsencrypt/boulder/test/ocsp/ocsp_forever/main.go new file mode 100644 index 000000000..25d3a5873 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/ocsp/ocsp_forever/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/letsencrypt/boulder/test/ocsp/helper" + prom "github.com/prometheus/client_golang/prometheus" + promhttp "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var listenAddress = flag.String("listen", ":8080", "Port to listen on") +var interval = flag.String("interval", "1m", "Time to sleep between fetches") + +var ( + response_count = prom.NewCounterVec(prom.CounterOpts{ + Name: "responses", + Help: "completed responses", + }, nil) + errors_count = prom.NewCounterVec(prom.CounterOpts{ + Name: "errors", + Help: "errored responses", + }, nil) + request_time_seconds_hist = prom.NewHistogram(prom.HistogramOpts{ + Name: "request_time_seconds", + Help: "time a request takes", + }) + request_time_seconds_summary = prom.NewSummary(prom.SummaryOpts{ + Name: "request_time_seconds_summary", + Help: "time a request takes", + }) + response_age_seconds = prom.NewHistogram(prom.HistogramOpts{ + Name: "response_age_seconds", + Help: "how old OCSP responses were", + Buckets: []float64{24 * time.Hour.Seconds(), 48 * time.Hour.Seconds(), + 72 * time.Hour.Seconds(), 96 * time.Hour.Seconds(), 120 * time.Hour.Seconds()}, + }) + response_age_seconds_summary = prom.NewSummary(prom.SummaryOpts{ + Name: "response_age_seconds_summary", + Help: "how old OCSP responses were", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 1: 0.0001}, + }) +) + +func init() { + prom.MustRegister(response_count) + prom.MustRegister(request_time_seconds_hist) + prom.MustRegister(request_time_seconds_summary) + prom.MustRegister(response_age_seconds) + prom.MustRegister(response_age_seconds_summary) +} + +func do(f string, config helper.Config) { + start := time.Now() + resp, err := helper.ReqFile(f, config) + latency := time.Since(start) + if err != nil { + errors_count.With(prom.Labels{}).Inc() + fmt.Fprintf(os.Stderr, "error for %s: %s\n", f, err) + } + request_time_seconds_hist.Observe(latency.Seconds()) + response_count.With(prom.Labels{}).Inc() + request_time_seconds_summary.Observe(latency.Seconds()) + if resp != nil { + response_age_seconds.Observe(time.Since(resp.ThisUpdate).Seconds()) + response_age_seconds_summary.Observe(time.Since(resp.ThisUpdate).Seconds()) + } +} + +func main() { + helper.RegisterFlags() + flag.Parse() + + config, err := helper.ConfigFromFlags() + if err != nil { + log.Fatal(err) + } + sleepTime, err := time.ParseDuration(*interval) + if err != nil { + log.Fatal(err) + } + http.Handle("/metrics", promhttp.Handler()) + go func() { + // The gosec linter complains that timeouts cannot be set here. That's fine, + // because this is test-only code. + ////nolint:gosec + err := http.ListenAndServe(*listenAddress, nil) + if err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + for { + for _, pattern := range flag.Args() { + // Note: re-glob this pattern on each run, in case new certificates have + // been added. This makes it easy to keep the list of certificates to be + // checked fresh. + files, err := filepath.Glob(pattern) + if err != nil { + log.Fatal(err) + } + // Loop through the available files (potentially hundreds or thousands), + // requesting one response per `sleepTime` + for _, f := range files { + do(f, config) + time.Sleep(sleepTime) + } + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/test/prometheus/prometheus.yml b/third-party/github.com/letsencrypt/boulder/test/prometheus/prometheus.yml new file mode 100644 index 000000000..76bf1c6f4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 1s + +scrape_configs: + - job_name: 'boulder' + static_configs: + - targets: + - boulder:8000 + - boulder:8001 + - boulder:8002 + - boulder:8003 + - boulder:8004 + - boulder:8005 + - boulder:8007 + - boulder:8008 + - boulder:8009 + - boulder:8010 + - boulder:8040 diff --git a/third-party/github.com/letsencrypt/boulder/test/proxysql/README.md b/third-party/github.com/letsencrypt/boulder/test/proxysql/README.md new file mode 100644 index 000000000..4996a7e43 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/proxysql/README.md @@ -0,0 +1,77 @@ +# ProxySQL in Boulder + +In an effort to keep Boulder's development environment reasonably close to +production we use ProxySQL in our Docker stack to proxy connections to our +MariaDB database. + +## Ports + +ProxySQL listens on the following ports: + - `6033` Proxy MySQL Interface + - `6032` Admin MySQL Interface + - `6080` Admin Web Interface + +## Accessing the Admin MySQL Interface + +```bash +mysql -uradmin -pradmin -h 127.0.0.1 --port 6032 +``` + +### MacOS + +You will need to bind the port in `docker-compose.yml`, like so: + +```yaml + bproxysql: + ports: + - 6032:6032 +``` + +## Accessing the Admin Web Interface + +You can access the ProxySQL web UI at https://127.0.0.1:6080. The default +username/ password are `stats`/ `stats`. + +### MacOS + +You will need to bind the port in `docker-compose.yml`, like so: + +```yaml + bproxysql: + ports: + - 6080:6080 +``` + +## Sending queries to a file + +To log all queries routed through the ProxySQL query parser, uncomment the +following line in the `mysql_variables` section of `test/proxysql/proxysql.cnf`, +like so: + +```ini +# If mysql_query_rules are marked log=1, they will be logged here. If unset, +# no queries are logged. +eventslog_filename="/test/proxysql/events.log" +``` + +Then set `log = 1;` for `rule_id = 1;` in the `mysql_query_rules` section, like so: + +``` +{ + rule_id = 1; + active = 1; + # Log all queries. + match_digest = "."; + # Set log=1 to log all queries to the eventslog_filename under + # mysql_variables. + log = 1; + apply = 0; +}, +``` + +## Sending ProxySQL logs to a file + +Replace the `entrypoint:` under `bproxysql` in `docker-compose.yml` with +`/test/proxysql/entrypoint.sh`. This is necessary because if you attempt to run +ProxySQL in the background (by removing the `-f` flag) Docker will simply kill +the container. diff --git a/third-party/github.com/letsencrypt/boulder/test/proxysql/entrypoint.sh b/third-party/github.com/letsencrypt/boulder/test/proxysql/entrypoint.sh new file mode 100644 index 000000000..11b5e0399 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/proxysql/entrypoint.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exec proxysql -f --idle-threads -c /test/proxysql/proxysql.cnf --initial 2>&1 | tee -a /test/proxysql/proxysql.log diff --git a/third-party/github.com/letsencrypt/boulder/test/proxysql/proxysql.cnf b/third-party/github.com/letsencrypt/boulder/test/proxysql/proxysql.cnf new file mode 100644 index 000000000..f918aa453 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/proxysql/proxysql.cnf @@ -0,0 +1,143 @@ +datadir = "/var/lib/proxysql"; +errorlog = "/test/proxysql/proxysql.log"; +admin_variables = +{ + # https://proxysql.com/documentation/global-variables/admin-variables Note + # that while admin variables are documented with an 'admin-' prefix, they + # are specified in the configuration with the prefix stripped. + mysql_ifaces = "0.0.0.0:6032"; + # admin:admin is only used for local connections. For remote connections, + # use radmin:radmin + admin_credentials = "admin:admin;radmin:radmin"; + web_enabled = "true"; + # Web UI is disabled by default. + web_port = 6080; + # These are the credentials used for the web interface. + stats_credentials = "stats:stats"; + debug = True; +}; +mysql_variables = +{ + threads = 4; + max_connections = 10240; + have_compress = True; + poll_timeout = 2000; + interfaces = "0.0.0.0:6033"; + stacksize = 1048576; + max_allowed_packet = 16777216; + # Allow up to 20 seconds to find a server, to limit how many failures + # Boulder sees when we do a primary swap + connect_timeout_server = 20000; + connect_timeout_server_max = 20000; + monitor_username = "proxysql"; + monitor_password = ""; + monitor_history = 600000; + monitor_connect_interval = 60000; + monitor_ping_interval = 10000; + monitor_read_only_interval = 1000; + monitor_read_only_timeout = 500; + monitor_writer_is_also_reader = False; + commands_stats = True; + sessions_sort = True; + connect_retries_on_failure = 10; + # Keep 90% of configured connections open. + free_connections_pct = 90; + connection_warming = True; + # If mysql_query_rules are marked log=1, they will be logged here. If unset, + # no queries are logged. + # eventslog_filename="/test/proxysql/events.log" + eventslog_filesize = 104857600; + eventslog_default_log = 1; + # The audit logs, if unset, are not logged. If set, every connection gets + # logged. Given Boulder's connection strategy, this can be noisy. + # auditlog_filename="/test/proxysql/audit.log" + auditlog_filesize = 104857600; +}; +mysql_servers = +( + { + address = "boulder-mysql"; + port = 3306; + hostgroup = 0; + max_connections = 100; + max_latency_ms = 200; + } +); +mysql_users = +( + { + username = "root"; + }, + { + username = "policy"; + }, + { + username = "sa"; + }, + { + username = "sa_ro"; + }, + { + username = "ocsp_resp"; + }, + { + username = "revoker"; + }, + { + username = "importer"; + }, + { + username = "mailer"; + }, + { + username = "cert_checker"; + }, + { + username = "test_setup"; + }, + { + username = "badkeyrevoker"; + }, + { + username = "incidents_sa"; + } +); +mysql_query_rules = +( + { + rule_id = 1; + active = 1; + match_digest = "."; + log = 0; + apply = 0; + }, + { + rule_id = 10; + username = "sa"; + timeout = 4900; + }, + { + rule_id = 11; + username = "sa_ro"; + timeout = 4900; + }, + { + rule_id = 16; + username = "badkeyrevoker"; + timeout = 3600000; + }, + { + rule_id = 17; + username = "mailer"; + timeout = 1800000; + }, + { + rule_id = 18; + username = "ocsp_resp"; + timeout = 4900; + } +); +scheduler = +( + +); diff --git a/third-party/github.com/letsencrypt/boulder/test/rate-limit-policies.yml b/third-party/github.com/letsencrypt/boulder/test/rate-limit-policies.yml new file mode 100644 index 000000000..fc63b5657 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/rate-limit-policies.yml @@ -0,0 +1,56 @@ +# See cmd/shell.go for definitions of these rate limits. +certificatesPerName: + window: 2160h + threshold: 2 + overrides: + ratelimit.me: 1 + lim.it: 0 + # Hostnames used by the letsencrypt client integration test. + le.wtf: 10000 + le1.wtf: 10000 + le2.wtf: 10000 + le3.wtf: 10000 + nginx.wtf: 10000 + good-caa-reserved.com: 10000 + bad-caa-reserved.com: 10000 + ecdsa.le.wtf: 10000 + must-staple.le.wtf: 10000 + registrationOverrides: + 101: 1000 +registrationsPerIP: + window: 168h # 1 week + threshold: 10000 + overrides: + 127.0.0.1: 1000000 +registrationsPerIPRange: + window: 168h # 1 week + threshold: 99999 + overrides: + 127.0.0.1: 1000000 +pendingAuthorizationsPerAccount: + window: 168h # 1 week, should match pending authorization lifetime. + threshold: 150 +invalidAuthorizationsPerAccount: + window: 5m + threshold: 3 +newOrdersPerAccount: + window: 3h + threshold: 1500 +certificatesPerFQDNSet: + window: 168h + threshold: 6 + overrides: + le.wtf: 10000 + le1.wtf: 10000 + le2.wtf: 10000 + le3.wtf: 10000 + le.wtf,le1.wtf: 10000 + good-caa-reserved.com: 10000 + nginx.wtf: 10000 + ecdsa.le.wtf: 10000 + must-staple.le.wtf: 10000 +certificatesPerFQDNSetFast: + window: 3h + threshold: 2 + overrides: + le.wtf: 100 diff --git a/third-party/github.com/letsencrypt/boulder/test/redis-cli.sh b/third-party/github.com/letsencrypt/boulder/test/redis-cli.sh new file mode 100644 index 000000000..921196a2c --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/redis-cli.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -feuo pipefail + +ARGS="-p 4218 \ + --tls \ + --cert /test/certs/ipki/redis/cert.pem \ + --key /test/certs/ipki/redis/key.pem \ + --cacert /test/certs/ipki/minica.pem \ + --user admin-user \ + --pass 435e9c4225f08813ef3af7c725f0d30d263b9cd3" + +exec docker compose exec bredis_1 redis-cli $ARGS "${@}" diff --git a/third-party/github.com/letsencrypt/boulder/test/redis-ocsp.config b/third-party/github.com/letsencrypt/boulder/test/redis-ocsp.config new file mode 100644 index 000000000..74b4ec950 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/redis-ocsp.config @@ -0,0 +1,33 @@ +port 0 +tls-port 4218 +save 60 1 +maxmemory-policy noeviction +loglevel warning +# List of renamed commands comes from: +# https://www.digitalocean.com/community/tutorials/how-to-secure-your-redis-installation-on-ubuntu-18-04 +rename-command BGREWRITEAOF "" +rename-command BGSAVE "" +rename-command CONFIG "" +rename-command DEBUG "" +rename-command DEL "" +rename-command FLUSHALL "" +rename-command FLUSHDB "" +rename-command KEYS "" +rename-command PEXPIRE "" +rename-command RENAME "" +rename-command SAVE "" +rename-command SHUTDOWN "" +rename-command SPOP "" +rename-command SREM "" +user default off +user rocsp-tool on +@all ~* >e4e9ce7845cb6adbbc44fb1d9deb05e6b4dc1386 +user ocsp-responder on +@all ~* >0e5a4c8b5faaf3194c8ad83c3dd9a0dd8a75982b +user boulder-ra on +@all ~* >b3b2fcbbf46fe39fd522c395a51f84d93a98ff2f +user admin-user on +@all ~* >435e9c4225f08813ef3af7c725f0d30d263b9cd3 +user unittest-rw on +@all ~* >824968fa490f4ecec1e52d5e34916bdb60d45f8d +masteruser admin-user +masterauth 435e9c4225f08813ef3af7c725f0d30d263b9cd3 +tls-protocols "TLSv1.3" +tls-cert-file /test/certs/ipki/redis/cert.pem +tls-key-file /test/certs/ipki/redis/key.pem +tls-ca-cert-file /test/certs/ipki/minica.pem diff --git a/third-party/github.com/letsencrypt/boulder/test/redis-ratelimits.config b/third-party/github.com/letsencrypt/boulder/test/redis-ratelimits.config new file mode 100644 index 000000000..667ae9e34 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/redis-ratelimits.config @@ -0,0 +1,30 @@ +port 0 +tls-port 4218 +save 60 1 +maxmemory-policy noeviction +loglevel warning +# List of renamed commands comes from: +# https://www.digitalocean.com/community/tutorials/how-to-secure-your-redis-installation-on-ubuntu-18-04 +rename-command BGREWRITEAOF "" +rename-command BGSAVE "" +rename-command CONFIG "" +rename-command DEBUG "" +rename-command FLUSHALL "" +rename-command FLUSHDB "" +rename-command KEYS "" +rename-command PEXPIRE "" +rename-command RENAME "" +rename-command SAVE "" +rename-command SHUTDOWN "" +rename-command SPOP "" +rename-command SREM "" +user default off +user boulder-wfe on +@all ~* >b3b2fcbbf46fe39fd522c395a51f84d93a98ff2f +user admin-user on +@all ~* >435e9c4225f08813ef3af7c725f0d30d263b9cd3 +user unittest-rw on +@all ~* >824968fa490f4ecec1e52d5e34916bdb60d45f8d +masteruser admin-user +masterauth 435e9c4225f08813ef3af7c725f0d30d263b9cd3 +tls-protocols "TLSv1.3" +tls-cert-file /test/certs/ipki/redis/cert.pem +tls-key-file /test/certs/ipki/redis/key.pem +tls-ca-cert-file /test/certs/ipki/minica.pem diff --git a/third-party/github.com/letsencrypt/boulder/test/s3-test-srv/main.go b/third-party/github.com/letsencrypt/boulder/test/s3-test-srv/main.go new file mode 100644 index 000000000..963b21f32 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/s3-test-srv/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "crypto/x509" + "flag" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/revocation" +) + +type s3TestSrv struct { + sync.RWMutex + allSerials map[string]revocation.Reason + allShards map[string][]byte +} + +func (srv *s3TestSrv) handleS3(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + srv.handleUpload(w, r) + } else if r.Method == "GET" { + srv.handleDownload(w, r) + } else { + w.WriteHeader(405) + } +} + +func (srv *s3TestSrv) handleUpload(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("failed to read request body")) + return + } + + crl, err := x509.ParseRevocationList(body) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("failed to parse body: %s", err))) + return + } + + srv.Lock() + defer srv.Unlock() + srv.allShards[r.URL.Path] = body + for _, rc := range crl.RevokedCertificateEntries { + srv.allSerials[core.SerialToString(rc.SerialNumber)] = revocation.Reason(rc.ReasonCode) + } + + w.WriteHeader(200) + w.Write([]byte("{}")) +} + +func (srv *s3TestSrv) handleDownload(w http.ResponseWriter, r *http.Request) { + srv.RLock() + defer srv.RUnlock() + body, ok := srv.allShards[r.URL.Path] + if !ok { + w.WriteHeader(404) + return + } + w.WriteHeader(200) + w.Write(body) +} + +func (srv *s3TestSrv) handleQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(405) + return + } + + serial := r.URL.Query().Get("serial") + if serial == "" { + w.WriteHeader(400) + return + } + + srv.RLock() + defer srv.RUnlock() + reason, ok := srv.allSerials[serial] + if !ok { + w.WriteHeader(404) + return + } + + w.WriteHeader(200) + w.Write([]byte(fmt.Sprintf("%d", reason))) +} + +func main() { + listenAddr := flag.String("listen", "0.0.0.0:4501", "Address to listen on") + flag.Parse() + + srv := s3TestSrv{ + allSerials: make(map[string]revocation.Reason), + allShards: make(map[string][]byte), + } + + http.HandleFunc("/", srv.handleS3) + http.HandleFunc("/query", srv.handleQuery) + + s := http.Server{ + ReadTimeout: 30 * time.Second, + Addr: *listenAddr, + } + + go func() { + err := s.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + cmd.FailOnError(err, "Running TLS server") + } + }() + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }() + + cmd.WaitForSignal() +} diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/aws_creds.ini b/third-party/github.com/letsencrypt/boulder/test/secrets/aws_creds.ini new file mode 100644 index 000000000..b3987ba37 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/aws_creds.ini @@ -0,0 +1,3 @@ +[default] +aws_access_key_id=AKIAIOSFODNN7EXAMPLE +aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/backfiller_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/backfiller_dburl new file mode 100644 index 000000000..b62d870a5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/backfiller_dburl @@ -0,0 +1 @@ +sa@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/badkeyrevoker_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/badkeyrevoker_dburl new file mode 100644 index 000000000..51f90c093 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/badkeyrevoker_dburl @@ -0,0 +1 @@ +badkeyrevoker@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/cert_checker_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/cert_checker_dburl new file mode 100644 index 000000000..16f6d8a8b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/cert_checker_dburl @@ -0,0 +1 @@ +cert_checker@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/expiration_mailer_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/expiration_mailer_dburl new file mode 100644 index 000000000..615415cd8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/expiration_mailer_dburl @@ -0,0 +1 @@ +mailer@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/incidents_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/incidents_dburl new file mode 100644 index 000000000..032afcfce --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/incidents_dburl @@ -0,0 +1 @@ +incidents_sa@tcp(boulder-proxysql:6033)/incidents_sa_integration?readTimeout=14s&timeout=1s diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/mailer_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/mailer_dburl new file mode 100644 index 000000000..615415cd8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/mailer_dburl @@ -0,0 +1 @@ +mailer@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/nonce_prefix_key b/third-party/github.com/letsencrypt/boulder/test/secrets/nonce_prefix_key new file mode 100644 index 000000000..d65802423 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/nonce_prefix_key @@ -0,0 +1 @@ +3b8c758dd85e113ea340ce0b3a99f389d40a308548af94d1730a7692c1874f1f diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_dburl new file mode 100644 index 000000000..4a789bad0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_dburl @@ -0,0 +1 @@ +ocsp_resp@tcp(boulder-proxysql:6033)/boulder_sa_integration?readTimeout=800ms&writeTimeout=800ms&timeout=100ms diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_redis_password b/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_redis_password new file mode 100644 index 000000000..a132ec74b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/ocsp_responder_redis_password @@ -0,0 +1 @@ +0e5a4c8b5faaf3194c8ad83c3dd9a0dd8a75982b diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/purger_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/purger_dburl new file mode 100644 index 000000000..d7afab58d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/purger_dburl @@ -0,0 +1 @@ +purger@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/ratelimits_redis_password b/third-party/github.com/letsencrypt/boulder/test/secrets/ratelimits_redis_password new file mode 100644 index 000000000..7f757aa97 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/ratelimits_redis_password @@ -0,0 +1 @@ +824968fa490f4ecec1e52d5e34916bdb60d45f8d diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/revoker_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/revoker_dburl new file mode 100644 index 000000000..3e31508e8 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/revoker_dburl @@ -0,0 +1 @@ +revoker@tcp(boulder-proxysql:6033)/boulder_sa_integration diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/rocsp_tool_password b/third-party/github.com/letsencrypt/boulder/test/secrets/rocsp_tool_password new file mode 100644 index 000000000..f659bd3fc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/rocsp_tool_password @@ -0,0 +1 @@ +e4e9ce7845cb6adbbc44fb1d9deb05e6b4dc1386 diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/sa_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_dburl new file mode 100644 index 000000000..4da95057b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_dburl @@ -0,0 +1 @@ +sa@tcp(boulder-proxysql:6033)/boulder_sa_integration?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/sa_redis_password b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_redis_password new file mode 100644 index 000000000..f6ea0069d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_redis_password @@ -0,0 +1 @@ +de75ae663596735b90e461e5924f71a4c5f622ab \ No newline at end of file diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/sa_ro_dburl b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_ro_dburl new file mode 100644 index 000000000..8e6cc85b5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/sa_ro_dburl @@ -0,0 +1 @@ +sa_ro@tcp(boulder-proxysql:6033)/boulder_sa_integration?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/smtp_password b/third-party/github.com/letsencrypt/boulder/test/secrets/smtp_password new file mode 100644 index 000000000..f3097ab13 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/smtp_password @@ -0,0 +1 @@ +password diff --git a/third-party/github.com/letsencrypt/boulder/test/secrets/wfe_ratelimits_redis_password b/third-party/github.com/letsencrypt/boulder/test/secrets/wfe_ratelimits_redis_password new file mode 100644 index 000000000..5e14c6610 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/secrets/wfe_ratelimits_redis_password @@ -0,0 +1 @@ +b3b2fcbbf46fe39fd522c395a51f84d93a98ff2f diff --git a/third-party/github.com/letsencrypt/boulder/test/startservers.py b/third-party/github.com/letsencrypt/boulder/test/startservers.py new file mode 100644 index 000000000..4098375a5 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/startservers.py @@ -0,0 +1,311 @@ +import atexit +import collections +import os +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time + +from helpers import waithealth, waitport, config_dir, CONFIG_NEXT + +Service = collections.namedtuple('Service', ('name', 'debug_port', 'grpc_port', 'host_override', 'cmd', 'deps')) + +# Keep these ports in sync with consul/config.hcl +SERVICES = ( + Service('boulder-remoteva-a', + 8011, 9397, 'rva.boulder', + ('./bin/boulder', 'boulder-va', '--config', os.path.join(config_dir, 'va-remote-a.json'), '--addr', ':9397', '--debug-addr', ':8011'), + None), + Service('boulder-remoteva-b', + 8012, 9498, 'rva.boulder', + ('./bin/boulder', 'boulder-va', '--config', os.path.join(config_dir, 'va-remote-b.json'), '--addr', ':9498', '--debug-addr', ':8012'), + None), + Service('remoteva-a', + 8211, 9897, 'rva.boulder', + ('./bin/boulder', 'remoteva', '--config', os.path.join(config_dir, 'remoteva-a.json'), '--addr', ':9897', '--debug-addr', ':8211'), + None), + Service('remoteva-b', + 8212, 9998, 'rva.boulder', + ('./bin/boulder', 'remoteva', '--config', os.path.join(config_dir, 'remoteva-b.json'), '--addr', ':9998', '--debug-addr', ':8212'), + None), + Service('boulder-sa-1', + 8003, 9395, 'sa.boulder', + ('./bin/boulder', 'boulder-sa', '--config', os.path.join(config_dir, 'sa.json'), '--addr', ':9395', '--debug-addr', ':8003'), + None), + Service('boulder-sa-2', + 8103, 9495, 'sa.boulder', + ('./bin/boulder', 'boulder-sa', '--config', os.path.join(config_dir, 'sa.json'), '--addr', ':9495', '--debug-addr', ':8103'), + None), + Service('aia-test-srv', + 4502, None, None, + ('./bin/aia-test-srv', '--addr', ':4502', '--hierarchy', 'test/certs/webpki/'), None), + Service('ct-test-srv', + 4600, None, None, + ('./bin/ct-test-srv', '--config', 'test/ct-test-srv/ct-test-srv.json'), None), + Service('boulder-publisher-1', + 8009, 9391, 'publisher.boulder', + ('./bin/boulder', 'boulder-publisher', '--config', os.path.join(config_dir, 'publisher.json'), '--addr', ':9391', '--debug-addr', ':8009'), + None), + Service('boulder-publisher-2', + 8109, 9491, 'publisher.boulder', + ('./bin/boulder', 'boulder-publisher', '--config', os.path.join(config_dir, 'publisher.json'), '--addr', ':9491', '--debug-addr', ':8109'), + None), + Service('mail-test-srv', + 9380, None, None, + ('./bin/mail-test-srv', '--closeFirst', '5', '--cert', 'test/certs/ipki/localhost/cert.pem', '--key', 'test/certs/ipki/localhost/key.pem'), + None), + Service('ocsp-responder', + 8005, None, None, + ('./bin/boulder', 'ocsp-responder', '--config', os.path.join(config_dir, 'ocsp-responder.json'), '--addr', ':4002', '--debug-addr', ':8005'), + ('boulder-ra-1', 'boulder-ra-2')), + Service('boulder-va-1', + 8004, 9392, 'va.boulder', + ('./bin/boulder', 'boulder-va', '--config', os.path.join(config_dir, 'va.json'), '--addr', ':9392', '--debug-addr', ':8004'), + ('boulder-remoteva-a', 'boulder-remoteva-b', 'remoteva-a', 'remoteva-b')), + Service('boulder-va-2', + 8104, 9492, 'va.boulder', + ('./bin/boulder', 'boulder-va', '--config', os.path.join(config_dir, 'va.json'), '--addr', ':9492', '--debug-addr', ':8104'), + ('boulder-remoteva-a', 'boulder-remoteva-b', 'remoteva-a', 'remoteva-b')), + Service('boulder-ca-1', + 8001, 9393, 'ca.boulder', + ('./bin/boulder', 'boulder-ca', '--config', os.path.join(config_dir, 'ca.json'), '--addr', ':9393', '--debug-addr', ':8001'), + ('boulder-sa-1', 'boulder-sa-2')), + Service('boulder-ca-2', + 8101, 9493, 'ca.boulder', + ('./bin/boulder', 'boulder-ca', '--config', os.path.join(config_dir, 'ca.json'), '--addr', ':9493', '--debug-addr', ':8101'), + ('boulder-sa-1', 'boulder-sa-2')), + Service('akamai-test-srv', + 6789, None, None, + ('./bin/akamai-test-srv', '--listen', 'localhost:6789', '--secret', 'its-a-secret'), + None), + Service('akamai-purger', + 9666, None, None, + ('./bin/boulder', 'akamai-purger', '--addr', ':9399', '--config', os.path.join(config_dir, 'akamai-purger.json'), '--debug-addr', ':9666'), + ('akamai-test-srv',)), + Service('s3-test-srv', + 4501, None, None, + ('./bin/s3-test-srv', '--listen', 'localhost:4501'), + None), + Service('crl-storer', + 9667, None, None, + ('./bin/boulder', 'crl-storer', '--config', os.path.join(config_dir, 'crl-storer.json'), '--addr', ':9309', '--debug-addr', ':9667'), + ('s3-test-srv',)), + Service('crl-updater', + 8021, None, None, + ('./bin/boulder', 'crl-updater', '--config', os.path.join(config_dir, 'crl-updater.json'), '--debug-addr', ':8021'), + ('boulder-ca-1', 'boulder-ca-2', 'boulder-sa-1', 'boulder-sa-2', 'crl-storer')), + Service('boulder-ra-1', + 8002, 9394, 'ra.boulder', + ('./bin/boulder', 'boulder-ra', '--config', os.path.join(config_dir, 'ra.json'), '--addr', ':9394', '--debug-addr', ':8002'), + ('boulder-sa-1', 'boulder-sa-2', 'boulder-ca-1', 'boulder-ca-2', 'boulder-va-1', 'boulder-va-2', 'akamai-purger', 'boulder-publisher-1', 'boulder-publisher-2')), + Service('boulder-ra-2', + 8102, 9494, 'ra.boulder', + ('./bin/boulder', 'boulder-ra', '--config', os.path.join(config_dir, 'ra.json'), '--addr', ':9494', '--debug-addr', ':8102'), + ('boulder-sa-1', 'boulder-sa-2', 'boulder-ca-1', 'boulder-ca-2', 'boulder-va-1', 'boulder-va-2', 'akamai-purger', 'boulder-publisher-1', 'boulder-publisher-2')), + Service('bad-key-revoker', + 8020, None, None, + ('./bin/boulder', 'bad-key-revoker', '--config', os.path.join(config_dir, 'bad-key-revoker.json'), '--debug-addr', ':8020'), + ('boulder-ra-1', 'boulder-ra-2', 'mail-test-srv')), + # Note: the nonce-service instances bind to specific ports, not "all interfaces", + # because they use their explicitly bound port in calculating the nonce + # prefix, which is used by WFEs when deciding where to redeem nonces. + # The `taro` and `zinc` instances simulate nonce services in two different + # datacenters. The WFE is configured to get nonces from one of these + # services, and potentially redeeem from either service (though in practice + # it will only redeem from the one that is configured for getting nonces). + Service('nonce-service-taro-1', + 8111, None, None, + ('./bin/boulder', 'nonce-service', '--config', os.path.join(config_dir, 'nonce-a.json'), '--addr', '10.77.77.77:9301', '--debug-addr', ':8111',), + None), + Service('nonce-service-taro-2', + 8113, None, None, + ('./bin/boulder', 'nonce-service', '--config', os.path.join(config_dir, 'nonce-a.json'), '--addr', '10.77.77.77:9501', '--debug-addr', ':8113',), + None), + Service('nonce-service-zinc-1', + 8112, None, None, + ('./bin/boulder', 'nonce-service', '--config', os.path.join(config_dir, 'nonce-b.json'), '--addr', '10.77.77.77:9401', '--debug-addr', ':8112',), + None), + Service('boulder-wfe2', + 4001, None, None, + ('./bin/boulder', 'boulder-wfe2', '--config', os.path.join(config_dir, 'wfe2.json'), '--addr', ':4001', '--tls-addr', ':4431', '--debug-addr', ':8013'), + ('boulder-ra-1', 'boulder-ra-2', 'boulder-sa-1', 'boulder-sa-2', 'nonce-service-taro-1', 'nonce-service-taro-2', 'nonce-service-zinc-1')), + Service('log-validator', + 8016, None, None, + ('./bin/boulder', 'log-validator', '--config', os.path.join(config_dir, 'log-validator.json'), '--debug-addr', ':8016'), + None), +) + +def _service_toposort(services): + """Yields Service objects in topologically sorted order. + + No service will be yielded until every service listed in its deps value + has been yielded. + """ + ready = set([s for s in services if not s.deps]) + blocked = set(services) - ready + done = set() + while ready: + service = ready.pop() + yield service + done.add(service.name) + new = set([s for s in blocked if all([d in done for d in s.deps])]) + ready |= new + blocked -= new + if blocked: + print("WARNING: services with unsatisfied dependencies:") + for s in blocked: + print(s.name, ":", s.deps) + raise(Exception("Unable to satisfy service dependencies")) + +processes = [] + +# NOTE(@cpu): We manage the challSrvProcess separately from the other global +# processes because we want integration tests to be able to stop/start it (e.g. +# to run the load-generator). +challSrvProcess = None + +def install(race_detection): + # Pass empty BUILD_TIME and BUILD_ID flags to avoid constantly invalidating the + # build cache with new BUILD_TIMEs, or invalidating it on merges with a new + # BUILD_ID. + go_build_flags='-tags "integration"' + if race_detection: + go_build_flags += ' -race' + + return subprocess.call(["/usr/bin/make", "GO_BUILD_FLAGS=%s" % go_build_flags]) == 0 + +def run(cmd, fakeclock): + e = os.environ.copy() + e.setdefault("GORACE", "halt_on_error=1") + if fakeclock: + e.setdefault("FAKECLOCK", fakeclock) + p = subprocess.Popen(cmd, env=e) + p.cmd = cmd + return p + +def start(fakeclock): + """Return True if everything builds and starts. + + Give up and return False if anything fails to build, or dies at + startup. Anything that did start before this point can be cleaned + up explicitly by calling stop(), or automatically atexit. + """ + signal.signal(signal.SIGTERM, lambda _, __: stop()) + signal.signal(signal.SIGINT, lambda _, __: stop()) + + # Check that we can resolve the service names before we try to start any + # services. This prevents a confusing error (timed out health check). + try: + socket.getaddrinfo('publisher.service.consul', None) + except Exception as e: + print("Error querying DNS. Is consul running? `docker compose ps bconsul`. %s" % (e)) + return False + + # Start the pebble-challtestsrv first so it can be used to resolve DNS for + # gRPC. + startChallSrv() + + # Processes are in order of dependency: Each process should be started + # before any services that intend to send it RPCs. On shutdown they will be + # killed in reverse order. + for service in _service_toposort(SERVICES): + print("Starting service", service.name) + try: + global processes + p = run(service.cmd, fakeclock) + processes.append(p) + if service.grpc_port is not None: + waithealth(' '.join(p.args), service.grpc_port, service.host_override) + else: + if not waitport(service.debug_port, ' '.join(p.args), perTickCheck=check): + return False + except Exception as e: + print("Error starting service %s: %s" % (service.name, e)) + return False + + print("All servers running. Hit ^C to kill.") + return True + +def check(): + """Return true if all started processes are still alive. + + Log about anything that died. The pebble-challtestsrv is not considered when + checking processes. + """ + global processes + busted = [] + stillok = [] + for p in processes: + if p.poll() is None: + stillok.append(p) + else: + busted.append(p) + if busted: + print("\n\nThese processes exited early (check above for their output):") + for p in busted: + print("\t'%s' with pid %d exited %d" % (p.cmd, p.pid, p.returncode)) + processes = stillok + return not busted + +def startChallSrv(): + """ + Start the pebble-challtestsrv and wait for it to become available. See also + stopChallSrv. + """ + global challSrvProcess + if challSrvProcess is not None: + raise(Exception("startChallSrv called more than once")) + + # NOTE(@cpu): We specify explicit bind addresses for -https01 and + # --tlsalpn01 here to allow HTTPS HTTP-01 responses on 443 for on interface + # and TLS-ALPN-01 responses on 443 for another interface. The choice of + # which is used is controlled by mock DNS data added by the relevant + # integration tests. + challSrvProcess = run([ + 'pebble-challtestsrv', + '--defaultIPv4', os.environ.get("FAKE_DNS"), + '-defaultIPv6', '', + '--dns01', ':8053,:8054', + '--doh', ':8343,:8443', + '--doh-cert', 'test/certs/ipki/10.77.77.77/cert.pem', + '--doh-cert-key', 'test/certs/ipki/10.77.77.77/key.pem', + '--management', ':8055', + '--http01', '10.77.77.77:80', + '-https01', '10.77.77.77:443', + '--tlsalpn01', '10.88.88.88:443'], + None) + # Wait for the pebble-challtestsrv management port. + if not waitport(8055, ' '.join(challSrvProcess.args)): + return False + +def stopChallSrv(): + """ + Stop the running pebble-challtestsrv (if any) and wait for it to terminate. + See also startChallSrv. + """ + global challSrvProcess + if challSrvProcess is None: + return + if challSrvProcess.poll() is None: + challSrvProcess.send_signal(signal.SIGTERM) + challSrvProcess.wait() + challSrvProcess = None + +@atexit.register +def stop(): + # When we are about to exit, send SIGTERM to each subprocess and wait for + # them to nicely die. This reflects the restart process in prod and allows + # us to exercise the graceful shutdown code paths. + global processes + for p in reversed(processes): + if p.poll() is None: + p.send_signal(signal.SIGTERM) + p.wait() + processes = [] + + # Also stop the challenge test server + stopChallSrv() diff --git a/third-party/github.com/letsencrypt/boulder/test/test-key-5.der b/third-party/github.com/letsencrypt/boulder/test/test-key-5.der new file mode 100644 index 000000000..25746250d Binary files /dev/null and b/third-party/github.com/letsencrypt/boulder/test/test-key-5.der differ diff --git a/third-party/github.com/letsencrypt/boulder/test/v2_integration.py b/third-party/github.com/letsencrypt/boulder/test/v2_integration.py new file mode 100644 index 000000000..2889b3fcd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/v2_integration.py @@ -0,0 +1,1725 @@ +# -*- coding: utf-8 -*- +""" +Integration test cases for ACMEv2 as implemented by boulder-wfe2. +""" +import subprocess +import requests +import datetime +import time +import os +import json +import re + +import OpenSSL + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +import chisel2 +from helpers import * + +from acme import errors as acme_errors + +from acme.messages import Status, CertificateRequest, Directory, NewRegistration +from acme import crypto_util as acme_crypto_util +from acme import client as acme_client +from acme import messages +from acme import challenges +from acme import errors + +import josepy + +import tempfile +import shutil +import atexit +import random +import string + +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +import socketserver +import socket + +import challtestsrv +challSrv = challtestsrv.ChallTestServer() + +def test_multidomain(): + chisel2.auth_and_issue([random_domain(), random_domain()]) + +def test_wildcardmultidomain(): + """ + Test issuance for a random domain and a random wildcard domain using DNS-01. + """ + chisel2.auth_and_issue([random_domain(), "*."+random_domain()], chall_type="dns-01") + +def test_http_challenge(): + chisel2.auth_and_issue([random_domain(), random_domain()], chall_type="http-01") + +def rand_http_chall(client): + d = random_domain() + csr_pem = chisel2.make_csr([d]) + order = client.new_order(csr_pem) + authzs = order.authorizations + for a in authzs: + for c in a.body.challenges: + if isinstance(c.chall, challenges.HTTP01): + return d, c.chall + raise(Exception("No HTTP-01 challenge found for random domain authz")) + +def check_challenge_dns_err(chalType): + """ + check_challenge_dns_err tests that performing an ACME challenge of the + specified type to a hostname that is configured to return SERVFAIL for all + queries produces the correct problem type and detail message. + """ + client = chisel2.make_client() + + # Create a random domains. + d = random_domain() + + # Configure the chall srv to SERVFAIL all queries for that domain. + challSrv.add_servfail_response(d) + + # Expect a DNS problem with a detail that matches a regex + expectedProbType = "dns" + expectedProbRegex = re.compile(r"SERVFAIL looking up (A|AAAA|TXT|CAA) for {0}".format(d)) + + # Try and issue for the domain with the given challenge type. + failed = False + try: + chisel2.auth_and_issue([d], client=client, chall_type=chalType) + except acme_errors.ValidationError as e: + # Mark that the auth_and_issue failed + failed = True + # Extract the failed challenge from each failed authorization + for authzr in e.failed_authzrs: + c = None + if chalType == "http-01": + c = chisel2.get_chall(authzr, challenges.HTTP01) + elif chalType == "dns-01": + c = chisel2.get_chall(authzr, challenges.DNS01) + elif chalType == "tls-alpn-01": + c = chisel2.get_chall(authzr, challenges.TLSALPN01) + else: + raise(Exception("Invalid challenge type requested: {0}".format(challType))) + + # The failed challenge's error should match expected + error = c.error + if error is None or error.typ != "urn:ietf:params:acme:error:{0}".format(expectedProbType): + raise(Exception("Expected {0} prob, got {1}".format(expectedProbType, error.typ))) + if not expectedProbRegex.search(error.detail): + raise(Exception("Prob detail did not match expectedProbRegex, got \"{0}\"".format(error.detail))) + finally: + challSrv.remove_servfail_response(d) + + # If there was no exception that means something went wrong. The test should fail. + if failed is False: + raise(Exception("No problem generated issuing for broken DNS identifier")) + +def test_http_challenge_dns_err(): + """ + test_http_challenge_dns_err tests that a HTTP-01 challenge for a domain + with broken DNS produces the correct problem response. + """ + check_challenge_dns_err("http-01") + +def test_dns_challenge_dns_err(): + """ + test_dns_challenge_dns_err tests that a DNS-01 challenge for a domain + with broken DNS produces the correct problem response. + """ + check_challenge_dns_err("dns-01") + +def test_tls_alpn_challenge_dns_err(): + """ + test_tls_alpn_challenge_dns_err tests that a TLS-ALPN-01 challenge for a domain + with broken DNS produces the correct problem response. + """ + check_challenge_dns_err("tls-alpn-01") + +def test_http_challenge_broken_redirect(): + """ + test_http_challenge_broken_redirect tests that a common webserver + misconfiguration receives the correct specialized error message when attempting + an HTTP-01 challenge. + """ + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + + # Create a broken HTTP redirect similar to a sort we see frequently "in the wild" + challengePath = "/.well-known/acme-challenge/{0}".format(token) + redirect = "http://{0}.well-known/acme-challenge/bad-bad-bad".format(d) + challSrv.add_http_redirect( + challengePath, + redirect) + + # Expect the specialized error message + expectedError = "10.77.77.77: Fetching {0}: Invalid host in redirect target \"{1}.well-known\". Check webserver config for missing '/' in redirect target.".format(redirect, d) + + # NOTE(@cpu): Can't use chisel2.expect_problem here because it doesn't let + # us interrogate the detail message easily. + try: + chisel2.auth_and_issue([d], client=client, chall_type="http-01") + except acme_errors.ValidationError as e: + for authzr in e.failed_authzrs: + c = chisel2.get_chall(authzr, challenges.HTTP01) + error = c.error + if error is None or error.typ != "urn:ietf:params:acme:error:connection": + raise(Exception("Expected connection prob, got %s" % (error.__str__()))) + if error.detail != expectedError: + raise(Exception("Expected prob detail %s, got %s" % (expectedError, error.detail))) + + challSrv.remove_http_redirect(challengePath) + +def test_failed_validation_limit(): + """ + Fail a challenge repeatedly for the same domain, with the same account. Once + we reach the rate limit we should get a rateLimitedError. Note that this + depends on the specific threshold configured in rate-limit-policies.yml. + + This also incidentally tests a fix for + https://github.com/letsencrypt/boulder/issues/4329. We expect to get + ValidationErrors, eventually followed by a rate limit error. + """ + domain = "fail." + random_domain() + csr_pem = chisel2.make_csr([domain]) + client = chisel2.make_client() + threshold = 3 + for _ in range(threshold): + order = client.new_order(csr_pem) + chall = order.authorizations[0].body.challenges[0] + client.answer_challenge(chall, chall.response(client.net.key)) + try: + client.poll_and_finalize(order) + except errors.ValidationError as e: + pass + chisel2.expect_problem("urn:ietf:params:acme:error:rateLimited", + lambda: chisel2.auth_and_issue([domain], client=client)) + + +def test_http_challenge_loop_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + + # Create a HTTP redirect from the challenge's validation path to itself + challengePath = "/.well-known/acme-challenge/{0}".format(token) + challSrv.add_http_redirect( + challengePath, + "http://{0}{1}".format(d, challengePath)) + + # Issuing for the name should fail because of the challenge domains's + # redirect loop. + chisel2.expect_problem("urn:ietf:params:acme:error:connection", + lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) + + challSrv.remove_http_redirect(challengePath) + +def test_http_challenge_badport_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + + # Create a HTTP redirect from the challenge's validation path to a host with + # an invalid port. + challengePath = "/.well-known/acme-challenge/{0}".format(token) + challSrv.add_http_redirect( + challengePath, + "http://{0}:1337{1}".format(d, challengePath)) + + # Issuing for the name should fail because of the challenge domain's + # invalid port redirect. + chisel2.expect_problem("urn:ietf:params:acme:error:connection", + lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) + + challSrv.remove_http_redirect(challengePath) + +def test_http_challenge_badhost_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + + # Create a HTTP redirect from the challenge's validation path to a bare IP + # hostname. + challengePath = "/.well-known/acme-challenge/{0}".format(token) + challSrv.add_http_redirect( + challengePath, + "https://127.0.0.1{0}".format(challengePath)) + + # Issuing for the name should cause a connection error because the redirect + # domain name is an IP address. + chisel2.expect_problem("urn:ietf:params:acme:error:connection", + lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) + + challSrv.remove_http_redirect(challengePath) + +def test_http_challenge_badproto_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + + # Create a HTTP redirect from the challenge's validation path to whacky + # non-http/https protocol URL. + challengePath = "/.well-known/acme-challenge/{0}".format(token) + challSrv.add_http_redirect( + challengePath, + "gopher://{0}{1}".format(d, challengePath)) + + # Issuing for the name should cause a connection error because the redirect + # domain name is an IP address. + chisel2.expect_problem("urn:ietf:params:acme:error:connection", + lambda: chisel2.auth_and_issue([d], client=client, chall_type="http-01")) + + challSrv.remove_http_redirect(challengePath) + +def test_http_challenge_http_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + # Calculate its keyauth so we can add it in a special non-standard location + # for the redirect result + resp = chall.response(client.net.key) + keyauth = resp.key_authorization + challSrv.add_http01_response("http-redirect", keyauth) + + # Create a HTTP redirect from the challenge's validation path to some other + # token path where we have registered the key authorization. + challengePath = "/.well-known/acme-challenge/{0}".format(token) + redirectPath = "/.well-known/acme-challenge/http-redirect?params=are&important=to¬=lose" + challSrv.add_http_redirect( + challengePath, + "http://{0}{1}".format(d, redirectPath)) + + chisel2.auth_and_issue([d], client=client, chall_type="http-01") + + challSrv.remove_http_redirect(challengePath) + challSrv.remove_http01_response("http-redirect") + + history = challSrv.http_request_history(d) + challSrv.clear_http_request_history(d) + + # There should have been at least two GET requests made to the + # challtestsrv. There may have been more if remote VAs were configured. + if len(history) < 2: + raise(Exception("Expected at least 2 HTTP request events on challtestsrv, found {1}".format(len(history)))) + + initialRequests = [] + redirectedRequests = [] + + for request in history: + # All requests should have been over HTTP + if request['HTTPS'] is True: + raise(Exception("Expected all requests to be HTTP")) + # Initial requests should have the expected initial HTTP-01 URL for the challenge + if request['URL'] == challengePath: + initialRequests.append(request) + # Redirected requests should have the expected redirect path URL with all + # its parameters + elif request['URL'] == redirectPath: + redirectedRequests.append(request) + else: + raise(Exception("Unexpected request URL {0} in challtestsrv history: {1}".format(request['URL'], request))) + + # There should have been at least 1 initial HTTP-01 validation request. + if len(initialRequests) < 1: + raise(Exception("Expected {0} initial HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(initialRequests)))) + + # There should have been at least 1 redirected HTTP request for each VA + if len(redirectedRequests) < 1: + raise(Exception("Expected {0} redirected HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(redirectedRequests)))) + +def test_http_challenge_https_redirect(): + client = chisel2.make_client() + + # Create an authz for a random domain and get its HTTP-01 challenge token + d, chall = rand_http_chall(client) + token = chall.encode("token") + # Calculate its keyauth so we can add it in a special non-standard location + # for the redirect result + resp = chall.response(client.net.key) + keyauth = resp.key_authorization + challSrv.add_http01_response("https-redirect", keyauth) + + # Create a HTTP redirect from the challenge's validation path to an HTTPS + # path with some parameters + challengePath = "/.well-known/acme-challenge/{0}".format(token) + redirectPath = "/.well-known/acme-challenge/https-redirect?params=are&important=to¬=lose" + challSrv.add_http_redirect( + challengePath, + "https://{0}{1}".format(d, redirectPath)) + + # Also add an A record for the domain pointing to the interface that the + # HTTPS HTTP-01 challtestsrv is bound. + challSrv.add_a_record(d, ["10.77.77.77"]) + + try: + chisel2.auth_and_issue([d], client=client, chall_type="http-01") + except errors.ValidationError as e: + problems = [] + for authzr in e.failed_authzrs: + for chall in authzr.body.challenges: + error = chall.error + if error: + problems.append(error.__str__()) + raise(Exception("validation problem: %s" % "; ".join(problems))) + + challSrv.remove_http_redirect(challengePath) + challSrv.remove_a_record(d) + + history = challSrv.http_request_history(d) + challSrv.clear_http_request_history(d) + + # There should have been at least two GET requests made to the challtestsrv by the VA + if len(history) < 2: + raise(Exception("Expected 2 HTTP request events on challtestsrv, found {0}".format(len(history)))) + + initialRequests = [] + redirectedRequests = [] + + for request in history: + # Initial requests should have the expected initial HTTP-01 URL for the challenge + if request['URL'] == challengePath: + initialRequests.append(request) + # Redirected requests should have the expected redirect path URL with all + # its parameters + elif request['URL'] == redirectPath: + redirectedRequests.append(request) + else: + raise(Exception("Unexpected request URL {0} in challtestsrv history: {1}".format(request['URL'], request))) + + # There should have been at least 1 initial HTTP-01 validation request. + if len(initialRequests) < 1: + raise(Exception("Expected {0} initial HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(initialRequests)))) + # All initial requests should have been over HTTP + for r in initialRequests: + if r['HTTPS'] is True: + raise(Exception("Expected all initial requests to be HTTP, got %s" % r)) + + # There should have been at least 1 redirected HTTP request for each VA + if len(redirectedRequests) < 1: + raise(Exception("Expected {0} redirected HTTP-01 request events on challtestsrv, found {1}".format(validation_attempts, len(redirectedRequests)))) + # All the redirected requests should have been over HTTPS with the correct + # SNI value + for r in redirectedRequests: + if r['HTTPS'] is False: + raise(Exception("Expected all redirected requests to be HTTPS")) + if r['ServerName'] != d: + raise(Exception("Expected all redirected requests to have ServerName {0} got \"{1}\"".format(d, r['ServerName']))) + +class SlowHTTPRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + try: + # Sleeptime needs to be larger than the RA->VA timeout (20s at the + # time of writing) + sleeptime = 22 + print("SlowHTTPRequestHandler: sleeping for {0}s\n".format(sleeptime)) + time.sleep(sleeptime) + self.send_response(200) + self.end_headers() + self.wfile.write(b"this is not an ACME key authorization") + except: + pass + +class SlowHTTPServer(HTTPServer): + # Override handle_error so we don't print a misleading stack trace when the + # VA terminates the connection due to timeout. + def handle_error(self, request, client_address): + pass + +def test_http_challenge_timeout(): + """ + test_http_challenge_timeout tests that the VA times out challenge requests + to a slow HTTP server appropriately. + """ + # Start a simple python HTTP server on port 80 in its own thread. + # NOTE(@cpu): The pebble-challtestsrv binds 10.77.77.77:80 for HTTP-01 + # challenges so we must use the 10.88.88.88 address for the throw away + # server for this test and add a mock DNS entry that directs the VA to it. + httpd = SlowHTTPServer(("10.88.88.88", 80), SlowHTTPRequestHandler) + thread = threading.Thread(target = httpd.serve_forever) + thread.daemon = False + thread.start() + + # Pick a random domain + hostname = random_domain() + + # Add A record for the domains to ensure the VA's requests are directed + # to the interface that we bound the HTTPServer to. + challSrv.add_a_record(hostname, ["10.88.88.88"]) + + start = datetime.datetime.utcnow() + end = 0 + + try: + # We expect a connection timeout error to occur + chisel2.expect_problem("urn:ietf:params:acme:error:connection", + lambda: chisel2.auth_and_issue([hostname], chall_type="http-01")) + end = datetime.datetime.utcnow() + finally: + # Shut down the HTTP server gracefully and join on its thread. + httpd.shutdown() + httpd.server_close() + thread.join() + + delta = end - start + # Expected duration should be the RA->VA timeout plus some padding (At + # present the timeout is 20s so adding 2s of padding = 22s) + expectedDuration = 22 + if delta.total_seconds() == 0 or delta.total_seconds() > expectedDuration: + raise(Exception("expected timeout to occur in under {0} seconds. Took {1}".format(expectedDuration, delta.total_seconds()))) + + +def test_tls_alpn_challenge(): + # Pick two random domains + domains = [random_domain(),random_domain()] + + # Add A records for these domains to ensure the VA's requests are directed + # to the interface that the challtestsrv has bound for TLS-ALPN-01 challenge + # responses + for host in domains: + challSrv.add_a_record(host, ["10.88.88.88"]) + chisel2.auth_and_issue(domains, chall_type="tls-alpn-01") + + for host in domains: + challSrv.remove_a_record(host) + +def test_overlapping_wildcard(): + """ + Test issuance for a random domain and a wildcard version of the same domain + using DNS-01. This should result in *two* distinct authorizations. + """ + domain = random_domain() + domains = [ domain, "*."+domain ] + client = chisel2.make_client(None) + csr_pem = chisel2.make_csr(domains) + order = client.new_order(csr_pem) + authzs = order.authorizations + + if len(authzs) != 2: + raise(Exception("order for %s had %d authorizations, expected 2" % + (domains, len(authzs)))) + + cleanup = chisel2.do_dns_challenges(client, authzs) + try: + order = client.poll_and_finalize(order) + finally: + cleanup() + +def test_highrisk_blocklist(): + """ + Test issuance for a subdomain of a HighRiskBlockedNames entry. It should + fail with a policy error. + """ + + # We include "example.org" in `test/hostname-policy.yaml` in the + # HighRiskBlockedNames list so issuing for "foo.example.org" should be + # blocked. + domain = "foo.example.org" + # We expect this to produce a policy problem + chisel2.expect_problem("urn:ietf:params:acme:error:rejectedIdentifier", + lambda: chisel2.auth_and_issue([domain], chall_type="dns-01")) + +def test_wildcard_exactblacklist(): + """ + Test issuance for a wildcard that would cover an exact blacklist entry. It + should fail with a policy error. + """ + + # We include "highrisk.le-test.hoffman-andrews.com" in `test/hostname-policy.yaml` + # Issuing for "*.le-test.hoffman-andrews.com" should be blocked + domain = "*.le-test.hoffman-andrews.com" + # We expect this to produce a policy problem + chisel2.expect_problem("urn:ietf:params:acme:error:rejectedIdentifier", + lambda: chisel2.auth_and_issue([domain], chall_type="dns-01")) + +def test_wildcard_authz_reuse(): + """ + Test that an authorization for a base domain obtained via HTTP-01 isn't + reused when issuing a wildcard for that base domain later on. + """ + + # Create one client to reuse across multiple issuances + client = chisel2.make_client(None) + + # Pick a random domain to issue for + domains = [ random_domain() ] + csr_pem = chisel2.make_csr(domains) + + # Submit an order for the name + order = client.new_order(csr_pem) + # Complete the order via an HTTP-01 challenge + cleanup = chisel2.do_http_challenges(client, order.authorizations) + try: + order = client.poll_and_finalize(order) + finally: + cleanup() + + # Now try to issue a wildcard for the random domain + domains[0] = "*." + domains[0] + csr_pem = chisel2.make_csr(domains) + order = client.new_order(csr_pem) + + # We expect all of the returned authorizations to be pending status + for authz in order.authorizations: + if authz.body.status != Status("pending"): + raise(Exception("order for %s included a non-pending authorization (status: %s) from a previous HTTP-01 order" % + ((domains), str(authz.body.status)))) + +def test_bad_overlap_wildcard(): + chisel2.expect_problem("urn:ietf:params:acme:error:malformed", + lambda: chisel2.auth_and_issue(["*.example.com", "www.example.com"])) + +def test_duplicate_orders(): + """ + Test that the same client issuing for the same domain names twice in a row + works without error. + """ + client = chisel2.make_client(None) + domains = [ random_domain() ] + chisel2.auth_and_issue(domains, client=client) + chisel2.auth_and_issue(domains, client=client) + +def test_order_reuse_failed_authz(): + """ + Test that creating an order for a domain name, failing an authorization in + that order, and submitting another new order request for the same name + doesn't reuse a failed authorization in the new order. + """ + + client = chisel2.make_client(None) + domains = [ random_domain() ] + csr_pem = chisel2.make_csr(domains) + + order = client.new_order(csr_pem) + firstOrderURI = order.uri + + # Pick the first authz's first challenge, doesn't matter what type it is + chall_body = order.authorizations[0].body.challenges[0] + # Answer it, but with nothing set up to solve the challenge request + client.answer_challenge(chall_body, chall_body.response(client.net.key)) + + deadline = datetime.datetime.now() + datetime.timedelta(seconds=60) + authzFailed = False + try: + # Poll the order's authorizations until they are non-pending, a timeout + # occurs, or there is an invalid authorization status. + client.poll_authorizations(order, deadline) + except acme_errors.ValidationError as e: + # We expect there to be a ValidationError from one of the authorizations + # being invalid. + authzFailed = True + + # If the poll ended and an authz's status isn't invalid then we reached the + # deadline, fail the test + if not authzFailed: + raise(Exception("timed out waiting for order %s to become invalid" % firstOrderURI)) + + # Make another order with the same domains + order = client.new_order(csr_pem) + + # It should not be the same order as before + if order.uri == firstOrderURI: + raise(Exception("new-order for %s returned a , now-invalid, order" % domains)) + + # We expect all of the returned authorizations to be pending status + for authz in order.authorizations: + if authz.body.status != Status("pending"): + raise(Exception("order for %s included a non-pending authorization (status: %s) from a previous order" % + ((domains), str(authz.body.status)))) + + # We expect the new order can be fulfilled + cleanup = chisel2.do_http_challenges(client, order.authorizations) + try: + order = client.poll_and_finalize(order) + finally: + cleanup() + +def test_order_finalize_early(): + """ + Test that finalizing an order before its fully authorized results in the + order having an error set and the status being invalid. + """ + # Create a client + client = chisel2.make_client(None) + + # Create a random domain and a csr + domains = [ random_domain() ] + csr_pem = chisel2.make_csr(domains) + + # Create an order for the domain + order = client.new_order(csr_pem) + + deadline = datetime.datetime.now() + datetime.timedelta(seconds=5) + + # Finalizing an order early should generate an orderNotReady error. + chisel2.expect_problem("urn:ietf:params:acme:error:orderNotReady", + lambda: client.finalize_order(order, deadline)) + +def test_revoke_by_account_unspecified(): + client = chisel2.make_client() + cert_file = temppath('test_revoke_by_account_0.pem') + order = chisel2.auth_and_issue([random_domain()], client=client, cert_output=cert_file.name) + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + + reset_akamai_purges() + client.revoke(josepy.ComparableX509(cert), 0) + + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked") + verify_akamai_purge() + +def test_revoke_by_account_with_reason(): + client = chisel2.make_client(None) + cert_file = temppath('test_revoke_by_account_1.pem') + order = chisel2.auth_and_issue([random_domain()], client=client, cert_output=cert_file.name) + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + + reset_akamai_purges() + + # Requesting revocation for keyCompromise should work, but not block the + # key. + client.revoke(josepy.ComparableX509(cert), 1) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "keyCompromise") + + verify_akamai_purge() + +def test_revoke_by_authz(): + domains = [random_domain()] + cert_file = temppath('test_revoke_by_authz.pem') + order = chisel2.auth_and_issue(domains, cert_output=cert_file.name) + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + + # create a new client and re-authz + client = chisel2.make_client(None) + chisel2.auth_and_issue(domains, client=client) + + reset_akamai_purges() + + # Even though we requested reason 1 ("keyCompromise"), the result should be + # 5 ("cessationOfOperation") due to the authorization method. + client.revoke(josepy.ComparableX509(cert), 1) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "cessationOfOperation") + + verify_akamai_purge() + +def test_revoke_by_privkey(): + domains = [random_domain()] + + # We have to make our own CSR so that we can hold on to the private key + # for revocation later. + key = rsa.generate_private_key(65537, 2048, default_backend()) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + csr_pem = acme_crypto_util.make_csr(key_pem, domains, False) + + # We have to do our own issuance because we made our own CSR. + issue_client = chisel2.make_client(None) + order = issue_client.new_order(csr_pem) + cleanup = chisel2.do_http_challenges(issue_client, order.authorizations) + try: + order = issue_client.poll_and_finalize(order) + finally: + cleanup() + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + + cert_file = tempfile.NamedTemporaryFile( + dir=tempdir, suffix='.test_revoke_by_privkey.pem', + mode='w+', delete=False) + cert_file.write(OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, cert).decode()) + cert_file.close() + + # Create a new client with the cert key as the account key. We don't + # register a server-side account with this client, as we don't need one. + revoke_client = chisel2.uninitialized_client(key=josepy.JWKRSA(key=key)) + + reset_akamai_purges() + + # Even though we requested reason 0 ("unspecified"), the result should be + # 1 ("keyCompromise") due to the authorization method. + revoke_client.revoke(josepy.ComparableX509(cert), 0) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "keyCompromise") + + verify_akamai_purge() + +def test_double_revocation(): + domains = [random_domain()] + + # We have to make our own CSR so that we can hold on to the private key + # for revocation later. + key = rsa.generate_private_key(65537, 2048, default_backend()) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + csr_pem = acme_crypto_util.make_csr(key_pem, domains, False) + + # We have to do our own issuance because we made our own CSR. + sub_client = chisel2.make_client(None) + order = sub_client.new_order(csr_pem) + cleanup = chisel2.do_http_challenges(sub_client, order.authorizations) + try: + order = sub_client.poll_and_finalize(order) + finally: + cleanup() + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + + cert_file = tempfile.NamedTemporaryFile( + dir=tempdir, suffix='.test_double_revoke.pem', + mode='w+', delete=False) + cert_file.write(OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, cert).decode()) + cert_file.close() + + # Create a new client with the cert key as the account key. We don't + # register a server-side account with this client, as we don't need one. + cert_client = chisel2.uninitialized_client(key=josepy.JWKRSA(key=key)) + + reset_akamai_purges() + + # First revoke for any reason. + sub_client.revoke(josepy.ComparableX509(cert), 0) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked") + verify_akamai_purge() + + # Re-revocation for anything other than keyCompromise should fail. + try: + sub_client.revoke(josepy.ComparableX509(cert), 3) + except messages.Error: + pass + else: + raise(Exception("Re-revoked for a bad reason")) + + # Re-revocation for keyCompromise should work, as long as it is done + # via the cert key to demonstrate said compromise. + reset_akamai_purges() + cert_client.revoke(josepy.ComparableX509(cert), 1) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "keyCompromise") + verify_akamai_purge() + + # A subsequent attempt should fail, because the cert is already revoked + # for keyCompromise. + try: + cert_client.revoke(josepy.ComparableX509(cert), 1) + except messages.Error: + pass + else: + raise(Exception("Re-revoked already keyCompromise'd cert")) + + # The same is true even when using the cert key. + try: + cert_client.revoke(josepy.ComparableX509(cert), 1) + except messages.Error: + pass + else: + raise(Exception("Re-revoked already keyCompromise'd cert")) + +def test_sct_embedding(): + order = chisel2.auth_and_issue([random_domain()]) + print(order.fullchain_pem.encode()) + cert = parse_cert(order) + + # make sure there is no poison extension + try: + cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.3")) + raise(Exception("certificate contains CT poison extension")) + except x509.ExtensionNotFound: + # do nothing + pass + + # make sure there is a SCT list extension + try: + sctList = cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.2")) + except x509.ExtensionNotFound: + raise(Exception("certificate doesn't contain SCT list extension")) + if len(sctList.value) != 2: + raise(Exception("SCT list contains wrong number of SCTs")) + for sct in sctList.value: + if sct.version != x509.certificate_transparency.Version.v1: + raise(Exception("SCT contains wrong version")) + if sct.entry_type != x509.certificate_transparency.LogEntryType.PRE_CERTIFICATE: + raise(Exception("SCT contains wrong entry type")) + +def test_only_return_existing_reg(): + client = chisel2.uninitialized_client() + email = "test@not-example.com" + client.new_account(messages.NewRegistration.from_data(email=email, + terms_of_service_agreed=True)) + + client = chisel2.uninitialized_client(key=client.net.key) + class extendedAcct(dict): + def json_dumps(self, indent=None): + return json.dumps(self) + acct = extendedAcct({ + "termsOfServiceAgreed": True, + "contact": [email], + "onlyReturnExisting": True + }) + resp = client.net.post(client.directory['newAccount'], acct) + if resp.status_code != 200: + raise(Exception("incorrect response returned for onlyReturnExisting")) + + other_client = chisel2.uninitialized_client() + newAcct = extendedAcct({ + "termsOfServiceAgreed": True, + "contact": [email], + "onlyReturnExisting": True + }) + chisel2.expect_problem("urn:ietf:params:acme:error:accountDoesNotExist", + lambda: other_client.net.post(other_client.directory['newAccount'], newAcct)) + +def BouncerHTTPRequestHandler(redirect, guestlist): + """ + BouncerHTTPRequestHandler returns a BouncerHandler class that acts like + a club bouncer in front of another server. The bouncer will respond to + GET requests by looking up the allowed number of requests in the guestlist + for the User-Agent making the request. If there is at least one guestlist + spot for that UA it will be redirected to the real server and the + guestlist will be decremented. Once the guestlist spots for a UA are + expended requests will get a bogus result and have to stand outside in the + cold + """ + class BouncerHandler(BaseHTTPRequestHandler): + def __init__(self, *args, **kwargs): + BaseHTTPRequestHandler.__init__(self, *args, **kwargs) + + def do_HEAD(self): + # This is used by wait_for_server + self.send_response(200) + self.end_headers() + + def do_GET(self): + ua = self.headers['User-Agent'] + guestlistAllows = BouncerHandler.guestlist.get(ua, 0) + # If there is still space on the guestlist for this UA then redirect + # the request and decrement the guestlist. + if guestlistAllows > 0: + BouncerHandler.guestlist[ua] -= 1 + self.log_message("BouncerHandler UA {0} is on the Guestlist. {1} requests remaining.".format(ua, BouncerHandler.guestlist[ua])) + self.send_response(302) + self.send_header("Location", BouncerHandler.redirect) + self.end_headers() + # Otherwise return a bogus result + else: + self.log_message("BouncerHandler UA {0} has no requests on the Guestlist. Sending request to the curb".format(ua)) + self.send_response(200) + self.end_headers() + self.wfile.write(u"(• ◡ •) <( VIPs only! )".encode()) + + BouncerHandler.guestlist = guestlist + BouncerHandler.redirect = redirect + return BouncerHandler + +def wait_for_server(addr): + while True: + try: + # NOTE(@cpu): Using HEAD here instead of GET because the + # BouncerHandler modifies its state for GET requests. + status = requests.head(addr).status_code + if status == 200: + return + except requests.exceptions.ConnectionError: + pass + time.sleep(0.5) + +def multiva_setup(client, guestlist): + """ + Setup a testing domain and backing multiva server setup. This will block + until the server is ready. The returned cleanup function should be used to + stop the server. The first bounceFirst requests to the server will be sent + to the real challtestsrv for a good answer, the rest will get a bad + answer. Domain name is randomly chosen with random_domain(). + """ + hostname = random_domain() + + csr_pem = chisel2.make_csr([hostname]) + order = client.new_order(csr_pem) + authz = order.authorizations[0] + chall = None + for c in authz.body.challenges: + if isinstance(c.chall, challenges.HTTP01): + chall = c.chall + if chall is None: + raise(Exception("No HTTP-01 challenge found for random domain authz")) + + token = chall.encode("token") + + # Calculate the challenge's keyauth so we can add a good keyauth response on + # the real challtestsrv that we redirect VIP requests to. + resp = chall.response(client.net.key) + keyauth = resp.key_authorization + challSrv.add_http01_response(token, keyauth) + + # Add an A record for the domains to ensure the VA's requests are directed + # to the interface that we bound the HTTPServer to. + challSrv.add_a_record(hostname, ["10.88.88.88"]) + + # Add an A record for the redirect target that sends it to the real chall + # test srv for a valid HTTP-01 response. + redirHostname = "pebble-challtestsrv.example.com" + challSrv.add_a_record(redirHostname, ["10.77.77.77"]) + + # Start a simple python HTTP server on port 80 in its own thread. + # NOTE(@cpu): The pebble-challtestsrv binds 10.77.77.77:80 for HTTP-01 + # challenges so we must use the 10.88.88.88 address for the throw away + # server for this test and add a mock DNS entry that directs the VA to it. + redirect = "http://{0}/.well-known/acme-challenge/{1}".format( + redirHostname, token) + httpd = HTTPServer(("10.88.88.88", 80), BouncerHTTPRequestHandler(redirect, guestlist)) + thread = threading.Thread(target = httpd.serve_forever) + thread.daemon = False + thread.start() + + def cleanup(): + # Remove the challtestsrv mocks + challSrv.remove_a_record(hostname) + challSrv.remove_a_record(redirHostname) + challSrv.remove_http01_response(token) + # Shut down the HTTP server gracefully and join on its thread. + httpd.shutdown() + httpd.server_close() + thread.join() + + return hostname, cleanup + +def test_http_multiva_threshold_pass(): + client = chisel2.make_client() + + # Configure a guestlist that will pass the multiVA threshold test by + # allowing the primary VA at some, but not all, remotes. + guestlist = {"boulder": 1, "boulder-remoteva-a": 1, "boulder-remoteva-b": 1, "remoteva-a": 1} + + hostname, cleanup = multiva_setup(client, guestlist) + + try: + # With the maximum number of allowed remote VA failures the overall + # challenge should still succeed. + chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") + finally: + cleanup() + +def test_http_multiva_primary_fail_remote_pass(): + client = chisel2.make_client() + + # Configure a guestlist that will fail the primary VA check but allow all of + # the remote VAs. + guestlist = {"boulder": 0, "boulder-remoteva-a": 1, "boulder-remoteva-b": 1, "remoteva-a": 1, "remoteva-b": 1} + + hostname, cleanup = multiva_setup(client, guestlist) + + foundException = False + + try: + # The overall validation should fail even if the remotes are allowed + # because the primary VA result cannot be overridden. + chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") + except acme_errors.ValidationError as e: + # NOTE(@cpu): Chisel2's expect_problem doesn't work in this case so this + # test needs to unpack an `acme_errors.ValidationError` on its own. It + # might be possible to clean this up in the future. + if len(e.failed_authzrs) != 1: + raise(Exception("expected one failed authz, found {0}".format(len(e.failed_authzrs)))) + challs = e.failed_authzrs[0].body.challenges + httpChall = None + for chall_body in challs: + if isinstance(chall_body.chall, challenges.HTTP01): + httpChall = chall_body + if httpChall is None: + raise(Exception("no HTTP-01 challenge in failed authz")) + if httpChall.error.typ != "urn:ietf:params:acme:error:unauthorized": + raise(Exception("expected unauthorized prob, found {0}".format(httpChall.error.typ))) + foundException = True + finally: + cleanup() + if foundException is False: + raise(Exception("Overall validation did not fail")) + +def test_http_multiva_threshold_fail(): + client = chisel2.make_client() + + # Configure a guestlist that will fail the multiVA threshold test by + # only allowing the primary VA. + guestlist = {"boulder": 1} + + hostname, cleanup = multiva_setup(client, guestlist) + + failed_authzrs = [] + try: + chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") + except acme_errors.ValidationError as e: + # NOTE(@cpu): Chisel2's expect_problem doesn't work in this case so this + # test needs to unpack an `acme_errors.ValidationError` on its own. It + # might be possible to clean this up in the future. + failed_authzrs = e.failed_authzrs + finally: + cleanup() + if len(failed_authzrs) != 1: + raise(Exception("expected one failed authz, found {0}".format(len(failed_authzrs)))) + challs = failed_authzrs[0].body.challenges + httpChall = None + for chall_body in challs: + if isinstance(chall_body.chall, challenges.HTTP01): + httpChall = chall_body + if httpChall is None: + raise(Exception("no HTTP-01 challenge in failed authz")) + if httpChall.error.typ != "urn:ietf:params:acme:error:unauthorized": + raise(Exception("expected unauthorized prob, found {0}".format(httpChall.error.typ))) + if not httpChall.error.detail.startswith("During secondary validation: "): + raise(Exception("expected 'During secondary validation' problem detail, found {0}".format(httpChall.error.detail))) + +class FakeH2ServerHandler(socketserver.BaseRequestHandler): + """ + FakeH2ServerHandler is a TCP socket handler that writes data representing an + initial HTTP/2 SETTINGS frame as a response to all received data. + """ + def handle(self): + # Read whatever the HTTP request was so that the response isn't seen as + # unsolicited. + self.data = self.request.recv(1024).strip() + # Blast some HTTP/2 bytes onto the socket + # Truncated example data from taken from the community forum: + # https://community.letsencrypt.org/t/le-validation-error-if-server-is-in-google-infrastructure/51841 + self.request.sendall(b"\x00\x00\x12\x04\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x80\x00") + +def wait_for_tcp_server(addr, port): + """ + wait_for_tcp_server attempts to make a TCP connection to the given + address/port every 0.5s until it succeeds. + """ + while True: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((addr, port)) + sock.sendall(b"\n") + return + except socket.error: + time.sleep(0.5) + pass + +def test_http2_http01_challenge(): + """ + test_http2_http01_challenge tests that an HTTP-01 challenge made to a HTTP/2 + server fails with a specific error message for this case. + """ + client = chisel2.make_client() + hostname = "fake.h2.example.com" + + # Add an A record for the test server to ensure the VA's requests are directed + # to the interface that we bind the FakeH2ServerHandler to. + challSrv.add_a_record(hostname, ["10.88.88.88"]) + + # Allow socket address reuse on the base TCPServer class. Failing to do this + # causes subsequent integration tests to fail with "Address in use" errors even + # though this test _does_ call shutdown() and server_close(). Even though the + # server was shut-down Python's socket will be in TIME_WAIT because of prev. client + # connections. Having the TCPServer set SO_REUSEADDR on the socket solves + # the problem. + socketserver.TCPServer.allow_reuse_address = True + # Create, start, and wait for a fake HTTP/2 server. + server = socketserver.TCPServer(("10.88.88.88", 80), FakeH2ServerHandler) + thread = threading.Thread(target = server.serve_forever) + thread.daemon = False + thread.start() + wait_for_tcp_server("10.88.88.88", 80) + + # Issuing an HTTP-01 challenge for this hostname should produce a connection + # problem with an error specific to the HTTP/2 misconfiguration. + expectedError = "Server is speaking HTTP/2 over HTTP" + try: + chisel2.auth_and_issue([hostname], client=client, chall_type="http-01") + except acme_errors.ValidationError as e: + for authzr in e.failed_authzrs: + c = chisel2.get_chall(authzr, challenges.HTTP01) + error = c.error + if error is None or error.typ != "urn:ietf:params:acme:error:connection": + raise(Exception("Expected connection prob, got %s" % (error.__str__()))) + if not error.detail.endswith(expectedError): + raise(Exception("Expected prob detail ending in %s, got %s" % (expectedError, error.detail))) + finally: + server.shutdown() + server.server_close() + thread.join() + +def test_new_order_policy_errs(): + """ + Test that creating an order with policy blocked identifiers returns + a problem with subproblems. + """ + client = chisel2.make_client(None) + + # 'in-addr.arpa' is present in `test/hostname-policy.yaml`'s + # HighRiskBlockedNames list. + csr_pem = chisel2.make_csr(["out-addr.in-addr.arpa", "between-addr.in-addr.arpa"]) + + # With two policy blocked names in the order we expect to get back a top + # level rejectedIdentifier with a detail message that references + # subproblems. + # + # TODO(@cpu): After https://github.com/certbot/certbot/issues/7046 is + # implemented in the upstream `acme` module this test should also ensure the + # subproblems are properly represented. + ok = False + try: + order = client.new_order(csr_pem) + except messages.Error as e: + ok = True + if e.typ != "urn:ietf:params:acme:error:rejectedIdentifier": + raise(Exception("Expected rejectedIdentifier type problem, got {0}".format(e.typ))) + if e.detail != 'Error creating new order :: Cannot issue for "between-addr.in-addr.arpa": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy (and 1 more problems. Refer to sub-problems for more information.)': + raise(Exception("Order problem detail did not match expected")) + if not ok: + raise(Exception("Expected problem, got no error")) + +def test_delete_unused_challenges(): + order = chisel2.auth_and_issue([random_domain()], chall_type="dns-01") + a = order.authorizations[0] + if len(a.body.challenges) != 1: + raise(Exception("too many challenges (%d) left after validation" % len(a.body.challenges))) + if not isinstance(a.body.challenges[0].chall, challenges.DNS01): + raise(Exception("wrong challenge type left after validation")) + + # intentionally fail a challenge + client = chisel2.make_client() + csr_pem = chisel2.make_csr([random_domain()]) + order = client.new_order(csr_pem) + c = chisel2.get_chall(order.authorizations[0], challenges.DNS01) + client.answer_challenge(c, c.response(client.net.key)) + for _ in range(5): + a, _ = client.poll(order.authorizations[0]) + if a.body.status == Status("invalid"): + break + time.sleep(1) + if len(a.body.challenges) != 1: + raise(Exception("too many challenges (%d) left after failed validation" % + len(a.body.challenges))) + if not isinstance(a.body.challenges[0].chall, challenges.DNS01): + raise(Exception("wrong challenge type left after validation")) + +def test_auth_deactivation_v2(): + client = chisel2.make_client(None) + csr_pem = chisel2.make_csr([random_domain()]) + order = client.new_order(csr_pem) + resp = client.deactivate_authorization(order.authorizations[0]) + if resp.body.status is not messages.STATUS_DEACTIVATED: + raise(Exception("unexpected authorization status")) + + order = chisel2.auth_and_issue([random_domain()], client=client) + resp = client.deactivate_authorization(order.authorizations[0]) + if resp.body.status is not messages.STATUS_DEACTIVATED: + raise(Exception("unexpected authorization status")) + +def test_ocsp(): + cert_file = temppath('test_ocsp.pem') + chisel2.auth_and_issue([random_domain()], cert_output=cert_file.name) + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "good") + +def test_ct_submission(): + hostname = random_domain() + + chisel2.auth_and_issue([hostname]) + + # These should correspond to the configured logs in ra.json. + log_groups = [ + ["http://boulder.service.consul:4600/submissions", "http://boulder.service.consul:4601/submissions", "http://boulder.service.consul:4602/submissions", "http://boulder.service.consul:4603/submissions"], + ["http://boulder.service.consul:4604/submissions", "http://boulder.service.consul:4605/submissions"], + ["http://boulder.service.consul:4606/submissions"], + ["http://boulder.service.consul:4607/submissions"], + ["http://boulder.service.consul:4608/submissions"], + ["http://boulder.service.consul:4609/submissions"], + ] + + # These should correspond to the logs with `submitFinal` in ra.json. + final_logs = [ + "http://boulder.service.consul:4600/submissions", + "http://boulder.service.consul:4601/submissions", + "http://boulder.service.consul:4606/submissions", + "http://boulder.service.consul:4609/submissions", + ] + + # We'd like to enforce strict limits here (exactly 1 submission per group, + # exactly two submissions overall) but the async nature of the race system + # means we can't -- a slowish submission to one log in a group could trigger + # a very fast submission to a different log in the same group, and then both + # submissions could succeed at the same time. Although the Go code will only + # use one of the SCTs, both logs will still have been submitted to, and it + # will show up here. + total_count = 0 + for i in range(len(log_groups)): + group_count = 0 + for j in range(len(log_groups[i])): + log = log_groups[i][j] + count = int(requests.get(log + "?hostnames=%s" % hostname).text) + threshold = 1 + if log in final_logs: + threshold += 1 + if count > threshold: + raise(Exception("Got %d submissions for log %s, expected at most %d" % (count, log, threshold))) + group_count += count + total_count += group_count + if total_count < 2: + raise(Exception("Got %d total submissions, expected at least 2" % total_count)) + +def check_ocsp_basic_oid(cert_file, issuer_file, url): + """ + This function checks if an OCSP response was successful, but doesn't verify + the signature or timestamp. This is useful when simulating the past, so we + don't incorrectly reject a response for being in the past. + """ + ocsp_request = make_ocsp_req(cert_file, issuer_file) + responses = fetch_ocsp(ocsp_request, url) + # An unauthorized response (for instance, if the OCSP responder doesn't know + # about this cert) will just be 30 03 0A 01 06. A "good" or "revoked" + # response will contain, among other things, the id-pkix-ocsp-basic OID + # identifying the response type. We look for that OID to confirm we got a + # successful response. + expected = bytearray.fromhex("06 09 2B 06 01 05 05 07 30 01 01") + for resp in responses: + if not expected in bytearray(resp): + raise(Exception("Did not receive successful OCSP response: %s doesn't contain %s" % + (base64.b64encode(resp), base64.b64encode(expected)))) + +ocsp_exp_unauth_setup_data = {} +@register_six_months_ago +def ocsp_exp_unauth_setup(): + client = chisel2.make_client(None) + cert_file = temppath('ocsp_exp_unauth_setup.pem') + chisel2.auth_and_issue([random_domain()], client=client, cert_output=cert_file.name) + + # Since our servers are pretending to be in the past, but the openssl cli + # isn't, we'll get an expired OCSP response. Just check that it exists; + # don't do the full verification (which would fail). + lastException = None + for issuer_file in glob.glob("test/certs/webpki/int-rsa-*.cert.pem"): + try: + check_ocsp_basic_oid(cert_file.name, issuer_file, "http://localhost:4002") + global ocsp_exp_unauth_setup_data + ocsp_exp_unauth_setup_data['cert_file'] = cert_file.name + return + except Exception as e: + lastException = e + continue + raise(lastException) + +def test_ocsp_exp_unauth(): + tries = 0 + if 'cert_file' not in ocsp_exp_unauth_setup_data: + raise Exception("ocsp_exp_unauth_setup didn't run") + cert_file = ocsp_exp_unauth_setup_data['cert_file'] + last_error = "" + while tries < 5: + try: + verify_ocsp(cert_file, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "XXX") + raise(Exception("Unexpected return from verify_ocsp")) + except subprocess.CalledProcessError as cpe: + last_error = cpe.output + if cpe.output == b"Responder Error: unauthorized (6)\n": + break + except e: + last_error = e + pass + tries += 1 + time.sleep(0.25) + else: + raise(Exception("timed out waiting for unauthorized OCSP response for expired certificate. Last error: {}".format(last_error))) + +def test_blocked_key_account(): + # Only config-next has a blocked keys file configured. + if not CONFIG_NEXT: + return + + with open("test/hierarchy/int-r4.key.pem", "rb") as key_file: + key = serialization.load_pem_private_key(key_file.read(), password=None, backend=default_backend()) + + # Create a client with the JWK set to a blocked private key + jwk = josepy.JWKRSA(key=key) + client = chisel2.uninitialized_client(jwk) + email = "test@not-example.com" + + # Try to create an account + testPass = False + try: + client.new_account(messages.NewRegistration.from_data(email=email, + terms_of_service_agreed=True)) + except acme_errors.Error as e: + if e.typ != "urn:ietf:params:acme:error:badPublicKey": + raise(Exception("problem did not have correct error type, had {0}".format(e.typ))) + if e.detail != "public key is forbidden": + raise(Exception("problem did not have correct error detail, had {0}".format(e.detail))) + testPass = True + + if testPass is False: + raise(Exception("expected account creation to fail with Error when using blocked key")) + +def test_blocked_key_cert(): + # Only config-next has a blocked keys file configured. + if not CONFIG_NEXT: + return + + with open("test/hierarchy/int-r4.key.pem", "r") as f: + pemBytes = f.read() + + domains = [random_domain(), random_domain()] + csr = acme_crypto_util.make_csr(pemBytes, domains, False) + + client = chisel2.make_client(None) + order = client.new_order(csr) + authzs = order.authorizations + + testPass = False + cleanup = chisel2.do_http_challenges(client, authzs) + try: + order = client.poll_and_finalize(order) + except acme_errors.Error as e: + if e.typ != "urn:ietf:params:acme:error:badCSR": + raise(Exception("problem did not have correct error type, had {0}".format(e.typ))) + if e.detail != "Error finalizing order :: invalid public key in CSR: public key is forbidden": + raise(Exception("problem did not have correct error detail, had {0}".format(e.detail))) + testPass = True + + if testPass is False: + raise(Exception("expected cert creation to fail with Error when using blocked key")) + +def test_expiration_mailer(): + email_addr = "integration.%x@letsencrypt.org" % random.randrange(2**16) + order = chisel2.auth_and_issue([random_domain()], email=email_addr) + cert = parse_cert(order) + # Check that the expiration mailer sends a reminder + expiry = cert.not_valid_after + no_reminder = expiry + datetime.timedelta(days=-31) + first_reminder = expiry + datetime.timedelta(days=-13) + last_reminder = expiry + datetime.timedelta(days=-2) + + requests.post("http://localhost:9381/clear", data='') + for time in (no_reminder, first_reminder, last_reminder): + print(get_future_output( + ["./bin/boulder", "expiration-mailer", + "--config", "%s/expiration-mailer.json" % config_dir, + "--debug-addr", ":8008"], + time)) + resp = requests.get("http://localhost:9381/count?to=%s" % email_addr) + mailcount = int(resp.text) + if mailcount != 2: + raise(Exception("\nExpiry mailer failed: expected 2 emails, got %d" % mailcount)) + +caa_recheck_setup_data = {} +@register_twenty_days_ago +def caa_recheck_setup(): + client = chisel2.make_client() + # Issue a certificate with the clock set back, and save the authzs to check + # later that they are valid (200). They should however require rechecking for + # CAA purposes. + numNames = 10 + # Generate numNames subdomains of a random domain + base_domain = random_domain() + domains = [ "{0}.{1}".format(str(n),base_domain) for n in range(numNames) ] + order = chisel2.auth_and_issue(domains, client=client) + + global caa_recheck_setup_data + caa_recheck_setup_data = { + 'client': client, + 'authzs': order.authorizations, + } + +def test_recheck_caa(): + """Request issuance for a domain where we have a old cached authz from when CAA + was good. We'll set a new CAA record forbidding issuance; the CAA should + recheck CAA and reject the request. + """ + if 'authzs' not in caa_recheck_setup_data: + raise(Exception("CAA authzs not prepared for test_caa")) + domains = [] + for a in caa_recheck_setup_data['authzs']: + response = caa_recheck_setup_data['client']._post(a.uri, None) + if response.status_code != 200: + raise(Exception("Unexpected response for CAA authz: ", + response.status_code)) + domain = a.body.identifier.value + domains.append(domain) + + # Set a forbidding CAA record on just one domain + challSrv.add_caa_issue(domains[3], ";") + + # Request issuance for the previously-issued domain name, which should + # now be denied due to CAA. + chisel2.expect_problem("urn:ietf:params:acme:error:caa", + lambda: chisel2.auth_and_issue(domains, client=caa_recheck_setup_data['client'])) + +def test_caa_good(): + domain = random_domain() + challSrv.add_caa_issue(domain, "happy-hacker-ca.invalid") + chisel2.auth_and_issue([domain]) + +def test_caa_reject(): + domain = random_domain() + challSrv.add_caa_issue(domain, "sad-hacker-ca.invalid") + chisel2.expect_problem("urn:ietf:params:acme:error:caa", + lambda: chisel2.auth_and_issue([domain])) + +def test_caa_extensions(): + goodCAA = "happy-hacker-ca.invalid" + + client = chisel2.make_client() + caa_account_uri = client.net.account.uri + caa_records = [ + {"domain": "accounturi.good-caa-reserved.com", "value":"{0}; accounturi={1}".format(goodCAA, caa_account_uri)}, + {"domain": "dns-01-only.good-caa-reserved.com", "value": "{0}; validationmethods=dns-01".format(goodCAA)}, + {"domain": "http-01-only.good-caa-reserved.com", "value": "{0}; validationmethods=http-01".format(goodCAA)}, + {"domain": "dns-01-or-http01.good-caa-reserved.com", "value": "{0}; validationmethods=dns-01,http-01".format(goodCAA)}, + ] + for policy in caa_records: + challSrv.add_caa_issue(policy["domain"], policy["value"]) + + chisel2.expect_problem("urn:ietf:params:acme:error:caa", + lambda: chisel2.auth_and_issue(["dns-01-only.good-caa-reserved.com"], chall_type="http-01")) + + chisel2.expect_problem("urn:ietf:params:acme:error:caa", + lambda: chisel2.auth_and_issue(["http-01-only.good-caa-reserved.com"], chall_type="dns-01")) + + ## Note: the additional names are to avoid rate limiting... + chisel2.auth_and_issue(["dns-01-only.good-caa-reserved.com", "www.dns-01-only.good-caa-reserved.com"], chall_type="dns-01") + chisel2.auth_and_issue(["http-01-only.good-caa-reserved.com", "www.http-01-only.good-caa-reserved.com"], chall_type="http-01") + chisel2.auth_and_issue(["dns-01-or-http-01.good-caa-reserved.com", "dns-01-only.good-caa-reserved.com"], chall_type="dns-01") + chisel2.auth_and_issue(["dns-01-or-http-01.good-caa-reserved.com", "http-01-only.good-caa-reserved.com"], chall_type="http-01") + + ## CAA should fail with an arbitrary account, but succeed with the CAA client. + chisel2.expect_problem("urn:ietf:params:acme:error:caa", lambda: chisel2.auth_and_issue(["accounturi.good-caa-reserved.com"])) + chisel2.auth_and_issue(["accounturi.good-caa-reserved.com"], client=client) + +def test_new_account(): + """ + Test creating new accounts with no email, empty email, one email, and a + tuple of multiple emails. + """ + for contact in (None, (), ("mailto:single@chisel.com",), ("mailto:one@chisel.com", "mailto:two@chisel.com")): + # We don't use `chisel2.make_client` or `messages.NewRegistration.from_data` + # here because they do too much client-side processing to make the + # contact addresses look "nice". + client = chisel2.uninitialized_client() + result = client.new_account(messages.NewRegistration(contact=contact, terms_of_service_agreed=True)) + actual = result.body.contact + if contact is not None and contact != actual: + raise(Exception("New Account failed: expected contact %s, got %s" % (contact, actual))) + +def test_account_update(): + """ + Create a new ACME client/account with one contact email. Then update the + account to a different contact emails. + """ + for contact in (None, (), ("mailto:single@chisel.com",), ("mailto:one@chisel.com", "mailto:two@chisel.com")): + # We don't use `chisel2.update_email` or `messages.NewRegistration.from_data` + # here because they do too much client-side processing to make the + # contact addresses look "nice". + print() + client = chisel2.make_client() + update = client.net.account.update(body=client.net.account.body.update(contact=contact)) + result = client.update_registration(update) + actual = result.body.contact + if contact is not None and contact != actual: + raise(Exception("New Account failed: expected contact %s, got %s" % (contact, actual))) + +def test_renewal_exemption(): + """ + Under a single domain, issue two certificates for different subdomains of + the same name, then renewals of each of them. Since the certificatesPerName + rate limit in testing is 2 per 90 days, and the renewals should not be + counted under the renewal exemption, each of these issuances should succeed. + Then do one last issuance (for a third subdomain of the same name) that we + expect to be rate limited, just to check that the rate limit is actually 2, + and we are testing what we think we are testing. See + https://letsencrypt.org/docs/rate-limits/ for more details. + """ + base_domain = random_domain() + # First issuance + chisel2.auth_and_issue(["www." + base_domain]) + # First Renewal + chisel2.auth_and_issue(["www." + base_domain]) + # Issuance of a different cert + chisel2.auth_and_issue(["blog." + base_domain]) + # Renew that one + chisel2.auth_and_issue(["blog." + base_domain]) + # Final, failed issuance, for another different cert + chisel2.expect_problem("urn:ietf:params:acme:error:rateLimited", + lambda: chisel2.auth_and_issue(["mail." + base_domain])) + +# TODO(#5545) +# - Phase 2: Once the new rate limits are authoritative in config-next, ensure +# that this test only runs in config. +# - Phase 3: Once the new rate limits are authoritative in config, remove this +# test entirely. +def test_certificates_per_name(): + chisel2.expect_problem("urn:ietf:params:acme:error:rateLimited", + lambda: chisel2.auth_and_issue([random_domain() + ".lim.it"])) + +def test_oversized_csr(): + # Number of names is chosen to be one greater than the configured RA/CA maxNames + numNames = 101 + # Generate numNames subdomains of a random domain + base_domain = random_domain() + domains = [ "{0}.{1}".format(str(n),base_domain) for n in range(numNames) ] + # We expect issuing for these domains to produce a malformed error because + # there are too many names in the request. + chisel2.expect_problem("urn:ietf:params:acme:error:malformed", + lambda: chisel2.auth_and_issue(domains)) + +def parse_cert(order): + return x509.load_pem_x509_certificate(order.fullchain_pem.encode(), default_backend()) + +def test_admin_revoker_cert(): + cert_file = temppath('test_admin_revoker_cert.pem') + order = chisel2.auth_and_issue([random_domain()], cert_output=cert_file.name) + parsed_cert = parse_cert(order) + + # Revoke certificate by serial + reset_akamai_purges() + run(["./bin/admin", + "-config", "%s/admin.json" % config_dir, + "-dry-run=false", + "revoke-cert", + "-serial", '%x' % parsed_cert.serial_number, + "-reason", "keyCompromise"]) + + # Wait for OCSP response to indicate revocation took place + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "keyCompromise") + verify_akamai_purge() + +def test_admin_revoker_batched(): + serialFile = tempfile.NamedTemporaryFile( + dir=tempdir, suffix='.test_admin_revoker_batched.serials.hex', + mode='w+', delete=False) + cert_files = [ + temppath('test_admin_revoker_batched.%d.pem' % x) for x in range(3) + ] + + for cert_file in cert_files: + order = chisel2.auth_and_issue([random_domain()], cert_output=cert_file.name) + serialFile.write("%x\n" % parse_cert(order).serial_number) + serialFile.close() + + run(["./bin/admin", + "-config", "%s/admin.json" % config_dir, + "-dry-run=false", + "revoke-cert", + "-serials-file", serialFile.name, + "-reason", "unspecified", + "-parallelism", "2"]) + + for cert_file in cert_files: + verify_ocsp(cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002", "revoked", "unspecified") + +def test_sct_embedding(): + order = chisel2.auth_and_issue([random_domain()]) + cert = parse_cert(order) + + # make sure there is no poison extension + try: + cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.3")) + raise(Exception("certificate contains CT poison extension")) + except x509.ExtensionNotFound: + # do nothing + pass + + # make sure there is a SCT list extension + try: + sctList = cert.extensions.get_extension_for_oid(x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.4.2")) + except x509.ExtensionNotFound: + raise(Exception("certificate doesn't contain SCT list extension")) + if len(sctList.value) != 2: + raise(Exception("SCT list contains wrong number of SCTs")) + for sct in sctList.value: + if sct.version != x509.certificate_transparency.Version.v1: + raise(Exception("SCT contains wrong version")) + if sct.entry_type != x509.certificate_transparency.LogEntryType.PRE_CERTIFICATE: + raise(Exception("SCT contains wrong entry type")) + delta = sct.timestamp - datetime.datetime.now() + if abs(delta) > datetime.timedelta(hours=1): + raise(Exception("Delta between SCT timestamp and now was too great " + "%s vs %s (%s)" % (sct.timestamp, datetime.datetime.now(), delta))) + +def test_auth_deactivation(): + client = chisel2.make_client(None) + d = random_domain() + csr_pem = chisel2.make_csr([d]) + order = client.new_order(csr_pem) + + resp = client.deactivate_authorization(order.authorizations[0]) + if resp.body.status is not messages.STATUS_DEACTIVATED: + raise Exception("unexpected authorization status") + + order = chisel2.auth_and_issue([random_domain()], client=client) + resp = client.deactivate_authorization(order.authorizations[0]) + if resp.body.status is not messages.STATUS_DEACTIVATED: + raise Exception("unexpected authorization status") + +def get_ocsp_response_and_reason(cert_file, issuer_glob, url): + """Returns the ocsp response output and revocation reason.""" + output = verify_ocsp(cert_file, issuer_glob, url, None) + m = re.search('Reason: (\w+)', output) + reason = m.group(1) if m is not None else "" + return output, reason + +ocsp_resigning_setup_data = {} +@register_twenty_days_ago +def ocsp_resigning_setup(): + """Issue and then revoke a cert in the past. + + Useful setup for test_ocsp_resigning, which needs to check that the + revocation reason is still correctly set after re-signing and old OCSP + response. + """ + client = chisel2.make_client(None) + cert_file = temppath('ocsp_resigning_setup.pem') + order = chisel2.auth_and_issue([random_domain()], client=client, cert_output=cert_file.name) + + cert = OpenSSL.crypto.load_certificate( + OpenSSL.crypto.FILETYPE_PEM, order.fullchain_pem) + # Revoke for reason 5: cessationOfOperation + client.revoke(josepy.ComparableX509(cert), 5) + + ocsp_response, reason = get_ocsp_response_and_reason( + cert_file.name, "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002") + global ocsp_resigning_setup_data + ocsp_resigning_setup_data = { + 'cert_file': cert_file.name, + 'response': ocsp_response, + 'reason': reason + } + +def test_ocsp_resigning(): + """Check that, after re-signing an OCSP, the reason is still set.""" + if 'response' not in ocsp_resigning_setup_data: + raise Exception("ocsp_resigning_setup didn't run") + + tries = 0 + while tries < 5: + resp, reason = get_ocsp_response_and_reason( + ocsp_resigning_setup_data['cert_file'], "test/certs/webpki/int-rsa-*.cert.pem", "http://localhost:4002") + if resp != ocsp_resigning_setup_data['response']: + break + tries += 1 + time.sleep(0.25) + else: + raise(Exception("timed out waiting for re-signed OCSP response for certificate")) + + if reason != ocsp_resigning_setup_data['reason']: + raise(Exception("re-signed ocsp response has different reason %s expected %s" % ( + reason, ocsp_resigning_setup_data['reason']))) + if reason != "cessationOfOperation": + raise(Exception("re-signed ocsp response has wrong reason %s" % reason)) diff --git a/third-party/github.com/letsencrypt/boulder/test/vars/vars.go b/third-party/github.com/letsencrypt/boulder/test/vars/vars.go new file mode 100644 index 000000000..deb2b56df --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/vars/vars.go @@ -0,0 +1,25 @@ +package vars + +import "fmt" + +const ( + dbURL = "%s@tcp(boulder-proxysql:6033)/%s" +) + +var ( + // DBConnSA is the sa database connection + DBConnSA = fmt.Sprintf(dbURL, "sa", "boulder_sa_test") + // DBConnSAMailer is the sa mailer database connection + DBConnSAMailer = fmt.Sprintf(dbURL, "mailer", "boulder_sa_test") + // DBConnSAFullPerms is the sa database connection with full perms + DBConnSAFullPerms = fmt.Sprintf(dbURL, "test_setup", "boulder_sa_test") + // DBConnSAIntegrationFullPerms is the sa database connection for the + // integration test DB, with full perms + DBConnSAIntegrationFullPerms = fmt.Sprintf(dbURL, "test_setup", "boulder_sa_integration") + // DBInfoSchemaRoot is the root user and the information_schema connection. + DBInfoSchemaRoot = fmt.Sprintf(dbURL, "root", "information_schema") + // DBConnIncidents is the incidents database connection. + DBConnIncidents = fmt.Sprintf(dbURL, "incidents_sa", "incidents_sa_test") + // DBConnIncidentsFullPerms is the incidents database connection with full perms. + DBConnIncidentsFullPerms = fmt.Sprintf(dbURL, "test_setup", "incidents_sa_test") +) diff --git a/third-party/github.com/letsencrypt/boulder/test/wait-for-it.sh b/third-party/github.com/letsencrypt/boulder/test/wait-for-it.sh new file mode 100644 index 000000000..35e79bcd7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/test/wait-for-it.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -e -u + +wait_tcp_port() { + local host="${1}" port="${2}" + + # see http://tldp.org/LDP/abs/html/devref1.html for description of this syntax. + local max_tries="40" + for n in `seq 1 "${max_tries}"` ; do + if { exec 6<>/dev/tcp/"${host}"/"${port}" ; } 2>/dev/null ; then + break + else + echo "$(date) - still trying to connect to ${host}:${port}" + sleep 1 + fi + if [ "${n}" -eq "${max_tries}" ]; then + echo "unable to connect" + exit 1 + fi + done + exec 6>&- + echo "Connected to ${host}:${port}" +} + +wait_tcp_port "${1}" "${2}" +shift 2 +exec "$@" diff --git a/third-party/github.com/letsencrypt/boulder/tn.sh b/third-party/github.com/letsencrypt/boulder/tn.sh new file mode 100644 index 000000000..a3cda0822 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/tn.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Outer wrapper for invoking test.sh with config-next inside docker-compose. +# + +set -o errexit + +if type realpath >/dev/null 2>&1 ; then + cd "$(realpath -- $(dirname -- "$0"))" +fi + +# Generate the test keys and certs necessary for the integration tests. +docker compose run bsetup + +# Use a predictable name for the container so we can grab the logs later +# for use when testing logs analysis tools. +docker rm boulder_tests || true +exec docker compose -f docker-compose.yml -f docker-compose.next.yml run boulder ./test.sh "$@" diff --git a/third-party/github.com/letsencrypt/boulder/tools/fetch-and-verify-go.sh b/third-party/github.com/letsencrypt/boulder/tools/fetch-and-verify-go.sh new file mode 100644 index 000000000..afd661d0b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/tools/fetch-and-verify-go.sh @@ -0,0 +1,307 @@ +#!/bin/bash +# +# Download Go, verify its signature, and if that all succeeds, move the tarball +# to go.tar.gz in the current directory. + +set -eu + +if [ $# -eq 0 ]; then + echo "usage: $0 [platform like linux-amd64]" + exit 1 +fi +VERSION="${1}" +PLATFORM="${2:-linux-amd64}" + +export GNUPGHOME="$(mktemp -d)" + +# From https://www.google.com/linuxrepositories/ +# +# Key Details +# Download: https://dl.google.com/linux/linux_signing_key.pub +# Key ID: Google, Inc. Linux Package Signing Key +# Fingerprint: 4CCA 1EAF 950C EE4A B839 76DC A040 830F 7FAC 5991 +# Google, Inc. (Linux Package Signing Authority) +# Fingerprint: EB4C 1BFD 4F04 2F6D DDCC EC91 7721 F63B D38B 4796 +gpg2 --import < 0 { + remoteCAAResults = make(chan *remoteVAResult, remoteVACount) + go va.performRemoteCAACheck(ctx, req, remoteCAAResults) + } + } + + checkResult := "success" + err := va.checkCAA(ctx, acmeID, params) + localCheckLatency := time.Since(checkStartTime) + var prob *probs.ProblemDetails + if err != nil { + prob = detailedError(err) + logEvent.Error = prob.Error() + logEvent.InternalError = err.Error() + prob.Detail = fmt.Sprintf("While processing CAA for %s: %s", req.Domain, prob.Detail) + checkResult = "failure" + } else if remoteCAAResults != nil { + if !features.Get().EnforceMultiCAA && features.Get().MultiCAAFullResults { + // If we're not going to enforce multi CAA but we are logging the + // differentials then collect and log the remote results in a separate go + // routine to avoid blocking the primary VA. + go func() { + _ = va.processRemoteCAAResults( + req.Domain, + req.AccountURIID, + string(validationMethod), + remoteCAAResults) + }() + } else if features.Get().EnforceMultiCAA { + remoteProb := va.processRemoteCAAResults( + req.Domain, + req.AccountURIID, + string(validationMethod), + remoteCAAResults) + + // If the remote result was a non-nil problem then fail the CAA check + if remoteProb != nil { + prob = remoteProb + // We only set .Error here, not InternalError, because the remote VA doesn't send + // us the internal error. But that's okay, because it got logged at the remote VA. + logEvent.Error = remoteProb.Error() + checkResult = "failure" + va.log.Infof("CAA check failed due to remote failures: identifier=%v err=%s", + req.Domain, remoteProb) + va.metrics.remoteCAACheckFailures.Inc() + } + } + } + checkLatency := time.Since(checkStartTime) + logEvent.ValidationLatency = checkLatency.Round(time.Millisecond).Seconds() + + va.metrics.localCAACheckTime.With(prometheus.Labels{ + "result": checkResult, + }).Observe(localCheckLatency.Seconds()) + va.metrics.caaCheckTime.With(prometheus.Labels{ + "result": checkResult, + }).Observe(checkLatency.Seconds()) + + va.log.AuditObject("CAA check result", logEvent) + + if prob != nil { + // The ProblemDetails will be serialized through gRPC, which requires UTF-8. + // It will also later be serialized in JSON, which defaults to UTF-8. Make + // sure it is UTF-8 clean now. + prob = filterProblemDetails(prob) + return &vapb.IsCAAValidResponse{Problem: &corepb.ProblemDetails{ + ProblemType: string(prob.Type), + Detail: replaceInvalidUTF8([]byte(prob.Detail)), + }}, nil + } else { + return &vapb.IsCAAValidResponse{}, nil + } +} + +// processRemoteCAAResults evaluates a primary VA result, and a channel of +// remote VA problems to produce a single overall validation result based on +// configured feature flags. The overall result is calculated based on the VA's +// configured `maxRemoteFailures` value. +// +// If the `MultiCAAFullResults` feature is enabled then +// `processRemoteCAAResults` will expect to read a result from the +// `remoteResultsChan` channel for each VA and will not produce an overall +// result until all remote VAs have responded. In this case +// `logRemoteDifferentials` will also be called to describe the differential +// between the primary and all of the remote VAs. +// +// If the `MultiCAAFullResults` feature flag is not enabled then +// `processRemoteCAAResults` will potentially return before all remote VAs have +// had a chance to respond. This happens if the success or failure threshold is +// met. This doesn't allow for logging the differential between the primary and +// remote VAs but is more performant. +func (va *ValidationAuthorityImpl) processRemoteCAAResults( + domain string, + acctID int64, + challengeType string, + remoteResultsChan <-chan *remoteVAResult) *probs.ProblemDetails { + + state := "failure" + start := va.clk.Now() + + defer func() { + va.metrics.remoteCAACheckTime.With(prometheus.Labels{ + "result": state, + }).Observe(va.clk.Since(start).Seconds()) + }() + + required := len(va.remoteVAs) - va.maxRemoteFailures + good := 0 + bad := 0 + + var remoteResults []*remoteVAResult + var firstProb *probs.ProblemDetails + // Due to channel behavior this could block indefinitely and we rely on gRPC + // honoring the context deadline used in client calls to prevent that from + // happening. + for result := range remoteResultsChan { + // Add the result to the slice + remoteResults = append(remoteResults, result) + if result.Problem == nil { + good++ + } else { + bad++ + // Store the first non-nil problem to return later (if `MultiCAAFullResults` + // is enabled). + if firstProb == nil { + firstProb = result.Problem + } + } + + // If MultiCAAFullResults isn't enabled then return early whenever the + // success or failure threshold is met. + if !features.Get().MultiCAAFullResults { + if good >= required { + state = "success" + return nil + } else if bad > va.maxRemoteFailures { + modifiedProblem := *result.Problem + modifiedProblem.Detail = "During secondary CAA checking: " + firstProb.Detail + return &modifiedProblem + } + } + + // If we haven't returned early because of MultiCAAFullResults being + // enabled we need to break the loop once all of the VAs have returned a + // result. + if len(remoteResults) == len(va.remoteVAs) { + break + } + } + // If we are using `features.MultiCAAFullResults` then we haven't returned + // early and can now log the differential between what the primary VA saw and + // what all of the remote VAs saw. + va.logRemoteResults( + domain, + acctID, + challengeType, + remoteResults) + + // Based on the threshold of good/bad return nil or a problem. + if good >= required { + state = "success" + return nil + } else if bad > va.maxRemoteFailures { + modifiedProblem := *firstProb + modifiedProblem.Detail = "During secondary CAA checking: " + firstProb.Detail + va.metrics.prospectiveRemoteCAACheckFailures.Inc() + return &modifiedProblem + } + + // This condition should not occur - it indicates the good/bad counts didn't + // meet either the required threshold or the maxRemoteFailures threshold. + return probs.ServerInternal("Too few remote IsCAAValid RPC results") +} + +// performRemoteCAACheck calls `isCAAValid` for each of the configured remoteVAs +// in a random order. The provided `results` chan should have an equal size to +// the number of remote VAs. The CAA checks will be performed in separate +// go-routines. If the result `error` from a remote `isCAAValid` RPC is nil or a +// nil `ProblemDetails` instance it is written directly to the `results` chan. +// If the err is a cancelled error it is treated as a nil error. Otherwise the +// error/problem is written to the results channel as-is. +func (va *ValidationAuthorityImpl) performRemoteCAACheck( + ctx context.Context, + req *vapb.IsCAAValidRequest, + results chan<- *remoteVAResult) { + for _, i := range rand.Perm(len(va.remoteVAs)) { + remoteVA := va.remoteVAs[i] + go func(rva RemoteVA) { + result := &remoteVAResult{ + VAHostname: rva.Address, + } + res, err := rva.IsCAAValid(ctx, req) + if err != nil { + if canceled.Is(err) { + // Handle the cancellation error. + result.Problem = probs.ServerInternal("Remote VA IsCAAValid RPC cancelled") + } else { + // Handle validation error. + va.log.Errf("Remote VA %q.IsCAAValid failed: %s", rva.Address, err) + result.Problem = probs.ServerInternal("Remote VA IsCAAValid RPC failed") + } + } else if res.Problem != nil { + prob, err := bgrpc.PBToProblemDetails(res.Problem) + if err != nil { + va.log.Infof("Remote VA %q.IsCAAValid returned malformed problem: %s", rva.Address, err) + result.Problem = probs.ServerInternal( + fmt.Sprintf("Remote VA IsCAAValid RPC returned malformed result: %s", err)) + } else { + va.log.Infof("Remote VA %q.IsCAAValid returned problem: %s", rva.Address, prob) + result.Problem = prob + } + } + results <- result + }(remoteVA) + } +} + +// checkCAA performs a CAA lookup & validation for the provided identifier. If +// the CAA lookup & validation fail a problem is returned. +func (va *ValidationAuthorityImpl) checkCAA( + ctx context.Context, + identifier identifier.ACMEIdentifier, + params *caaParams) error { + if core.IsAnyNilOrZero(params, params.validationMethod, params.accountURIID) { + return probs.ServerInternal("expected validationMethod or accountURIID not provided to checkCAA") + } + + foundAt, valid, response, err := va.checkCAARecords(ctx, identifier, params) + if err != nil { + return berrors.DNSError("%s", err) + } + + va.log.AuditInfof("Checked CAA records for %s, [Present: %t, Account ID: %d, Challenge: %s, Valid for issuance: %t, Found at: %q] Response=%q", + identifier.Value, foundAt != "", params.accountURIID, params.validationMethod, valid, foundAt, response) + if !valid { + return berrors.CAAError("CAA record for %s prevents issuance", foundAt) + } + return nil +} + +// caaResult represents the result of querying CAA for a single name. It breaks +// the CAA resource records down by category, keeping only the issue and +// issuewild records. It also records whether any unrecognized RRs were marked +// critical, and stores the raw response text for logging and debugging. +type caaResult struct { + name string + present bool + issue []*dns.CAA + issuewild []*dns.CAA + criticalUnknown bool + dig string + resolvers bdns.ResolverAddrs + err error +} + +// filterCAA processes a set of CAA resource records and picks out the only bits +// we care about. It returns two slices of CAA records, representing the issue +// records and the issuewild records respectively, and a boolean indicating +// whether any unrecognized records had the critical bit set. +func filterCAA(rrs []*dns.CAA) ([]*dns.CAA, []*dns.CAA, bool) { + var issue, issuewild []*dns.CAA + var criticalUnknown bool + + for _, caaRecord := range rrs { + switch strings.ToLower(caaRecord.Tag) { + case "issue": + issue = append(issue, caaRecord) + case "issuewild": + issuewild = append(issuewild, caaRecord) + case "iodef": + // We support the iodef property tag insofar as we recognize it, but we + // never choose to send notifications to the specified addresses. So we + // do not store the contents of the property tag, but also avoid setting + // the criticalUnknown bit if there are critical iodef tags. + continue + case "issuemail": + // We support the issuemail property tag insofar as we recognize it and + // therefore do not bail out if someone has a critical issuemail tag. But + // of course we do not do any further processing, as we do not issue + // S/MIME certificates. + continue + default: + // The critical flag is the bit with significance 128. However, many CAA + // record users have misinterpreted the RFC and concluded that the bit + // with significance 1 is the critical bit. This is sufficiently + // widespread that that bit must reasonably be considered an alias for + // the critical bit. The remaining bits are 0/ignore as proscribed by the + // RFC. + if (caaRecord.Flag & (128 | 1)) != 0 { + criticalUnknown = true + } + } + } + + return issue, issuewild, criticalUnknown +} + +// parallelCAALookup makes parallel requests for the target name and all parent +// names. It returns a slice of CAA results, with the results from querying the +// FQDN in the zeroth index, and the results from querying the TLD in the last +// index. +func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string) []caaResult { + labels := strings.Split(name, ".") + results := make([]caaResult, len(labels)) + var wg sync.WaitGroup + + for i := range len(labels) { + // Start the concurrent DNS lookup. + wg.Add(1) + go func(name string, r *caaResult) { + r.name = name + var records []*dns.CAA + records, r.dig, r.resolvers, r.err = va.dnsClient.LookupCAA(ctx, name) + if len(records) > 0 { + r.present = true + } + r.issue, r.issuewild, r.criticalUnknown = filterCAA(records) + wg.Done() + }(strings.Join(labels[i:], "."), &results[i]) + } + + wg.Wait() + return results +} + +// selectCAA picks the relevant CAA resource record set to be used, i.e. the set +// for the "closest parent" of the FQDN in question, including the domain +// itself. If we encountered an error for a lookup before we found a successful, +// non-empty response, assume there could have been real records hidden by it, +// and return that error. +func selectCAA(rrs []caaResult) (*caaResult, error) { + for _, res := range rrs { + if res.err != nil { + return nil, res.err + } + if res.present { + return &res, nil + } + } + return nil, nil +} + +// getCAA returns the CAA Relevant Resource Set[1] for the given FQDN, i.e. the +// first CAA RRSet found by traversing upwards from the FQDN by removing the +// leftmost label. It returns nil if no RRSet is found on any parent of the +// given FQDN. The returned result also contains the raw CAA response, and an +// error if one is encountered while querying or parsing the records. +// +// [1]: https://datatracker.ietf.org/doc/html/rfc8659#name-relevant-resource-record-se +func (va *ValidationAuthorityImpl) getCAA(ctx context.Context, hostname string) (*caaResult, error) { + hostname = strings.TrimRight(hostname, ".") + + // See RFC 6844 "Certification Authority Processing" for pseudocode, as + // amended by https://www.rfc-editor.org/errata/eid5065. + // Essentially: check CAA records for the FDQN to be issued, and all + // parent domains. + // + // The lookups are performed in parallel in order to avoid timing out + // the RPC call. + // + // We depend on our resolver to snap CNAME and DNAME records. + results := va.parallelCAALookup(ctx, hostname) + return selectCAA(results) +} + +// checkCAARecords fetches the CAA records for the given identifier and then +// validates them. If the identifier argument's value has a wildcard prefix then +// the prefix is stripped and validation will be performed against the base +// domain, honouring any issueWild CAA records encountered as appropriate. +// checkCAARecords returns four values: the first is a string indicating at +// which name (i.e. FQDN or parent thereof) CAA records were found, if any. The +// second is a bool indicating whether issuance for the identifier is valid. The +// unmodified *dns.CAA records that were processed/filtered are returned as the +// third argument. Any errors encountered are returned as the fourth return +// value (or nil). +func (va *ValidationAuthorityImpl) checkCAARecords( + ctx context.Context, + identifier identifier.ACMEIdentifier, + params *caaParams) (string, bool, string, error) { + hostname := strings.ToLower(identifier.Value) + // If this is a wildcard name, remove the prefix + var wildcard bool + if strings.HasPrefix(hostname, `*.`) { + hostname = strings.TrimPrefix(identifier.Value, `*.`) + wildcard = true + } + caaSet, err := va.getCAA(ctx, hostname) + if err != nil { + return "", false, "", err + } + raw := "" + if caaSet != nil { + raw = caaSet.dig + } + valid, foundAt := va.validateCAA(caaSet, wildcard, params) + return foundAt, valid, raw, nil +} + +// validateCAA checks a provided *caaResult. When the wildcard argument is true +// this means the issueWild records must be validated as well. This function +// returns a boolean indicating whether issuance is allowed by this set of CAA +// records, and a string indicating the name at which the CAA records allowing +// issuance were found (if any -- since finding no records at all allows +// issuance). +func (va *ValidationAuthorityImpl) validateCAA(caaSet *caaResult, wildcard bool, params *caaParams) (bool, string) { + if caaSet == nil { + // No CAA records found, can issue + va.metrics.caaCounter.WithLabelValues("no records").Inc() + return true, "" + } + + if caaSet.criticalUnknown { + // Contains unknown critical directives + va.metrics.caaCounter.WithLabelValues("record with unknown critical directive").Inc() + return false, caaSet.name + } + + if len(caaSet.issue) == 0 && !wildcard { + // Although CAA records exist, none of them pertain to issuance in this case. + // (e.g. there is only an issuewild directive, but we are checking for a + // non-wildcard identifier, or there is only an iodef or non-critical unknown + // directive.) + va.metrics.caaCounter.WithLabelValues("no relevant records").Inc() + return true, caaSet.name + } + + // Per RFC 8659 Section 5.3: + // - "Each issuewild Property MUST be ignored when processing a request for + // an FQDN that is not a Wildcard Domain Name."; and + // - "If at least one issuewild Property is specified in the Relevant RRset + // for a Wildcard Domain Name, each issue Property MUST be ignored when + // processing a request for that Wildcard Domain Name." + // So we default to checking the `caaSet.Issue` records and only check + // `caaSet.Issuewild` when `wildcard` is true and there are 1 or more + // `Issuewild` records. + records := caaSet.issue + if wildcard && len(caaSet.issuewild) > 0 { + records = caaSet.issuewild + } + + // There are CAA records pertaining to issuance in our case. Note that this + // includes the case of the unsatisfiable CAA record value ";", used to + // prevent issuance by any CA under any circumstance. + // + // Our CAA identity must be found in the chosen checkSet. + for _, caa := range records { + parsedDomain, parsedParams, err := parseCAARecord(caa) + if err != nil { + continue + } + + if !caaDomainMatches(parsedDomain, va.issuerDomain) { + continue + } + + if !caaAccountURIMatches(parsedParams, va.accountURIPrefixes, params.accountURIID) { + continue + } + + if !caaValidationMethodMatches(parsedParams, params.validationMethod) { + continue + } + + va.metrics.caaCounter.WithLabelValues("authorized").Inc() + return true, caaSet.name + } + + // The list of authorized issuers is non-empty, but we are not in it. Fail. + va.metrics.caaCounter.WithLabelValues("unauthorized").Inc() + return false, caaSet.name +} + +// parseCAARecord extracts the domain and parameters (if any) from a +// issue/issuewild CAA record. This follows RFC 8659 Section 4.2 and Section 4.3 +// (https://www.rfc-editor.org/rfc/rfc8659.html#section-4). It returns the +// domain name (which may be the empty string if the record forbids issuance) +// and a tag-value map of CAA parameters, or a descriptive error if the record +// is malformed. +func parseCAARecord(caa *dns.CAA) (string, map[string]string, error) { + isWSP := func(r rune) bool { + return r == '\t' || r == ' ' + } + + // Semi-colons (ASCII 0x3B) are prohibited from being specified in the + // parameter tag or value, hence we can simply split on semi-colons. + parts := strings.Split(caa.Value, ";") + domain := strings.TrimFunc(parts[0], isWSP) + paramList := parts[1:] + parameters := make(map[string]string) + + // Handle the case where a semi-colon is specified following the domain + // but no parameters are given. + if len(paramList) == 1 && strings.TrimFunc(paramList[0], isWSP) == "" { + return domain, parameters, nil + } + + for _, parameter := range paramList { + // A parameter tag cannot include equal signs (ASCII 0x3D), + // however they are permitted in the value itself. + tv := strings.SplitN(parameter, "=", 2) + if len(tv) != 2 { + return "", nil, fmt.Errorf("parameter not formatted as tag=value: %q", parameter) + } + + tag := strings.TrimFunc(tv[0], isWSP) + //lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints. + for _, r := range []rune(tag) { + // ASCII alpha/digits. + // tag = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT)) + if r < 0x30 || (r > 0x39 && r < 0x41) || (r > 0x5a && r < 0x61) || r > 0x7a { + return "", nil, fmt.Errorf("tag contains disallowed character: %q", tag) + } + } + + value := strings.TrimFunc(tv[1], isWSP) + //lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints. + for _, r := range []rune(value) { + // ASCII without whitespace/semi-colons. + // value = *(%x21-3A / %x3C-7E) + if r < 0x21 || (r > 0x3a && r < 0x3c) || r > 0x7e { + return "", nil, fmt.Errorf("value contains disallowed character: %q", value) + } + } + + parameters[tag] = value + } + + return domain, parameters, nil +} + +// caaDomainMatches checks that the issuer domain name listed in the parsed +// CAA record matches the domain name we expect. +func caaDomainMatches(caaDomain string, issuerDomain string) bool { + return caaDomain == issuerDomain +} + +// caaAccountURIMatches checks that the accounturi CAA parameter, if present, +// matches one of the specific account URIs we expect. We support multiple +// account URI prefixes to handle accounts which were registered under ACMEv1. +// See RFC 8657 Section 3: https://www.rfc-editor.org/rfc/rfc8657.html#section-3 +func caaAccountURIMatches(caaParams map[string]string, accountURIPrefixes []string, accountID int64) bool { + accountURI, ok := caaParams["accounturi"] + if !ok { + return true + } + + // If the accounturi is not formatted according to RFC 3986, reject it. + _, err := url.Parse(accountURI) + if err != nil { + return false + } + + for _, prefix := range accountURIPrefixes { + if accountURI == fmt.Sprintf("%s%d", prefix, accountID) { + return true + } + } + return false +} + +var validationMethodRegexp = regexp.MustCompile(`^[[:alnum:]-]+$`) + +// caaValidationMethodMatches checks that the validationmethods CAA parameter, +// if present, contains the exact name of the ACME validation method used to +// validate this domain. +// See RFC 8657 Section 4: https://www.rfc-editor.org/rfc/rfc8657.html#section-4 +func caaValidationMethodMatches(caaParams map[string]string, method core.AcmeChallenge) bool { + commaSeparatedMethods, ok := caaParams["validationmethods"] + if !ok { + return true + } + + for _, m := range strings.Split(commaSeparatedMethods, ",") { + // If any listed method does not match the ABNF 1*(ALPHA / DIGIT / "-"), + // immediately reject the whole record. + if !validationMethodRegexp.MatchString(m) { + return false + } + + caaMethod := core.AcmeChallenge(m) + if !caaMethod.IsValid() { + continue + } + + if caaMethod == method { + return true + } + } + return false +} diff --git a/third-party/github.com/letsencrypt/boulder/va/caa_test.go b/third-party/github.com/letsencrypt/boulder/va/caa_test.go new file mode 100644 index 000000000..c6f00b0b7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/caa_test.go @@ -0,0 +1,1465 @@ +package va + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "testing" + + "github.com/miekg/dns" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" + + blog "github.com/letsencrypt/boulder/log" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +// caaMockDNS implements the `dns.DNSClient` interface with a set of useful test +// answers for CAA queries. +type caaMockDNS struct{} + +func (mock caaMockDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { + return nil, bdns.ResolverAddrs{"caaMockDNS"}, nil +} + +func (mock caaMockDNS) LookupHost(_ context.Context, hostname string) ([]net.IP, bdns.ResolverAddrs, error) { + ip := net.ParseIP("127.0.0.1") + return []net.IP{ip}, bdns.ResolverAddrs{"caaMockDNS"}, nil +} + +func (mock caaMockDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { + var results []*dns.CAA + var record dns.CAA + switch strings.TrimRight(domain, ".") { + case "caa-timeout.com": + return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("error") + case "reserved.com": + record.Tag = "issue" + record.Value = "ca.com" + results = append(results, &record) + case "mixedcase.com": + record.Tag = "iSsUe" + record.Value = "ca.com" + results = append(results, &record) + case "critical.com": + record.Flag = 1 + record.Tag = "issue" + record.Value = "ca.com" + results = append(results, &record) + case "present.com", "present.servfail.com": + record.Tag = "issue" + record.Value = "letsencrypt.org" + results = append(results, &record) + case "com": + // com has no CAA records. + return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, nil + case "gonetld": + return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("NXDOMAIN") + case "servfail.com", "servfail.present.com": + return results, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("SERVFAIL") + case "multi-crit-present.com": + record.Flag = 1 + record.Tag = "issue" + record.Value = "ca.com" + results = append(results, &record) + secondRecord := record + secondRecord.Value = "letsencrypt.org" + results = append(results, &secondRecord) + case "unknown-critical.com": + record.Flag = 128 + record.Tag = "foo" + record.Value = "bar" + results = append(results, &record) + case "unknown-critical2.com": + record.Flag = 1 + record.Tag = "foo" + record.Value = "bar" + results = append(results, &record) + case "unknown-noncritical.com": + record.Flag = 0x7E // all bits we don't treat as meaning "critical" + record.Tag = "foo" + record.Value = "bar" + results = append(results, &record) + case "present-with-parameter.com": + record.Tag = "issue" + record.Value = " letsencrypt.org ;foo=bar;baz=bar" + results = append(results, &record) + case "present-with-invalid-tag.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; a_b=123" + results = append(results, &record) + case "present-with-invalid-value.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; ab=1 2 3" + results = append(results, &record) + case "present-dns-only.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; validationmethods=dns-01" + results = append(results, &record) + case "present-http-only.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; validationmethods=http-01" + results = append(results, &record) + case "present-http-or-dns.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; validationmethods=http-01,dns-01" + results = append(results, &record) + case "present-dns-only-correct-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123; validationmethods=dns-01" + results = append(results, &record) + case "present-http-only-correct-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123; validationmethods=http-01" + results = append(results, &record) + case "present-http-only-incorrect-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321; validationmethods=http-01" + results = append(results, &record) + case "present-correct-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123" + results = append(results, &record) + case "present-incorrect-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321" + results = append(results, &record) + case "present-multiple-accounturi.com": + record.Tag = "issue" + record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321" + results = append(results, &record) + secondRecord := record + secondRecord.Tag = "issue" + secondRecord.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123" + results = append(results, &secondRecord) + case "unsatisfiable.com": + record.Tag = "issue" + record.Value = ";" + results = append(results, &record) + case "unsatisfiable-wildcard.com": + // Forbidden issuance - issuewild doesn't contain LE + record.Tag = "issuewild" + record.Value = ";" + results = append(results, &record) + case "unsatisfiable-wildcard-override.com": + // Forbidden issuance - issue allows LE, issuewild overrides and does not + record.Tag = "issue" + record.Value = "letsencrypt.org" + results = append(results, &record) + secondRecord := record + secondRecord.Tag = "issuewild" + secondRecord.Value = "ca.com" + results = append(results, &secondRecord) + case "satisfiable-wildcard-override.com": + // Ok issuance - issue doesn't allow LE, issuewild overrides and does + record.Tag = "issue" + record.Value = "ca.com" + results = append(results, &record) + secondRecord := record + secondRecord.Tag = "issuewild" + secondRecord.Value = "letsencrypt.org" + results = append(results, &secondRecord) + case "satisfiable-multi-wildcard.com": + // Ok issuance - first issuewild doesn't permit LE but second does + record.Tag = "issuewild" + record.Value = "ca.com" + results = append(results, &record) + secondRecord := record + secondRecord.Tag = "issuewild" + secondRecord.Value = "letsencrypt.org" + results = append(results, &secondRecord) + case "satisfiable-wildcard.com": + // Ok issuance - issuewild allows LE + record.Tag = "issuewild" + record.Value = "letsencrypt.org" + results = append(results, &record) + } + var response string + if len(results) > 0 { + response = "foo" + } + return results, response, bdns.ResolverAddrs{"caaMockDNS"}, nil +} + +func TestCAATimeout(t *testing.T) { + va, _ := setup(nil, 0, "", nil, caaMockDNS{}) + + params := &caaParams{ + accountURIID: 12345, + validationMethod: core.ChallengeTypeHTTP01, + } + + err := va.checkCAA(ctx, identifier.DNSIdentifier("caa-timeout.com"), params) + test.AssertErrorIs(t, err, berrors.DNS) + test.AssertContains(t, err.Error(), "error") +} + +func TestCAAChecking(t *testing.T) { + testCases := []struct { + Name string + Domain string + FoundAt string + Valid bool + }{ + { + Name: "Bad (Reserved)", + Domain: "reserved.com", + FoundAt: "reserved.com", + Valid: false, + }, + { + Name: "Bad (Reserved, Mixed case Issue)", + Domain: "mixedcase.com", + FoundAt: "mixedcase.com", + Valid: false, + }, + { + Name: "Bad (Critical)", + Domain: "critical.com", + FoundAt: "critical.com", + Valid: false, + }, + { + Name: "Bad (NX Critical)", + Domain: "nx.critical.com", + FoundAt: "critical.com", + Valid: false, + }, + { + Name: "Good (absent)", + Domain: "absent.com", + FoundAt: "", + Valid: true, + }, + { + Name: "Good (example.co.uk, absent)", + Domain: "example.co.uk", + FoundAt: "", + Valid: true, + }, + { + Name: "Good (present and valid)", + Domain: "present.com", + FoundAt: "present.com", + Valid: true, + }, + { + Name: "Good (present on parent)", + Domain: "child.present.com", + FoundAt: "present.com", + Valid: true, + }, + { + Name: "Good (present w/ servfail exception?)", + Domain: "present.servfail.com", + FoundAt: "present.servfail.com", + Valid: true, + }, + { + Name: "Good (multiple critical, one matching)", + Domain: "multi-crit-present.com", + FoundAt: "multi-crit-present.com", + Valid: true, + }, + { + Name: "Bad (unknown critical)", + Domain: "unknown-critical.com", + FoundAt: "unknown-critical.com", + Valid: false, + }, + { + Name: "Bad (unknown critical 2)", + Domain: "unknown-critical2.com", + FoundAt: "unknown-critical2.com", + Valid: false, + }, + { + Name: "Good (unknown non-critical, no issue/issuewild)", + Domain: "unknown-noncritical.com", + FoundAt: "unknown-noncritical.com", + Valid: true, + }, + { + Name: "Good (issue rec with unknown params)", + Domain: "present-with-parameter.com", + FoundAt: "present-with-parameter.com", + Valid: true, + }, + { + Name: "Bad (issue rec with invalid tag)", + Domain: "present-with-invalid-tag.com", + FoundAt: "present-with-invalid-tag.com", + Valid: false, + }, + { + Name: "Bad (issue rec with invalid value)", + Domain: "present-with-invalid-value.com", + FoundAt: "present-with-invalid-value.com", + Valid: false, + }, + { + Name: "Bad (restricts to dns-01, but tested with http-01)", + Domain: "present-dns-only.com", + FoundAt: "present-dns-only.com", + Valid: false, + }, + { + Name: "Good (restricts to http-01, tested with http-01)", + Domain: "present-http-only.com", + FoundAt: "present-http-only.com", + Valid: true, + }, + { + Name: "Good (restricts to http-01 or dns-01, tested with http-01)", + Domain: "present-http-or-dns.com", + FoundAt: "present-http-or-dns.com", + Valid: true, + }, + { + Name: "Good (restricts to accounturi, tested with correct account)", + Domain: "present-correct-accounturi.com", + FoundAt: "present-correct-accounturi.com", + Valid: true, + }, + { + Name: "Good (restricts to http-01 and accounturi, tested with correct account)", + Domain: "present-http-only-correct-accounturi.com", + FoundAt: "present-http-only-correct-accounturi.com", + Valid: true, + }, + { + Name: "Bad (restricts to dns-01 and accounturi, tested with http-01)", + Domain: "present-dns-only-correct-accounturi.com", + FoundAt: "present-dns-only-correct-accounturi.com", + Valid: false, + }, + { + Name: "Bad (restricts to http-01 and accounturi, tested with incorrect account)", + Domain: "present-http-only-incorrect-accounturi.com", + FoundAt: "present-http-only-incorrect-accounturi.com", + Valid: false, + }, + { + Name: "Bad (restricts to accounturi, tested with incorrect account)", + Domain: "present-incorrect-accounturi.com", + FoundAt: "present-incorrect-accounturi.com", + Valid: false, + }, + { + Name: "Good (restricts to multiple accounturi, tested with a correct account)", + Domain: "present-multiple-accounturi.com", + FoundAt: "present-multiple-accounturi.com", + Valid: true, + }, + { + Name: "Bad (unsatisfiable issue record)", + Domain: "unsatisfiable.com", + FoundAt: "unsatisfiable.com", + Valid: false, + }, + { + Name: "Bad (unsatisfiable issue, wildcard)", + Domain: "*.unsatisfiable.com", + FoundAt: "unsatisfiable.com", + Valid: false, + }, + { + Name: "Bad (unsatisfiable wildcard)", + Domain: "*.unsatisfiable-wildcard.com", + FoundAt: "unsatisfiable-wildcard.com", + Valid: false, + }, + { + Name: "Bad (unsatisfiable wildcard override)", + Domain: "*.unsatisfiable-wildcard-override.com", + FoundAt: "unsatisfiable-wildcard-override.com", + Valid: false, + }, + { + Name: "Good (satisfiable wildcard)", + Domain: "*.satisfiable-wildcard.com", + FoundAt: "satisfiable-wildcard.com", + Valid: true, + }, + { + Name: "Good (multiple issuewild, one satisfiable)", + Domain: "*.satisfiable-multi-wildcard.com", + FoundAt: "satisfiable-multi-wildcard.com", + Valid: true, + }, + { + Name: "Good (satisfiable wildcard override)", + Domain: "*.satisfiable-wildcard-override.com", + FoundAt: "satisfiable-wildcard-override.com", + Valid: true, + }, + } + + accountURIID := int64(123) + method := core.ChallengeTypeHTTP01 + params := &caaParams{accountURIID: accountURIID, validationMethod: method} + + va, _ := setup(nil, 0, "", nil, caaMockDNS{}) + va.accountURIPrefixes = []string{"https://letsencrypt.org/acct/reg/"} + + for _, caaTest := range testCases { + mockLog := va.log.(*blog.Mock) + defer mockLog.Clear() + t.Run(caaTest.Name, func(t *testing.T) { + ident := identifier.DNSIdentifier(caaTest.Domain) + foundAt, valid, _, err := va.checkCAARecords(ctx, ident, params) + if err != nil { + t.Errorf("checkCAARecords error for %s: %s", caaTest.Domain, err) + } + if foundAt != caaTest.FoundAt { + t.Errorf("checkCAARecords presence mismatch for %s: got %q expected %q", caaTest.Domain, foundAt, caaTest.FoundAt) + } + if valid != caaTest.Valid { + t.Errorf("checkCAARecords validity mismatch for %s: got %t expected %t", caaTest.Domain, valid, caaTest.Valid) + } + }) + } +} + +func TestCAALogging(t *testing.T) { + va, _ := setup(nil, 0, "", nil, caaMockDNS{}) + + testCases := []struct { + Name string + Domain string + AccountURIID int64 + ChallengeType core.AcmeChallenge + ExpectedLogline string + }{ + { + Domain: "reserved.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for reserved.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"reserved.com\"] Response=\"foo\"", + }, + { + Domain: "reserved.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeDNS01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for reserved.com, [Present: true, Account ID: 12345, Challenge: dns-01, Valid for issuance: false, Found at: \"reserved.com\"] Response=\"foo\"", + }, + { + Domain: "mixedcase.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for mixedcase.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"mixedcase.com\"] Response=\"foo\"", + }, + { + Domain: "critical.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for critical.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"critical.com\"] Response=\"foo\"", + }, + { + Domain: "present.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present.com\"] Response=\"foo\"", + }, + { + Domain: "not.here.but.still.present.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for not.here.but.still.present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present.com\"] Response=\"foo\"", + }, + { + Domain: "multi-crit-present.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for multi-crit-present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"multi-crit-present.com\"] Response=\"foo\"", + }, + { + Domain: "present-with-parameter.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for present-with-parameter.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present-with-parameter.com\"] Response=\"foo\"", + }, + { + Domain: "satisfiable-wildcard-override.com", + AccountURIID: 12345, + ChallengeType: core.ChallengeTypeHTTP01, + ExpectedLogline: "INFO: [AUDIT] Checked CAA records for satisfiable-wildcard-override.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"satisfiable-wildcard-override.com\"] Response=\"foo\"", + }, + } + + for _, tc := range testCases { + t.Run(tc.Domain, func(t *testing.T) { + mockLog := va.log.(*blog.Mock) + defer mockLog.Clear() + + params := &caaParams{ + accountURIID: tc.AccountURIID, + validationMethod: tc.ChallengeType, + } + _ = va.checkCAA(ctx, identifier.ACMEIdentifier{Type: identifier.DNS, Value: tc.Domain}, params) + + caaLogLines := mockLog.GetAllMatching(`Checked CAA records for`) + if len(caaLogLines) != 1 { + t.Errorf("checkCAARecords didn't audit log CAA record info. Instead got:\n%s\n", + strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } else { + test.AssertEquals(t, caaLogLines[0], tc.ExpectedLogline) + } + }) + } +} + +// TestIsCAAValidErrMessage tests that an error result from `va.IsCAAValid` +// includes the domain name that was being checked in the failure detail. +func TestIsCAAValidErrMessage(t *testing.T) { + va, _ := setup(nil, 0, "", nil, caaMockDNS{}) + + // Call IsCAAValid with a domain we know fails with a generic error from the + // caaMockDNS. + domain := "caa-timeout.com" + resp, err := va.IsCAAValid(ctx, &vapb.IsCAAValidRequest{ + Domain: domain, + ValidationMethod: string(core.ChallengeTypeHTTP01), + AccountURIID: 12345, + }) + + // The lookup itself should not return an error + test.AssertNotError(t, err, "Unexpected error calling IsCAAValidRequest") + // The result should not be nil + test.AssertNotNil(t, resp, "Response to IsCAAValidRequest was nil") + // The result's Problem should not be nil + test.AssertNotNil(t, resp.Problem, "Response Problem was nil") + // The result's Problem should be an error message that includes the domain. + test.AssertEquals(t, resp.Problem.Detail, fmt.Sprintf("While processing CAA for %s: error", domain)) +} + +// TestIsCAAValidParams tests that the IsCAAValid method rejects any requests +// which do not have the necessary parameters to do CAA Account and Method +// Binding checks. +func TestIsCAAValidParams(t *testing.T) { + va, _ := setup(nil, 0, "", nil, caaMockDNS{}) + + // Calling IsCAAValid without a ValidationMethod should fail. + _, err := va.IsCAAValid(ctx, &vapb.IsCAAValidRequest{ + Domain: "present.com", + AccountURIID: 12345, + }) + test.AssertError(t, err, "calling IsCAAValid without a ValidationMethod") + + // Calling IsCAAValid with an invalid ValidationMethod should fail. + _, err = va.IsCAAValid(ctx, &vapb.IsCAAValidRequest{ + Domain: "present.com", + ValidationMethod: "tls-sni-01", + AccountURIID: 12345, + }) + test.AssertError(t, err, "calling IsCAAValid with a bad ValidationMethod") + + // Calling IsCAAValid without an AccountURIID should fail. + _, err = va.IsCAAValid(ctx, &vapb.IsCAAValidRequest{ + Domain: "present.com", + ValidationMethod: string(core.ChallengeTypeHTTP01), + }) + test.AssertError(t, err, "calling IsCAAValid without an AccountURIID") +} + +var errCAABrokenDNSClient = errors.New("dnsClient is broken") + +// caaBrokenDNS implements the `dns.DNSClient` interface, but always returns +// errors. +type caaBrokenDNS struct{} + +func (b caaBrokenDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { + return nil, bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient +} + +func (b caaBrokenDNS) LookupHost(_ context.Context, hostname string) ([]net.IP, bdns.ResolverAddrs, error) { + return nil, bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient +} + +func (b caaBrokenDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { + return nil, "", bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient +} + +func TestDisabledMultiCAARechecking(t *testing.T) { + brokenRVA := setupRemote(nil, "broken", caaBrokenDNS{}) + remoteVAs := []RemoteVA{{brokenRVA, "broken"}} + va, _ := setup(nil, 0, "local", remoteVAs, nil) + + features.Set(features.Config{ + EnforceMultiCAA: false, + MultiCAAFullResults: false, + }) + defer features.Reset() + + isValidRes, err := va.IsCAAValid(context.TODO(), &vapb.IsCAAValidRequest{ + Domain: "present.com", + ValidationMethod: string(core.ChallengeTypeDNS01), + AccountURIID: 1, + }) + test.AssertNotError(t, err, "Error during IsCAAValid") + // The primary VA can successfully recheck the CAA record and is allowed to + // issue for this domain. If `EnforceMultiCAA`` was enabled, the configured + // remote VA with broken dns.Client would fail the check and return a + // Problem, but that code path could never trigger. + test.AssertBoxedNil(t, isValidRes.Problem, "IsCAAValid returned a problem, but should not have") +} + +// caaHijackedDNS implements the `dns.DNSClient` interface with a set of useful +// test answers for CAA queries. It returns alternate CAA records than what +// caaMockDNS returns simulating either a BGP hijack or DNS records that have +// changed while queries were inflight. +type caaHijackedDNS struct{} + +func (h caaHijackedDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { + return nil, bdns.ResolverAddrs{"caaHijackedDNS"}, nil +} + +func (h caaHijackedDNS) LookupHost(_ context.Context, hostname string) ([]net.IP, bdns.ResolverAddrs, error) { + ip := net.ParseIP("127.0.0.1") + return []net.IP{ip}, bdns.ResolverAddrs{"caaHijackedDNS"}, nil +} +func (h caaHijackedDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { + // These records are altered from their caaMockDNS counterparts. Use this to + // tickle remoteValidationFailures. + var results []*dns.CAA + var record dns.CAA + switch strings.TrimRight(domain, ".") { + case "present.com", "present.servfail.com": + record.Tag = "issue" + record.Value = "other-ca.com" + results = append(results, &record) + case "present-dns-only.com": + return results, "", bdns.ResolverAddrs{"caaHijackedDNS"}, fmt.Errorf("SERVFAIL") + case "satisfiable-wildcard.com": + record.Tag = "issuewild" + record.Value = ";" + results = append(results, &record) + secondRecord := record + secondRecord.Tag = "issue" + secondRecord.Value = ";" + results = append(results, &secondRecord) + } + var response string + if len(results) > 0 { + response = "foo" + } + return results, response, bdns.ResolverAddrs{"caaHijackedDNS"}, nil +} + +func TestMultiCAARechecking(t *testing.T) { + // The remote differential log order is non-deterministic, so let's use + // the same UA for all applicable RVAs. + const ( + localUA = "local" + remoteUA = "remote" + brokenUA = "broken" + hijackedUA = "hijacked" + ) + remoteVA := setupRemote(nil, remoteUA, nil) + brokenVA := setupRemote(nil, brokenUA, caaBrokenDNS{}) + // Returns incorrect results + hijackedVA := setupRemote(nil, hijackedUA, caaHijackedDNS{}) + + testCases := []struct { + name string + maxLookupFailures int + domains string + remoteVAs []RemoteVA + expectedProbSubstring string + expectedProbType probs.ProblemType + expectedDiffLogSubstring string + localDNSClient bdns.Client + }{ + { + name: "all VAs functional, no CAA records", + domains: "present-dns-only.com", + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "broken localVA, RVAs functional, no CAA records", + domains: "present-dns-only.com", + localDNSClient: caaBrokenDNS{}, + expectedProbSubstring: "While processing CAA for present-dns-only.com: dnsClient is broken", + expectedProbType: probs.DNSProblem, + remoteVAs: []RemoteVA{ + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "functional localVA, 1 broken RVA, no CAA records", + domains: "present-dns-only.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.DNSProblem, + expectedDiffLogSubstring: `RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"broken","Problem":{"type":"dns","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {brokenVA, brokenUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "functional localVA, all broken RVAs, no CAA records", + domains: "present-dns-only.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.DNSProblem, + expectedDiffLogSubstring: `RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"broken","Problem":{"type":"dns","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {brokenVA, brokenUA}, + {brokenVA, brokenUA}, + {brokenVA, brokenUA}, + }, + }, + { + name: "all VAs functional, CAA issue type present", + domains: "present.com", + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "functional localVA, 1 broken RVA, CAA issue type present", + domains: "present.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.DNSProblem, + expectedDiffLogSubstring: `RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"broken","Problem":{"type":"dns","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {brokenVA, brokenUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "functional localVA, all broken RVAs, CAA issue type present", + domains: "present.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.DNSProblem, + expectedDiffLogSubstring: `RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"broken","Problem":{"type":"dns","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {brokenVA, brokenUA}, + {brokenVA, brokenUA}, + {brokenVA, brokenUA}, + }, + }, + { + // The localVA kicks off the background goroutines before doing its + // own check. But if its own check fails, it doesn't wait for their + // results. + name: "all VAs functional, CAA issue type forbids issuance", + domains: "unsatisfiable.com", + expectedProbSubstring: "CAA record for unsatisfiable.com prevents issuance", + expectedProbType: probs.CAAProblem, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "1 hijacked RVA, CAA issue type present", + domains: "present.com", + expectedProbSubstring: "CAA record for present.com prevents issuance", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "2 hijacked RVAs, CAA issue type present", + domains: "present.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":1,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "3 hijacked RVAs, CAA issue type present", + domains: "present.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + }, + }, + { + name: "1 hijacked RVA, CAA issuewild type present", + domains: "satisfiable-wildcard.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "2 hijacked RVAs, CAA issuewild type present", + domains: "satisfiable-wildcard.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":1,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "3 hijacked RVAs, CAA issuewild type present", + domains: "satisfiable-wildcard.com", + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + }, + }, + { + name: "1 hijacked RVA, CAA issuewild type present, 1 failure allowed", + domains: "satisfiable-wildcard.com", + maxLookupFailures: 1, + expectedDiffLogSubstring: `RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "2 hijacked RVAs, CAA issuewild type present, 1 failure allowed", + domains: "satisfiable-wildcard.com", + maxLookupFailures: 1, + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":1,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {remoteVA, remoteUA}, + }, + }, + { + name: "3 hijacked RVAs, CAA issuewild type present, 1 failure allowed", + domains: "satisfiable-wildcard.com", + maxLookupFailures: 1, + expectedProbSubstring: "During secondary CAA checking: While processing CAA", + expectedProbType: probs.CAAProblem, + expectedDiffLogSubstring: `RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"hijacked","Problem":{"type":"caa","detail":"While processing CAA for`, + localDNSClient: caaMockDNS{}, + remoteVAs: []RemoteVA{ + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + {hijackedVA, hijackedUA}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + va, mockLog := setup(nil, tc.maxLookupFailures, localUA, tc.remoteVAs, tc.localDNSClient) + defer mockLog.Clear() + + // MultiCAAFullResults: false is inherently flaky because of the + // non-deterministic nature of concurrent goroutine returns. We, + // boulder dev, made a decision to skip testing that path and + // eventually make MultiCAAFullResults: true the default. + features.Set(features.Config{ + EnforceMultiCAA: true, + MultiCAAFullResults: true, + }) + defer features.Reset() + + isValidRes, err := va.IsCAAValid(context.TODO(), &vapb.IsCAAValidRequest{ + Domain: tc.domains, + ValidationMethod: string(core.ChallengeTypeDNS01), + AccountURIID: 1, + }) + test.AssertNotError(t, err, "Should not have errored, but did") + + if tc.expectedProbSubstring != "" { + test.AssertContains(t, isValidRes.Problem.Detail, tc.expectedProbSubstring) + } else if isValidRes.Problem != nil { + test.AssertBoxedNil(t, isValidRes.Problem, "IsCAAValidRequest returned a problem, but should not have") + } + + if tc.expectedProbType != "" { + test.AssertEquals(t, string(tc.expectedProbType), isValidRes.Problem.ProblemType) + } + + var invalidRVACount int + for _, x := range va.remoteVAs { + if x.Address == "broken" || x.Address == "hijacked" { + invalidRVACount++ + } + } + + gotRequestProbs := mockLog.GetAllMatching(".IsCAAValid returned problem: ") + test.AssertEquals(t, len(gotRequestProbs), invalidRVACount) + + gotDifferential := mockLog.GetAllMatching("remoteVADifferentials JSON=.*") + if features.Get().MultiCAAFullResults && tc.expectedDiffLogSubstring != "" { + test.AssertEquals(t, len(gotDifferential), 1) + test.AssertContains(t, gotDifferential[0], tc.expectedDiffLogSubstring) + } else { + test.AssertEquals(t, len(gotDifferential), 0) + } + + gotAnyRemoteFailures := mockLog.GetAllMatching("CAA check failed due to remote failures:") + if len(gotAnyRemoteFailures) >= 1 { + // The primary VA only emits this line once. + test.AssertEquals(t, len(gotAnyRemoteFailures), 1) + } else { + test.AssertEquals(t, len(gotAnyRemoteFailures), 0) + } + }) + } +} + +func TestCAAFailure(t *testing.T) { + hs := httpSrv(t, expectedToken) + defer hs.Close() + + va, _ := setup(hs, 0, "", nil, caaMockDNS{}) + + err := va.checkCAA(ctx, dnsi("reserved.com"), &caaParams{1, core.ChallengeTypeHTTP01}) + if err == nil { + t.Fatalf("Expected CAA rejection for reserved.com, got success") + } + test.AssertErrorIs(t, err, berrors.CAA) + + err = va.checkCAA(ctx, dnsi("example.gonetld"), &caaParams{1, core.ChallengeTypeHTTP01}) + if err == nil { + t.Fatalf("Expected CAA rejection for gonetld, got success") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) + test.AssertContains(t, prob.Error(), "NXDOMAIN") +} + +func TestFilterCAA(t *testing.T) { + testCases := []struct { + name string + input []*dns.CAA + expectedIssueVals []string + expectedWildVals []string + expectedCU bool + }{ + { + name: "recognized non-critical", + input: []*dns.CAA{ + {Tag: "issue", Value: "a"}, + {Tag: "issuewild", Value: "b"}, + {Tag: "iodef", Value: "c"}, + {Tag: "issuemail", Value: "c"}, + }, + expectedIssueVals: []string{"a"}, + expectedWildVals: []string{"b"}, + }, + { + name: "recognized critical", + input: []*dns.CAA{ + {Tag: "issue", Value: "a", Flag: 128}, + {Tag: "issuewild", Value: "b", Flag: 128}, + {Tag: "iodef", Value: "c", Flag: 128}, + {Tag: "issuemail", Value: "c", Flag: 128}, + }, + expectedIssueVals: []string{"a"}, + expectedWildVals: []string{"b"}, + }, + { + name: "unrecognized non-critical", + input: []*dns.CAA{ + {Tag: "unknown", Flag: 2}, + }, + }, + { + name: "unrecognized critical", + input: []*dns.CAA{ + {Tag: "unknown", Flag: 128}, + }, + expectedCU: true, + }, + { + name: "unrecognized improper critical", + input: []*dns.CAA{ + {Tag: "unknown", Flag: 1}, + }, + expectedCU: true, + }, + { + name: "unrecognized very improper critical", + input: []*dns.CAA{ + {Tag: "unknown", Flag: 9}, + }, + expectedCU: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + issue, wild, cu := filterCAA(tc.input) + for _, tag := range issue { + test.AssertSliceContains(t, tc.expectedIssueVals, tag.Value) + } + for _, tag := range wild { + test.AssertSliceContains(t, tc.expectedWildVals, tag.Value) + } + test.AssertEquals(t, tc.expectedCU, cu) + }) + } +} + +func TestSelectCAA(t *testing.T) { + expected := dns.CAA{Tag: "issue", Value: "foo"} + + // An empty slice of caaResults should return nil, nil + r := []caaResult{} + s, err := selectCAA(r) + test.Assert(t, s == nil, "set is not nil") + test.AssertNotError(t, err, "error is not nil") + + // A slice of empty caaResults should return nil, "", nil + r = []caaResult{ + {"", false, nil, nil, false, "", nil, nil}, + {"", false, nil, nil, false, "", nil, nil}, + {"", false, nil, nil, false, "", nil, nil}, + } + s, err = selectCAA(r) + test.Assert(t, s == nil, "set is not nil") + test.AssertNotError(t, err, "error is not nil") + + // A slice of caaResults containing an error followed by a CAA + // record should return the error + r = []caaResult{ + {"foo.com", false, nil, nil, false, "", nil, errors.New("oops")}, + {"com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, + } + s, err = selectCAA(r) + test.Assert(t, s == nil, "set is not nil") + test.AssertError(t, err, "error is nil") + test.AssertEquals(t, err.Error(), "oops") + + // A slice of caaResults containing a good record that precedes an + // error, should return that good record, not the error + r = []caaResult{ + {"foo.com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, + {"com", false, nil, nil, false, "", nil, errors.New("")}, + } + s, err = selectCAA(r) + test.AssertEquals(t, len(s.issue), 1) + test.Assert(t, s.issue[0] == &expected, "Incorrect record returned") + test.AssertEquals(t, s.dig, "foo") + test.Assert(t, err == nil, "error is not nil") + + // A slice of caaResults containing multiple CAA records should + // return the first non-empty CAA record + r = []caaResult{ + {"bar.foo.com", false, []*dns.CAA{}, []*dns.CAA{}, false, "", nil, nil}, + {"foo.com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, + {"com", true, []*dns.CAA{&expected}, nil, false, "bar", nil, nil}, + } + s, err = selectCAA(r) + test.AssertEquals(t, len(s.issue), 1) + test.Assert(t, s.issue[0] == &expected, "Incorrect record returned") + test.AssertEquals(t, s.dig, "foo") + test.AssertNotError(t, err, "expect nil error") +} + +func TestAccountURIMatches(t *testing.T) { + tests := []struct { + name string + params map[string]string + prefixes []string + id int64 + want bool + }{ + { + name: "empty accounturi", + params: map[string]string{}, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + }, + id: 123456, + want: true, + }, + { + name: "non-uri accounturi", + params: map[string]string{ + "accounturi": "\\invalid 😎/123456", + }, + prefixes: []string{ + "\\invalid 😎", + }, + id: 123456, + want: false, + }, + { + name: "simple match", + params: map[string]string{ + "accounturi": "https://acme-v01.api.letsencrypt.org/acme/reg/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + }, + id: 123456, + want: true, + }, + { + name: "accountid mismatch", + params: map[string]string{ + "accounturi": "https://acme-v01.api.letsencrypt.org/acme/reg/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + }, + id: 123457, + want: false, + }, + { + name: "multiple prefixes, match first", + params: map[string]string{ + "accounturi": "https://acme-staging.api.letsencrypt.org/acme/reg/123456", + }, + prefixes: []string{ + "https://acme-staging.api.letsencrypt.org/acme/reg/", + "https://acme-staging-v02.api.letsencrypt.org/acme/acct/", + }, + id: 123456, + want: true, + }, + { + name: "multiple prefixes, match second", + params: map[string]string{ + "accounturi": "https://acme-v02.api.letsencrypt.org/acme/acct/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + "https://acme-v02.api.letsencrypt.org/acme/acct/", + }, + id: 123456, + want: true, + }, + { + name: "multiple prefixes, match none", + params: map[string]string{ + "accounturi": "https://acme-v02.api.letsencrypt.org/acme/acct/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/acct/", + "https://acme-v03.api.letsencrypt.org/acme/acct/", + }, + id: 123456, + want: false, + }, + { + name: "three prefixes", + params: map[string]string{ + "accounturi": "https://acme-v02.api.letsencrypt.org/acme/acct/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + "https://acme-v02.api.letsencrypt.org/acme/acct/", + "https://acme-v03.api.letsencrypt.org/acme/acct/", + }, + id: 123456, + want: true, + }, + { + name: "multiple prefixes, wrong accountid", + params: map[string]string{ + "accounturi": "https://acme-v02.api.letsencrypt.org/acme/acct/123456", + }, + prefixes: []string{ + "https://acme-v01.api.letsencrypt.org/acme/reg/", + "https://acme-v02.api.letsencrypt.org/acme/acct/", + }, + id: 654321, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := caaAccountURIMatches(tc.params, tc.prefixes, tc.id) + test.AssertEquals(t, got, tc.want) + }) + } +} + +func TestValidationMethodMatches(t *testing.T) { + tests := []struct { + name string + params map[string]string + method core.AcmeChallenge + want bool + }{ + { + name: "empty validationmethods", + params: map[string]string{}, + method: core.ChallengeTypeHTTP01, + want: true, + }, + { + name: "only comma", + params: map[string]string{ + "validationmethods": ",", + }, + method: core.ChallengeTypeHTTP01, + want: false, + }, + { + name: "malformed method", + params: map[string]string{ + "validationmethods": "howdy !", + }, + method: core.ChallengeTypeHTTP01, + want: false, + }, + { + name: "invalid method", + params: map[string]string{ + "validationmethods": "tls-sni-01", + }, + method: core.ChallengeTypeHTTP01, + want: false, + }, + { + name: "simple match", + params: map[string]string{ + "validationmethods": "http-01", + }, + method: core.ChallengeTypeHTTP01, + want: true, + }, + { + name: "simple mismatch", + params: map[string]string{ + "validationmethods": "dns-01", + }, + method: core.ChallengeTypeHTTP01, + want: false, + }, + { + name: "multiple choices, match first", + params: map[string]string{ + "validationmethods": "http-01,dns-01", + }, + method: core.ChallengeTypeHTTP01, + want: true, + }, + { + name: "multiple choices, match second", + params: map[string]string{ + "validationmethods": "http-01,dns-01", + }, + method: core.ChallengeTypeDNS01, + want: true, + }, + { + name: "multiple choices, match none", + params: map[string]string{ + "validationmethods": "http-01,dns-01", + }, + method: core.ChallengeTypeTLSALPN01, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := caaValidationMethodMatches(tc.params, tc.method) + test.AssertEquals(t, got, tc.want) + }) + } +} + +func TestExtractIssuerDomainAndParameters(t *testing.T) { + tests := []struct { + name string + value string + wantDomain string + wantParameters map[string]string + expectErrSubstr string + }{ + { + name: "empty record is valid", + value: "", + wantDomain: "", + wantParameters: map[string]string{}, + expectErrSubstr: "", + }, + { + name: "only semicolon is valid", + value: ";", + wantDomain: "", + wantParameters: map[string]string{}, + expectErrSubstr: "", + }, + { + name: "only semicolon and whitespace is valid", + value: " ; ", + wantDomain: "", + wantParameters: map[string]string{}, + expectErrSubstr: "", + }, + { + name: "only domain is valid", + value: "letsencrypt.org", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{}, + expectErrSubstr: "", + }, + { + name: "only domain with trailing semicolon is valid", + value: "letsencrypt.org;", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{}, + expectErrSubstr: "", + }, + { + name: "domain with params and whitespace is valid", + value: " letsencrypt.org ;foo=bar;baz=bar", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"foo": "bar", "baz": "bar"}, + expectErrSubstr: "", + }, + { + name: "domain with params and different whitespace is valid", + value: " letsencrypt.org ;foo=bar;baz=bar", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"foo": "bar", "baz": "bar"}, + expectErrSubstr: "", + }, + { + name: "empty params are valid", + value: "letsencrypt.org; foo=; baz = bar", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"foo": "", "baz": "bar"}, + expectErrSubstr: "", + }, + { + name: "whitespace around params is valid", + value: "letsencrypt.org; foo= ; baz = bar", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"foo": "", "baz": "bar"}, + expectErrSubstr: "", + }, + { + name: "comma-separated param values are valid", + value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a=b ", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"foo": "b1,b2,b3", "baz": "a=b"}, + expectErrSubstr: "", + }, + { + name: "spaces in param values are invalid", + value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a = b ", + expectErrSubstr: "value contains disallowed character", + }, + { + name: "spaces in param values are still invalid", + value: "letsencrypt.org; foo=b1,b2,b3 ; baz=a= b", + expectErrSubstr: "value contains disallowed character", + }, + { + name: "param without equals sign is invalid", + value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a;b ", + expectErrSubstr: "parameter not formatted as tag=value", + }, + { + name: "hyphens in param values are valid", + value: "letsencrypt.org; 1=2; baz=a-b", + wantDomain: "letsencrypt.org", + wantParameters: map[string]string{"1": "2", "baz": "a-b"}, + expectErrSubstr: "", + }, + { + name: "underscores in param tags are invalid", + value: "letsencrypt.org; a_b=123", + expectErrSubstr: "tag contains disallowed character", + }, + { + name: "multiple spaces in param values are extra invalid", + value: "letsencrypt.org; ab=1 2 3", + expectErrSubstr: "value contains disallowed character", + }, + { + name: "hyphens in param tags are invalid", + value: "letsencrypt.org; 1=2; a-b=c", + expectErrSubstr: "tag contains disallowed character", + }, + { + name: "high codepoints in params are invalid", + value: "letsencrypt.org; foo=a\u2615b", + expectErrSubstr: "value contains disallowed character", + }, + { + name: "missing semicolons between params are invalid", + value: "letsencrypt.org; foo=b1,b2,b3 baz=a", + expectErrSubstr: "value contains disallowed character", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotDomain, gotParameters, gotErr := parseCAARecord(&dns.CAA{Value: tc.value}) + + if tc.expectErrSubstr == "" { + test.AssertNotError(t, gotErr, "") + } else { + test.AssertError(t, gotErr, "") + test.AssertContains(t, gotErr.Error(), tc.expectErrSubstr) + } + + if tc.wantDomain != "" { + test.AssertEquals(t, gotDomain, tc.wantDomain) + } + + if tc.wantParameters != nil { + test.AssertDeepEquals(t, gotParameters, tc.wantParameters) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/va/config/config.go b/third-party/github.com/letsencrypt/boulder/va/config/config.go new file mode 100644 index 000000000..28a430619 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/config/config.go @@ -0,0 +1,52 @@ +package vacfg + +import ( + "fmt" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" +) + +// Common contains all of the shared fields for a VA and a Remote VA (RVA). +type Common struct { + cmd.ServiceConfig + UserAgent string + + IssuerDomain string + + // DNSTries is the number of times to try a DNS query (that has a temporary error) + // before giving up. May be short-circuited by deadlines. A zero value + // will be turned into 1. + DNSTries int + DNSProvider *cmd.DNSProvider `validate:"required_without=DNSStaticResolvers"` + // DNSStaticResolvers is a list of DNS resolvers. Each entry must + // be a host or IP and port separated by a colon. IPv6 addresses + // must be enclosed in square brackets. + DNSStaticResolvers []string `validate:"required_without=DNSProvider,dive,hostname_port"` + DNSTimeout config.Duration `validate:"required"` + DNSAllowLoopbackAddresses bool + + AccountURIPrefixes []string `validate:"min=1,dive,required,url"` +} + +// SetDefaultsAndValidate performs some basic sanity checks on fields stored in +// the Common struct, defaulting them to a sane value when necessary. This +// method does mutate the Common struct. +func (c *Common) SetDefaultsAndValidate(grpcAddr, debugAddr *string) error { + if *grpcAddr != "" { + c.GRPC.Address = *grpcAddr + } + if *debugAddr != "" { + c.DebugAddr = *debugAddr + } + + if c.DNSTimeout.Duration <= 0 { + return fmt.Errorf("'dnsTimeout' is required") + } + + if c.DNSTries < 1 { + c.DNSTries = 1 + } + + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/va/dns.go b/third-party/github.com/letsencrypt/boulder/va/dns.go new file mode 100644 index 000000000..5ab61b9b1 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/dns.go @@ -0,0 +1,93 @@ +package va + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" +) + +// getAddr will query for all A/AAAA records associated with hostname and return +// the preferred address, the first net.IP in the addrs slice, and all addresses +// resolved. This is the same choice made by the Go internal resolution library +// used by net/http. If there is an error resolving the hostname, or if no +// usable IP addresses are available then a berrors.DNSError instance is +// returned with a nil net.IP slice. +func (va ValidationAuthorityImpl) getAddrs(ctx context.Context, hostname string) ([]net.IP, bdns.ResolverAddrs, error) { + addrs, resolvers, err := va.dnsClient.LookupHost(ctx, hostname) + if err != nil { + return nil, resolvers, berrors.DNSError("%v", err) + } + + if len(addrs) == 0 { + // This should be unreachable, as no valid IP addresses being found results + // in an error being returned from LookupHost. + return nil, resolvers, berrors.DNSError("No valid IP addresses found for %s", hostname) + } + va.log.Debugf("Resolved addresses for %s: %s", hostname, addrs) + return addrs, resolvers, nil +} + +// availableAddresses takes a ValidationRecord and splits the AddressesResolved +// into a list of IPv4 and IPv6 addresses. +func availableAddresses(allAddrs []net.IP) (v4 []net.IP, v6 []net.IP) { + for _, addr := range allAddrs { + if addr.To4() != nil { + v4 = append(v4, addr) + } else { + v6 = append(v6, addr) + } + } + return +} + +func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string) ([]core.ValidationRecord, error) { + if ident.Type != identifier.DNS { + va.log.Infof("Identifier type for DNS challenge was not DNS: %s", ident) + return nil, berrors.MalformedError("Identifier type for DNS was not itself DNS") + } + + // Compute the digest of the key authorization file + h := sha256.New() + h.Write([]byte(keyAuthorization)) + authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + // Look for the required record in the DNS + challengeSubdomain := fmt.Sprintf("%s.%s", core.DNSPrefix, ident.Value) + txts, resolvers, err := va.dnsClient.LookupTXT(ctx, challengeSubdomain) + if err != nil { + return nil, berrors.DNSError("%s", err) + } + + // If there weren't any TXT records return a distinct error message to allow + // troubleshooters to differentiate between no TXT records and + // invalid/incorrect TXT records. + if len(txts) == 0 { + return nil, berrors.UnauthorizedError("No TXT record found at %s", challengeSubdomain) + } + + for _, element := range txts { + if subtle.ConstantTimeCompare([]byte(element), []byte(authorizedKeysDigest)) == 1 { + // Successful challenge validation + return []core.ValidationRecord{{Hostname: ident.Value, ResolverAddrs: resolvers}}, nil + } + } + + invalidRecord := txts[0] + if len(invalidRecord) > 100 { + invalidRecord = invalidRecord[0:100] + "..." + } + var andMore string + if len(txts) > 1 { + andMore = fmt.Sprintf(" (and %d more)", len(txts)-1) + } + return nil, berrors.UnauthorizedError("Incorrect TXT record %q%s found at %s", + invalidRecord, andMore, challengeSubdomain) +} diff --git a/third-party/github.com/letsencrypt/boulder/va/dns_test.go b/third-party/github.com/letsencrypt/boulder/va/dns_test.go new file mode 100644 index 000000000..a545228a4 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/dns_test.go @@ -0,0 +1,210 @@ +package va + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +func TestDNSValidationEmpty(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + // This test calls PerformValidation directly, because that is where the + // metrics checked below are incremented. + req := createValidationRequest("empty-txts.com", core.ChallengeTypeDNS01) + res, _ := va.PerformValidation(context.Background(), req) + test.AssertEquals(t, res.Problems.ProblemType, "unauthorized") + test.AssertEquals(t, res.Problems.Detail, "No TXT record found at _acme-challenge.empty-txts.com") + + test.AssertMetricWithLabelsEquals(t, va.metrics.validationTime, prometheus.Labels{ + "type": "dns-01", + "result": "invalid", + "problem_type": "unauthorized", + }, 1) +} + +func TestDNSValidationWrong(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + _, err := va.validateDNS01(context.Background(), dnsi("wrong-dns01.com"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"a\" found at _acme-challenge.wrong-dns01.com") +} + +func TestDNSValidationWrongMany(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateDNS01(context.Background(), dnsi("wrong-many-dns01.com"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"a\" (and 4 more) found at _acme-challenge.wrong-many-dns01.com") +} + +func TestDNSValidationWrongLong(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateDNS01(context.Background(), dnsi("long-dns01.com"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\" found at _acme-challenge.long-dns01.com") +} + +func TestDNSValidationFailure(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateDNS01(ctx, dnsi("localhost"), expectedKeyAuthorization) + prob := detailedError(err) + + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) +} + +func TestDNSValidationInvalid(t *testing.T) { + var notDNS = identifier.ACMEIdentifier{ + Type: identifier.IdentifierType("iris"), + Value: "790DB180-A274-47A4-855F-31C428CB1072", + } + + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateDNS01(ctx, notDNS, expectedKeyAuthorization) + prob := detailedError(err) + + test.AssertEquals(t, prob.Type, probs.MalformedProblem) +} + +func TestDNSValidationServFail(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateDNS01(ctx, dnsi("servfail.com"), expectedKeyAuthorization) + + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) +} + +func TestDNSValidationNoServer(t *testing.T) { + va, log := setup(nil, 0, "", nil, nil) + staticProvider, err := bdns.NewStaticProvider([]string{}) + test.AssertNotError(t, err, "Couldn't make new static provider") + + va.dnsClient = bdns.NewTest( + time.Second*5, + staticProvider, + metrics.NoopRegisterer, + clock.New(), + 1, + log, + nil) + + _, err = va.validateDNS01(ctx, dnsi("localhost"), expectedKeyAuthorization) + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) +} + +func TestDNSValidationOK(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, prob := va.validateDNS01(ctx, dnsi("good-dns01.com"), expectedKeyAuthorization) + + test.Assert(t, prob == nil, "Should be valid.") +} + +func TestDNSValidationNoAuthorityOK(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, prob := va.validateDNS01(ctx, dnsi("no-authority-dns01.com"), expectedKeyAuthorization) + + test.Assert(t, prob == nil, "Should be valid.") +} + +func TestAvailableAddresses(t *testing.T) { + v6a := net.ParseIP("::1") + v6b := net.ParseIP("2001:db8::2:1") // 2001:DB8 is reserved for docs (RFC 3849) + v4a := net.ParseIP("127.0.0.1") + v4b := net.ParseIP("192.0.2.1") // 192.0.2.0/24 is reserved for docs (RFC 5737) + + testcases := []struct { + input []net.IP + v4 []net.IP + v6 []net.IP + }{ + // An empty validation record + { + []net.IP{}, + []net.IP{}, + []net.IP{}, + }, + // A validation record with one IPv4 address + { + []net.IP{v4a}, + []net.IP{v4a}, + []net.IP{}, + }, + // A dual homed record with an IPv4 and IPv6 address + { + []net.IP{v4a, v6a}, + []net.IP{v4a}, + []net.IP{v6a}, + }, + // The same as above but with the v4/v6 order flipped + { + []net.IP{v6a, v4a}, + []net.IP{v4a}, + []net.IP{v6a}, + }, + // A validation record with just IPv6 addresses + { + []net.IP{v6a, v6b}, + []net.IP{}, + []net.IP{v6a, v6b}, + }, + // A validation record with interleaved IPv4/IPv6 records + { + []net.IP{v6a, v4a, v6b, v4b}, + []net.IP{v4a, v4b}, + []net.IP{v6a, v6b}, + }, + } + + for _, tc := range testcases { + // Split the input record into v4/v6 addresses + v4result, v6result := availableAddresses(tc.input) + + // Test that we got the right number of v4 results + test.Assert(t, len(tc.v4) == len(v4result), + fmt.Sprintf("Wrong # of IPv4 results: expected %d, got %d", len(tc.v4), len(v4result))) + + // Check that all of the v4 results match expected values + for i, v4addr := range tc.v4 { + test.Assert(t, v4addr.String() == v4result[i].String(), + fmt.Sprintf("Wrong v4 result index %d: expected %q got %q", i, v4addr.String(), v4result[i].String())) + } + + // Test that we got the right number of v6 results + test.Assert(t, len(tc.v6) == len(v6result), + fmt.Sprintf("Wrong # of IPv6 results: expected %d, got %d", len(tc.v6), len(v6result))) + + // Check that all of the v6 results match expected values + for i, v6addr := range tc.v6 { + test.Assert(t, v6addr.String() == v6result[i].String(), + fmt.Sprintf("Wrong v6 result index %d: expected %q got %q", i, v6addr.String(), v6result[i].String())) + } + } +} diff --git a/third-party/github.com/letsencrypt/boulder/va/http.go b/third-party/github.com/letsencrypt/boulder/va/http.go new file mode 100644 index 000000000..5702e66bd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/http.go @@ -0,0 +1,678 @@ +package va + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/iana" + "github.com/letsencrypt/boulder/identifier" +) + +const ( + // maxRedirect is the maximum number of redirects the VA will follow + // processing an HTTP-01 challenge. + maxRedirect = 10 + // maxResponseSize holds the maximum number of bytes that will be read from an + // HTTP-01 challenge response. The expected payload should be ~87 bytes. Since + // it may be padded by whitespace which we previously allowed accept up to 128 + // bytes before rejecting a response (32 byte b64 encoded token + . + 32 byte + // b64 encoded key fingerprint). + maxResponseSize = 128 + // maxPathSize is the maximum number of bytes we will accept in the path of a + // redirect URL. + maxPathSize = 2000 +) + +// preresolvedDialer is a struct type that provides a DialContext function which +// will connect to the provided IP and port instead of letting DNS resolve +// The hostname of the preresolvedDialer is used to ensure the dial only completes +// using the pre-resolved IP/port when used for the correct host. +type preresolvedDialer struct { + ip net.IP + port int + hostname string + timeout time.Duration +} + +// a dialerMismatchError is produced when a preresolvedDialer is used to dial +// a host other than the dialer's specified hostname. +type dialerMismatchError struct { + // The original dialer information + dialerHost string + dialerIP string + dialerPort int + // The host that the dialer was incorrectly used with + host string +} + +func (e *dialerMismatchError) Error() string { + return fmt.Sprintf( + "preresolvedDialer mismatch: dialer is for %q (ip: %q port: %d) not %q", + e.dialerHost, e.dialerIP, e.dialerPort, e.host) +} + +// DialContext for a preresolvedDialer shaves 10ms off of the context it was +// given before calling the default transport DialContext using the pre-resolved +// IP and port as the host. If the original host being dialed by DialContext +// does not match the expected hostname in the preresolvedDialer an error will +// be returned instead. This helps prevents a bug that might use +// a preresolvedDialer for the wrong host. +// +// Shaving the context helps us be able to differentiate between timeouts during +// connect and timeouts after connect. +// +// Using preresolved information for the host argument given to the real +// transport dial lets us have fine grained control over IP address resolution for +// domain names. +func (d *preresolvedDialer) DialContext( + ctx context.Context, + network, + origAddr string) (net.Conn, error) { + deadline, ok := ctx.Deadline() + if !ok { + // Shouldn't happen: All requests should have a deadline by this point. + deadline = time.Now().Add(100 * time.Second) + } else { + // Set the context deadline slightly shorter than the HTTP deadline, so we + // get a useful error rather than a generic "deadline exceeded" error. This + // lets us give a more specific error to the subscriber. + deadline = deadline.Add(-10 * time.Millisecond) + } + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + + // NOTE(@cpu): I don't capture and check the origPort here because using + // `net.SplitHostPort` and also supporting the va's custom httpPort and + // httpsPort is cumbersome. The initial origAddr may be "example.com:80" + // if the URL used for the dial input was "http://example.com" without an + // explicit port. Checking for equality here will fail unless we add + // special case logic for converting 80/443 -> httpPort/httpsPort when + // configured. This seems more likely to cause bugs than catch them so I'm + // ignoring this for now. In the future if we remove the httpPort/httpsPort + // (we should!) we can also easily enforce that the preresolved dialer port + // matches expected here. + origHost, _, err := net.SplitHostPort(origAddr) + if err != nil { + return nil, err + } + // If the hostname we're dialing isn't equal to the hostname the dialer was + // constructed for then a bug has occurred where we've mismatched the + // preresolved dialer. + if origHost != d.hostname { + return nil, &dialerMismatchError{ + dialerHost: d.hostname, + dialerIP: d.ip.String(), + dialerPort: d.port, + host: origHost, + } + } + + // Make a new dial address using the pre-resolved IP and port. + targetAddr := net.JoinHostPort(d.ip.String(), strconv.Itoa(d.port)) + + // Create a throw-away dialer using default values and the dialer timeout + // (populated from the VA singleDialTimeout). + throwAwayDialer := &net.Dialer{ + Timeout: d.timeout, + // Default KeepAlive - see Golang src/net/http/transport.go DefaultTransport + KeepAlive: 30 * time.Second, + } + return throwAwayDialer.DialContext(ctx, network, targetAddr) +} + +// a dialerFunc meets the function signature requirements of +// a http.Transport.DialContext handler. +type dialerFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// httpTransport constructs a HTTP Transport with settings appropriate for +// HTTP-01 validation. The provided dialerFunc is used as the Transport's +// DialContext handler. +func httpTransport(df dialerFunc) *http.Transport { + return &http.Transport{ + DialContext: df, + // We are talking to a client that does not yet have a certificate, + // so we accept a temporary, invalid one. + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + // We don't expect to make multiple requests to a client, so close + // connection immediately. + DisableKeepAlives: true, + // We don't want idle connections, but 0 means "unlimited," so we pick 1. + MaxIdleConns: 1, + IdleConnTimeout: time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } +} + +// httpValidationTarget bundles all of the information needed to make an HTTP-01 +// validation request against a target. +type httpValidationTarget struct { + // the hostname being validated + host string + // the port for the validation request + port int + // the path for the validation request + path string + // query data for validation request (potentially populated when + // following redirects) + query string + // all of the IP addresses available for the host + available []net.IP + // the IP addresses that were tried for validation previously that were cycled + // out of cur by calls to nextIP() + tried []net.IP + // the IP addresses that will be drawn from by calls to nextIP() to set curIP + next []net.IP + // the current IP address being used for validation (if any) + cur net.IP + // the DNS resolver(s) that will attempt to fulfill the validation request + resolvers bdns.ResolverAddrs +} + +// nextIP changes the cur IP by removing the first entry from the next slice and +// setting it to cur. If cur was previously set the value will be added to the +// tried slice to keep track of IPs that were previously used. If nextIP() is +// called but vt.next is empty an error is returned. +func (vt *httpValidationTarget) nextIP() error { + if len(vt.next) == 0 { + return fmt.Errorf( + "host %q has no IP addresses remaining to use", + vt.host) + } + vt.tried = append(vt.tried, vt.cur) + vt.cur = vt.next[0] + vt.next = vt.next[1:] + return nil +} + +// newHTTPValidationTarget creates a httpValidationTarget for the given host, +// port, and path. This involves querying DNS for the IP addresses for the host. +// An error is returned if there are no usable IP addresses or if the DNS +// lookups fail. +func (va *ValidationAuthorityImpl) newHTTPValidationTarget( + ctx context.Context, + host string, + port int, + path string, + query string) (*httpValidationTarget, error) { + // Resolve IP addresses for the hostname + addrs, resolvers, err := va.getAddrs(ctx, host) + if err != nil { + return nil, err + } + + target := &httpValidationTarget{ + host: host, + port: port, + path: path, + query: query, + available: addrs, + resolvers: resolvers, + } + + // Separate the addresses into the available v4 and v6 addresses + v4Addrs, v6Addrs := availableAddresses(addrs) + hasV6Addrs := len(v6Addrs) > 0 + hasV4Addrs := len(v4Addrs) > 0 + + if !hasV6Addrs && !hasV4Addrs { + // If there are no v6 addrs and no v4addrs there was a bug with getAddrs or + // availableAddresses and we need to return an error. + return nil, fmt.Errorf("host %q has no IPv4 or IPv6 addresses", host) + } else if !hasV6Addrs && hasV4Addrs { + // If there are no v6 addrs and there are v4 addrs then use the first v4 + // address. There's no fallback address. + target.next = []net.IP{v4Addrs[0]} + } else if hasV6Addrs && hasV4Addrs { + // If there are both v6 addrs and v4 addrs then use the first v6 address and + // fallback with the first v4 address. + target.next = []net.IP{v6Addrs[0], v4Addrs[0]} + } else if hasV6Addrs && !hasV4Addrs { + // If there are just v6 addrs then use the first v6 address. There's no + // fallback address. + target.next = []net.IP{v6Addrs[0]} + } + + // Advance the target using nextIP to populate the cur IP before returning + _ = target.nextIP() + return target, nil +} + +// extractRequestTarget extracts the hostname and port specified in the provided +// HTTP redirect request. If the request's URL's protocol schema is not HTTP or +// HTTPS an error is returned. If an explicit port is specified in the request's +// URL and it isn't the VA's HTTP or HTTPS port, an error is returned. If the +// request's URL's Host is a bare IPv4 or IPv6 address and not a domain name an +// error is returned. +func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (string, int, error) { + // A nil request is certainly not a valid redirect and has no port to extract. + if req == nil { + return "", 0, fmt.Errorf("redirect HTTP request was nil") + } + + reqScheme := req.URL.Scheme + + // The redirect request must use HTTP or HTTPs protocol schemes regardless of the port.. + if reqScheme != "http" && reqScheme != "https" { + return "", 0, berrors.ConnectionFailureError( + "Invalid protocol scheme in redirect target. "+ + `Only "http" and "https" protocol schemes are supported, not %q`, reqScheme) + } + + // Try and split an explicit port number from the request URL host. If there is + // one we need to make sure its a valid port. If there isn't one we need to + // pick the port based on the reqScheme default port. + reqHost := req.URL.Host + var reqPort int + if h, p, err := net.SplitHostPort(reqHost); err == nil { + reqHost = h + reqPort, err = strconv.Atoi(p) + if err != nil { + return "", 0, err + } + + // The explicit port must match the VA's configured HTTP or HTTPS port. + if reqPort != va.httpPort && reqPort != va.httpsPort { + return "", 0, berrors.ConnectionFailureError( + "Invalid port in redirect target. Only ports %d and %d are supported, not %d", + va.httpPort, va.httpsPort, reqPort) + } + } else if reqScheme == "http" { + reqPort = va.httpPort + } else if reqScheme == "https" { + reqPort = va.httpsPort + } else { + // This shouldn't happen but defensively return an internal server error in + // case it does. + return "", 0, fmt.Errorf("unable to determine redirect HTTP request port") + } + + if reqHost == "" { + return "", 0, berrors.ConnectionFailureError("Invalid empty hostname in redirect target") + } + + // Check that the request host isn't a bare IP address. We only follow + // redirects to hostnames. + if net.ParseIP(reqHost) != nil { + return "", 0, berrors.ConnectionFailureError("Invalid host in redirect target %q. Only domain names are supported, not IP addresses", reqHost) + } + + // Often folks will misconfigure their webserver to send an HTTP redirect + // missing a `/' between the FQDN and the path. E.g. in Apache using: + // Redirect / https://bad-redirect.org + // Instead of + // Redirect / https://bad-redirect.org/ + // Will produce an invalid HTTP-01 redirect target like: + // https://bad-redirect.org.well-known/acme-challenge/xxxx + // This happens frequently enough we want to return a distinct error message + // for this case by detecting the reqHost ending in ".well-known". + if strings.HasSuffix(reqHost, ".well-known") { + return "", 0, berrors.ConnectionFailureError( + "Invalid host in redirect target %q. Check webserver config for missing '/' in redirect target.", + reqHost, + ) + } + + if _, err := iana.ExtractSuffix(reqHost); err != nil { + return "", 0, berrors.ConnectionFailureError("Invalid hostname in redirect target, must end in IANA registered TLD") + } + + return reqHost, reqPort, nil +} + +// setupHTTPValidation sets up a preresolvedDialer and a validation record for +// the given request URL and httpValidationTarget. If the req URL is empty, or +// the validation target is nil or has no available IP addresses, an error will +// be returned. +func (va *ValidationAuthorityImpl) setupHTTPValidation( + reqURL string, + target *httpValidationTarget) (*preresolvedDialer, core.ValidationRecord, error) { + if reqURL == "" { + return nil, + core.ValidationRecord{}, + fmt.Errorf("reqURL can not be nil") + } + if target == nil { + // This is the only case where returning an empty validation record makes + // sense - we can't construct a better one, something has gone quite wrong. + return nil, + core.ValidationRecord{}, + fmt.Errorf("httpValidationTarget can not be nil") + } + + // Construct a base validation record with the validation target's + // information. + record := core.ValidationRecord{ + Hostname: target.host, + Port: strconv.Itoa(target.port), + AddressesResolved: target.available, + URL: reqURL, + ResolverAddrs: target.resolvers, + } + + // Get the target IP to build a preresolved dialer with + targetIP := target.cur + if targetIP == nil { + return nil, + record, + fmt.Errorf( + "host %q has no IP addresses remaining to use", + target.host) + } + record.AddressUsed = targetIP + + dialer := &preresolvedDialer{ + ip: targetIP, + port: target.port, + hostname: target.host, + timeout: va.singleDialTimeout, + } + return dialer, record, nil +} + +// fetchHTTP invokes processHTTPValidation and if an error result is +// returned, converts it to a problem. Otherwise the results from +// processHTTPValidation are returned. +func (va *ValidationAuthorityImpl) fetchHTTP( + ctx context.Context, + host string, + path string) ([]byte, []core.ValidationRecord, error) { + body, records, err := va.processHTTPValidation(ctx, host, path) + if err != nil { + return body, records, err + } + return body, records, nil +} + +// fallbackErr returns true only for net.OpError instances where the op is equal +// to "dial", or url.Error instances wrapping such an error. fallbackErr returns +// false for all other errors. By policy, only dial errors (not read or write +// errors) are eligible for fallback from an IPv6 to an IPv4 address. +func fallbackErr(err error) bool { + // Err shouldn't ever be nil if we're considering it for fallback + if err == nil { + return false + } + // Net OpErrors are fallback errs only if the operation was a "dial" + // All other errs are not fallback errs + var netOpError *net.OpError + return errors.As(err, &netOpError) && netOpError.Op == "dial" +} + +// processHTTPValidation performs an HTTP validation for the given host, port +// and path. If successful the body of the HTTP response is returned along with +// the validation records created during the validation. If not successful +// a non-nil error and potentially some ValidationRecords are returned. +func (va *ValidationAuthorityImpl) processHTTPValidation( + ctx context.Context, + host string, + path string) ([]byte, []core.ValidationRecord, error) { + // Create a target for the host, port and path with no query parameters + target, err := va.newHTTPValidationTarget(ctx, host, va.httpPort, path, "") + if err != nil { + return nil, nil, err + } + + // Create an initial GET Request + initialURL := url.URL{ + Scheme: "http", + Host: host, + Path: path, + } + initialReq, err := http.NewRequest("GET", initialURL.String(), nil) + if err != nil { + return nil, nil, newIPError(target.cur, err) + } + + // Add a context to the request. Shave some time from the + // overall context deadline so that we are not racing with gRPC when the + // HTTP server is timing out. This avoids returning ServerInternal + // errors when we should be returning Connection errors. This may fix a flaky + // integration test: https://github.com/letsencrypt/boulder/issues/4087 + // Note: The gRPC interceptor in grpc/interceptors.go already shaves some time + // off RPCs, but this takes off additional time because HTTP-related timeouts + // are so common (and because it might fix a flaky build). + deadline, ok := ctx.Deadline() + if !ok { + return nil, nil, fmt.Errorf("processHTTPValidation had no deadline") + } else { + deadline = deadline.Add(-200 * time.Millisecond) + } + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + initialReq = initialReq.WithContext(ctx) + if va.userAgent != "" { + initialReq.Header.Set("User-Agent", va.userAgent) + } + // Some of our users use mod_security. Mod_security sees a lack of Accept + // headers as bot behavior and rejects requests. While this is a bug in + // mod_security's rules (given that the HTTP specs disagree with that + // requirement), we add the Accept header now in order to fix our + // mod_security users' mysterious breakages. See + // and + // . This was done + // because it's a one-line fix with no downside. We're not likely to want to + // do many more things to satisfy misunderstandings around HTTP. + initialReq.Header.Set("Accept", "*/*") + + // Set up the initial validation request and a base validation record + dialer, baseRecord, err := va.setupHTTPValidation(initialReq.URL.String(), target) + if err != nil { + return nil, []core.ValidationRecord{}, newIPError(target.cur, err) + } + + // Build a transport for this validation that will use the preresolvedDialer's + // DialContext function + transport := httpTransport(dialer.DialContext) + + va.log.AuditInfof("Attempting to validate HTTP-01 for %q with GET to %q", + initialReq.Host, initialReq.URL.String()) + + // Create a closure around records & numRedirects we can use with a HTTP + // client to process redirects per our own policy (e.g. resolving IP + // addresses explicitly, not following redirects to ports != [80,443], etc) + records := []core.ValidationRecord{baseRecord} + numRedirects := 0 + processRedirect := func(req *http.Request, via []*http.Request) error { + va.log.Debugf("processing a HTTP redirect from the server to %q", req.URL.String()) + // Only process up to maxRedirect redirects + if numRedirects > maxRedirect { + return berrors.ConnectionFailureError("Too many redirects") + } + numRedirects++ + va.metrics.http01Redirects.Inc() + + // If TLS was used, record the negotiated key exchange mechanism in the most + // recent validationRecord. + // TODO(#7321): Remove this when we have collected enough data. + if req.Response.TLS != nil { + records[len(records)-1].UsedRSAKEX = usedRSAKEX(req.Response.TLS.CipherSuite) + } + + if req.Response.TLS != nil && req.Response.TLS.Version < tls.VersionTLS12 { + return berrors.ConnectionFailureError( + "validation attempt was redirected to an HTTPS server that doesn't " + + "support TLSv1.2 or better. See " + + "https://community.letsencrypt.org/t/rejecting-sha-1-csrs-and-validation-using-tls-1-0-1-1-urls/175144") + } + + // If the response contains an HTTP 303 or any other forbidden redirect, + // do not follow it. The four allowed redirect status codes are defined + // explicitly in BRs Section 3.2.2.4.19. Although the go stdlib currently + // limits redirects to a set of status codes with only one additional + // entry (303), we capture the full list of allowed codes here in case the + // go stdlib expands the set of redirects it follows in the future. + acceptableRedirects := map[int]struct{}{ + 301: {}, 302: {}, 307: {}, 308: {}, + } + if _, present := acceptableRedirects[req.Response.StatusCode]; !present { + return berrors.ConnectionFailureError("received disallowed redirect status code") + } + + // Lowercase the redirect host immediately, as the dialer and redirect + // validation expect it to have been lowercased already. + req.URL.Host = strings.ToLower(req.URL.Host) + + // Extract the redirect target's host and port. This will return an error if + // the redirect request scheme, host or port is not acceptable. + redirHost, redirPort, err := va.extractRequestTarget(req) + if err != nil { + return err + } + + redirPath := req.URL.Path + if len(redirPath) > maxPathSize { + return berrors.ConnectionFailureError("Redirect target too long") + } + + // If the redirect URL has query parameters we need to preserve + // those in the redirect path + redirQuery := "" + if req.URL.RawQuery != "" { + redirQuery = req.URL.RawQuery + } + + // Check for a redirect loop. If any URL is found twice before the + // redirect limit, return error. + for _, record := range records { + if req.URL.String() == record.URL { + return berrors.ConnectionFailureError("Redirect loop detected") + } + } + + // Create a validation target for the redirect host. This will resolve IP + // addresses for the host explicitly. + redirTarget, err := va.newHTTPValidationTarget(ctx, redirHost, redirPort, redirPath, redirQuery) + if err != nil { + return err + } + + // Setup validation for the target. This will produce a preresolved dialer we can + // assign to the client transport in order to connect to the redirect target using + // the IP address we selected. + redirDialer, redirRecord, err := va.setupHTTPValidation(req.URL.String(), redirTarget) + records = append(records, redirRecord) + if err != nil { + return err + } + + va.log.Debugf("following redirect to host %q url %q", req.Host, req.URL.String()) + // Replace the transport's DialContext with the new preresolvedDialer for + // the redirect. + transport.DialContext = redirDialer.DialContext + return nil + } + + // Create a new HTTP client configured to use the customized transport and + // to check HTTP redirects encountered with processRedirect + client := http.Client{ + Transport: transport, + CheckRedirect: processRedirect, + } + + // Make the initial validation request. This may result in redirects being + // followed. + httpResponse, err := client.Do(initialReq) + // If there was an error and its a kind of error we consider a fallback error, + // then try to fallback. + if err != nil && fallbackErr(err) { + // Try to advance to another IP. If there was an error advancing we don't + // have a fallback address to use and must return the original error. + advanceTargetIPErr := target.nextIP() + if advanceTargetIPErr != nil { + return nil, records, newIPError(records[len(records)-1].AddressUsed, err) + } + + // setup another validation to retry the target with the new IP and append + // the retry record. + retryDialer, retryRecord, err := va.setupHTTPValidation(initialReq.URL.String(), target) + if err != nil { + return nil, records, newIPError(records[len(records)-1].AddressUsed, err) + } + + records = append(records, retryRecord) + va.metrics.http01Fallbacks.Inc() + // Replace the transport's dialer with the preresolvedDialer for the retry + // host. + transport.DialContext = retryDialer.DialContext + + // Perform the retry + httpResponse, err = client.Do(initialReq) + // If the retry still failed there isn't anything more to do, return the + // error immediately. + if err != nil { + return nil, records, newIPError(retryRecord.AddressUsed, err) + } + } else if err != nil { + // if the error was not a fallbackErr then return immediately. + return nil, records, newIPError(records[len(records)-1].AddressUsed, err) + } + + if httpResponse.StatusCode != 200 { + return nil, records, newIPError(records[len(records)-1].AddressUsed, berrors.UnauthorizedError("Invalid response from %s: %d", + records[len(records)-1].URL, httpResponse.StatusCode)) + } + + // At this point we've made a successful request (be it from a retry or + // otherwise) and can read and process the response body. + body, err := io.ReadAll(&io.LimitedReader{R: httpResponse.Body, N: maxResponseSize}) + closeErr := httpResponse.Body.Close() + if err == nil { + err = closeErr + } + if err != nil { + return nil, records, newIPError(records[len(records)-1].AddressUsed, berrors.UnauthorizedError("Error reading HTTP response body: %v", err)) + } + + // io.LimitedReader will silently truncate a Reader so if the + // resulting payload is the same size as maxResponseSize fail + if len(body) >= maxResponseSize { + return nil, records, newIPError(records[len(records)-1].AddressUsed, berrors.UnauthorizedError("Invalid response from %s: %q", + records[len(records)-1].URL, body)) + } + + // We were successful, so record the negotiated key exchange mechanism in the + // last validationRecord. + // TODO(#7321): Remove this when we have collected enough data. + if httpResponse.TLS != nil { + records[len(records)-1].UsedRSAKEX = usedRSAKEX(httpResponse.TLS.CipherSuite) + } + + return body, records, nil +} + +func (va *ValidationAuthorityImpl) validateHTTP01(ctx context.Context, ident identifier.ACMEIdentifier, token string, keyAuthorization string) ([]core.ValidationRecord, error) { + if ident.Type != identifier.DNS { + va.log.Infof("Got non-DNS identifier for HTTP validation: %s", ident) + return nil, berrors.MalformedError("Identifier type for HTTP validation was not DNS") + } + + // Perform the fetch + path := fmt.Sprintf(".well-known/acme-challenge/%s", token) + body, validationRecords, err := va.fetchHTTP(ctx, ident.Value, "/"+path) + if err != nil { + return validationRecords, err + } + payload := strings.TrimRightFunc(string(body), unicode.IsSpace) + + if payload != keyAuthorization { + problem := berrors.UnauthorizedError("The key authorization file from the server did not match this challenge. Expected %q (got %q)", + keyAuthorization, payload) + va.log.Infof("%s for %s", problem, ident) + return validationRecords, problem + } + + return validationRecords, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/va/http_test.go b/third-party/github.com/letsencrypt/boulder/va/http_test.go new file mode 100644 index 000000000..038803539 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/http_test.go @@ -0,0 +1,1544 @@ +package va + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + mrand "math/rand" + "net" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/miekg/dns" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/must" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" + + "testing" +) + +// TestDialerMismatchError tests that using a preresolvedDialer for one host for +// a dial to another host produces the expected dialerMismatchError. +func TestDialerMismatchError(t *testing.T) { + d := preresolvedDialer{ + ip: net.ParseIP("127.0.0.1"), + port: 1337, + hostname: "letsencrypt.org", + } + + expectedErr := dialerMismatchError{ + dialerHost: d.hostname, + dialerIP: d.ip.String(), + dialerPort: d.port, + host: "lettuceencrypt.org", + } + + _, err := d.DialContext( + context.Background(), + "tincan-and-string", + "lettuceencrypt.org:80") + test.AssertEquals(t, err.Error(), expectedErr.Error()) +} + +// TestPreresolvedDialerTimeout tests that the preresolvedDialer's DialContext +// will timeout after the expected singleDialTimeout. This ensures timeouts at +// the TCP level are handled correctly. +func TestPreresolvedDialerTimeout(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + // Timeouts below 50ms tend to be flaky. + va.singleDialTimeout = 50 * time.Millisecond + + // The context timeout needs to be larger than the singleDialTimeout + ctxTimeout := 500 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), ctxTimeout) + defer cancel() + + va.dnsClient = dnsMockReturnsUnroutable{&bdns.MockClient{}} + // NOTE(@jsha): The only method I've found so far to trigger a connect timeout + // is to connect to an unrouteable IP address. This usually generates + // a connection timeout, but will rarely return "Network unreachable" instead. + // If we get that, just retry until we get something other than "Network unreachable". + var err error + var took time.Duration + for range 20 { + started := time.Now() + _, _, err = va.fetchHTTP(ctx, "unroutable.invalid", "/.well-known/acme-challenge/whatever") + took = time.Since(started) + if err != nil && strings.Contains(err.Error(), "Network unreachable") { + continue + } else { + break + } + } + if err == nil { + t.Fatalf("Connection should've timed out") + } + + // Check that the HTTP connection doesn't return too fast, and times + // out after the expected time + if took < va.singleDialTimeout { + t.Fatalf("fetch returned before %s (took: %s) with %q", va.singleDialTimeout, took, err.Error()) + } + if took > 2*va.singleDialTimeout { + t.Fatalf("fetch didn't timeout after %s (took: %s)", va.singleDialTimeout, took) + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + + expectMatch := regexp.MustCompile( + "Fetching http://unroutable.invalid/.well-known/acme-challenge/.*: Timeout during connect") + if !expectMatch.MatchString(prob.Detail) { + t.Errorf("Problem details incorrect. Got %q, expected to match %q", + prob.Detail, expectMatch) + } +} + +func TestHTTPTransport(t *testing.T) { + dummyDialerFunc := func(_ context.Context, _, _ string) (net.Conn, error) { + return nil, nil + } + transport := httpTransport(dummyDialerFunc) + // The HTTP Transport should have a TLS config that skips verifying + // certificates. + test.AssertEquals(t, transport.TLSClientConfig.InsecureSkipVerify, true) + // Keep alives should be disabled + test.AssertEquals(t, transport.DisableKeepAlives, true) + test.AssertEquals(t, transport.MaxIdleConns, 1) + test.AssertEquals(t, transport.IdleConnTimeout.String(), "1s") + test.AssertEquals(t, transport.TLSHandshakeTimeout.String(), "10s") +} + +func TestHTTPValidationTarget(t *testing.T) { + // NOTE(@cpu): See `bdns/mocks.go` and the mock `LookupHost` function for the + // hostnames used in this test. + testCases := []struct { + Name string + Host string + ExpectedError error + ExpectedIPs []string + }{ + { + Name: "No IPs for host", + Host: "always.invalid", + ExpectedError: berrors.DNSError("No valid IP addresses found for always.invalid"), + }, + { + Name: "Only IPv4 addrs for host", + Host: "some.example.com", + ExpectedIPs: []string{"127.0.0.1"}, + }, + { + Name: "Only IPv6 addrs for host", + Host: "ipv6.localhost", + ExpectedIPs: []string{"::1"}, + }, + { + Name: "Both IPv6 and IPv4 addrs for host", + Host: "ipv4.and.ipv6.localhost", + // In this case we expect 1 IPv6 address first, and then 1 IPv4 address + ExpectedIPs: []string{"::1", "127.0.0.1"}, + }, + } + + const ( + examplePort = 1234 + examplePath = "/.well-known/path/i/took" + exampleQuery = "my-path=was&my=own" + ) + + va, _ := setup(nil, 0, "", nil, nil) + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + target, err := va.newHTTPValidationTarget( + context.Background(), + tc.Host, + examplePort, + examplePath, + exampleQuery) + if err != nil && tc.ExpectedError == nil { + t.Fatalf("Unexpected error from NewHTTPValidationTarget: %v", err) + } else if err != nil && tc.ExpectedError != nil { + test.AssertMarshaledEquals(t, err, tc.ExpectedError) + } else if err == nil { + // The target should be populated. + test.AssertNotEquals(t, target.host, "") + test.AssertNotEquals(t, target.port, 0) + test.AssertNotEquals(t, target.path, "") + // Calling ip() on the target should give the expected IPs in the right + // order. + for i, expectedIP := range tc.ExpectedIPs { + gotIP := target.cur + if gotIP == nil { + t.Errorf("Expected IP %d to be %s got nil", i, expectedIP) + } else { + test.AssertEquals(t, gotIP.String(), expectedIP) + } + // Advance to the next IP + _ = target.nextIP() + } + } + }) + } +} + +func TestExtractRequestTarget(t *testing.T) { + mustURL := func(rawURL string) *url.URL { + return must.Do(url.Parse(rawURL)) + } + + testCases := []struct { + Name string + Req *http.Request + ExpectedError error + ExpectedHost string + ExpectedPort int + }{ + { + Name: "nil input req", + ExpectedError: fmt.Errorf("redirect HTTP request was nil"), + }, + { + Name: "invalid protocol scheme", + Req: &http.Request{ + URL: mustURL("gopher://letsencrypt.org"), + }, + ExpectedError: fmt.Errorf("Invalid protocol scheme in redirect target. " + + `Only "http" and "https" protocol schemes are supported, ` + + `not "gopher"`), + }, + { + Name: "invalid explicit port", + Req: &http.Request{ + URL: mustURL("https://weird.port.letsencrypt.org:9999"), + }, + ExpectedError: fmt.Errorf("Invalid port in redirect target. Only ports 80 " + + "and 443 are supported, not 9999"), + }, + { + Name: "invalid empty hostname", + Req: &http.Request{ + URL: mustURL("https:///who/needs/a/hostname?not=me"), + }, + ExpectedError: errors.New("Invalid empty hostname in redirect target"), + }, + { + Name: "invalid .well-known hostname", + Req: &http.Request{ + URL: mustURL("https://my.webserver.is.misconfigured.well-known/acme-challenge/xxx"), + }, + ExpectedError: errors.New(`Invalid host in redirect target "my.webserver.is.misconfigured.well-known". Check webserver config for missing '/' in redirect target.`), + }, + { + Name: "invalid non-iana hostname", + Req: &http.Request{ + URL: mustURL("https://my.tld.is.cpu/pretty/cool/right?yeah=Ithoughtsotoo"), + }, + ExpectedError: errors.New("Invalid hostname in redirect target, must end in IANA registered TLD"), + }, + { + Name: "bare IP", + Req: &http.Request{ + URL: mustURL("https://10.10.10.10"), + }, + ExpectedError: fmt.Errorf(`Invalid host in redirect target "10.10.10.10". ` + + "Only domain names are supported, not IP addresses"), + }, + { + Name: "valid HTTP redirect, explicit port", + Req: &http.Request{ + URL: mustURL("http://cpu.letsencrypt.org:80"), + }, + ExpectedHost: "cpu.letsencrypt.org", + ExpectedPort: 80, + }, + { + Name: "valid HTTP redirect, implicit port", + Req: &http.Request{ + URL: mustURL("http://cpu.letsencrypt.org"), + }, + ExpectedHost: "cpu.letsencrypt.org", + ExpectedPort: 80, + }, + { + Name: "valid HTTPS redirect, explicit port", + Req: &http.Request{ + URL: mustURL("https://cpu.letsencrypt.org:443/hello.world"), + }, + ExpectedHost: "cpu.letsencrypt.org", + ExpectedPort: 443, + }, + { + Name: "valid HTTPS redirect, implicit port", + Req: &http.Request{ + URL: mustURL("https://cpu.letsencrypt.org/hello.world"), + }, + ExpectedHost: "cpu.letsencrypt.org", + ExpectedPort: 443, + }, + } + + va, _ := setup(nil, 0, "", nil, nil) + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + host, port, err := va.extractRequestTarget(tc.Req) + if err != nil && tc.ExpectedError == nil { + t.Errorf("Expected nil err got %v", err) + } else if err != nil && tc.ExpectedError != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedError.Error()) + } else if err == nil && tc.ExpectedError != nil { + t.Errorf("Expected err %v, got nil", tc.ExpectedError) + } else { + test.AssertEquals(t, host, tc.ExpectedHost) + test.AssertEquals(t, port, tc.ExpectedPort) + } + }) + } +} + +// TestHTTPValidationDNSError attempts validation for a domain name that always +// generates a DNS error, and checks that a log line with the detailed error is +// generated. +func TestHTTPValidationDNSError(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + _, _, prob := va.fetchHTTP(ctx, "always.error", "/.well-known/acme-challenge/whatever") + test.AssertError(t, prob, "Expected validation fetch to fail") + matchingLines := mockLog.GetAllMatching(`read udp: some net error`) + if len(matchingLines) != 1 { + t.Errorf("Didn't see expected DNS error logged. Instead, got:\n%s", + strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } +} + +// TestHTTPValidationDNSIdMismatchError tests that performing an HTTP-01 +// challenge with a domain name that always returns a DNS ID mismatch error from +// the mock resolver results in valid query/response data being logged in +// a format we can decode successfully. +func TestHTTPValidationDNSIdMismatchError(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + _, _, prob := va.fetchHTTP(ctx, "id.mismatch", "/.well-known/acme-challenge/whatever") + test.AssertError(t, prob, "Expected validation fetch to fail") + matchingLines := mockLog.GetAllMatching(`logDNSError ID mismatch`) + if len(matchingLines) != 1 { + t.Errorf("Didn't see expected DNS error logged. Instead, got:\n%s", + strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } + expectedRegex := regexp.MustCompile( + `INFO: logDNSError ID mismatch ` + + `chosenServer=\[mock.server\] ` + + `hostname=\[id\.mismatch\] ` + + `respHostname=\[id\.mismatch\.\] ` + + `queryType=\[A\] ` + + `msg=\[([A-Za-z0-9+=/\=]+)\] ` + + `resp=\[([A-Za-z0-9+=/\=]+)\] ` + + `err\=\[dns: id mismatch\]`, + ) + + matches := expectedRegex.FindAllStringSubmatch(matchingLines[0], -1) + test.AssertEquals(t, len(matches), 1) + submatches := matches[0] + test.AssertEquals(t, len(submatches), 3) + + msgBytes, err := base64.StdEncoding.DecodeString(submatches[1]) + test.AssertNotError(t, err, "bad base64 encoded query msg") + msg := new(dns.Msg) + err = msg.Unpack(msgBytes) + test.AssertNotError(t, err, "bad packed query msg") + + respBytes, err := base64.StdEncoding.DecodeString(submatches[2]) + test.AssertNotError(t, err, "bad base64 encoded resp msg") + resp := new(dns.Msg) + err = resp.Unpack(respBytes) + test.AssertNotError(t, err, "bad packed response msg") +} + +func TestSetupHTTPValidation(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + mustTarget := func(t *testing.T, host string, port int, path string) *httpValidationTarget { + target, err := va.newHTTPValidationTarget( + context.Background(), + host, + port, + path, + "") + if err != nil { + t.Fatalf("Failed to construct httpValidationTarget for %q", host) + return nil + } + return target + } + + httpInputURL := "http://ipv4.and.ipv6.localhost/yellow/brick/road" + httpsInputURL := "https://ipv4.and.ipv6.localhost/yellow/brick/road" + + testCases := []struct { + Name string + InputURL string + InputTarget *httpValidationTarget + ExpectedRecord core.ValidationRecord + ExpectedDialer *preresolvedDialer + ExpectedError error + }{ + { + Name: "nil target", + InputURL: httpInputURL, + ExpectedError: fmt.Errorf("httpValidationTarget can not be nil"), + }, + { + Name: "empty input URL", + InputTarget: &httpValidationTarget{}, + ExpectedError: fmt.Errorf("reqURL can not be nil"), + }, + { + Name: "target with no IPs", + InputURL: httpInputURL, + InputTarget: &httpValidationTarget{ + host: "ipv4.and.ipv6.localhost", + port: va.httpPort, + path: "idk", + }, + ExpectedRecord: core.ValidationRecord{ + URL: "http://ipv4.and.ipv6.localhost/yellow/brick/road", + Hostname: "ipv4.and.ipv6.localhost", + Port: strconv.Itoa(va.httpPort), + }, + ExpectedError: fmt.Errorf(`host "ipv4.and.ipv6.localhost" has no IP addresses remaining to use`), + }, + { + Name: "HTTP input req", + InputTarget: mustTarget(t, "ipv4.and.ipv6.localhost", va.httpPort, "/yellow/brick/road"), + InputURL: httpInputURL, + ExpectedRecord: core.ValidationRecord{ + Hostname: "ipv4.and.ipv6.localhost", + Port: strconv.Itoa(va.httpPort), + URL: "http://ipv4.and.ipv6.localhost/yellow/brick/road", + AddressesResolved: []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("::1"), + ResolverAddrs: []string{"MockClient"}, + }, + ExpectedDialer: &preresolvedDialer{ + ip: net.ParseIP("::1"), + port: va.httpPort, + timeout: va.singleDialTimeout, + }, + }, + { + Name: "HTTPS input req", + InputTarget: mustTarget(t, "ipv4.and.ipv6.localhost", va.httpsPort, "/yellow/brick/road"), + InputURL: httpsInputURL, + ExpectedRecord: core.ValidationRecord{ + Hostname: "ipv4.and.ipv6.localhost", + Port: strconv.Itoa(va.httpsPort), + URL: "https://ipv4.and.ipv6.localhost/yellow/brick/road", + AddressesResolved: []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("::1"), + ResolverAddrs: []string{"MockClient"}, + }, + ExpectedDialer: &preresolvedDialer{ + ip: net.ParseIP("::1"), + port: va.httpsPort, + timeout: va.singleDialTimeout, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + outDialer, outRecord, err := va.setupHTTPValidation(tc.InputURL, tc.InputTarget) + if err != nil && tc.ExpectedError == nil { + t.Errorf("Expected nil error, got %v", err) + } else if err == nil && tc.ExpectedError != nil { + t.Errorf("Expected %v error, got nil", tc.ExpectedError) + } else if err != nil && tc.ExpectedError != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedError.Error()) + } + if tc.ExpectedDialer == nil && outDialer != nil { + t.Errorf("Expected nil dialer, got %v", outDialer) + } else if tc.ExpectedDialer != nil { + test.AssertMarshaledEquals(t, outDialer, tc.ExpectedDialer) + } + // In all cases we expect there to have been a validation record + test.AssertMarshaledEquals(t, outRecord, tc.ExpectedRecord) + }) + } +} + +// A more concise version of httpSrv() that supports http.go tests +func httpTestSrv(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewUnstartedServer(mux) + + server.Start() + httpPort := getPort(server) + + // A path that always returns an OK response + mux.HandleFunc("/ok", func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + fmt.Fprint(resp, "ok") + }) + + // A path that always times out by sleeping longer than the validation context + // allows + mux.HandleFunc("/timeout", func(resp http.ResponseWriter, req *http.Request) { + time.Sleep(time.Second) + resp.WriteHeader(http.StatusOK) + fmt.Fprint(resp, "sorry, I'm a slow server") + }) + + // A path that always redirects to itself, creating a loop that will terminate + // when detected. + mux.HandleFunc("/loop", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + fmt.Sprintf("http://example.com:%d/loop", httpPort), + http.StatusMovedPermanently) + }) + + // A path that sequentially redirects, creating an incrementing redirect + // that will terminate when the redirect limit is reached and ensures each + // URL is different than the last. + for i := range maxRedirect + 2 { + mux.HandleFunc(fmt.Sprintf("/max-redirect/%d", i), + func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + fmt.Sprintf("http://example.com:%d/max-redirect/%d", httpPort, i+1), + http.StatusMovedPermanently, + ) + }) + } + + // A path that always redirects to a URL with a non-HTTP/HTTPs protocol scheme + mux.HandleFunc("/redir-bad-proto", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "gopher://example.com", + http.StatusMovedPermanently, + ) + }) + + // A path that always redirects to a URL with a port other than the configured + // HTTP/HTTPS port + mux.HandleFunc("/redir-bad-port", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "https://example.com:1987", + http.StatusMovedPermanently, + ) + }) + + // A path that always redirects to a URL with a bare IP address + mux.HandleFunc("/redir-bad-host", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "https://127.0.0.1", + http.StatusMovedPermanently, + ) + }) + + mux.HandleFunc("/bad-status-code", func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusGone) + fmt.Fprint(resp, "sorry, I'm gone") + }) + + // A path that always responds with a 303 redirect + mux.HandleFunc("/303-see-other", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "http://example.org/303-see-other", + http.StatusSeeOther, + ) + }) + + tooLargeBuf := bytes.NewBuffer([]byte{}) + for range maxResponseSize + 10 { + tooLargeBuf.WriteByte(byte(97)) + } + mux.HandleFunc("/resp-too-big", func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + fmt.Fprint(resp, tooLargeBuf) + }) + + // Create a buffer that starts with invalid UTF8 and is bigger than + // maxResponseSize + tooLargeInvalidUTF8 := bytes.NewBuffer([]byte{}) + tooLargeInvalidUTF8.WriteString("f\xffoo") + tooLargeInvalidUTF8.Write(tooLargeBuf.Bytes()) + // invalid-utf8-body Responds with body that is larger than + // maxResponseSize and starts with an invalid UTF8 string. This is to + // test the codepath where invalid UTF8 is converted to valid UTF8 + // that can be passed as an error message via grpc. + mux.HandleFunc("/invalid-utf8-body", func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + fmt.Fprint(resp, tooLargeInvalidUTF8) + }) + + mux.HandleFunc("/redir-path-too-long", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "https://example.com/this-is-too-long-01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789", + http.StatusMovedPermanently) + }) + + // A path that redirects to an uppercase public suffix (#4215) + mux.HandleFunc("/redir-uppercase-publicsuffix", func(resp http.ResponseWriter, req *http.Request) { + http.Redirect( + resp, + req, + "http://example.COM/ok", + http.StatusMovedPermanently) + }) + + // A path that returns a body containing printf formatting verbs + mux.HandleFunc("/printf-verbs", func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + fmt.Fprint(resp, "%"+"2F.well-known%"+"2F"+tooLargeBuf.String()) + }) + + return server +} + +type testNetErr struct{} + +func (e *testNetErr) Error() string { + return "testNetErr" +} + +func (e *testNetErr) Temporary() bool { + return false +} + +func (e *testNetErr) Timeout() bool { + return false +} + +func TestFallbackErr(t *testing.T) { + untypedErr := errors.New("the least interesting kind of error") + berr := berrors.InternalServerError("code violet: class neptune") + netOpErr := &net.OpError{ + Op: "siphon", + Err: fmt.Errorf("port was clogged. please empty packets"), + } + netDialOpErr := &net.OpError{ + Op: "dial", + Err: fmt.Errorf("your call is important to us - please stay on the line"), + } + netErr := &testNetErr{} + + testCases := []struct { + Name string + Err error + ExpectFallback bool + }{ + { + Name: "Nil error", + Err: nil, + }, + { + Name: "Standard untyped error", + Err: untypedErr, + }, + { + Name: "A Boulder error instance", + Err: berr, + }, + { + Name: "A non-dial net.OpError instance", + Err: netOpErr, + }, + { + Name: "A dial net.OpError instance", + Err: netDialOpErr, + ExpectFallback: true, + }, + { + Name: "A generic net.Error instance", + Err: netErr, + }, + { + Name: "A URL error wrapping a standard error", + Err: &url.Error{ + Op: "ivy", + URL: "https://en.wikipedia.org/wiki/Operation_Ivy_(band)", + Err: errors.New("take warning"), + }, + }, + { + Name: "A URL error wrapping a nil error", + Err: &url.Error{ + Err: nil, + }, + }, + { + Name: "A URL error wrapping a Boulder error instance", + Err: &url.Error{ + Err: berr, + }, + }, + { + Name: "A URL error wrapping a non-dial net OpError", + Err: &url.Error{ + Err: netOpErr, + }, + }, + { + Name: "A URL error wrapping a dial net.OpError", + Err: &url.Error{ + Err: netDialOpErr, + }, + ExpectFallback: true, + }, + { + Name: "A URL error wrapping a generic net Error", + Err: &url.Error{ + Err: netErr, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + if isFallback := fallbackErr(tc.Err); isFallback != tc.ExpectFallback { + t.Errorf( + "Expected fallbackErr for %t to be %v was %v\n", + tc.Err, tc.ExpectFallback, isFallback) + } + }) + } +} + +func TestFetchHTTP(t *testing.T) { + // Create a test server + testSrv := httpTestSrv(t) + defer testSrv.Close() + + // Setup a VA. By providing the testSrv to setup the VA will use the testSrv's + // randomly assigned port as its HTTP port. + va, _ := setup(testSrv, 0, "", nil, nil) + + // We need to know the randomly assigned HTTP port for testcases as well + httpPort := getPort(testSrv) + + // For the looped test case we expect one validation record per redirect + // until boulder detects that a url has been used twice indicating a + // redirect loop. Because it is hitting the /loop endpoint it will encounter + // this scenario after the base url and fail on the second time hitting the + // redirect with a port definition. On i=0 it will encounter the first + // redirect to the url with a port definition and on i=1 it will encounter + // the second redirect to the url with the port and get an expected error. + expectedLoopRecords := []core.ValidationRecord{} + for i := range 2 { + // The first request will not have a port # in the URL. + url := "http://example.com/loop" + if i != 0 { + url = fmt.Sprintf("http://example.com:%d/loop", httpPort) + } + expectedLoopRecords = append(expectedLoopRecords, + core.ValidationRecord{ + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: url, + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }) + } + + // For the too many redirect test case we expect one validation record per + // redirect up to maxRedirect (inclusive). There is also +1 record for the + // base lookup, giving a termination criteria of > maxRedirect+1 + expectedTooManyRedirRecords := []core.ValidationRecord{} + for i := range maxRedirect + 2 { + // The first request will not have a port # in the URL. + url := "http://example.com/max-redirect/0" + if i != 0 { + url = fmt.Sprintf("http://example.com:%d/max-redirect/%d", httpPort, i) + } + expectedTooManyRedirRecords = append(expectedTooManyRedirRecords, + core.ValidationRecord{ + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: url, + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }) + } + + expectedTruncatedResp := bytes.NewBuffer([]byte{}) + for range maxResponseSize { + expectedTruncatedResp.WriteByte(byte(97)) + } + + testCases := []struct { + Name string + Host string + Path string + ExpectedBody string + ExpectedRecords []core.ValidationRecord + ExpectedProblem *probs.ProblemDetails + }{ + { + Name: "No IPs for host", + Host: "always.invalid", + Path: "/.well-known/whatever", + ExpectedProblem: probs.DNS( + "No valid IP addresses found for always.invalid"), + // There are no validation records in this case because the base record + // is only constructed once a URL is made. + ExpectedRecords: nil, + }, + { + Name: "Timeout for host with standard ACME allowed port", + Host: "example.com", + Path: "/timeout", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching http://example.com/timeout: " + + "Timeout after connect (your server may be slow or overloaded)"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/timeout", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Connecting to bad port", + Host: "example.com:" + strconv.Itoa(httpPort), + Path: "/timeout", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching http://example.com:" + strconv.Itoa(httpPort) + "/timeout: " + + "Error getting validation data"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com:" + strconv.Itoa(httpPort), + Port: strconv.Itoa(httpPort), + URL: "http://example.com:" + strconv.Itoa(httpPort) + "/timeout", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Redirect loop", + Host: "example.com", + Path: "/loop", + ExpectedProblem: probs.Connection(fmt.Sprintf( + "127.0.0.1: Fetching http://example.com:%d/loop: Redirect loop detected", httpPort)), + ExpectedRecords: expectedLoopRecords, + }, + { + Name: "Too many redirects", + Host: "example.com", + Path: "/max-redirect/0", + ExpectedProblem: probs.Connection(fmt.Sprintf( + "127.0.0.1: Fetching http://example.com:%d/max-redirect/12: Too many redirects", httpPort)), + ExpectedRecords: expectedTooManyRedirRecords, + }, + { + Name: "Redirect to bad protocol", + Host: "example.com", + Path: "/redir-bad-proto", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching gopher://example.com: Invalid protocol scheme in " + + `redirect target. Only "http" and "https" protocol schemes ` + + `are supported, not "gopher"`), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/redir-bad-proto", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Redirect to bad port", + Host: "example.com", + Path: "/redir-bad-port", + ExpectedProblem: probs.Connection(fmt.Sprintf( + "127.0.0.1: Fetching https://example.com:1987: Invalid port in redirect target. "+ + "Only ports %d and 443 are supported, not 1987", httpPort)), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/redir-bad-port", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Redirect to bad host (bare IP address)", + Host: "example.com", + Path: "/redir-bad-host", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching https://127.0.0.1: Invalid host in redirect target " + + `"127.0.0.1". Only domain names are supported, not IP addresses`), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/redir-bad-host", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Redirect to long path", + Host: "example.com", + Path: "/redir-path-too-long", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching https://example.com/this-is-too-long-01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789: Redirect target too long"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/redir-path-too-long", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Wrong HTTP status code", + Host: "example.com", + Path: "/bad-status-code", + ExpectedProblem: probs.Unauthorized( + "127.0.0.1: Invalid response from http://example.com/bad-status-code: 410"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/bad-status-code", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "HTTP status code 303 redirect", + Host: "example.com", + Path: "/303-see-other", + ExpectedProblem: probs.Connection( + "127.0.0.1: Fetching http://example.org/303-see-other: received disallowed redirect status code"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/303-see-other", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Response too large", + Host: "example.com", + Path: "/resp-too-big", + ExpectedProblem: probs.Unauthorized(fmt.Sprintf( + "127.0.0.1: Invalid response from http://example.com/resp-too-big: %q", expectedTruncatedResp.String(), + )), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/resp-too-big", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Broken IPv6 only", + Host: "ipv6.localhost", + Path: "/ok", + ExpectedProblem: probs.Connection( + "::1: Fetching http://ipv6.localhost/ok: Connection refused"), + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "ipv6.localhost", + Port: strconv.Itoa(httpPort), + URL: "http://ipv6.localhost/ok", + AddressesResolved: []net.IP{net.ParseIP("::1")}, + AddressUsed: net.ParseIP("::1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Dual homed w/ broken IPv6, working IPv4", + Host: "ipv4.and.ipv6.localhost", + Path: "/ok", + ExpectedBody: "ok", + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "ipv4.and.ipv6.localhost", + Port: strconv.Itoa(httpPort), + URL: "http://ipv4.and.ipv6.localhost/ok", + AddressesResolved: []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, + // The first validation record should have used the IPv6 addr + AddressUsed: net.ParseIP("::1"), + ResolverAddrs: []string{"MockClient"}, + }, + { + Hostname: "ipv4.and.ipv6.localhost", + Port: strconv.Itoa(httpPort), + URL: "http://ipv4.and.ipv6.localhost/ok", + AddressesResolved: []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, + // The second validation record should have used the IPv4 addr as a fallback + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Working IPv4 only", + Host: "example.com", + Path: "/ok", + ExpectedBody: "ok", + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/ok", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Redirect to uppercase Public Suffix", + Host: "example.com", + Path: "/redir-uppercase-publicsuffix", + ExpectedBody: "ok", + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/redir-uppercase-publicsuffix", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/ok", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + { + Name: "Reflected response body containing printf verbs", + Host: "example.com", + Path: "/printf-verbs", + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.UnauthorizedProblem, + Detail: fmt.Sprintf("127.0.0.1: Invalid response from http://example.com/printf-verbs: %q", + ("%2F.well-known%2F" + expectedTruncatedResp.String())[:maxResponseSize]), + HTTPStatus: http.StatusForbidden, + }, + ExpectedRecords: []core.ValidationRecord{ + { + Hostname: "example.com", + Port: strconv.Itoa(httpPort), + URL: "http://example.com/printf-verbs", + AddressesResolved: []net.IP{net.ParseIP("127.0.0.1")}, + AddressUsed: net.ParseIP("127.0.0.1"), + ResolverAddrs: []string{"MockClient"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500) + defer cancel() + body, records, err := va.fetchHTTP(ctx, tc.Host, tc.Path) + if tc.ExpectedProblem == nil { + test.AssertNotError(t, err, "expected nil prob") + } else { + test.AssertError(t, err, "expected non-nil prob") + prob := detailedError(err) + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ExpectedBody != "" { + test.AssertEquals(t, string(body), tc.ExpectedBody) + } + // in all cases we expect validation records to be present and matching expected + test.AssertMarshaledEquals(t, records, tc.ExpectedRecords) + }) + } +} + +// All paths that get assigned to tokens MUST be valid tokens +const pathWrongToken = "i6lNAC4lOOLYCl-A08VJt9z_tKYvVk63Dumo8icsBjQ" +const path404 = "404" +const path500 = "500" +const pathFound = "GBq8SwWq3JsbREFdCamk5IX3KLsxW5ULeGs98Ajl_UM" +const pathMoved = "5J4FIMrWNfmvHZo-QpKZngmuhqZGwRm21-oEgUDstJM" +const pathRedirectInvalidPort = "port-redirect" +const pathWait = "wait" +const pathWaitLong = "wait-long" +const pathReLookup = "7e-P57coLM7D3woNTp_xbJrtlkDYy6PWf3mSSbLwCr4" +const pathReLookupInvalid = "re-lookup-invalid" +const pathRedirectToFailingURL = "re-to-failing-url" +const pathLooper = "looper" +const pathValid = "valid" +const rejectUserAgent = "rejectMe" + +func httpSrv(t *testing.T, token string) *httptest.Server { + m := http.NewServeMux() + + server := httptest.NewUnstartedServer(m) + + defaultToken := token + currentToken := defaultToken + + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, path404) { + t.Logf("HTTPSRV: Got a 404 req\n") + http.NotFound(w, r) + } else if strings.HasSuffix(r.URL.Path, path500) { + t.Logf("HTTPSRV: Got a 500 req\n") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } else if strings.HasSuffix(r.URL.Path, pathMoved) { + t.Logf("HTTPSRV: Got a http.StatusMovedPermanently redirect req\n") + if currentToken == defaultToken { + currentToken = pathMoved + } + http.Redirect(w, r, pathValid, http.StatusMovedPermanently) + } else if strings.HasSuffix(r.URL.Path, pathFound) { + t.Logf("HTTPSRV: Got a http.StatusFound redirect req\n") + if currentToken == defaultToken { + currentToken = pathFound + } + http.Redirect(w, r, pathMoved, http.StatusFound) + } else if strings.HasSuffix(r.URL.Path, pathWait) { + t.Logf("HTTPSRV: Got a wait req\n") + time.Sleep(time.Second * 3) + } else if strings.HasSuffix(r.URL.Path, pathWaitLong) { + t.Logf("HTTPSRV: Got a wait-long req\n") + time.Sleep(time.Second * 10) + } else if strings.HasSuffix(r.URL.Path, pathReLookup) { + t.Logf("HTTPSRV: Got a redirect req to a valid hostname\n") + if currentToken == defaultToken { + currentToken = pathReLookup + } + port := getPort(server) + http.Redirect(w, r, fmt.Sprintf("http://other.valid.com:%d/path", port), http.StatusFound) + } else if strings.HasSuffix(r.URL.Path, pathReLookupInvalid) { + t.Logf("HTTPSRV: Got a redirect req to an invalid hostname\n") + http.Redirect(w, r, "http://invalid.invalid/path", http.StatusFound) + } else if strings.HasSuffix(r.URL.Path, pathRedirectToFailingURL) { + t.Logf("HTTPSRV: Redirecting to a URL that will fail\n") + port := getPort(server) + http.Redirect(w, r, fmt.Sprintf("http://other.valid.com:%d/%s", port, path500), http.StatusMovedPermanently) + } else if strings.HasSuffix(r.URL.Path, pathLooper) { + t.Logf("HTTPSRV: Got a loop req\n") + http.Redirect(w, r, r.URL.String(), http.StatusMovedPermanently) + } else if strings.HasSuffix(r.URL.Path, pathRedirectInvalidPort) { + t.Logf("HTTPSRV: Got a port redirect req\n") + // Port 8080 is not the VA's httpPort or httpsPort and should be rejected + http.Redirect(w, r, "http://other.valid.com:8080/path", http.StatusFound) + } else if r.Header.Get("User-Agent") == rejectUserAgent { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("found trap User-Agent")) + } else { + t.Logf("HTTPSRV: Got a valid req\n") + t.Logf("HTTPSRV: Path = %s\n", r.URL.Path) + + ch := core.Challenge{Token: currentToken} + keyAuthz, _ := ch.ExpectedKeyAuthorization(accountKey) + t.Logf("HTTPSRV: Key Authz = '%s%s'\n", keyAuthz, "\\n\\r \\t") + + fmt.Fprint(w, keyAuthz, "\n\r \t") + currentToken = defaultToken + } + }) + + server.Start() + return server +} + +func TestHTTPBadPort(t *testing.T) { + hs := httpSrv(t, expectedToken) + defer hs.Close() + + va, _ := setup(hs, 0, "", nil, nil) + + // Pick a random port between 40000 and 65000 - with great certainty we won't + // have an HTTP server listening on this port and the test will fail as + // intended + badPort := 40000 + mrand.Intn(25000) + va.httpPort = badPort + + _, err := va.validateHTTP01(ctx, dnsi("localhost"), expectedToken, expectedKeyAuthorization) + if err == nil { + t.Fatalf("Server's down; expected refusal. Where did we connect?") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + if !strings.Contains(prob.Detail, "Connection refused") { + t.Errorf("Expected a connection refused error, got %q", prob.Detail) + } +} + +func TestHTTPKeyAuthorizationFileMismatch(t *testing.T) { + m := http.NewServeMux() + hs := httptest.NewUnstartedServer(m) + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("\xef\xffAABBCC")) + }) + hs.Start() + + va, _ := setup(hs, 0, "", nil, nil) + _, err := va.validateHTTP01(ctx, dnsi("localhost.com"), expectedToken, expectedKeyAuthorization) + + if err == nil { + t.Fatalf("Expected validation to fail when file mismatched.") + } + expected := `The key authorization file from the server did not match this challenge. Expected "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0.9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI" (got "\xef\xffAABBCC")` + if err.Error() != expected { + t.Errorf("validation failed with %s, expected %s", err, expected) + } +} + +func TestHTTP(t *testing.T) { + // NOTE: We do not attempt to shut down the server. The problem is that the + // "wait-long" handler sleeps for ten seconds, but this test finishes in less + // than that. So if we try to call hs.Close() at the end of the test, we'll be + // closing the test server while a request is still pending. Unfortunately, + // there appears to be an issue in httptest that trips Go's race detector when + // that happens, failing the test. So instead, we live with leaving the server + // around till the process exits. + // TODO(#1989): close hs + hs := httpSrv(t, expectedToken) + + va, log := setup(hs, 0, "", nil, nil) + + _, err := va.validateHTTP01(ctx, dnsi("localhost.com"), expectedToken, expectedKeyAuthorization) + if err != nil { + t.Errorf("Unexpected failure in HTTP validation: %s", err) + } + test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), path404, ka(path404)) + if err == nil { + t.Fatalf("Should have found a 404 for the challenge.") + } + test.AssertErrorIs(t, err, berrors.Unauthorized) + test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1) + + log.Clear() + // The "wrong token" will actually be the expectedToken. It's wrong + // because it doesn't match pathWrongToken. + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathWrongToken, ka(pathWrongToken)) + if err == nil { + t.Fatalf("Should have found the wrong token value.") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) + test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathMoved, ka(pathMoved)) + if err != nil { + t.Fatalf("Failed to follow http.StatusMovedPermanently redirect") + } + redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"` + matchedValidRedirect := log.GetAllMatching(redirectValid) + test.AssertEquals(t, len(matchedValidRedirect), 1) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathFound, ka(pathFound)) + if err != nil { + t.Fatalf("Failed to follow http.StatusFound redirect") + } + redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"` + matchedMovedRedirect := log.GetAllMatching(redirectMoved) + test.AssertEquals(t, len(matchedValidRedirect), 1) + test.AssertEquals(t, len(matchedMovedRedirect), 1) + + ipIdentifier := identifier.ACMEIdentifier{Type: identifier.IdentifierType("ip"), Value: "127.0.0.1"} + _, err = va.validateHTTP01(ctx, ipIdentifier, pathFound, ka(pathFound)) + if err == nil { + t.Fatalf("IdentifierType IP shouldn't have worked.") + } + test.AssertErrorIs(t, err, berrors.Malformed) + + _, err = va.validateHTTP01(ctx, identifier.ACMEIdentifier{Type: identifier.DNS, Value: "always.invalid"}, pathFound, ka(pathFound)) + if err == nil { + t.Fatalf("Domain name is invalid.") + } + prob = detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) +} + +func TestHTTPTimeout(t *testing.T) { + hs := httpSrv(t, expectedToken) + // TODO(#1989): close hs + + va, _ := setup(hs, 0, "", nil, nil) + + started := time.Now() + timeout := 250 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := va.validateHTTP01(ctx, dnsi("localhost"), pathWaitLong, ka(pathWaitLong)) + if err == nil { + t.Fatalf("Connection should've timed out") + } + + took := time.Since(started) + // Check that the HTTP connection doesn't return before a timeout, and times + // out after the expected time + if took < timeout-200*time.Millisecond { + t.Fatalf("HTTP timed out before %s: %s with %s", timeout, took, err) + } + if took > 2*timeout { + t.Fatalf("HTTP connection didn't timeout after %s", timeout) + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + test.AssertEquals(t, prob.Detail, "127.0.0.1: Fetching http://localhost/.well-known/acme-challenge/wait-long: Timeout after connect (your server may be slow or overloaded)") +} + +// dnsMockReturnsUnroutable is a DNSClient mock that always returns an +// unroutable address for LookupHost. This is useful in testing connect +// timeouts. +type dnsMockReturnsUnroutable struct { + *bdns.MockClient +} + +func (mock dnsMockReturnsUnroutable) LookupHost(_ context.Context, hostname string) ([]net.IP, bdns.ResolverAddrs, error) { + return []net.IP{net.ParseIP("198.51.100.1")}, bdns.ResolverAddrs{"dnsMockReturnsUnroutable"}, nil +} + +// TestHTTPDialTimeout tests that we give the proper "Timeout during connect" +// error when dial fails. We do this by using a mock DNS client that resolves +// everything to an unroutable IP address. +func TestHTTPDialTimeout(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + started := time.Now() + timeout := 250 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + va.dnsClient = dnsMockReturnsUnroutable{&bdns.MockClient{}} + // The only method I've found so far to trigger a connect timeout is to + // connect to an unrouteable IP address. This usually generates a connection + // timeout, but will rarely return "Network unreachable" instead. If we get + // that, just retry until we get something other than "Network unreachable". + var err error + for range 20 { + _, err = va.validateHTTP01(ctx, dnsi("unroutable.invalid"), expectedToken, expectedKeyAuthorization) + if err != nil && strings.Contains(err.Error(), "network is unreachable") { + continue + } else { + break + } + } + if err == nil { + t.Fatalf("Connection should've timed out") + } + took := time.Since(started) + // Check that the HTTP connection doesn't return too fast, and times + // out after the expected time + if took < (timeout-200*time.Millisecond)/2 { + t.Fatalf("HTTP returned before %s (%s) with %q", timeout, took, err.Error()) + } + if took > 2*timeout { + t.Fatalf("HTTP connection didn't timeout after %s seconds", timeout) + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + expectMatch := regexp.MustCompile( + "Fetching http://unroutable.invalid/.well-known/acme-challenge/.*: Timeout during connect") + if !expectMatch.MatchString(prob.Detail) { + t.Errorf("Problem details incorrect. Got %q, expected to match %q", + prob.Detail, expectMatch) + } +} + +func TestHTTPRedirectLookup(t *testing.T) { + hs := httpSrv(t, expectedToken) + defer hs.Close() + va, log := setup(hs, 0, "", nil, nil) + + _, err := va.validateHTTP01(ctx, dnsi("localhost.com"), pathMoved, ka(pathMoved)) + if err != nil { + t.Fatalf("Unexpected failure in redirect (%s): %s", pathMoved, err) + } + redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"` + matchedValidRedirect := log.GetAllMatching(redirectValid) + test.AssertEquals(t, len(matchedValidRedirect), 1) + test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 2) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathFound, ka(pathFound)) + if err != nil { + t.Fatalf("Unexpected failure in redirect (%s): %s", pathFound, err) + } + redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"` + matchedMovedRedirect := log.GetAllMatching(redirectMoved) + test.AssertEquals(t, len(matchedMovedRedirect), 1) + test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 3) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathReLookupInvalid, ka(pathReLookupInvalid)) + test.AssertError(t, err, "error for pathReLookupInvalid should not be nil") + test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 1) + prob := detailedError(err) + test.AssertDeepEquals(t, prob, probs.Connection(`127.0.0.1: Fetching http://invalid.invalid/path: Invalid hostname in redirect target, must end in IANA registered TLD`)) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathReLookup, ka(pathReLookup)) + if err != nil { + t.Fatalf("Unexpected error in redirect (%s): %s", pathReLookup, err) + } + redirectPattern := `following redirect to host "" url "http://other.valid.com:\d+/path"` + test.AssertEquals(t, len(log.GetAllMatching(redirectPattern)), 1) + test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 1) + test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for other.valid.com: \[127.0.0.1\]`)), 1) + + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathRedirectInvalidPort, ka(pathRedirectInvalidPort)) + test.AssertNotNil(t, err, "error for pathRedirectInvalidPort should not be nil") + prob = detailedError(err) + test.AssertEquals(t, prob.Detail, fmt.Sprintf( + "127.0.0.1: Fetching http://other.valid.com:8080/path: Invalid port in redirect target. "+ + "Only ports %d and %d are supported, not 8080", va.httpPort, va.httpsPort)) + + // This case will redirect from a valid host to a host that is throwing + // HTTP 500 errors. The test case is ensuring that the connection error + // is referencing the redirected to host, instead of the original host. + log.Clear() + _, err = va.validateHTTP01(ctx, dnsi("localhost.com"), pathRedirectToFailingURL, ka(pathRedirectToFailingURL)) + test.AssertNotNil(t, err, "err should not be nil") + prob = detailedError(err) + test.AssertDeepEquals(t, prob, + probs.Unauthorized( + fmt.Sprintf("127.0.0.1: Invalid response from http://other.valid.com:%d/500: 500", + va.httpPort))) +} + +func TestHTTPRedirectLoop(t *testing.T) { + hs := httpSrv(t, expectedToken) + defer hs.Close() + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateHTTP01(ctx, dnsi("localhost"), "looper", ka("looper")) + if prob == nil { + t.Fatalf("Challenge should have failed for looper") + } +} + +func TestHTTPRedirectUserAgent(t *testing.T) { + hs := httpSrv(t, expectedToken) + defer hs.Close() + va, _ := setup(hs, 0, "", nil, nil) + va.userAgent = rejectUserAgent + + _, prob := va.validateHTTP01(ctx, dnsi("localhost"), pathMoved, ka(pathMoved)) + if prob == nil { + t.Fatalf("Challenge with rejectUserAgent should have failed (%s).", pathMoved) + } + + _, prob = va.validateHTTP01(ctx, dnsi("localhost"), pathFound, ka(pathFound)) + if prob == nil { + t.Fatalf("Challenge with rejectUserAgent should have failed (%s).", pathFound) + } +} + +func getPort(hs *httptest.Server) int { + url, err := url.Parse(hs.URL) + if err != nil { + panic(fmt.Sprintf("Failed to parse hs URL: %q - %s", hs.URL, err.Error())) + } + _, portString, err := net.SplitHostPort(url.Host) + if err != nil { + panic(fmt.Sprintf("Failed to split hs URL host: %q - %s", url.Host, err.Error())) + } + port, err := strconv.ParseInt(portString, 10, 64) + if err != nil { + panic(fmt.Sprintf("Failed to parse hs URL port: %q - %s", portString, err.Error())) + } + return int(port) +} + +func TestValidateHTTP(t *testing.T) { + token := core.NewToken() + + hs := httpSrv(t, token) + defer hs.Close() + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateHTTP01(ctx, dnsi("localhost"), token, ka(token)) + test.Assert(t, prob == nil, "validation failed") +} + +func TestLimitedReader(t *testing.T) { + token := core.NewToken() + + hs := httpSrv(t, "012345\xff67890123456789012345678901234567890123456789012345678901234567890123456789") + va, _ := setup(hs, 0, "", nil, nil) + defer hs.Close() + + _, err := va.validateHTTP01(ctx, dnsi("localhost"), token, ka(token)) + + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) + test.Assert(t, strings.HasPrefix(prob.Detail, "127.0.0.1: Invalid response from "), + "Expected failure due to truncation") + + if !utf8.ValidString(err.Error()) { + t.Errorf("Problem Detail contained an invalid UTF-8 string") + } +} diff --git a/third-party/github.com/letsencrypt/boulder/va/proto/va.pb.go b/third-party/github.com/letsencrypt/boulder/va/proto/va.pb.go new file mode 100644 index 000000000..8e8ee1950 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/proto/va.pb.go @@ -0,0 +1,498 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v3.20.1 +// source: va.proto + +package proto + +import ( + proto "github.com/letsencrypt/boulder/core/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type IsCAAValidRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // NOTE: Domain may be a name with a wildcard prefix (e.g. `*.example.com`) + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + ValidationMethod string `protobuf:"bytes,2,opt,name=validationMethod,proto3" json:"validationMethod,omitempty"` + AccountURIID int64 `protobuf:"varint,3,opt,name=accountURIID,proto3" json:"accountURIID,omitempty"` +} + +func (x *IsCAAValidRequest) Reset() { + *x = IsCAAValidRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IsCAAValidRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IsCAAValidRequest) ProtoMessage() {} + +func (x *IsCAAValidRequest) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IsCAAValidRequest.ProtoReflect.Descriptor instead. +func (*IsCAAValidRequest) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{0} +} + +func (x *IsCAAValidRequest) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *IsCAAValidRequest) GetValidationMethod() string { + if x != nil { + return x.ValidationMethod + } + return "" +} + +func (x *IsCAAValidRequest) GetAccountURIID() int64 { + if x != nil { + return x.AccountURIID + } + return 0 +} + +// If CAA is valid for the requested domain, the problem will be empty +type IsCAAValidResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Problem *proto.ProblemDetails `protobuf:"bytes,1,opt,name=problem,proto3" json:"problem,omitempty"` +} + +func (x *IsCAAValidResponse) Reset() { + *x = IsCAAValidResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IsCAAValidResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IsCAAValidResponse) ProtoMessage() {} + +func (x *IsCAAValidResponse) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IsCAAValidResponse.ProtoReflect.Descriptor instead. +func (*IsCAAValidResponse) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{1} +} + +func (x *IsCAAValidResponse) GetProblem() *proto.ProblemDetails { + if x != nil { + return x.Problem + } + return nil +} + +type PerformValidationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Challenge *proto.Challenge `protobuf:"bytes,2,opt,name=challenge,proto3" json:"challenge,omitempty"` + Authz *AuthzMeta `protobuf:"bytes,3,opt,name=authz,proto3" json:"authz,omitempty"` + ExpectedKeyAuthorization string `protobuf:"bytes,4,opt,name=expectedKeyAuthorization,proto3" json:"expectedKeyAuthorization,omitempty"` +} + +func (x *PerformValidationRequest) Reset() { + *x = PerformValidationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PerformValidationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PerformValidationRequest) ProtoMessage() {} + +func (x *PerformValidationRequest) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PerformValidationRequest.ProtoReflect.Descriptor instead. +func (*PerformValidationRequest) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{2} +} + +func (x *PerformValidationRequest) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *PerformValidationRequest) GetChallenge() *proto.Challenge { + if x != nil { + return x.Challenge + } + return nil +} + +func (x *PerformValidationRequest) GetAuthz() *AuthzMeta { + if x != nil { + return x.Authz + } + return nil +} + +func (x *PerformValidationRequest) GetExpectedKeyAuthorization() string { + if x != nil { + return x.ExpectedKeyAuthorization + } + return "" +} + +type AuthzMeta struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RegID int64 `protobuf:"varint,2,opt,name=regID,proto3" json:"regID,omitempty"` +} + +func (x *AuthzMeta) Reset() { + *x = AuthzMeta{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthzMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthzMeta) ProtoMessage() {} + +func (x *AuthzMeta) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthzMeta.ProtoReflect.Descriptor instead. +func (*AuthzMeta) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{3} +} + +func (x *AuthzMeta) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AuthzMeta) GetRegID() int64 { + if x != nil { + return x.RegID + } + return 0 +} + +type ValidationResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Records []*proto.ValidationRecord `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + Problems *proto.ProblemDetails `protobuf:"bytes,2,opt,name=problems,proto3" json:"problems,omitempty"` +} + +func (x *ValidationResult) Reset() { + *x = ValidationResult{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidationResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidationResult) ProtoMessage() {} + +func (x *ValidationResult) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidationResult.ProtoReflect.Descriptor instead. +func (*ValidationResult) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{4} +} + +func (x *ValidationResult) GetRecords() []*proto.ValidationRecord { + if x != nil { + return x.Records + } + return nil +} + +func (x *ValidationResult) GetProblems() *proto.ProblemDetails { + if x != nil { + return x.Problems + } + return nil +} + +var File_va_proto protoreflect.FileDescriptor + +var file_va_proto_rawDesc = []byte{ + 0x0a, 0x08, 0x76, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x76, 0x61, 0x1a, 0x15, + 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x7b, 0x0a, 0x11, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, + 0x6c, 0x69, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x2a, 0x0a, 0x10, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x76, 0x61, + 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x22, + 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x55, 0x52, 0x49, 0x49, 0x44, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x55, 0x52, 0x49, + 0x49, 0x44, 0x22, 0x44, 0x0a, 0x12, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x62, + 0x6c, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, + 0x07, 0x70, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x22, 0xc2, 0x01, 0x0a, 0x18, 0x50, 0x65, 0x72, + 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2d, 0x0a, + 0x09, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0f, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, + 0x65, 0x52, 0x09, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, 0x23, 0x0a, 0x05, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x76, 0x61, + 0x2e, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x05, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x12, 0x3a, 0x0a, 0x18, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x4b, 0x65, 0x79, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x18, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x4b, 0x65, 0x79, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x31, 0x0a, + 0x09, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, + 0x67, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x65, 0x67, 0x49, 0x44, + 0x22, 0x76, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x12, 0x30, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x72, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x62, 0x6c, 0x65, + 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, + 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x52, 0x08, + 0x70, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x32, 0x4f, 0x0a, 0x02, 0x56, 0x41, 0x12, 0x49, + 0x0a, 0x11, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x76, 0x61, 0x2e, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x14, 0x2e, 0x76, 0x61, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x00, 0x32, 0x44, 0x0a, 0x03, 0x43, 0x41, 0x41, + 0x12, 0x3d, 0x0a, 0x0a, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x15, + 0x2e, 0x76, 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x76, 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, + 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, + 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, + 0x72, 0x2f, 0x76, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_va_proto_rawDescOnce sync.Once + file_va_proto_rawDescData = file_va_proto_rawDesc +) + +func file_va_proto_rawDescGZIP() []byte { + file_va_proto_rawDescOnce.Do(func() { + file_va_proto_rawDescData = protoimpl.X.CompressGZIP(file_va_proto_rawDescData) + }) + return file_va_proto_rawDescData +} + +var file_va_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_va_proto_goTypes = []interface{}{ + (*IsCAAValidRequest)(nil), // 0: va.IsCAAValidRequest + (*IsCAAValidResponse)(nil), // 1: va.IsCAAValidResponse + (*PerformValidationRequest)(nil), // 2: va.PerformValidationRequest + (*AuthzMeta)(nil), // 3: va.AuthzMeta + (*ValidationResult)(nil), // 4: va.ValidationResult + (*proto.ProblemDetails)(nil), // 5: core.ProblemDetails + (*proto.Challenge)(nil), // 6: core.Challenge + (*proto.ValidationRecord)(nil), // 7: core.ValidationRecord +} +var file_va_proto_depIdxs = []int32{ + 5, // 0: va.IsCAAValidResponse.problem:type_name -> core.ProblemDetails + 6, // 1: va.PerformValidationRequest.challenge:type_name -> core.Challenge + 3, // 2: va.PerformValidationRequest.authz:type_name -> va.AuthzMeta + 7, // 3: va.ValidationResult.records:type_name -> core.ValidationRecord + 5, // 4: va.ValidationResult.problems:type_name -> core.ProblemDetails + 2, // 5: va.VA.PerformValidation:input_type -> va.PerformValidationRequest + 0, // 6: va.CAA.IsCAAValid:input_type -> va.IsCAAValidRequest + 4, // 7: va.VA.PerformValidation:output_type -> va.ValidationResult + 1, // 8: va.CAA.IsCAAValid:output_type -> va.IsCAAValidResponse + 7, // [7:9] is the sub-list for method output_type + 5, // [5:7] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_va_proto_init() } +func file_va_proto_init() { + if File_va_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_va_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IsCAAValidRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_va_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IsCAAValidResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_va_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PerformValidationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_va_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AuthzMeta); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_va_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidationResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_va_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_va_proto_goTypes, + DependencyIndexes: file_va_proto_depIdxs, + MessageInfos: file_va_proto_msgTypes, + }.Build() + File_va_proto = out.File + file_va_proto_rawDesc = nil + file_va_proto_goTypes = nil + file_va_proto_depIdxs = nil +} diff --git a/third-party/github.com/letsencrypt/boulder/va/proto/va.proto b/third-party/github.com/letsencrypt/boulder/va/proto/va.proto new file mode 100644 index 000000000..76a37320a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/proto/va.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package va; +option go_package = "github.com/letsencrypt/boulder/va/proto"; + +import "core/proto/core.proto"; + +service VA { + rpc PerformValidation(PerformValidationRequest) returns (ValidationResult) {} +} + +service CAA { + rpc IsCAAValid(IsCAAValidRequest) returns (IsCAAValidResponse) {} +} + +message IsCAAValidRequest { + // NOTE: Domain may be a name with a wildcard prefix (e.g. `*.example.com`) + string domain = 1; + string validationMethod = 2; + int64 accountURIID = 3; +} + +// If CAA is valid for the requested domain, the problem will be empty +message IsCAAValidResponse { + core.ProblemDetails problem = 1; +} + +message PerformValidationRequest { + string domain = 1; + core.Challenge challenge = 2; + AuthzMeta authz = 3; + string expectedKeyAuthorization = 4; +} + +message AuthzMeta { + string id = 1; + int64 regID = 2; +} + +message ValidationResult { + repeated core.ValidationRecord records = 1; + core.ProblemDetails problems = 2; +} diff --git a/third-party/github.com/letsencrypt/boulder/va/proto/va_grpc.pb.go b/third-party/github.com/letsencrypt/boulder/va/proto/va_grpc.pb.go new file mode 100644 index 000000000..b7c3df4f3 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/proto/va_grpc.pb.go @@ -0,0 +1,201 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.20.1 +// source: va.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + VA_PerformValidation_FullMethodName = "/va.VA/PerformValidation" +) + +// VAClient is the client API for VA service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type VAClient interface { + PerformValidation(ctx context.Context, in *PerformValidationRequest, opts ...grpc.CallOption) (*ValidationResult, error) +} + +type vAClient struct { + cc grpc.ClientConnInterface +} + +func NewVAClient(cc grpc.ClientConnInterface) VAClient { + return &vAClient{cc} +} + +func (c *vAClient) PerformValidation(ctx context.Context, in *PerformValidationRequest, opts ...grpc.CallOption) (*ValidationResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidationResult) + err := c.cc.Invoke(ctx, VA_PerformValidation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VAServer is the server API for VA service. +// All implementations must embed UnimplementedVAServer +// for forward compatibility +type VAServer interface { + PerformValidation(context.Context, *PerformValidationRequest) (*ValidationResult, error) + mustEmbedUnimplementedVAServer() +} + +// UnimplementedVAServer must be embedded to have forward compatible implementations. +type UnimplementedVAServer struct { +} + +func (UnimplementedVAServer) PerformValidation(context.Context, *PerformValidationRequest) (*ValidationResult, error) { + return nil, status.Errorf(codes.Unimplemented, "method PerformValidation not implemented") +} +func (UnimplementedVAServer) mustEmbedUnimplementedVAServer() {} + +// UnsafeVAServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to VAServer will +// result in compilation errors. +type UnsafeVAServer interface { + mustEmbedUnimplementedVAServer() +} + +func RegisterVAServer(s grpc.ServiceRegistrar, srv VAServer) { + s.RegisterService(&VA_ServiceDesc, srv) +} + +func _VA_PerformValidation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PerformValidationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VAServer).PerformValidation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VA_PerformValidation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VAServer).PerformValidation(ctx, req.(*PerformValidationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// VA_ServiceDesc is the grpc.ServiceDesc for VA service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var VA_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "va.VA", + HandlerType: (*VAServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "PerformValidation", + Handler: _VA_PerformValidation_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "va.proto", +} + +const ( + CAA_IsCAAValid_FullMethodName = "/va.CAA/IsCAAValid" +) + +// CAAClient is the client API for CAA service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type CAAClient interface { + IsCAAValid(ctx context.Context, in *IsCAAValidRequest, opts ...grpc.CallOption) (*IsCAAValidResponse, error) +} + +type cAAClient struct { + cc grpc.ClientConnInterface +} + +func NewCAAClient(cc grpc.ClientConnInterface) CAAClient { + return &cAAClient{cc} +} + +func (c *cAAClient) IsCAAValid(ctx context.Context, in *IsCAAValidRequest, opts ...grpc.CallOption) (*IsCAAValidResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IsCAAValidResponse) + err := c.cc.Invoke(ctx, CAA_IsCAAValid_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CAAServer is the server API for CAA service. +// All implementations must embed UnimplementedCAAServer +// for forward compatibility +type CAAServer interface { + IsCAAValid(context.Context, *IsCAAValidRequest) (*IsCAAValidResponse, error) + mustEmbedUnimplementedCAAServer() +} + +// UnimplementedCAAServer must be embedded to have forward compatible implementations. +type UnimplementedCAAServer struct { +} + +func (UnimplementedCAAServer) IsCAAValid(context.Context, *IsCAAValidRequest) (*IsCAAValidResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method IsCAAValid not implemented") +} +func (UnimplementedCAAServer) mustEmbedUnimplementedCAAServer() {} + +// UnsafeCAAServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CAAServer will +// result in compilation errors. +type UnsafeCAAServer interface { + mustEmbedUnimplementedCAAServer() +} + +func RegisterCAAServer(s grpc.ServiceRegistrar, srv CAAServer) { + s.RegisterService(&CAA_ServiceDesc, srv) +} + +func _CAA_IsCAAValid_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IsCAAValidRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CAAServer).IsCAAValid(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CAA_IsCAAValid_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CAAServer).IsCAAValid(ctx, req.(*IsCAAValidRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CAA_ServiceDesc is the grpc.ServiceDesc for CAA service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CAA_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "va.CAA", + HandlerType: (*CAAServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "IsCAAValid", + Handler: _CAA_IsCAAValid_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "va.proto", +} diff --git a/third-party/github.com/letsencrypt/boulder/va/tlsalpn.go b/third-party/github.com/letsencrypt/boulder/va/tlsalpn.go new file mode 100644 index 000000000..f4a23e793 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/tlsalpn.go @@ -0,0 +1,302 @@ +package va + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" +) + +const ( + // ALPN protocol ID for TLS-ALPN-01 challenge + // https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-5.2 + ACMETLS1Protocol = "acme-tls/1" +) + +var ( + // As defined in https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-04#section-5.1 + // id-pe OID + 31 (acmeIdentifier) + IdPeAcmeIdentifier = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31} + // OID for the Subject Alternative Name extension, as defined in + // https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 + IdCeSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17} +) + +// certAltNames collects up all of a certificate's subject names (Subject CN and +// Subject Alternate Names) and reduces them to a unique, sorted set, typically for an +// error message +func certAltNames(cert *x509.Certificate) []string { + var names []string + if cert.Subject.CommonName != "" { + names = append(names, cert.Subject.CommonName) + } + names = append(names, cert.DNSNames...) + names = append(names, cert.EmailAddresses...) + for _, id := range cert.IPAddresses { + names = append(names, id.String()) + } + for _, id := range cert.URIs { + names = append(names, id.String()) + } + names = core.UniqueLowerNames(names) + return names +} + +func (va *ValidationAuthorityImpl) tryGetChallengeCert( + ctx context.Context, + identifier identifier.ACMEIdentifier, + tlsConfig *tls.Config, +) (*x509.Certificate, *tls.ConnectionState, core.ValidationRecord, error) { + + allAddrs, resolvers, err := va.getAddrs(ctx, identifier.Value) + validationRecord := core.ValidationRecord{ + Hostname: identifier.Value, + AddressesResolved: allAddrs, + Port: strconv.Itoa(va.tlsPort), + ResolverAddrs: resolvers, + } + if err != nil { + return nil, nil, validationRecord, err + } + + // Split the available addresses into v4 and v6 addresses + v4, v6 := availableAddresses(allAddrs) + addresses := append(v4, v6...) + + // This shouldn't happen, but be defensive about it anyway + if len(addresses) < 1 { + return nil, nil, validationRecord, berrors.MalformedError("no IP addresses found for %q", identifier.Value) + } + + // If there is at least one IPv6 address then try it first + if len(v6) > 0 { + address := net.JoinHostPort(v6[0].String(), validationRecord.Port) + validationRecord.AddressUsed = v6[0] + + cert, cs, err := va.getChallengeCert(ctx, address, identifier, tlsConfig) + + // If there is no problem, return immediately + if err == nil { + return cert, cs, validationRecord, nil + } + + // Otherwise, we note that we tried an address and fall back to trying IPv4 + validationRecord.AddressesTried = append(validationRecord.AddressesTried, validationRecord.AddressUsed) + va.metrics.ipv4FallbackCounter.Inc() + } + + // If there are no IPv4 addresses and we tried an IPv6 address return + // an error - there's nothing left to try + if len(v4) == 0 && len(validationRecord.AddressesTried) > 0 { + return nil, nil, validationRecord, berrors.MalformedError("Unable to contact %q at %q, no IPv4 addresses to try as fallback", + validationRecord.Hostname, validationRecord.AddressesTried[0]) + } else if len(v4) == 0 && len(validationRecord.AddressesTried) == 0 { + // It shouldn't be possible that there are no IPv4 addresses and no previous + // attempts at an IPv6 address connection but be defensive about it anyway + return nil, nil, validationRecord, berrors.MalformedError("No IP addresses found for %q", validationRecord.Hostname) + } + + // Otherwise if there are no IPv6 addresses, or there was an error + // talking to the first IPv6 address, try the first IPv4 address + validationRecord.AddressUsed = v4[0] + address := net.JoinHostPort(v4[0].String(), validationRecord.Port) + cert, cs, err := va.getChallengeCert(ctx, address, identifier, tlsConfig) + return cert, cs, validationRecord, err +} + +func (va *ValidationAuthorityImpl) getChallengeCert( + ctx context.Context, + hostPort string, + identifier identifier.ACMEIdentifier, + config *tls.Config, +) (*x509.Certificate, *tls.ConnectionState, error) { + va.log.Info(fmt.Sprintf("%s [%s] Attempting to validate for %s %s", core.ChallengeTypeTLSALPN01, identifier, hostPort, config.ServerName)) + // We expect a self-signed challenge certificate, do not verify it here. + config.InsecureSkipVerify = true + + dialCtx, cancel := context.WithTimeout(ctx, va.singleDialTimeout) + defer cancel() + + dialer := &tls.Dialer{Config: config} + conn, err := dialer.DialContext(dialCtx, "tcp", hostPort) + if err != nil { + va.log.Infof("%s connection failure for %s. err=[%#v] errStr=[%s]", core.ChallengeTypeTLSALPN01, identifier, err, err) + host, _, splitErr := net.SplitHostPort(hostPort) + if splitErr == nil && net.ParseIP(host) != nil { + // Wrap the validation error and the IP of the remote host in an + // IPError so we can display the IP in the problem details returned + // to the client. + return nil, nil, ipError{net.ParseIP(host), err} + } + return nil, nil, err + } + defer conn.Close() + + // tls.Dialer.DialContext guarantees that the *net.Conn it returns is a *tls.Conn. + cs := conn.(*tls.Conn).ConnectionState() + certs := cs.PeerCertificates + if len(certs) == 0 { + va.log.Infof("%s challenge for %s resulted in no certificates", core.ChallengeTypeTLSALPN01, identifier.Value) + return nil, nil, berrors.UnauthorizedError("No certs presented for %s challenge", core.ChallengeTypeTLSALPN01) + } + for i, cert := range certs { + va.log.AuditInfof("%s challenge for %s received certificate (%d of %d): cert=[%s]", + core.ChallengeTypeTLSALPN01, identifier.Value, i+1, len(certs), hex.EncodeToString(cert.Raw)) + } + return certs[0], &cs, nil +} + +func checkExpectedSAN(cert *x509.Certificate, name identifier.ACMEIdentifier) error { + if len(cert.DNSNames) != 1 { + return errors.New("wrong number of dNSNames") + } + + for _, ext := range cert.Extensions { + if IdCeSubjectAltName.Equal(ext.Id) { + expectedSANs, err := asn1.Marshal([]asn1.RawValue{ + {Tag: 2, Class: 2, Bytes: []byte(cert.DNSNames[0])}, + }) + if err != nil || !bytes.Equal(expectedSANs, ext.Value) { + return errors.New("SAN extension does not match expected bytes") + } + } + } + + if !strings.EqualFold(cert.DNSNames[0], name.Value) { + return errors.New("dNSName does not match expected identifier") + } + + return nil +} + +// Confirm that of the OIDs provided, all of them are in the provided list of +// extensions. Also confirms that of the extensions provided that none are +// repeated. Per RFC8737, allows unexpected extensions. +func checkAcceptableExtensions(exts []pkix.Extension, requiredOIDs []asn1.ObjectIdentifier) error { + oidSeen := make(map[string]bool) + + for _, ext := range exts { + if oidSeen[ext.Id.String()] { + return fmt.Errorf("Extension OID %s seen twice", ext.Id) + } + oidSeen[ext.Id.String()] = true + } + + for _, required := range requiredOIDs { + if !oidSeen[required.String()] { + return fmt.Errorf("Required extension OID %s is not present", required) + } + } + + return nil +} + +func (va *ValidationAuthorityImpl) validateTLSALPN01(ctx context.Context, identifier identifier.ACMEIdentifier, keyAuthorization string) ([]core.ValidationRecord, error) { + if identifier.Type != "dns" { + va.log.Info(fmt.Sprintf("Identifier type for TLS-ALPN-01 was not DNS: %s", identifier)) + return nil, berrors.MalformedError("Identifier type for TLS-ALPN-01 was not DNS") + } + + cert, cs, tvr, problem := va.tryGetChallengeCert(ctx, identifier, &tls.Config{ + MinVersion: tls.VersionTLS12, + NextProtos: []string{ACMETLS1Protocol}, + ServerName: identifier.Value, + }) + // Copy the single validationRecord into the slice that we have to return, and + // get a reference to it so we can modify it if we have to. + validationRecords := []core.ValidationRecord{tvr} + validationRecord := &validationRecords[0] + if problem != nil { + return validationRecords, problem + } + + if cs.NegotiatedProtocol != ACMETLS1Protocol { + return validationRecords, berrors.UnauthorizedError( + "Cannot negotiate ALPN protocol %q for %s challenge", + ACMETLS1Protocol, + core.ChallengeTypeTLSALPN01) + } + + badCertErr := func(msg string) error { + hostPort := net.JoinHostPort(validationRecord.AddressUsed.String(), validationRecord.Port) + + return berrors.UnauthorizedError( + "Incorrect validation certificate for %s challenge. "+ + "Requested %s from %s. %s", + core.ChallengeTypeTLSALPN01, identifier.Value, hostPort, msg) + } + + // The certificate must be self-signed. + err := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature) + if err != nil || !bytes.Equal(cert.RawSubject, cert.RawIssuer) { + return validationRecords, badCertErr( + "Received certificate which is not self-signed.") + } + + // The certificate must have the subjectAltName and acmeIdentifier + // extensions, and only one of each. + allowedOIDs := []asn1.ObjectIdentifier{ + IdPeAcmeIdentifier, IdCeSubjectAltName, + } + err = checkAcceptableExtensions(cert.Extensions, allowedOIDs) + if err != nil { + return validationRecords, badCertErr( + fmt.Sprintf("Received certificate with unexpected extensions: %q", err)) + } + + // The certificate returned must have a subjectAltName extension containing + // only the dNSName being validated and no other entries. + err = checkExpectedSAN(cert, identifier) + if err != nil { + names := strings.Join(certAltNames(cert), ", ") + return validationRecords, badCertErr( + fmt.Sprintf("Received certificate with unexpected identifiers (%q): %q", names, err)) + } + + // Verify key authorization in acmeValidation extension + h := sha256.Sum256([]byte(keyAuthorization)) + for _, ext := range cert.Extensions { + if IdPeAcmeIdentifier.Equal(ext.Id) { + va.metrics.tlsALPNOIDCounter.WithLabelValues(IdPeAcmeIdentifier.String()).Inc() + if !ext.Critical { + return validationRecords, badCertErr( + "Received certificate with acmeValidationV1 extension that is not Critical.") + } + var extValue []byte + rest, err := asn1.Unmarshal(ext.Value, &extValue) + if err != nil || len(rest) > 0 || len(h) != len(extValue) { + return validationRecords, badCertErr( + "Received certificate with malformed acmeValidationV1 extension value.") + } + if subtle.ConstantTimeCompare(h[:], extValue) != 1 { + return validationRecords, badCertErr(fmt.Sprintf( + "Received certificate with acmeValidationV1 extension value %s but expected %s.", + hex.EncodeToString(extValue), + hex.EncodeToString(h[:]), + )) + } + // We were successful, so record the negotiated key exchange mechanism in + // the validationRecord. + // TODO(#7321): Remove this when we have collected enough data. + validationRecord.UsedRSAKEX = usedRSAKEX(cs.CipherSuite) + return validationRecords, nil + } + } + + return validationRecords, badCertErr( + "Received certificate with no acmeValidationV1 extension.") +} diff --git a/third-party/github.com/letsencrypt/boulder/va/tlsalpn_test.go b/third-party/github.com/letsencrypt/boulder/va/tlsalpn_test.go new file mode 100644 index 000000000..9e11bd319 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/tlsalpn_test.go @@ -0,0 +1,860 @@ +package va + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +func tlsCertTemplate(names []string) *x509.Certificate { + return &x509.Certificate{ + SerialNumber: big.NewInt(1337), + Subject: pkix.Name{ + Organization: []string{"tests"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: names, + } +} + +func makeACert(names []string) *tls.Certificate { + template := tlsCertTemplate(names) + certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + return &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } +} + +// tlssniSrvWithNames is kept around for the use of TestValidateTLSALPN01UnawareSrv +func tlssniSrvWithNames(t *testing.T, names ...string) *httptest.Server { + t.Helper() + + cert := makeACert(names) + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{*cert}, + ClientAuth: tls.NoClientCert, + GetCertificate: func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return cert, nil + }, + NextProtos: []string{"http/1.1"}, + } + + hs := httptest.NewUnstartedServer(http.DefaultServeMux) + hs.TLS = tlsConfig + hs.StartTLS() + return hs +} + +func tlsalpn01SrvWithCert(t *testing.T, acmeCert *tls.Certificate, tlsVersion uint16) *httptest.Server { + t.Helper() + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{}, + ClientAuth: tls.NoClientCert, + GetCertificate: func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return acmeCert, nil + }, + NextProtos: []string{"http/1.1", ACMETLS1Protocol}, + MinVersion: tlsVersion, + MaxVersion: tlsVersion, + } + + hs := httptest.NewUnstartedServer(http.DefaultServeMux) + hs.TLS = tlsConfig + hs.Config.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){ + ACMETLS1Protocol: func(_ *http.Server, conn *tls.Conn, _ http.Handler) { + _ = conn.Close() + }, + } + hs.StartTLS() + return hs +} + +func tlsalpn01Srv( + t *testing.T, + keyAuthorization string, + oid asn1.ObjectIdentifier, + tlsVersion uint16, + names ...string) (*httptest.Server, error) { + template := tlsCertTemplate(names) + + shasum := sha256.Sum256([]byte(keyAuthorization)) + encHash, err := asn1.Marshal(shasum[:]) + if err != nil { + return nil, err + } + acmeExtension := pkix.Extension{ + Id: oid, + Critical: true, + Value: encHash, + } + template.ExtraExtensions = []pkix.Extension{acmeExtension} + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + if err != nil { + return nil, err + } + + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + return tlsalpn01SrvWithCert(t, acmeCert, tlsVersion), nil +} + +func TestTLSALPN01FailIP(t *testing.T) { + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + + port := getPort(hs) + _, err = va.validateTLSALPN01(ctx, identifier.ACMEIdentifier{ + Type: identifier.IdentifierType("ip"), + Value: net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), + }, expectedKeyAuthorization) + if err == nil { + t.Fatalf("IdentifierType IP shouldn't have worked.") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.MalformedProblem) +} + +func slowTLSSrv() *httptest.Server { + server := httptest.NewUnstartedServer(http.DefaultServeMux) + server.TLS = &tls.Config{ + NextProtos: []string{"http/1.1", ACMETLS1Protocol}, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + time.Sleep(100 * time.Millisecond) + return makeACert([]string{"nomatter"}), nil + }, + } + server.StartTLS() + return server +} + +func TestTLSALPNTimeoutAfterConnect(t *testing.T) { + hs := slowTLSSrv() + va, _ := setup(hs, 0, "", nil, nil) + + timeout := 50 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + started := time.Now() + _, err := va.validateTLSALPN01(ctx, dnsi("slow.server"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("Validation should've failed") + } + // Check that the TLS connection doesn't return before a timeout, and times + // out after the expected time + took := time.Since(started) + // Check that the HTTP connection doesn't return too fast, and times + // out after the expected time + if took < timeout/2 { + t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, err) + } + if took > 2*timeout { + t.Fatalf("TLSSNI didn't timeout after %s (took %s to return %#v)", timeout, + took, err) + } + if err == nil { + t.Fatalf("Connection should've timed out") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + + expected := "127.0.0.1: Timeout after connect (your server may be slow or overloaded)" + if prob.Detail != expected { + t.Errorf("Wrong error detail. Expected %q, got %q", expected, prob.Detail) + } +} + +func TestTLSALPN01DialTimeout(t *testing.T) { + hs := slowTLSSrv() + va, _ := setup(hs, 0, "", nil, dnsMockReturnsUnroutable{&bdns.MockClient{}}) + started := time.Now() + + timeout := 50 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // The only method I've found so far to trigger a connect timeout is to + // connect to an unrouteable IP address. This usually generates a connection + // timeout, but will rarely return "Network unreachable" instead. If we get + // that, just retry until we get something other than "Network unreachable". + var err error + for range 20 { + _, err = va.validateTLSALPN01(ctx, dnsi("unroutable.invalid"), expectedKeyAuthorization) + if err != nil && strings.Contains(err.Error(), "Network unreachable") { + continue + } else { + break + } + } + + if err == nil { + t.Fatalf("Validation should've failed") + } + // Check that the TLS connection doesn't return before a timeout, and times + // out after the expected time + took := time.Since(started) + // Check that the HTTP connection doesn't return too fast, and times + // out after the expected time + if took < timeout/2 { + t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, err) + } + if took > 2*timeout { + t.Fatalf("TLSSNI didn't timeout after %s", timeout) + } + if err == nil { + t.Fatalf("Connection should've timed out") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + expected := "198.51.100.1: Timeout during connect (likely firewall problem)" + if prob.Detail != expected { + t.Errorf("Wrong error detail. Expected %q, got %q", expected, prob.Detail) + } +} + +func TestTLSALPN01Refused(t *testing.T) { + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + // Take down validation server and check that validation fails. + hs.Close() + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("Server's down; expected refusal. Where did we connect?") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + expected := "127.0.0.1: Connection refused" + if prob.Detail != expected { + t.Errorf("Wrong error detail. Expected %q, got %q", expected, prob.Detail) + } +} + +func TestTLSALPN01TalkingToHTTP(t *testing.T) { + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + httpOnly := httpSrv(t, "") + va.tlsPort = getPort(httpOnly) + + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, err, "TLS-SNI-01 validation passed when talking to a HTTP-only server") + prob := detailedError(err) + expected := "Server only speaks HTTP, not TLS" + if !strings.HasSuffix(prob.Error(), expected) { + t.Errorf("Got wrong error detail. Expected %q, got %q", expected, prob) + } +} + +func brokenTLSSrv() *httptest.Server { + server := httptest.NewUnstartedServer(http.DefaultServeMux) + server.TLS = &tls.Config{ + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return nil, fmt.Errorf("Failing on purpose") + }, + } + server.StartTLS() + return server +} + +func TestTLSError(t *testing.T) { + hs := brokenTLSSrv() + + va, _ := setup(hs, 0, "", nil, nil) + + _, err := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("TLS validation should have failed: What cert was used?") + } + prob := detailedError(err) + if prob.Type != probs.TLSProblem { + t.Errorf("Wrong problem type: got %s, expected type %s", + prob, probs.TLSProblem) + } +} + +func TestDNSError(t *testing.T) { + hs := brokenTLSSrv() + + va, _ := setup(hs, 0, "", nil, nil) + + _, err := va.validateTLSALPN01(ctx, dnsi("always.invalid"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("TLS validation should have failed: what IP was used?") + } + prob := detailedError(err) + if prob.Type != probs.DNSProblem { + t.Errorf("Wrong problem type: got %s, expected type %s", + prob, probs.DNSProblem) + } +} + +func TestCertNames(t *testing.T) { + uri, err := url.Parse("ftp://something.else:1234") + test.AssertNotError(t, err, "failed to parse fake URI") + + // We duplicate names inside the fields corresponding to the SAN set + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + Subject: pkix.Name{ + // We also duplicate a name from the SANs as the CN + CommonName: "hello.world", + }, + DNSNames: []string{ + "hello.world", "goodbye.world", + "hello.world", "goodbye.world", + "bonjour.le.monde", "au.revoir.le.monde", + "bonjour.le.monde", "au.revoir.le.monde", + }, + EmailAddresses: []string{ + "hello@world.gov", "hello@world.gov", + }, + IPAddresses: []net.IP{ + net.ParseIP("192.168.0.1"), net.ParseIP("192.168.0.1"), + net.ParseIP("2001:db8::68"), net.ParseIP("2001:db8::68"), + }, + URIs: []*url.URL{ + uri, uri, + }, + } + + // We expect only unique names, in sorted order. + expected := []string{ + "192.168.0.1", + "2001:db8::68", + "au.revoir.le.monde", + "bonjour.le.monde", + "ftp://something.else:1234", + "goodbye.world", + "hello.world", + "hello@world.gov", + } + + // Create the certificate, check that certNames provides the expected result + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + test.AssertNotError(t, err, "Error creating certificate") + + cert, err := x509.ParseCertificate(certBytes) + test.AssertNotError(t, err, "Error parsing certificate") + + actual := certAltNames(cert) + test.AssertDeepEquals(t, actual, expected) +} + +func TestTLSALPN01Success(t *testing.T) { + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if prob != nil { + t.Errorf("Validation failed: %v", prob) + } + test.AssertMetricWithLabelsEquals( + t, va.metrics.tlsALPNOIDCounter, prometheus.Labels{"oid": IdPeAcmeIdentifier.String()}, 1) + + hs.Close() +} + +func TestTLSALPN01ObsoleteFailure(t *testing.T) { + // NOTE: unfortunately another document claimed the OID we were using in + // draft-ietf-acme-tls-alpn-01 for their own extension and IANA chose to + // assign it early. Because of this we had to increment the + // id-pe-acmeIdentifier OID. We supported this obsolete OID for a long time, + // but no longer do so. + // As defined in https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-5.1 + // id-pe OID + 30 (acmeIdentifier) + 1 (v1) + IdPeAcmeIdentifierV1Obsolete := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 30, 1} + + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifierV1Obsolete, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertNotNil(t, prob, "expected validation to fail") +} + +func TestValidateTLSALPN01BadChallenge(t *testing.T) { + badKeyAuthorization := ka("bad token") + + hs, err := tlsalpn01Srv(t, badKeyAuthorization, IdPeAcmeIdentifier, 0, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + + if err == nil { + t.Fatalf("TLS ALPN validation should have failed.") + } + + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) + + expectedDigest := sha256.Sum256([]byte(expectedKeyAuthorization)) + badDigest := sha256.Sum256([]byte(badKeyAuthorization)) + + test.AssertContains(t, err.Error(), string(core.ChallengeTypeTLSALPN01)) + test.AssertContains(t, err.Error(), hex.EncodeToString(expectedDigest[:])) + test.AssertContains(t, err.Error(), hex.EncodeToString(badDigest[:])) +} + +func TestValidateTLSALPN01BrokenSrv(t *testing.T) { + hs := brokenTLSSrv() + + va, _ := setup(hs, 0, "", nil, nil) + + _, err := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("TLS ALPN validation should have failed.") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.TLSProblem) +} + +func TestValidateTLSALPN01UnawareSrv(t *testing.T) { + hs := tlssniSrvWithNames(t, "expected") + + va, _ := setup(hs, 0, "", nil, nil) + + _, err := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if err == nil { + t.Fatalf("TLS ALPN validation should have failed.") + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.TLSProblem) +} + +// TestValidateTLSALPN01BadUTFSrv tests that validating TLS-ALPN-01 against +// a host that returns a certificate with a SAN/CN that contains invalid UTF-8 +// will result in a problem with the invalid UTF-8. +func TestValidateTLSALPN01BadUTFSrv(t *testing.T) { + _, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, 0, "expected", "\xf0\x28\x8c\xbc") + test.AssertContains(t, err.Error(), "cannot be encoded as an IA5String") +} + +// TestValidateTLSALPN01MalformedExtnValue tests that validating TLS-ALPN-01 +// against a host that returns a certificate that contains an ASN.1 DER +// acmeValidation extension value that does not parse or is the wrong length +// will result in an Unauthorized problem +func TestValidateTLSALPN01MalformedExtnValue(t *testing.T) { + names := []string{"expected"} + template := tlsCertTemplate(names) + + wrongTypeDER, _ := asn1.Marshal("a string") + wrongLengthDER, _ := asn1.Marshal(make([]byte, 31)) + badExtensions := []pkix.Extension{ + { + Id: IdPeAcmeIdentifier, + Critical: true, + Value: wrongTypeDER, + }, + { + Id: IdPeAcmeIdentifier, + Critical: true, + Value: wrongLengthDER, + }, + } + + for _, badExt := range badExtensions { + template.ExtraExtensions = []pkix.Extension{badExt} + certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + hs := tlsalpn01SrvWithCert(t, acmeCert, 0) + va, _ := setup(hs, 0, "", nil, nil) + + _, err := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + hs.Close() + + if err == nil { + t.Errorf("TLS ALPN validation should have failed for acmeValidation extension %+v.", + badExt) + continue + } + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) + test.AssertContains(t, prob.Detail, string(core.ChallengeTypeTLSALPN01)) + test.AssertContains(t, prob.Detail, "malformed acmeValidationV1 extension value") + } +} + +func TestTLSALPN01TLSVersion(t *testing.T) { + for _, tc := range []struct { + version uint16 + expectError bool + }{ + { + version: tls.VersionTLS11, + expectError: true, + }, + { + version: tls.VersionTLS12, + expectError: false, + }, + { + version: tls.VersionTLS13, + expectError: false, + }, + } { + // Create a server that only negotiates the given TLS version + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, tc.version, "expected") + test.AssertNotError(t, err, "Error creating test server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + if !tc.expectError { + if prob != nil { + t.Errorf("expected success, got: %v", prob) + } + // The correct TLS-ALPN-01 OID counter should have been incremented + test.AssertMetricWithLabelsEquals( + t, va.metrics.tlsALPNOIDCounter, prometheus.Labels{"oid": IdPeAcmeIdentifier.String()}, 1) + } else { + test.AssertNotNil(t, prob, "expected validation error") + test.AssertMetricWithLabelsEquals( + t, va.metrics.tlsALPNOIDCounter, prometheus.Labels{"oid": IdPeAcmeIdentifier.String()}, 0) + } + + hs.Close() + } +} + +func TestTLSALPN01WrongName(t *testing.T) { + // Create a cert with a different name from what we're validating + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, tls.VersionTLS12, "incorrect") + test.AssertNotError(t, err, "failed to set up tls-alpn-01 server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, prob, "validation should have failed") +} + +func TestTLSALPN01ExtraNames(t *testing.T) { + // Create a cert with two names when we only want to validate one. + hs, err := tlsalpn01Srv(t, expectedKeyAuthorization, IdPeAcmeIdentifier, tls.VersionTLS12, "expected", "extra") + test.AssertNotError(t, err, "failed to set up tls-alpn-01 server") + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, prob, "validation should have failed") +} + +func TestTLSALPN01NotSelfSigned(t *testing.T) { + // Create a cert with an extra non-dnsName identifier. + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + Subject: pkix.Name{ + Organization: []string{"tests"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + DNSNames: []string{"expected"}, + IPAddresses: []net.IP{net.ParseIP("192.168.0.1")}, + } + + shasum := sha256.Sum256([]byte(expectedKeyAuthorization)) + encHash, err := asn1.Marshal(shasum[:]) + test.AssertNotError(t, err, "failed to create key authorization") + + acmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: encHash, + } + template.ExtraExtensions = []pkix.Extension{acmeExtension} + + parent := &x509.Certificate{ + SerialNumber: big.NewInt(1234), + Subject: pkix.Name{ + Organization: []string{"testissuer"}, + }, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + // Note that this currently only tests that the subject and issuer are the + // same; it does not test the case where the cert is signed by a different key. + certBytes, err := x509.CreateCertificate(rand.Reader, template, parent, &TheKey.PublicKey, &TheKey) + test.AssertNotError(t, err, "failed to create acme-tls/1 cert") + + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + hs := tlsalpn01SrvWithCert(t, acmeCert, tls.VersionTLS12) + + va, _ := setup(hs, 0, "", nil, nil) + + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, err, "validation should have failed") + test.AssertContains(t, err.Error(), "not self-signed") +} + +func TestTLSALPN01ExtraIdentifiers(t *testing.T) { + // Create a cert with an extra non-dnsName identifier. + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + Subject: pkix.Name{ + Organization: []string{"tests"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: []string{"expected"}, + IPAddresses: []net.IP{net.ParseIP("192.168.0.1")}, + } + + shasum := sha256.Sum256([]byte(expectedKeyAuthorization)) + encHash, err := asn1.Marshal(shasum[:]) + test.AssertNotError(t, err, "failed to create key authorization") + + acmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: encHash, + } + template.ExtraExtensions = []pkix.Extension{acmeExtension} + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + test.AssertNotError(t, err, "failed to create acme-tls/1 cert") + + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + hs := tlsalpn01SrvWithCert(t, acmeCert, tls.VersionTLS12) + + va, _ := setup(hs, 0, "", nil, nil) + + _, prob := va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, prob, "validation should have failed") +} + +func TestTLSALPN01ExtraSANs(t *testing.T) { + // Create a cert with multiple SAN extensions + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + Subject: pkix.Name{ + Organization: []string{"tests"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + shasum := sha256.Sum256([]byte(expectedKeyAuthorization)) + encHash, err := asn1.Marshal(shasum[:]) + test.AssertNotError(t, err, "failed to create key authorization") + + acmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: encHash, + } + + subjectAltName := pkix.Extension{} + subjectAltName.Id = asn1.ObjectIdentifier{2, 5, 29, 17} + subjectAltName.Critical = false + subjectAltName.Value, err = asn1.Marshal([]asn1.RawValue{ + {Tag: 2, Class: 2, Bytes: []byte(`expected`)}, + }) + test.AssertNotError(t, err, "failed to marshal first SAN") + + extraSubjectAltName := pkix.Extension{} + extraSubjectAltName.Id = asn1.ObjectIdentifier{2, 5, 29, 17} + extraSubjectAltName.Critical = false + extraSubjectAltName.Value, err = asn1.Marshal([]asn1.RawValue{ + {Tag: 2, Class: 2, Bytes: []byte(`expected`)}, + }) + test.AssertNotError(t, err, "failed to marshal extra SAN") + + template.ExtraExtensions = []pkix.Extension{acmeExtension, subjectAltName, extraSubjectAltName} + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + test.AssertNotError(t, err, "failed to create acme-tls/1 cert") + + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + hs := tlsalpn01SrvWithCert(t, acmeCert, tls.VersionTLS12) + + va, _ := setup(hs, 0, "", nil, nil) + + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, err, "validation should have failed") + // In go >= 1.19, the TLS client library detects that the certificate has + // a duplicate extension and terminates the connection itself. + prob := detailedError(err) + test.AssertContains(t, prob.Error(), "Error getting validation data") +} + +func TestTLSALPN01ExtraAcmeExtensions(t *testing.T) { + // Create a cert with multiple SAN extensions + template := &x509.Certificate{ + SerialNumber: big.NewInt(1337), + Subject: pkix.Name{ + Organization: []string{"tests"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: []string{"expected"}, + } + + shasum := sha256.Sum256([]byte(expectedKeyAuthorization)) + encHash, err := asn1.Marshal(shasum[:]) + test.AssertNotError(t, err, "failed to create key authorization") + + acmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: encHash, + } + + extraAcmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: encHash, + } + + template.ExtraExtensions = []pkix.Extension{acmeExtension, extraAcmeExtension} + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &TheKey.PublicKey, &TheKey) + test.AssertNotError(t, err, "failed to create acme-tls/1 cert") + + acmeCert := &tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: &TheKey, + } + + hs := tlsalpn01SrvWithCert(t, acmeCert, tls.VersionTLS12) + + va, _ := setup(hs, 0, "", nil, nil) + + _, err = va.validateTLSALPN01(ctx, dnsi("expected"), expectedKeyAuthorization) + test.AssertError(t, err, "validation should have failed") + prob := detailedError(err) + // In go >= 1.19, the TLS client library detects that the certificate has + // a duplicate extension and terminates the connection itself. + test.AssertContains(t, prob.Error(), "Error getting validation data") +} + +func TestAcceptableExtensions(t *testing.T) { + requireAcmeAndSAN := []asn1.ObjectIdentifier{ + IdPeAcmeIdentifier, + IdCeSubjectAltName, + } + + var err error + subjectAltName := pkix.Extension{} + subjectAltName.Id = asn1.ObjectIdentifier{2, 5, 29, 17} + subjectAltName.Critical = false + subjectAltName.Value, err = asn1.Marshal([]asn1.RawValue{ + {Tag: 2, Class: 2, Bytes: []byte(`expected`)}, + }) + test.AssertNotError(t, err, "failed to marshal SAN") + + acmeExtension := pkix.Extension{ + Id: IdPeAcmeIdentifier, + Critical: true, + Value: []byte{}, + } + + weirdExt := pkix.Extension{ + Id: asn1.ObjectIdentifier{99, 99, 99, 99}, + Critical: false, + Value: []byte(`because I'm tacky`), + } + + doubleAcmeExts := []pkix.Extension{subjectAltName, acmeExtension, acmeExtension} + err = checkAcceptableExtensions(doubleAcmeExts, requireAcmeAndSAN) + test.AssertError(t, err, "Two ACME extensions isn't okay") + + doubleSANExts := []pkix.Extension{subjectAltName, subjectAltName, acmeExtension} + err = checkAcceptableExtensions(doubleSANExts, requireAcmeAndSAN) + test.AssertError(t, err, "Two SAN extensions isn't okay") + + onlyUnexpectedExt := []pkix.Extension{weirdExt} + err = checkAcceptableExtensions(onlyUnexpectedExt, requireAcmeAndSAN) + test.AssertError(t, err, "Missing required extensions") + test.AssertContains(t, err.Error(), "Required extension OID 1.3.6.1.5.5.7.1.31 is not present") + + okayExts := []pkix.Extension{acmeExtension, subjectAltName} + err = checkAcceptableExtensions(okayExts, requireAcmeAndSAN) + test.AssertNotError(t, err, "Correct type and number of extensions") + + okayWithUnexpectedExt := []pkix.Extension{weirdExt, acmeExtension, subjectAltName} + err = checkAcceptableExtensions(okayWithUnexpectedExt, requireAcmeAndSAN) + test.AssertNotError(t, err, "Correct type and number of extensions") +} diff --git a/third-party/github.com/letsencrypt/boulder/va/utf8filter.go b/third-party/github.com/letsencrypt/boulder/va/utf8filter.go new file mode 100644 index 000000000..3d0f1ec8a --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/utf8filter.go @@ -0,0 +1,38 @@ +package va + +import ( + "strings" + "unicode/utf8" + + "github.com/letsencrypt/boulder/probs" +) + +// replaceInvalidUTF8 replaces all invalid UTF-8 encodings with +// Unicode REPLACEMENT CHARACTER. +func replaceInvalidUTF8(input []byte) string { + if utf8.Valid(input) { + return string(input) + } + + var b strings.Builder + + // Ranging over a string in Go produces runes. When the range keyword + // encounters an invalid UTF-8 encoding, it returns REPLACEMENT CHARACTER. + for _, v := range string(input) { + b.WriteRune(v) + } + return b.String() +} + +// Call replaceInvalidUTF8 on all string fields of a ProblemDetails +// and return the result. +func filterProblemDetails(prob *probs.ProblemDetails) *probs.ProblemDetails { + if prob == nil { + return nil + } + return &probs.ProblemDetails{ + Type: probs.ProblemType(replaceInvalidUTF8([]byte(prob.Type))), + Detail: replaceInvalidUTF8([]byte(prob.Detail)), + HTTPStatus: prob.HTTPStatus, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/va/utf8filter_test.go b/third-party/github.com/letsencrypt/boulder/va/utf8filter_test.go new file mode 100644 index 000000000..5c8cfff0e --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/utf8filter_test.go @@ -0,0 +1,33 @@ +package va + +import ( + "testing" + + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +func TestReplaceInvalidUTF8(t *testing.T) { + input := "f\xffoo" + expected := "f\ufffdoo" + result := replaceInvalidUTF8([]byte(input)) + if result != expected { + t.Errorf("replaceInvalidUTF8(%q): got %q, expected %q", input, result, expected) + } +} + +func TestFilterProblemDetails(t *testing.T) { + test.Assert(t, filterProblemDetails(nil) == nil, "nil should filter to nil") + result := filterProblemDetails(&probs.ProblemDetails{ + Type: probs.ProblemType([]byte{0xff, 0xfe, 0xfd}), + Detail: "seems okay so far whoah no \xFF\xFE\xFD", + HTTPStatus: 999, + }) + + expected := &probs.ProblemDetails{ + Type: "���", + Detail: "seems okay so far whoah no ���", + HTTPStatus: 999, + } + test.AssertDeepEquals(t, result, expected) +} diff --git a/third-party/github.com/letsencrypt/boulder/va/va.go b/third-party/github.com/letsencrypt/boulder/va/va.go new file mode 100644 index 000000000..d43346bbc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/va.go @@ -0,0 +1,745 @@ +package va + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net" + "net/url" + "os" + "regexp" + "strings" + "syscall" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/canceled" + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/probs" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +var ( + // badTLSHeader contains the string 'HTTP /' which is returned when + // we try to talk TLS to a server that only talks HTTP + badTLSHeader = []byte{0x48, 0x54, 0x54, 0x50, 0x2f} + // h2SettingsFrameErrRegex is a regex against a net/http error indicating + // a malformed HTTP response that matches the initial SETTINGS frame of an + // HTTP/2 connection. This happens when a server configures HTTP/2 on port + // :80, failing HTTP-01 challenges. + // + // The regex first matches the error string prefix and then matches the raw + // bytes of an arbitrarily sized HTTP/2 SETTINGS frame: + // 0x00 0x00 0x?? 0x04 0x00 0x00 0x00 0x00 + // + // The third byte is variable and indicates the frame size. Typically + // this will be 0x12. + // The 0x04 in the fourth byte indicates that the frame is SETTINGS type. + // + // See: + // * https://tools.ietf.org/html/rfc7540#section-4.1 + // * https://tools.ietf.org/html/rfc7540#section-6.5 + // + // NOTE(@cpu): Using a regex is a hack but unfortunately for this case + // http.Client.Do() will return a url.Error err that wraps + // a errors.ErrorString instance. There isn't much else to do with one of + // those except match the encoded byte string with a regex. :-X + // + // NOTE(@cpu): The first component of this regex is optional to avoid an + // integration test flake. In some (fairly rare) conditions the malformed + // response error will be returned simply as a http.badStringError without + // the broken transport prefix. Most of the time the error is returned with + // a transport connection error prefix. + h2SettingsFrameErrRegex = regexp.MustCompile(`(?:net\/http\: HTTP\/1\.x transport connection broken: )?malformed HTTP response \"\\x00\\x00\\x[a-f0-9]{2}\\x04\\x00\\x00\\x00\\x00\\x00.*"`) +) + +// RemoteClients wraps the vapb.VAClient and vapb.CAAClient interfaces to aid in +// mocking remote VAs for testing. +type RemoteClients struct { + vapb.VAClient + vapb.CAAClient +} + +// RemoteVA embeds RemoteClients and adds a field containing the address of the +// remote gRPC server since the underlying gRPC client doesn't provide a way to +// extract this metadata which is useful for debugging gRPC connection issues. +type RemoteVA struct { + RemoteClients + Address string +} + +type vaMetrics struct { + validationTime *prometheus.HistogramVec + localValidationTime *prometheus.HistogramVec + remoteValidationTime *prometheus.HistogramVec + remoteValidationFailures prometheus.Counter + caaCheckTime *prometheus.HistogramVec + localCAACheckTime *prometheus.HistogramVec + remoteCAACheckTime *prometheus.HistogramVec + remoteCAACheckFailures prometheus.Counter + prospectiveRemoteCAACheckFailures prometheus.Counter + tlsALPNOIDCounter *prometheus.CounterVec + http01Fallbacks prometheus.Counter + http01Redirects prometheus.Counter + caaCounter *prometheus.CounterVec + ipv4FallbackCounter prometheus.Counter +} + +func initMetrics(stats prometheus.Registerer) *vaMetrics { + validationTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "validation_time", + Help: "Total time taken to validate a challenge and aggregate results", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"type", "result", "problem_type"}) + stats.MustRegister(validationTime) + localValidationTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "local_validation_time", + Help: "Time taken to locally validate a challenge", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"type", "result"}) + stats.MustRegister(localValidationTime) + remoteValidationTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "remote_validation_time", + Help: "Time taken to remotely validate a challenge", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"type"}) + stats.MustRegister(remoteValidationTime) + remoteValidationFailures := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "remote_validation_failures", + Help: "Number of validations failed due to remote VAs returning failure when consensus is enforced", + }) + stats.MustRegister(remoteValidationFailures) + caaCheckTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "caa_check_time", + Help: "Total time taken to check CAA records and aggregate results", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"result"}) + stats.MustRegister(caaCheckTime) + localCAACheckTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "caa_check_time_local", + Help: "Time taken to locally check CAA records", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"result"}) + stats.MustRegister(localCAACheckTime) + remoteCAACheckTime := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "caa_check_time_remote", + Help: "Time taken to remotely check CAA records", + Buckets: metrics.InternetFacingBuckets, + }, + []string{"result"}) + stats.MustRegister(remoteCAACheckTime) + remoteCAACheckFailures := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "remote_caa_check_failures", + Help: "Number of CAA checks failed due to remote VAs returning failure when consensus is enforced", + }) + stats.MustRegister(remoteCAACheckFailures) + prospectiveRemoteCAACheckFailures := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "prospective_remote_caa_check_failures", + Help: "Number of CAA rechecks that would have failed due to remote VAs returning failure if consesus were enforced", + }) + stats.MustRegister(prospectiveRemoteCAACheckFailures) + tlsALPNOIDCounter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "tls_alpn_oid_usage", + Help: "Number of TLS ALPN validations using either of the two OIDs", + }, + []string{"oid"}, + ) + stats.MustRegister(tlsALPNOIDCounter) + http01Fallbacks := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "http01_fallbacks", + Help: "Number of IPv6 to IPv4 HTTP-01 fallback requests made", + }) + stats.MustRegister(http01Fallbacks) + http01Redirects := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "http01_redirects", + Help: "Number of HTTP-01 redirects followed", + }) + stats.MustRegister(http01Redirects) + caaCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "caa_sets_processed", + Help: "A counter of CAA sets processed labelled by result", + }, []string{"result"}) + stats.MustRegister(caaCounter) + ipv4FallbackCounter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "tls_alpn_ipv4_fallback", + Help: "A counter of IPv4 fallbacks during TLS ALPN validation", + }) + stats.MustRegister(ipv4FallbackCounter) + + return &vaMetrics{ + validationTime: validationTime, + remoteValidationTime: remoteValidationTime, + localValidationTime: localValidationTime, + remoteValidationFailures: remoteValidationFailures, + caaCheckTime: caaCheckTime, + localCAACheckTime: localCAACheckTime, + remoteCAACheckTime: remoteCAACheckTime, + remoteCAACheckFailures: remoteCAACheckFailures, + prospectiveRemoteCAACheckFailures: prospectiveRemoteCAACheckFailures, + tlsALPNOIDCounter: tlsALPNOIDCounter, + http01Fallbacks: http01Fallbacks, + http01Redirects: http01Redirects, + caaCounter: caaCounter, + ipv4FallbackCounter: ipv4FallbackCounter, + } +} + +// PortConfig specifies what ports the VA should call to on the remote +// host when performing its checks. +type portConfig struct { + HTTPPort int + HTTPSPort int + TLSPort int +} + +// newDefaultPortConfig is a constructor which returns a portConfig with default +// settings. +// +// CABF BRs section 1.6.1: Authorized Ports: One of the following ports: 80 +// (http), 443 (https), 25 (smtp), 22 (ssh). +// +// RFC 8555 section 8.3: Dereference the URL using an HTTP GET request. This +// request MUST be sent to TCP port 80 on the HTTP server. +// +// RFC 8737 section 3: The ACME server initiates a TLS connection to the chosen +// IP address. This connection MUST use TCP port 443. +func newDefaultPortConfig() *portConfig { + return &portConfig{ + HTTPPort: 80, + HTTPSPort: 443, + TLSPort: 443, + } +} + +// ValidationAuthorityImpl represents a VA +type ValidationAuthorityImpl struct { + vapb.UnsafeVAServer + vapb.UnsafeCAAServer + log blog.Logger + dnsClient bdns.Client + issuerDomain string + httpPort int + httpsPort int + tlsPort int + userAgent string + clk clock.Clock + remoteVAs []RemoteVA + maxRemoteFailures int + accountURIPrefixes []string + singleDialTimeout time.Duration + + metrics *vaMetrics +} + +var _ vapb.VAServer = (*ValidationAuthorityImpl)(nil) +var _ vapb.CAAServer = (*ValidationAuthorityImpl)(nil) + +// NewValidationAuthorityImpl constructs a new VA +func NewValidationAuthorityImpl( + resolver bdns.Client, + remoteVAs []RemoteVA, + maxRemoteFailures int, + userAgent string, + issuerDomain string, + stats prometheus.Registerer, + clk clock.Clock, + logger blog.Logger, + accountURIPrefixes []string, +) (*ValidationAuthorityImpl, error) { + + if len(accountURIPrefixes) == 0 { + return nil, errors.New("no account URI prefixes configured") + } + + pc := newDefaultPortConfig() + + va := &ValidationAuthorityImpl{ + log: logger, + dnsClient: resolver, + issuerDomain: issuerDomain, + httpPort: pc.HTTPPort, + httpsPort: pc.HTTPSPort, + tlsPort: pc.TLSPort, + userAgent: userAgent, + clk: clk, + metrics: initMetrics(stats), + remoteVAs: remoteVAs, + maxRemoteFailures: maxRemoteFailures, + accountURIPrefixes: accountURIPrefixes, + // singleDialTimeout specifies how long an individual `DialContext` operation may take + // before timing out. This timeout ignores the base RPC timeout and is strictly + // used for the DialContext operations that take place during an + // HTTP-01 challenge validation. + singleDialTimeout: 10 * time.Second, + } + + return va, nil +} + +// Used for audit logging +type verificationRequestEvent struct { + ID string `json:",omitempty"` + Requester int64 `json:",omitempty"` + Hostname string `json:",omitempty"` + Challenge core.Challenge `json:",omitempty"` + ValidationLatency float64 + UsedRSAKEX bool `json:",omitempty"` + Error string `json:",omitempty"` + InternalError string `json:",omitempty"` +} + +// ipError is an error type used to pass though the IP address of the remote +// host when an error occurs during HTTP-01 and TLS-ALPN domain validation. +type ipError struct { + ip net.IP + err error +} + +// newIPError wraps an error and the IP of the remote host in an ipError so we +// can display the IP in the problem details returned to the client. +func newIPError(ip net.IP, err error) error { + return ipError{ip: ip, err: err} +} + +// Unwrap returns the underlying error. +func (i ipError) Unwrap() error { + return i.err +} + +// Error returns a string representation of the error. +func (i ipError) Error() string { + return fmt.Sprintf("%s: %s", i.ip, i.err) +} + +// detailedError returns a ProblemDetails corresponding to an error +// that occurred during HTTP-01 or TLS-ALPN domain validation. Specifically it +// tries to unwrap known Go error types and present something a little more +// meaningful. It additionally handles `berrors.ConnectionFailure` errors by +// passing through the detailed message. +func detailedError(err error) *probs.ProblemDetails { + var ipErr ipError + if errors.As(err, &ipErr) { + detailedErr := detailedError(ipErr.err) + if ipErr.ip == nil { + // This should never happen. + return detailedErr + } + // Prefix the error message with the IP address of the remote host. + detailedErr.Detail = fmt.Sprintf("%s: %s", ipErr.ip, detailedErr.Detail) + return detailedErr + } + // net/http wraps net.OpError in a url.Error. Unwrap them. + var urlErr *url.Error + if errors.As(err, &urlErr) { + prob := detailedError(urlErr.Err) + prob.Detail = fmt.Sprintf("Fetching %s: %s", urlErr.URL, prob.Detail) + return prob + } + + var tlsErr tls.RecordHeaderError + if errors.As(err, &tlsErr) && bytes.Equal(tlsErr.RecordHeader[:], badTLSHeader) { + return probs.Malformed("Server only speaks HTTP, not TLS") + } + + var netOpErr *net.OpError + if errors.As(err, &netOpErr) { + if fmt.Sprintf("%T", netOpErr.Err) == "tls.alert" { + // All the tls.alert error strings are reasonable to hand back to a + // user. Confirmed against Go 1.8. + return probs.TLS(netOpErr.Error()) + } else if netOpErr.Timeout() && netOpErr.Op == "dial" { + return probs.Connection("Timeout during connect (likely firewall problem)") + } else if netOpErr.Timeout() { + return probs.Connection(fmt.Sprintf("Timeout during %s (your server may be slow or overloaded)", netOpErr.Op)) + } + } + var syscallErr *os.SyscallError + if errors.As(err, &syscallErr) { + switch syscallErr.Err { + case syscall.ECONNREFUSED: + return probs.Connection("Connection refused") + case syscall.ENETUNREACH: + return probs.Connection("Network unreachable") + case syscall.ECONNRESET: + return probs.Connection("Connection reset by peer") + } + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return probs.Connection("Timeout after connect (your server may be slow or overloaded)") + } + if errors.Is(err, berrors.ConnectionFailure) { + return probs.Connection(err.Error()) + } + if errors.Is(err, berrors.Unauthorized) { + return probs.Unauthorized(err.Error()) + } + if errors.Is(err, berrors.DNS) { + return probs.DNS(err.Error()) + } + if errors.Is(err, berrors.Malformed) { + return probs.Malformed(err.Error()) + } + if errors.Is(err, berrors.CAA) { + return probs.CAA(err.Error()) + } + + if h2SettingsFrameErrRegex.MatchString(err.Error()) { + return probs.Connection("Server is speaking HTTP/2 over HTTP") + } + return probs.Connection("Error getting validation data") +} + +// validateChallenge simply passes through to the appropriate validation method +// depending on the challenge type. +func (va *ValidationAuthorityImpl) validateChallenge( + ctx context.Context, + ident identifier.ACMEIdentifier, + kind core.AcmeChallenge, + token string, + keyAuthorization string, +) ([]core.ValidationRecord, error) { + // Strip a (potential) leading wildcard token from the identifier. + ident.Value = strings.TrimPrefix(ident.Value, "*.") + + switch kind { + case core.ChallengeTypeHTTP01: + return va.validateHTTP01(ctx, ident, token, keyAuthorization) + case core.ChallengeTypeDNS01: + return va.validateDNS01(ctx, ident, keyAuthorization) + case core.ChallengeTypeTLSALPN01: + return va.validateTLSALPN01(ctx, ident, keyAuthorization) + } + return nil, berrors.MalformedError("invalid challenge type %s", kind) +} + +// performRemoteValidation coordinates the whole process of kicking off and +// collecting results from calls to remote VAs' PerformValidation function. It +// returns a problem if too many remote perspectives failed to corroborate +// domain control, or nil if enough succeeded to surpass our corroboration +// threshold. +func (va *ValidationAuthorityImpl) performRemoteValidation( + ctx context.Context, + req *vapb.PerformValidationRequest, +) *probs.ProblemDetails { + if len(va.remoteVAs) == 0 { + return nil + } + + start := va.clk.Now() + defer func() { + va.metrics.remoteValidationTime.With(prometheus.Labels{ + "type": req.Challenge.Type, + }).Observe(va.clk.Since(start).Seconds()) + }() + + type rvaResult struct { + hostname string + response *vapb.ValidationResult + err error + } + + results := make(chan *rvaResult) + + for _, i := range rand.Perm(len(va.remoteVAs)) { + remoteVA := va.remoteVAs[i] + go func(rva RemoteVA, out chan<- *rvaResult) { + res, err := rva.PerformValidation(ctx, req) + out <- &rvaResult{ + hostname: rva.Address, + response: res, + err: err, + } + }(remoteVA, results) + } + + required := len(va.remoteVAs) - va.maxRemoteFailures + good := 0 + bad := 0 + var firstProb *probs.ProblemDetails + + for res := range results { + var currProb *probs.ProblemDetails + + if res.err != nil { + bad++ + + if canceled.Is(res.err) { + currProb = probs.ServerInternal("Remote PerformValidation RPC canceled") + } else { + va.log.Errf("Remote VA %q.PerformValidation failed: %s", res.hostname, res.err) + currProb = probs.ServerInternal("Remote PerformValidation RPC failed") + } + } else if res.response.Problems != nil { + bad++ + + var err error + currProb, err = bgrpc.PBToProblemDetails(res.response.Problems) + if err != nil { + va.log.Errf("Remote VA %q.PerformValidation returned malformed problem: %s", res.hostname, err) + currProb = probs.ServerInternal("Remote PerformValidation RPC returned malformed result") + } + } else { + good++ + } + + if firstProb == nil && currProb != nil { + firstProb = currProb + } + + // Return as soon as we have enough successes or failures for a definitive result. + if good >= required { + return nil + } + if bad > va.maxRemoteFailures { + va.metrics.remoteValidationFailures.Inc() + firstProb.Detail = fmt.Sprintf("During secondary validation: %s", firstProb.Detail) + return firstProb + } + + // If we somehow haven't returned early, we need to break the loop once all + // of the VAs have returned a result. + if good+bad >= len(va.remoteVAs) { + break + } + } + + // This condition should not occur - it indicates the good/bad counts neither + // met the required threshold nor the maxRemoteFailures threshold. + return probs.ServerInternal("Too few remote PerformValidation RPC results") +} + +// logRemoteResults is called by `processRemoteCAAResults` when the +// `MultiCAAFullResults` feature flag is enabled. It produces a JSON log line +// that contains the results each remote VA returned. +func (va *ValidationAuthorityImpl) logRemoteResults( + domain string, + acctID int64, + challengeType string, + remoteResults []*remoteVAResult) { + + var successes, failures []*remoteVAResult + + for _, result := range remoteResults { + if result.Problem != nil { + failures = append(failures, result) + } else { + successes = append(successes, result) + } + } + if len(failures) == 0 { + // There's no point logging a differential line if everything succeeded. + return + } + + logOb := struct { + Domain string + AccountID int64 + ChallengeType string + RemoteSuccesses int + RemoteFailures []*remoteVAResult + }{ + Domain: domain, + AccountID: acctID, + ChallengeType: challengeType, + RemoteSuccesses: len(successes), + RemoteFailures: failures, + } + + logJSON, err := json.Marshal(logOb) + if err != nil { + // log a warning - a marshaling failure isn't expected given the data + // isn't critical enough to break validation by returning an error the + // caller. + va.log.Warningf("Could not marshal log object in "+ + "logRemoteDifferential: %s", err) + return + } + + va.log.Infof("remoteVADifferentials JSON=%s", string(logJSON)) +} + +// remoteVAResult is a struct that combines a problem details instance (that may +// be nil) with the remote VA hostname that produced it. +type remoteVAResult struct { + VAHostname string + Problem *probs.ProblemDetails +} + +// performLocalValidation performs primary domain control validation and then +// checks CAA. If either step fails, it immediately returns a bare error so +// that our audit logging can include the underlying error. +func (va *ValidationAuthorityImpl) performLocalValidation( + ctx context.Context, + ident identifier.ACMEIdentifier, + regid int64, + kind core.AcmeChallenge, + token string, + keyAuthorization string, +) ([]core.ValidationRecord, error) { + // Do primary domain control validation. Any kind of error returned by this + // counts as a validation error, and will be converted into an appropriate + // probs.ProblemDetails by the calling function. + records, err := va.validateChallenge(ctx, ident, kind, token, keyAuthorization) + if err != nil { + return records, err + } + + // Do primary CAA checks. Any kind of error returned by this counts as not + // receiving permission to issue, and will be converted into an appropriate + // probs.ProblemDetails by the calling function. + err = va.checkCAA(ctx, ident, &caaParams{ + accountURIID: regid, + validationMethod: kind, + }) + if err != nil { + return records, err + } + + return records, nil +} + +// PerformValidation validates the challenge for the domain in the request. +// The returned result will always contain a list of validation records, even +// when it also contains a problem. +func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *vapb.PerformValidationRequest) (*vapb.ValidationResult, error) { + // TODO(#7514): Add req.ExpectedKeyAuthorization to this check + if core.IsAnyNilOrZero(req, req.Domain, req.Challenge, req.Authz) { + return nil, berrors.InternalServerError("Incomplete validation request") + } + + challenge, err := bgrpc.PBToChallenge(req.Challenge) + if err != nil { + return nil, errors.New("challenge failed to deserialize") + } + + err = challenge.CheckPending() + if err != nil { + return nil, berrors.MalformedError("challenge failed consistency check: %s", err) + } + + // TODO(#7514): Remove this fallback and belt-and-suspenders check. + keyAuthorization := req.ExpectedKeyAuthorization + if len(keyAuthorization) == 0 { + keyAuthorization = req.Challenge.KeyAuthorization + } + if len(keyAuthorization) == 0 { + return nil, errors.New("no expected keyAuthorization provided") + } + + // Set up variables and a deferred closure to report validation latency + // metrics and log validation errors. Below here, do not use := to redeclare + // `prob`, or this will fail. + var prob *probs.ProblemDetails + var localLatency time.Duration + vStart := va.clk.Now() + logEvent := verificationRequestEvent{ + ID: req.Authz.Id, + Requester: req.Authz.RegID, + Hostname: req.Domain, + Challenge: challenge, + } + defer func() { + problemType := "" + if prob != nil { + problemType = string(prob.Type) + logEvent.Error = prob.Error() + logEvent.Challenge.Error = prob + logEvent.Challenge.Status = core.StatusInvalid + } else { + logEvent.Challenge.Status = core.StatusValid + } + + va.metrics.localValidationTime.With(prometheus.Labels{ + "type": string(logEvent.Challenge.Type), + "result": string(logEvent.Challenge.Status), + }).Observe(localLatency.Seconds()) + + va.metrics.validationTime.With(prometheus.Labels{ + "type": string(logEvent.Challenge.Type), + "result": string(logEvent.Challenge.Status), + "problem_type": problemType, + }).Observe(time.Since(vStart).Seconds()) + + logEvent.ValidationLatency = time.Since(vStart).Round(time.Millisecond).Seconds() + va.log.AuditObject("Validation result", logEvent) + }() + + // Do local validation. Note that we process the result in a couple ways + // *before* checking whether it returned an error. These few checks are + // carefully written to ensure that they work whether the local validation + // was successful or not, and cannot themselves fail. + records, err := va.performLocalValidation( + ctx, + identifier.DNSIdentifier(req.Domain), + req.Authz.RegID, + challenge.Type, + challenge.Token, + keyAuthorization) + localLatency = time.Since(vStart) + + // Check for malformed ValidationRecords + logEvent.Challenge.ValidationRecord = records + if err == nil && !logEvent.Challenge.RecordsSane() { + err = errors.New("records from local validation failed sanity check") + } + + // Copy the "UsedRSAKEX" value from the last validationRecord into the log + // event. Only the last record should have this bool set, because we only + // record it if/when validation is finally successful, but we use the loop + // just in case that assumption changes. + // TODO(#7321): Remove this when we have collected enough data. + for _, record := range records { + logEvent.UsedRSAKEX = record.UsedRSAKEX || logEvent.UsedRSAKEX + } + + if err != nil { + logEvent.InternalError = err.Error() + prob = detailedError(err) + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob)) + } + + // Do remote validation. We do this after local validation is complete to + // avoid wasting work when validation will fail anyway. This only returns a + // singular problem, because the remote VAs have already audit-logged their + // own validation records, and it's not helpful to present multiple large + // errors to the end user. + prob = va.performRemoteValidation(ctx, req) + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob)) +} + +// usedRSAKEX returns true if the given cipher suite involves the use of an +// RSA key exchange mechanism. +// TODO(#7321): Remove this when we have collected enough data. +func usedRSAKEX(cs uint16) bool { + return strings.HasPrefix(tls.CipherSuiteName(cs), "TLS_RSA_") +} diff --git a/third-party/github.com/letsencrypt/boulder/va/va_test.go b/third-party/github.com/letsencrypt/boulder/va/va_test.go new file mode 100644 index 000000000..a7ca0ee06 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/va/va_test.go @@ -0,0 +1,698 @@ +package va + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + + "github.com/letsencrypt/boulder/bdns" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/identifier" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" + vapb "github.com/letsencrypt/boulder/va/proto" +) + +var expectedToken = "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" +var expectedThumbprint = "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI" +var expectedKeyAuthorization = ka(expectedToken) + +func ka(token string) string { + return token + "." + expectedThumbprint +} + +func bigIntFromB64(b64 string) *big.Int { + bytes, _ := base64.URLEncoding.DecodeString(b64) + x := big.NewInt(0) + x.SetBytes(bytes) + return x +} + +func intFromB64(b64 string) int { + return int(bigIntFromB64(b64).Int64()) +} + +var n = bigIntFromB64("n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw==") +var e = intFromB64("AQAB") +var d = bigIntFromB64("bWUC9B-EFRIo8kpGfh0ZuyGPvMNKvYWNtB_ikiH9k20eT-O1q_I78eiZkpXxXQ0UTEs2LsNRS-8uJbvQ-A1irkwMSMkK1J3XTGgdrhCku9gRldY7sNA_AKZGh-Q661_42rINLRCe8W-nZ34ui_qOfkLnK9QWDDqpaIsA-bMwWWSDFu2MUBYwkHTMEzLYGqOe04noqeq1hExBTHBOBdkMXiuFhUq1BU6l-DqEiWxqg82sXt2h-LMnT3046AOYJoRioz75tSUQfGCshWTBnP5uDjd18kKhyv07lhfSJdrPdM5Plyl21hsFf4L_mHCuoFau7gdsPfHPxxjVOcOpBrQzwQ==") +var p = bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") +var q = bigIntFromB64("uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc=") + +var TheKey = rsa.PrivateKey{ + PublicKey: rsa.PublicKey{N: n, E: e}, + D: d, + Primes: []*big.Int{p, q}, +} + +var accountKey = &jose.JSONWebKey{Key: TheKey.Public()} + +// Return an ACME DNS identifier for the given hostname +func dnsi(hostname string) identifier.ACMEIdentifier { + return identifier.DNSIdentifier(hostname) +} + +var ctx context.Context + +func TestMain(m *testing.M) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute) + ret := m.Run() + cancel() + os.Exit(ret) +} + +var accountURIPrefixes = []string{"http://boulder.service.consul:4000/acme/reg/"} + +func createValidationRequest(domain string, challengeType core.AcmeChallenge) *vapb.PerformValidationRequest { + return &vapb.PerformValidationRequest{ + Domain: domain, + Challenge: &corepb.Challenge{ + Type: string(challengeType), + Status: string(core.StatusPending), + Token: expectedToken, + Validationrecords: nil, + KeyAuthorization: expectedKeyAuthorization, + }, + Authz: &vapb.AuthzMeta{ + Id: "", + RegID: 1, + }, + } +} + +// setup returns an in-memory VA and a mock logger. The default resolver client +// is MockClient{}, but can be overridden. +func setup(srv *httptest.Server, maxRemoteFailures int, userAgent string, remoteVAs []RemoteVA, mockDNSClientOverride bdns.Client) (*ValidationAuthorityImpl, *blog.Mock) { + features.Reset() + fc := clock.NewFake() + + logger := blog.NewMock() + + if userAgent == "" { + userAgent = "user agent 1.0" + } + + va, err := NewValidationAuthorityImpl( + &bdns.MockClient{Log: logger}, + nil, + maxRemoteFailures, + userAgent, + "letsencrypt.org", + metrics.NoopRegisterer, + fc, + logger, + accountURIPrefixes, + ) + + if mockDNSClientOverride != nil { + va.dnsClient = mockDNSClientOverride + } + + // Adjusting industry regulated ACME challenge port settings is fine during + // testing + if srv != nil { + port := getPort(srv) + va.httpPort = port + va.tlsPort = port + } + + if err != nil { + panic(fmt.Sprintf("Failed to create validation authority: %v", err)) + } + if remoteVAs != nil { + va.remoteVAs = remoteVAs + } + return va, logger +} + +func setupRemote(srv *httptest.Server, userAgent string, mockDNSClientOverride bdns.Client) RemoteClients { + rva, _ := setup(srv, 0, userAgent, nil, mockDNSClientOverride) + + return RemoteClients{VAClient: &inMemVA{*rva}, CAAClient: &inMemVA{*rva}} +} + +type multiSrv struct { + *httptest.Server + + mu sync.Mutex + allowedUAs map[string]bool +} + +func (s *multiSrv) setAllowedUAs(allowedUAs map[string]bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowedUAs = allowedUAs +} + +const slowRemoteSleepMillis = 1000 + +func httpMultiSrv(t *testing.T, token string, allowedUAs map[string]bool) *multiSrv { + t.Helper() + m := http.NewServeMux() + + server := httptest.NewUnstartedServer(m) + ms := &multiSrv{server, sync.Mutex{}, allowedUAs} + + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.UserAgent() == "slow remote" { + time.Sleep(slowRemoteSleepMillis) + } + ms.mu.Lock() + defer ms.mu.Unlock() + if ms.allowedUAs[r.UserAgent()] { + ch := core.Challenge{Token: token} + keyAuthz, _ := ch.ExpectedKeyAuthorization(accountKey) + fmt.Fprint(w, keyAuthz, "\n\r \t") + } else { + fmt.Fprint(w, "???") + } + }) + + ms.Start() + return ms +} + +// cancelledVA is a mock that always returns context.Canceled for +// PerformValidation calls +type cancelledVA struct{} + +func (v cancelledVA) PerformValidation(_ context.Context, _ *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return nil, context.Canceled +} + +func (v cancelledVA) IsCAAValid(_ context.Context, _ *vapb.IsCAAValidRequest, _ ...grpc.CallOption) (*vapb.IsCAAValidResponse, error) { + return nil, context.Canceled +} + +// brokenRemoteVA is a mock for the VAClient and CAAClient interfaces that always return +// errors. +type brokenRemoteVA struct{} + +// errBrokenRemoteVA is the error returned by a brokenRemoteVA's +// PerformValidation and IsSafeDomain functions. +var errBrokenRemoteVA = errors.New("brokenRemoteVA is broken") + +// PerformValidation returns errBrokenRemoteVA unconditionally +func (b brokenRemoteVA) PerformValidation(_ context.Context, _ *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return nil, errBrokenRemoteVA +} + +func (b brokenRemoteVA) IsCAAValid(_ context.Context, _ *vapb.IsCAAValidRequest, _ ...grpc.CallOption) (*vapb.IsCAAValidResponse, error) { + return nil, errBrokenRemoteVA +} + +// inMemVA is a wrapper which fulfills the VAClient and CAAClient +// interfaces, but then forwards requests directly to its inner +// ValidationAuthorityImpl rather than over the network. This lets a local +// in-memory mock VA act like a remote VA. +type inMemVA struct { + rva ValidationAuthorityImpl +} + +func (inmem inMemVA) PerformValidation(ctx context.Context, req *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return inmem.rva.PerformValidation(ctx, req) +} + +func (inmem inMemVA) IsCAAValid(ctx context.Context, req *vapb.IsCAAValidRequest, _ ...grpc.CallOption) (*vapb.IsCAAValidResponse, error) { + return inmem.rva.IsCAAValid(ctx, req) +} + +func TestValidateMalformedChallenge(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + _, err := va.validateChallenge(ctx, dnsi("example.com"), "fake-type-01", expectedToken, expectedKeyAuthorization) + + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.MalformedProblem) +} + +func TestPerformValidationInvalid(t *testing.T) { + va, _ := setup(nil, 0, "", nil, nil) + + req := createValidationRequest("foo.com", core.ChallengeTypeDNS01) + res, _ := va.PerformValidation(context.Background(), req) + test.Assert(t, res.Problems != nil, "validation succeeded") + + test.AssertMetricWithLabelsEquals(t, va.metrics.validationTime, prometheus.Labels{ + "type": "dns-01", + "result": "invalid", + "problem_type": "unauthorized", + }, 1) +} + +func TestInternalErrorLogged(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + req := createValidationRequest("nonexistent.com", core.ChallengeTypeHTTP01) + _, err := va.PerformValidation(ctx, req) + test.AssertNotError(t, err, "failed validation should not be an error") + matchingLogs := mockLog.GetAllMatching( + `Validation result JSON=.*"InternalError":"127.0.0.1: Get.*nonexistent.com/\.well-known.*: context deadline exceeded`) + test.AssertEquals(t, len(matchingLogs), 1) +} + +func TestPerformValidationValid(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + // create a challenge with well known token + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + res, _ := va.PerformValidation(context.Background(), req) + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) + + test.AssertMetricWithLabelsEquals(t, va.metrics.validationTime, prometheus.Labels{ + "type": "dns-01", + "result": "valid", + "problem_type": "", + }, 1) + resultLog := mockLog.GetAllMatching(`Validation result`) + if len(resultLog) != 1 { + t.Fatalf("Wrong number of matching lines for 'Validation result'") + } + if !strings.Contains(resultLog[0], `"Hostname":"good-dns01.com"`) { + t.Error("PerformValidation didn't log validation hostname.") + } +} + +// TestPerformValidationWildcard tests that the VA properly strips the `*.` +// prefix from a wildcard name provided to the PerformValidation function. +func TestPerformValidationWildcard(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + // create a challenge with well known token + req := createValidationRequest("*.good-dns01.com", core.ChallengeTypeDNS01) + // perform a validation for a wildcard name + res, _ := va.PerformValidation(context.Background(), req) + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) + + test.AssertMetricWithLabelsEquals(t, va.metrics.validationTime, prometheus.Labels{ + "type": "dns-01", + "result": "valid", + "problem_type": "", + }, 1) + resultLog := mockLog.GetAllMatching(`Validation result`) + if len(resultLog) != 1 { + t.Fatalf("Wrong number of matching lines for 'Validation result'") + } + + // We expect that the top level Hostname reflect the wildcard name + if !strings.Contains(resultLog[0], `"Hostname":"*.good-dns01.com"`) { + t.Errorf("PerformValidation didn't log correct validation hostname.") + } + // We expect that the ValidationRecord contain the correct non-wildcard + // hostname that was validated + if !strings.Contains(resultLog[0], `"hostname":"good-dns01.com"`) { + t.Errorf("PerformValidation didn't log correct validation record hostname.") + } +} + +func TestDCVAndCAASequencing(t *testing.T) { + va, mockLog := setup(nil, 0, "", nil, nil) + + // When validation succeeds, CAA should be checked. + mockLog.Clear() + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + res, err := va.PerformValidation(context.Background(), req) + test.AssertNotError(t, err, "performing validation") + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) + caaLog := mockLog.GetAllMatching(`Checked CAA records for`) + test.AssertEquals(t, len(caaLog), 1) + + // When validation fails, CAA should be skipped. + mockLog.Clear() + req = createValidationRequest("bad-dns01.com", core.ChallengeTypeDNS01) + res, err = va.PerformValidation(context.Background(), req) + test.AssertNotError(t, err, "performing validation") + test.Assert(t, res.Problems != nil, "validation succeeded") + caaLog = mockLog.GetAllMatching(`Checked CAA records for`) + test.AssertEquals(t, len(caaLog), 0) +} + +func TestMultiVA(t *testing.T) { + // Create a new challenge to use for the httpSrv + req := createValidationRequest("localhost", core.ChallengeTypeHTTP01) + + const ( + remoteUA1 = "remote 1" + remoteUA2 = "remote 2" + localUA = "local 1" + ) + allowedUAs := map[string]bool{ + localUA: true, + remoteUA1: true, + remoteUA2: true, + } + + // Create an IPv4 test server + ms := httpMultiSrv(t, expectedToken, allowedUAs) + defer ms.Close() + + remoteVA1 := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2 := setupRemote(ms.Server, remoteUA2, nil) + remoteVAs := []RemoteVA{ + {remoteVA1, remoteUA1}, + {remoteVA2, remoteUA2}, + } + brokenVA := RemoteClients{ + VAClient: brokenRemoteVA{}, + CAAClient: brokenRemoteVA{}, + } + cancelledVA := RemoteClients{ + VAClient: cancelledVA{}, + CAAClient: cancelledVA{}, + } + + unauthorized := probs.Unauthorized(fmt.Sprintf( + `The key authorization file from the server did not match this challenge. Expected %q (got "???")`, + expectedKeyAuthorization)) + expectedInternalErrLine := fmt.Sprintf( + `ERR: \[AUDIT\] Remote VA "broken".PerformValidation failed: %s`, + errBrokenRemoteVA.Error()) + testCases := []struct { + Name string + RemoteVAs []RemoteVA + AllowedUAs map[string]bool + ExpectedProb *probs.ProblemDetails + ExpectedLog string + }{ + { + // With local and both remote VAs working there should be no problem. + Name: "Local and remote VAs OK", + RemoteVAs: remoteVAs, + AllowedUAs: allowedUAs, + }, + { + // If the local VA fails everything should fail + Name: "Local VA bad, remote VAs OK", + RemoteVAs: remoteVAs, + AllowedUAs: map[string]bool{remoteUA1: true, remoteUA2: true}, + ExpectedProb: unauthorized, + }, + { + // If a remote VA fails with an internal err it should fail + Name: "Local VA ok, remote VA internal err", + RemoteVAs: []RemoteVA{ + {remoteVA1, remoteUA1}, + {brokenVA, "broken"}, + }, + AllowedUAs: allowedUAs, + ExpectedProb: probs.ServerInternal("During secondary validation: Remote PerformValidation RPC failed"), + // The real failure cause should be logged + ExpectedLog: expectedInternalErrLine, + }, + { + // With only one working remote VA there should be a validation failure + Name: "Local VA and one remote VA OK", + RemoteVAs: remoteVAs, + AllowedUAs: map[string]bool{localUA: true, remoteUA2: true}, + ExpectedProb: probs.Unauthorized(fmt.Sprintf( + `During secondary validation: The key authorization file from the server did not match this challenge. Expected %q (got "???")`, + expectedKeyAuthorization)), + }, + { + // Any remote VA cancellations are a problem. + Name: "Local VA and one remote VA OK, one cancelled VA", + RemoteVAs: []RemoteVA{ + {remoteVA1, remoteUA1}, + {cancelledVA, remoteUA2}, + }, + AllowedUAs: allowedUAs, + ExpectedProb: probs.ServerInternal("During secondary validation: Remote PerformValidation RPC canceled"), + }, + { + // Any remote VA cancellations are a problem. + Name: "Local VA OK, two cancelled remote VAs", + RemoteVAs: []RemoteVA{ + {cancelledVA, remoteUA1}, + {cancelledVA, remoteUA2}, + }, + AllowedUAs: allowedUAs, + ExpectedProb: probs.ServerInternal("During secondary validation: Remote PerformValidation RPC canceled"), + }, + { + // With the local and remote VAs seeing diff problems, we expect a problem. + Name: "Local and remote VA differential, full results, enforce multi VA", + RemoteVAs: remoteVAs, + AllowedUAs: map[string]bool{localUA: true}, + ExpectedProb: probs.Unauthorized(fmt.Sprintf( + `During secondary validation: The key authorization file from the server did not match this challenge. Expected %q (got "???")`, + expectedKeyAuthorization)), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // Configure the test server with the testcase allowed UAs. + ms.setAllowedUAs(tc.AllowedUAs) + + // Configure a primary VA with testcase remote VAs. + localVA, mockLog := setup(ms.Server, 0, localUA, tc.RemoteVAs, nil) + + // Perform all validations + res, _ := localVA.PerformValidation(ctx, req) + if res.Problems == nil && tc.ExpectedProb != nil { + t.Errorf("expected prob %v, got nil", tc.ExpectedProb) + } else if res.Problems != nil && tc.ExpectedProb == nil { + t.Errorf("expected no prob, got %v", res.Problems) + } else if res.Problems != nil && tc.ExpectedProb != nil { + // That result should match expected. + test.AssertEquals(t, res.Problems.ProblemType, string(tc.ExpectedProb.Type)) + test.AssertEquals(t, res.Problems.Detail, tc.ExpectedProb.Detail) + } + + if tc.ExpectedLog != "" { + lines := mockLog.GetAllMatching(tc.ExpectedLog) + if len(lines) != 1 { + t.Fatalf("Got log %v; expected %q", mockLog.GetAll(), tc.ExpectedLog) + } + } + }) + } +} + +func TestMultiVAEarlyReturn(t *testing.T) { + const ( + remoteUA1 = "remote 1" + remoteUA2 = "slow remote" + localUA = "local 1" + ) + allowedUAs := map[string]bool{ + localUA: true, + remoteUA1: false, // forbid UA 1 to provoke early return + remoteUA2: true, + } + + ms := httpMultiSrv(t, expectedToken, allowedUAs) + defer ms.Close() + + remoteVA1 := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2 := setupRemote(ms.Server, remoteUA2, nil) + + remoteVAs := []RemoteVA{ + {remoteVA1, remoteUA1}, + {remoteVA2, remoteUA2}, + } + + // Create a local test VA with the two remote VAs + localVA, _ := setup(ms.Server, 0, localUA, remoteVAs, nil) + + // Perform all validations + start := time.Now() + req := createValidationRequest("localhost", core.ChallengeTypeHTTP01) + res, _ := localVA.PerformValidation(ctx, req) + + // It should always fail + if res.Problems == nil { + t.Error("expected prob from PerformValidation, got nil") + } + + elapsed := time.Since(start).Round(time.Millisecond).Milliseconds() + + // The slow UA should sleep for `slowRemoteSleepMillis`. But the first remote + // VA should fail quickly and the early-return code should cause the overall + // overall validation to return a prob quickly (i.e. in less than half of + // `slowRemoteSleepMillis`). + if elapsed > slowRemoteSleepMillis/2 { + t.Errorf( + "Expected an early return from PerformValidation in < %d ms, took %d ms", + slowRemoteSleepMillis/2, elapsed) + } +} + +func TestMultiVAPolicy(t *testing.T) { + const ( + remoteUA1 = "remote 1" + remoteUA2 = "remote 2" + localUA = "local 1" + ) + // Forbid both remote UAs to ensure that multi-va fails + allowedUAs := map[string]bool{ + localUA: true, + remoteUA1: false, + remoteUA2: false, + } + + ms := httpMultiSrv(t, expectedToken, allowedUAs) + defer ms.Close() + + remoteVA1 := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2 := setupRemote(ms.Server, remoteUA2, nil) + + remoteVAs := []RemoteVA{ + {remoteVA1, remoteUA1}, + {remoteVA2, remoteUA2}, + } + + // Create a local test VA with the two remote VAs + localVA, _ := setup(ms.Server, 0, localUA, remoteVAs, nil) + + // Perform validation for a domain not in the disabledDomains list + req := createValidationRequest("letsencrypt.org", core.ChallengeTypeHTTP01) + res, _ := localVA.PerformValidation(ctx, req) + // It should fail + if res.Problems == nil { + t.Error("expected prob from PerformValidation, got nil") + } +} + +func TestDetailedError(t *testing.T) { + cases := []struct { + err error + ip net.IP + expected string + }{ + { + err: ipError{ + ip: net.ParseIP("192.168.1.1"), + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{ + Syscall: "getsockopt", + Err: syscall.ECONNREFUSED, + }, + }, + }, + expected: "192.168.1.1: Connection refused", + }, + { + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{ + Syscall: "getsockopt", + Err: syscall.ECONNREFUSED, + }, + }, + expected: "Connection refused", + }, + { + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{ + Syscall: "getsockopt", + Err: syscall.ECONNRESET, + }, + }, + ip: nil, + expected: "Connection reset by peer", + }, + } + for _, tc := range cases { + actual := detailedError(tc.err).Detail + if actual != tc.expected { + t.Errorf("Wrong detail for %v. Got %q, expected %q", tc.err, actual, tc.expected) + } + } +} + +func TestLogRemoteDifferentials(t *testing.T) { + // Create some remote VAs + remoteVA1 := setupRemote(nil, "remote 1", nil) + remoteVA2 := setupRemote(nil, "remote 2", nil) + remoteVA3 := setupRemote(nil, "remote 3", nil) + remoteVAs := []RemoteVA{ + {remoteVA1, "remote 1"}, + {remoteVA2, "remote 2"}, + {remoteVA3, "remote 3"}, + } + + // Set up a local VA that allows a max of 2 remote failures. + localVA, mockLog := setup(nil, 2, "local 1", remoteVAs, nil) + + egProbA := probs.DNS("root DNS servers closed at 4:30pm") + egProbB := probs.OrderNotReady("please take a number") + + testCases := []struct { + name string + remoteProbs []*remoteVAResult + expectedLog string + }{ + { + name: "all results equal (nil)", + remoteProbs: []*remoteVAResult{ + {Problem: nil, VAHostname: "remoteA"}, + {Problem: nil, VAHostname: "remoteB"}, + {Problem: nil, VAHostname: "remoteC"}, + }, + }, + { + name: "all results equal (not nil)", + remoteProbs: []*remoteVAResult{ + {Problem: egProbA, VAHostname: "remoteA"}, + {Problem: egProbA, VAHostname: "remoteB"}, + {Problem: egProbA, VAHostname: "remoteC"}, + }, + expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"remoteA","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}},{"VAHostname":"remoteB","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}},{"VAHostname":"remoteC","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}}]}`, + }, + { + name: "differing results, some non-nil", + remoteProbs: []*remoteVAResult{ + {Problem: nil, VAHostname: "remoteA"}, + {Problem: egProbB, VAHostname: "remoteB"}, + {Problem: nil, VAHostname: "remoteC"}, + }, + expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"remoteB","Problem":{"type":"orderNotReady","detail":"please take a number","status":403}}]}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockLog.Clear() + + localVA.logRemoteResults( + "example.com", 1999, "blorpus-01", tc.remoteProbs) + + lines := mockLog.GetAllMatching("remoteVADifferentials JSON=.*") + if tc.expectedLog != "" { + test.AssertEquals(t, len(lines), 1) + test.AssertEquals(t, lines[0], tc.expectedLog) + } else { + test.AssertEquals(t, len(lines), 0) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/web/context.go b/third-party/github.com/letsencrypt/boulder/web/context.go new file mode 100644 index 000000000..249438589 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/context.go @@ -0,0 +1,200 @@ +package web + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" + + blog "github.com/letsencrypt/boulder/log" +) + +// RequestEvent is a structured record of the metadata we care about for a +// single web request. It is generated when a request is received, passed to +// the request handler which can populate its fields as appropriate, and then +// logged when the request completes. +type RequestEvent struct { + // These fields are not rendered in JSON; instead, they are rendered + // whitespace-separated ahead of the JSON. This saves bytes in the logs since + // we don't have to include field names, quotes, or commas -- all of these + // fields are known to not include whitespace. + Method string `json:"-"` + Endpoint string `json:"-"` + Requester int64 `json:"-"` + Code int `json:"-"` + Latency float64 `json:"-"` + RealIP string `json:"-"` + + Slug string `json:",omitempty"` + InternalErrors []string `json:",omitempty"` + Error string `json:",omitempty"` + UserAgent string `json:"ua,omitempty"` + // Origin is sent by the browser from XHR-based clients. + Origin string `json:",omitempty"` + Extra map[string]interface{} `json:",omitempty"` + + // For endpoints that create objects, the ID of the newly created object. + Created string `json:",omitempty"` + + // For challenge and authorization GETs and POSTs: + // the status of the authorization at the time the request began. + Status string `json:",omitempty"` + // The DNS name, if there is a single relevant name, for instance + // in an authorization or challenge request. + DNSName string `json:",omitempty"` + // The set of DNS names, if there are potentially multiple relevant + // names, for instance in a new-order, finalize, or revoke request. + DNSNames []string `json:",omitempty"` + + // For challenge POSTs, the challenge type. + ChallengeType string `json:",omitempty"` + + // suppressed controls whether this event will be logged when the request + // completes. If true, no log line will be emitted. Can only be set by + // calling .Suppress(); automatically unset by adding an internal error. + suppressed bool `json:"-"` +} + +// AddError formats the given message with the given args and appends it to the +// list of internal errors that have occurred as part of handling this event. +// If the RequestEvent has been suppressed, this un-suppresses it. +func (e *RequestEvent) AddError(msg string, args ...interface{}) { + e.InternalErrors = append(e.InternalErrors, fmt.Sprintf(msg, args...)) + e.suppressed = false +} + +// Suppress causes the RequestEvent to not be logged at all when the request +// is complete. This is a no-op if an internal error has been added to the event +// (logging errors takes precedence over suppressing output). +func (e *RequestEvent) Suppress() { + if len(e.InternalErrors) == 0 { + e.suppressed = true + } +} + +type WFEHandlerFunc func(context.Context, *RequestEvent, http.ResponseWriter, *http.Request) + +func (f WFEHandlerFunc) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) { + f(r.Context(), e, w, r) +} + +type wfeHandler interface { + ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) +} + +type TopHandler struct { + wfe wfeHandler + log blog.Logger +} + +func NewTopHandler(log blog.Logger, wfe wfeHandler) *TopHandler { + return &TopHandler{ + wfe: wfe, + log: log, + } +} + +// responseWriterWithStatus satisfies http.ResponseWriter, but keeps track of the +// status code for logging. +type responseWriterWithStatus struct { + http.ResponseWriter + code int +} + +// WriteHeader stores a status code for generating stats. +func (r *responseWriterWithStatus) WriteHeader(code int) { + r.code = code + r.ResponseWriter.WriteHeader(code) +} + +func (th *TopHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Check that this header is well-formed, since we assume it is when logging. + realIP := r.Header.Get("X-Real-IP") + if net.ParseIP(realIP) == nil { + realIP = "0.0.0.0" + } + + logEvent := &RequestEvent{ + RealIP: realIP, + Method: r.Method, + UserAgent: r.Header.Get("User-Agent"), + Origin: r.Header.Get("Origin"), + Extra: make(map[string]interface{}), + } + // We specifically override the default r.Context() because we would prefer + // for clients to not be able to cancel our operations in arbitrary places. + // Instead we start a new context, and apply timeouts in our various RPCs. + ctx := context.WithoutCancel(r.Context()) + r = r.WithContext(ctx) + + // Some clients will send a HTTP Host header that includes the default port + // for the scheme that they are using. Previously when we were fronted by + // Akamai they would rewrite the header and strip out the unnecessary port, + // now that they are not in our request path we need to strip these ports out + // ourselves. + // + // The main reason we want to strip these ports out is so that when this header + // is sent to the /directory endpoint we don't reply with directory URLs that + // also contain these ports. + // + // We unconditionally strip :443 even when r.TLS is nil because the WFE2 + // may be deployed HTTP-only behind another service that terminates HTTPS on + // its behalf. + r.Host = strings.TrimSuffix(r.Host, ":443") + r.Host = strings.TrimSuffix(r.Host, ":80") + + begin := time.Now() + rwws := &responseWriterWithStatus{w, 0} + defer func() { + logEvent.Code = rwws.code + if logEvent.Code == 0 { + // If we haven't explicitly set a status code golang will set it + // to 200 itself when writing to the wire + logEvent.Code = http.StatusOK + } + logEvent.Latency = time.Since(begin).Seconds() + th.logEvent(logEvent) + }() + th.wfe.ServeHTTP(logEvent, rwws, r) +} + +func (th *TopHandler) logEvent(logEvent *RequestEvent) { + if logEvent.suppressed { + return + } + var msg string + jsonEvent, err := json.Marshal(logEvent) + if err != nil { + th.log.AuditErrf("failed to marshal logEvent - %s - %#v", msg, err) + return + } + th.log.Infof("%s %s %d %d %d %s JSON=%s", + logEvent.Method, logEvent.Endpoint, logEvent.Requester, logEvent.Code, + int(logEvent.Latency*1000), logEvent.RealIP, jsonEvent) +} + +// GetClientAddr returns a comma-separated list of HTTP clients involved in +// making this request, starting with the original requester and ending with the +// remote end of our TCP connection (which is typically our own proxy). +func GetClientAddr(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return xff + "," + r.RemoteAddr + } + return r.RemoteAddr +} + +func KeyTypeToString(pub crypto.PublicKey) string { + switch pk := pub.(type) { + case *rsa.PublicKey: + return fmt.Sprintf("RSA %d", pk.N.BitLen()) + case *ecdsa.PublicKey: + return fmt.Sprintf("ECDSA %s", pk.Params().Name) + } + return "unknown" +} diff --git a/third-party/github.com/letsencrypt/boulder/web/context_test.go b/third-party/github.com/letsencrypt/boulder/web/context_test.go new file mode 100644 index 000000000..a5e806c55 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/context_test.go @@ -0,0 +1,119 @@ +package web + +import ( + "bytes" + "crypto/tls" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" +) + +type myHandler struct{} + +func (m myHandler) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + e.Endpoint = "/endpoint" + _, _ = w.Write([]byte("hi")) +} + +func TestLogCode(t *testing.T) { + mockLog := blog.UseMock() + th := NewTopHandler(mockLog, myHandler{}) + req, err := http.NewRequest("GET", "/thisisignored", &bytes.Reader{}) + if err != nil { + t.Fatal(err) + } + th.ServeHTTP(httptest.NewRecorder(), req) + expected := `INFO: GET /endpoint 0 201 0 0.0.0.0 JSON={}` + if len(mockLog.GetAllMatching(expected)) != 1 { + t.Errorf("Expected exactly one log line matching %q. Got \n%s", + expected, strings.Join(mockLog.GetAllMatching(".*"), "\n")) + } +} + +type codeHandler struct{} + +func (ch codeHandler) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) { + e.Endpoint = "/endpoint" + _, _ = w.Write([]byte("hi")) +} + +func TestStatusCodeLogging(t *testing.T) { + mockLog := blog.UseMock() + th := NewTopHandler(mockLog, codeHandler{}) + req, err := http.NewRequest("GET", "/thisisignored", &bytes.Reader{}) + if err != nil { + t.Fatal(err) + } + th.ServeHTTP(httptest.NewRecorder(), req) + expected := `INFO: GET /endpoint 0 200 0 0.0.0.0 JSON={}` + if len(mockLog.GetAllMatching(expected)) != 1 { + t.Errorf("Expected exactly one log line matching %q. Got \n%s", + expected, strings.Join(mockLog.GetAllMatching(".*"), "\n")) + } +} + +func TestOrigin(t *testing.T) { + mockLog := blog.UseMock() + th := NewTopHandler(mockLog, myHandler{}) + req, err := http.NewRequest("GET", "/thisisignored", &bytes.Reader{}) + if err != nil { + t.Fatal(err) + } + req.Header.Add("Origin", "https://example.com") + th.ServeHTTP(httptest.NewRecorder(), req) + expected := `INFO: GET /endpoint 0 201 0 0.0.0.0 JSON={.*"Origin":"https://example.com"}` + if len(mockLog.GetAllMatching(expected)) != 1 { + t.Errorf("Expected exactly one log line matching %q. Got \n%s", + expected, strings.Join(mockLog.GetAllMatching(".*"), "\n")) + } +} + +type hostHeaderHandler struct { + f func(*RequestEvent, http.ResponseWriter, *http.Request) +} + +func (hhh hostHeaderHandler) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) { + hhh.f(e, w, r) +} + +func TestHostHeaderRewrite(t *testing.T) { + mockLog := blog.UseMock() + hhh := hostHeaderHandler{f: func(_ *RequestEvent, _ http.ResponseWriter, r *http.Request) { + t.Helper() + test.AssertEquals(t, r.Host, "localhost") + }} + th := NewTopHandler(mockLog, &hhh) + + req, err := http.NewRequest("GET", "/", &bytes.Reader{}) + test.AssertNotError(t, err, "http.NewRequest failed") + req.Host = "localhost:80" + fmt.Println("here") + th.ServeHTTP(httptest.NewRecorder(), req) + + req, err = http.NewRequest("GET", "/", &bytes.Reader{}) + test.AssertNotError(t, err, "http.NewRequest failed") + req.Host = "localhost:443" + req.TLS = &tls.ConnectionState{} + th.ServeHTTP(httptest.NewRecorder(), req) + + req, err = http.NewRequest("GET", "/", &bytes.Reader{}) + test.AssertNotError(t, err, "http.NewRequest failed") + req.Host = "localhost:443" + req.TLS = nil + th.ServeHTTP(httptest.NewRecorder(), req) + + hhh.f = func(_ *RequestEvent, _ http.ResponseWriter, r *http.Request) { + t.Helper() + test.AssertEquals(t, r.Host, "localhost:123") + } + req, err = http.NewRequest("GET", "/", &bytes.Reader{}) + test.AssertNotError(t, err, "http.NewRequest failed") + req.Host = "localhost:123" + th.ServeHTTP(httptest.NewRecorder(), req) +} diff --git a/third-party/github.com/letsencrypt/boulder/web/docs.go b/third-party/github.com/letsencrypt/boulder/web/docs.go new file mode 100644 index 000000000..f5d218f4b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/docs.go @@ -0,0 +1,2 @@ +// This package collects types that are common to both wfe and wfe2. +package web diff --git a/third-party/github.com/letsencrypt/boulder/web/jwk.go b/third-party/github.com/letsencrypt/boulder/web/jwk.go new file mode 100644 index 000000000..6a842c850 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/jwk.go @@ -0,0 +1,19 @@ +package web + +import ( + "encoding/json" + "os" + + "github.com/go-jose/go-jose/v4" +) + +// LoadJWK loads a JSON encoded JWK specified by filename or returns an error +func LoadJWK(filename string) (*jose.JSONWebKey, error) { + var jwk jose.JSONWebKey + if jsonBytes, err := os.ReadFile(filename); err != nil { + return nil, err + } else if err = json.Unmarshal(jsonBytes, &jwk); err != nil { + return nil, err + } + return &jwk, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/web/probs.go b/third-party/github.com/letsencrypt/boulder/web/probs.go new file mode 100644 index 000000000..31f8596c0 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/probs.go @@ -0,0 +1,93 @@ +package web + +import ( + "errors" + "fmt" + + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/probs" +) + +func problemDetailsForBoulderError(err *berrors.BoulderError, msg string) *probs.ProblemDetails { + var outProb *probs.ProblemDetails + + switch err.Type { + case berrors.Malformed: + outProb = probs.Malformed(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.Unauthorized: + outProb = probs.Unauthorized(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.NotFound: + outProb = probs.NotFound(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.RateLimit: + outProb = probs.RateLimited(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.InternalServer: + // Internal server error messages may include sensitive data, so we do + // not include it. + outProb = probs.ServerInternal(msg) + case berrors.RejectedIdentifier: + outProb = probs.RejectedIdentifier(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.InvalidEmail: + outProb = probs.InvalidContact(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.CAA: + outProb = probs.CAA(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.MissingSCTs: + // MissingSCTs are an internal server error, but with a specific error + // message related to the SCT problem + outProb = probs.ServerInternal(fmt.Sprintf("%s :: %s", msg, "Unable to meet CA SCT embedding requirements")) + case berrors.OrderNotReady: + outProb = probs.OrderNotReady(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.BadPublicKey: + outProb = probs.BadPublicKey(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.BadCSR: + outProb = probs.BadCSR(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.AlreadyRevoked: + outProb = probs.AlreadyRevoked(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.BadRevocationReason: + outProb = probs.BadRevocationReason(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.UnsupportedContact: + outProb = probs.UnsupportedContact(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.Conflict: + outProb = probs.Conflict(fmt.Sprintf("%s :: %s", msg, err)) + default: + // Internal server error messages may include sensitive data, so we do + // not include it. + outProb = probs.ServerInternal(msg) + } + + if len(err.SubErrors) > 0 { + var subProbs []probs.SubProblemDetails + for _, subErr := range err.SubErrors { + subProbs = append(subProbs, subProblemDetailsForSubError(subErr, msg)) + } + return outProb.WithSubProblems(subProbs) + } + + return outProb +} + +// ProblemDetailsForError turns an error into a ProblemDetails with the special +// case of returning the same error back if its already a ProblemDetails. If the +// error is of an type unknown to ProblemDetailsForError, it will return a +// ServerInternal ProblemDetails. +func ProblemDetailsForError(err error, msg string) *probs.ProblemDetails { + var probsProblemDetails *probs.ProblemDetails + var berrorsBoulderError *berrors.BoulderError + if errors.As(err, &probsProblemDetails) { + return probsProblemDetails + } else if errors.As(err, &berrorsBoulderError) { + return problemDetailsForBoulderError(berrorsBoulderError, msg) + } else { + // Internal server error messages may include sensitive data, so we do + // not include it. + return probs.ServerInternal(msg) + } +} + +// subProblemDetailsForSubError converts a SubBoulderError into +// a SubProblemDetails using problemDetailsForBoulderError. +func subProblemDetailsForSubError(subErr berrors.SubBoulderError, msg string) probs.SubProblemDetails { + return probs.SubProblemDetails{ + Identifier: subErr.Identifier, + ProblemDetails: *problemDetailsForBoulderError(subErr.BoulderError, msg), + } +} diff --git a/third-party/github.com/letsencrypt/boulder/web/probs_test.go b/third-party/github.com/letsencrypt/boulder/web/probs_test.go new file mode 100644 index 000000000..130109cda --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/probs_test.go @@ -0,0 +1,101 @@ +package web + +import ( + "fmt" + "reflect" + "testing" + + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +func TestProblemDetailsFromError(t *testing.T) { + // errMsg is used as the msg argument for `ProblemDetailsForError` and is + // always returned in the problem detail. + const errMsg = "testError" + // detailMsg is used as the msg argument for the individual error types and is + // sometimes not present in the produced problem's detail. + const detailMsg = "testDetail" + // fullDetail is what we expect the problem detail to look like when it + // contains both the error message and the detail message + fullDetail := fmt.Sprintf("%s :: %s", errMsg, detailMsg) + testCases := []struct { + err error + statusCode int + problem probs.ProblemType + detail string + }{ + // boulder/errors error types + // Internal server errors expect just the `errMsg` in detail. + {berrors.InternalServerError(detailMsg), 500, probs.ServerInternalProblem, errMsg}, + // Other errors expect the full detail message + {berrors.MalformedError(detailMsg), 400, probs.MalformedProblem, fullDetail}, + {berrors.UnauthorizedError(detailMsg), 403, probs.UnauthorizedProblem, fullDetail}, + {berrors.NotFoundError(detailMsg), 404, probs.MalformedProblem, fullDetail}, + {berrors.RateLimitError(0, detailMsg), 429, probs.RateLimitedProblem, fullDetail + ": see https://letsencrypt.org/docs/rate-limits/"}, + {berrors.InvalidEmailError(detailMsg), 400, probs.InvalidContactProblem, fullDetail}, + {berrors.RejectedIdentifierError(detailMsg), 400, probs.RejectedIdentifierProblem, fullDetail}, + } + for _, c := range testCases { + p := ProblemDetailsForError(c.err, errMsg) + if p.HTTPStatus != c.statusCode { + t.Errorf("Incorrect status code for %s. Expected %d, got %d", reflect.TypeOf(c.err).Name(), c.statusCode, p.HTTPStatus) + } + if p.Type != c.problem { + t.Errorf("Expected problem urn %#v, got %#v", c.problem, p.Type) + } + if p.Detail != c.detail { + t.Errorf("Expected detailed message %q, got %q", c.detail, p.Detail) + } + } + + expected := &probs.ProblemDetails{ + Type: probs.MalformedProblem, + HTTPStatus: 200, + Detail: "gotcha", + } + p := ProblemDetailsForError(expected, "k") + test.AssertDeepEquals(t, expected, p) +} + +func TestSubProblems(t *testing.T) { + topErr := (&berrors.BoulderError{ + Type: berrors.CAA, + Detail: "CAA policy forbids issuance", + }).WithSubErrors( + []berrors.SubBoulderError{ + { + Identifier: identifier.DNSIdentifier("threeletter.agency"), + BoulderError: &berrors.BoulderError{ + Type: berrors.CAA, + Detail: "Forbidden by ■■■■■■■■■■■ and directive ■■■■", + }, + }, + { + Identifier: identifier.DNSIdentifier("area51.threeletter.agency"), + BoulderError: &berrors.BoulderError{ + Type: berrors.NotFound, + Detail: "No Such Area...", + }, + }, + }) + + prob := problemDetailsForBoulderError(topErr, "problem with subproblems") + test.AssertEquals(t, len(prob.SubProblems), len(topErr.SubErrors)) + + subProbsMap := make(map[string]probs.SubProblemDetails, len(prob.SubProblems)) + + for _, subProb := range prob.SubProblems { + subProbsMap[subProb.Identifier.Value] = subProb + } + + subProbA, foundA := subProbsMap["threeletter.agency"] + subProbB, foundB := subProbsMap["area51.threeletter.agency"] + test.AssertEquals(t, foundA, true) + test.AssertEquals(t, foundB, true) + + test.AssertEquals(t, subProbA.Type, probs.CAAProblem) + test.AssertEquals(t, subProbB.Type, probs.MalformedProblem) +} diff --git a/third-party/github.com/letsencrypt/boulder/web/relative.go b/third-party/github.com/letsencrypt/boulder/web/relative.go new file mode 100644 index 000000000..0a29e88ee --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/relative.go @@ -0,0 +1,36 @@ +package web + +import ( + "net/http" + "net/url" +) + +// RelativeEndpoint takes a path component of URL and constructs a new URL using +// the host and port from the request combined the provided path. +func RelativeEndpoint(request *http.Request, endpoint string) string { + var result string + proto := "http" + host := request.Host + + // If the request was received via TLS, use `https://` for the protocol + if request.TLS != nil { + proto = "https" + } + + // Allow upstream proxies to specify the forwarded protocol. Allow this value + // to override our own guess. + if specifiedProto := request.Header.Get("X-Forwarded-Proto"); specifiedProto != "" { + proto = specifiedProto + } + + // Default to "localhost" when no request.Host is provided. Otherwise requests + // with an empty `Host` produce results like `http:///acme/new-authz` + if request.Host == "" { + host = "localhost" + } + + resultUrl := url.URL{Scheme: proto, Host: host, Path: endpoint} + result = resultUrl.String() + + return result +} diff --git a/third-party/github.com/letsencrypt/boulder/web/send_error.go b/third-party/github.com/letsencrypt/boulder/web/send_error.go new file mode 100644 index 000000000..c0e68d707 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/send_error.go @@ -0,0 +1,66 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/probs" +) + +// SendError does a few things that we want for each error response: +// - Adds both the external and the internal error to a RequestEvent. +// - If the ProblemDetails provided is a ServerInternalProblem, audit logs the +// internal error. +// - Prefixes the Type field of the ProblemDetails with the RFC8555 namespace. +// - Sends an HTTP response containing the error and an error code to the user. +// +// The internal error (ierr) may be nil if no information beyond the +// ProblemDetails is needed for internal debugging. +func SendError( + log blog.Logger, + response http.ResponseWriter, + logEvent *RequestEvent, + prob *probs.ProblemDetails, + ierr error, +) { + // Write the JSON problem response + response.Header().Set("Content-Type", "application/problem+json") + if prob.HTTPStatus != 0 { + response.WriteHeader(prob.HTTPStatus) + } else { + // All problems should have an HTTPStatus set, because all of the functions + // in the probs package which construct a problem set one. A problem details + // object getting to this point without a status set is an error. + response.WriteHeader(http.StatusInternalServerError) + } + + // Record details to the log event + logEvent.Error = fmt.Sprintf("%d :: %s :: %s", prob.HTTPStatus, prob.Type, prob.Detail) + if len(prob.SubProblems) > 0 { + subDetails := make([]string, len(prob.SubProblems)) + for i, sub := range prob.SubProblems { + subDetails[i] = fmt.Sprintf("\"%s :: %s :: %s\"", sub.Identifier.Value, sub.Type, sub.Detail) + } + logEvent.Error += fmt.Sprintf(" [%s]", strings.Join(subDetails, ", ")) + } + if ierr != nil { + logEvent.AddError(fmt.Sprintf("%s", ierr)) + } + + // Set the proper namespace for the problem and any sub-problems. + prob.Type = probs.ProblemType(probs.ErrorNS) + prob.Type + for i := range prob.SubProblems { + prob.SubProblems[i].Type = probs.ProblemType(probs.ErrorNS) + prob.SubProblems[i].Type + } + + problemDoc, err := json.MarshalIndent(prob, "", " ") + if err != nil { + log.AuditErrf("Could not marshal error message: %s - %+v", err, prob) + problemDoc = []byte("{\"detail\": \"Problem marshalling error message.\"}") + } + + response.Write(problemDoc) +} diff --git a/third-party/github.com/letsencrypt/boulder/web/send_error_test.go b/third-party/github.com/letsencrypt/boulder/web/send_error_test.go new file mode 100644 index 000000000..4bdedee53 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/web/send_error_test.go @@ -0,0 +1,96 @@ +package web + +import ( + "errors" + "net/http/httptest" + "testing" + + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/test" +) + +func TestSendErrorSubProblemNamespace(t *testing.T) { + rw := httptest.NewRecorder() + prob := ProblemDetailsForError((&berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "bad", + }).WithSubErrors( + []berrors.SubBoulderError{ + { + Identifier: identifier.DNSIdentifier("example.com"), + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "nop", + }, + }, + { + Identifier: identifier.DNSIdentifier("what about example.com"), + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "nah", + }, + }, + }), + "dfoop", + ) + SendError(log.NewMock(), rw, &RequestEvent{}, prob, errors.New("it bad")) + + body := rw.Body.String() + test.AssertUnmarshaledEquals(t, body, `{ + "type": "urn:ietf:params:acme:error:malformed", + "detail": "dfoop :: bad", + "status": 400, + "subproblems": [ + { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "dfoop :: nop", + "status": 400, + "identifier": { + "type": "dns", + "value": "example.com" + } + }, + { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "dfoop :: nah", + "status": 400, + "identifier": { + "type": "dns", + "value": "what about example.com" + } + } + ] + }`) +} + +func TestSendErrorSubProbLogging(t *testing.T) { + rw := httptest.NewRecorder() + prob := ProblemDetailsForError((&berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "bad", + }).WithSubErrors( + []berrors.SubBoulderError{ + { + Identifier: identifier.DNSIdentifier("example.com"), + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "nop", + }, + }, + { + Identifier: identifier.DNSIdentifier("what about example.com"), + BoulderError: &berrors.BoulderError{ + Type: berrors.Malformed, + Detail: "nah", + }, + }, + }), + "dfoop", + ) + logEvent := RequestEvent{} + SendError(log.NewMock(), rw, &logEvent, prob, errors.New("it bad")) + + test.AssertEquals(t, logEvent.Error, `400 :: malformed :: dfoop :: bad ["example.com :: malformed :: dfoop :: nop", "what about example.com :: malformed :: dfoop :: nah"]`) +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/README.md b/third-party/github.com/letsencrypt/boulder/wfe2/README.md new file mode 100644 index 000000000..066c3684f --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/README.md @@ -0,0 +1,7 @@ +WFE v2 +============ + +The `wfe2` package is copied from the `wfe` package in order to implement the +["ACME v2"](https://letsencrypt.org/2017/06/14/acme-v2-api.html) API. This design choice +was made to facilitate a clean separation between v1 and v2 code and to support +running a separate API process on a different port alongside the v1 API process. diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/cache.go b/third-party/github.com/letsencrypt/boulder/wfe2/cache.go new file mode 100644 index 000000000..e1b0c9724 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/cache.go @@ -0,0 +1,118 @@ +package wfe2 + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/golang/groupcache/lru" + "github.com/jmhodges/clock" + corepb "github.com/letsencrypt/boulder/core/proto" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// AccountGetter represents the ability to get an account by ID - either from the SA +// or from a cache. +type AccountGetter interface { + GetRegistration(ctx context.Context, regID *sapb.RegistrationID, opts ...grpc.CallOption) (*corepb.Registration, error) +} + +// accountCache is an implementation of AccountGetter that first tries a local +// in-memory cache, and if the account is not there, calls out to an underlying +// AccountGetter. It is safe for concurrent access so long as the underlying +// AccountGetter is. +type accountCache struct { + // Note: This must be a regular mutex, not an RWMutex, because cache.Get() + // actually mutates the lru.Cache (by updating the last-used info). + sync.Mutex + under AccountGetter + ttl time.Duration + cache *lru.Cache + clk clock.Clock + requests *prometheus.CounterVec +} + +func NewAccountCache( + under AccountGetter, + maxEntries int, + ttl time.Duration, + clk clock.Clock, + stats prometheus.Registerer, +) *accountCache { + requestsCount := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "cache_requests", + }, []string{"status"}) + stats.MustRegister(requestsCount) + return &accountCache{ + under: under, + ttl: ttl, + cache: lru.New(maxEntries), + clk: clk, + requests: requestsCount, + } +} + +type accountEntry struct { + account *corepb.Registration + expires time.Time +} + +func (ac *accountCache) GetRegistration(ctx context.Context, regID *sapb.RegistrationID, opts ...grpc.CallOption) (*corepb.Registration, error) { + ac.Lock() + val, ok := ac.cache.Get(regID.Id) + ac.Unlock() + if !ok { + ac.requests.WithLabelValues("miss").Inc() + return ac.queryAndStore(ctx, regID) + } + entry, ok := val.(accountEntry) + if !ok { + ac.requests.WithLabelValues("wrongtype").Inc() + return nil, fmt.Errorf("shouldn't happen: wrong type %T for cache entry", entry) + } + if entry.expires.Before(ac.clk.Now()) { + // Note: this has a slight TOCTOU issue but it's benign. If the entry for this account + // was expired off by some other goroutine and then a fresh one added, removing it a second + // time will just cause a slightly lower cache rate. + // We have to actively remove expired entries, because otherwise each retrieval counts as + // a "use" and they won't exit the cache on their own. + ac.Lock() + ac.cache.Remove(regID.Id) + ac.Unlock() + ac.requests.WithLabelValues("expired").Inc() + return ac.queryAndStore(ctx, regID) + } + if entry.account.Id != regID.Id { + ac.requests.WithLabelValues("wrong id from cache").Inc() + return nil, fmt.Errorf("shouldn't happen: wrong account ID. expected %d, got %d", regID.Id, entry.account.Id) + } + copied := new(corepb.Registration) + proto.Merge(copied, entry.account) + ac.requests.WithLabelValues("hit").Inc() + return copied, nil +} + +func (ac *accountCache) queryAndStore(ctx context.Context, regID *sapb.RegistrationID) (*corepb.Registration, error) { + account, err := ac.under.GetRegistration(ctx, regID) + if err != nil { + return nil, err + } + if account.Id != regID.Id { + ac.requests.WithLabelValues("wrong id from SA").Inc() + return nil, fmt.Errorf("shouldn't happen: wrong account ID from backend. expected %d, got %d", regID.Id, account.Id) + } + // Make sure we have our own copy that no one has a pointer to. + copied := new(corepb.Registration) + proto.Merge(copied, account) + ac.Lock() + ac.cache.Add(regID.Id, accountEntry{ + account: copied, + expires: ac.clk.Now().Add(ac.ttl), + }) + ac.Unlock() + return account, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/cache_test.go b/third-party/github.com/letsencrypt/boulder/wfe2/cache_test.go new file mode 100644 index 000000000..13d5310dc --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/cache_test.go @@ -0,0 +1,145 @@ +package wfe2 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jmhodges/clock" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/metrics" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + "google.golang.org/grpc" +) + +type recordingBackend struct { + requests []int64 +} + +func (rb *recordingBackend) GetRegistration( + ctx context.Context, + regID *sapb.RegistrationID, + opts ...grpc.CallOption, +) (*corepb.Registration, error) { + rb.requests = append(rb.requests, regID.Id) + return &corepb.Registration{ + Id: regID.Id, + Contact: []string{"example@example.com"}, + }, nil +} + +func TestCacheAddRetrieve(t *testing.T) { + ctx := context.Background() + backend := &recordingBackend{} + + cache := NewAccountCache(backend, 10, time.Second, clock.NewFake(), metrics.NoopRegisterer) + + result, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, result.Id, int64(1234)) + test.AssertEquals(t, len(backend.requests), 1) + + // Request it again. This should hit the cache so our backend should not see additional requests. + result, err = cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, result.Id, int64(1234)) + test.AssertEquals(t, len(backend.requests), 1) +} + +// Test that the cache copies values before giving them out, so code that receives a cached +// value can't modify the cache's contents. +func TestCacheCopy(t *testing.T) { + ctx := context.Background() + backend := &recordingBackend{} + + cache := NewAccountCache(backend, 10, time.Second, clock.NewFake(), metrics.NoopRegisterer) + + _, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + test.AssertEquals(t, cache.cache.Len(), 1) + + // Request it again. This should hit the cache. + result, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + // Modify a pointer value inside the result + result.Contact[0] = "different@example.com" + + result, err = cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + test.AssertDeepEquals(t, result.Contact, []string{"example@example.com"}) +} + +// Test that the cache expires values. +func TestCacheExpires(t *testing.T) { + ctx := context.Background() + backend := &recordingBackend{} + + clk := clock.NewFake() + cache := NewAccountCache(backend, 10, time.Second, clk, metrics.NoopRegisterer) + + _, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + // Request it again. This should hit the cache. + _, err = cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + test.AssertEquals(t, cache.cache.Len(), 1) + + // "Sleep" 10 seconds to expire the entry + clk.Sleep(10 * time.Second) + + // This should not hit the cache + _, err = cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 2) +} + +type wrongIDBackend struct{} + +func (wib wrongIDBackend) GetRegistration( + ctx context.Context, + regID *sapb.RegistrationID, + opts ...grpc.CallOption, +) (*corepb.Registration, error) { + return &corepb.Registration{ + Id: regID.Id + 1, + Contact: []string{"example@example.com"}, + }, nil +} + +func TestWrongId(t *testing.T) { + ctx := context.Background() + cache := NewAccountCache(wrongIDBackend{}, 10, time.Second, clock.NewFake(), metrics.NoopRegisterer) + + _, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertError(t, err, "expected error when backend returns wrong ID") +} + +type errorBackend struct{} + +func (eb errorBackend) GetRegistration(ctx context.Context, + regID *sapb.RegistrationID, + opts ...grpc.CallOption, +) (*corepb.Registration, error) { + return nil, errors.New("some error") +} + +func TestErrorPassthrough(t *testing.T) { + ctx := context.Background() + cache := NewAccountCache(errorBackend{}, 10, time.Second, clock.NewFake(), metrics.NoopRegisterer) + + _, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertError(t, err, "expected error when backend errors") + test.AssertEquals(t, err.Error(), "some error") +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/stale.go b/third-party/github.com/letsencrypt/boulder/wfe2/stale.go new file mode 100644 index 000000000..0e423a82b --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/stale.go @@ -0,0 +1,74 @@ +package wfe2 + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/web" +) + +// requiredStale checks if a request is a GET request with a logEvent indicating +// the endpoint starts with getAPIPrefix. If true then the caller is expected to +// apply staleness requirements via staleEnoughToGETOrder, staleEnoughToGETCert +// and staleEnoughToGETAuthz. +func requiredStale(req *http.Request, logEvent *web.RequestEvent) bool { + return req.Method == http.MethodGet && strings.HasPrefix(logEvent.Endpoint, getAPIPrefix) +} + +// staleEnoughToGETOrder checks if the given order was created long enough ago +// in the past to be acceptably stale for accessing via the Boulder specific GET +// API. +func (wfe *WebFrontEndImpl) staleEnoughToGETOrder(order *corepb.Order) *probs.ProblemDetails { + return wfe.staleEnoughToGET("Order", order.Created.AsTime()) +} + +// staleEnoughToGETCert checks if the given cert was issued long enough in the +// past to be acceptably stale for accessing via the Boulder specific GET API. +func (wfe *WebFrontEndImpl) staleEnoughToGETCert(cert *corepb.Certificate) *probs.ProblemDetails { + return wfe.staleEnoughToGET("Certificate", cert.Issued.AsTime()) +} + +// staleEnoughToGETAuthz checks if the given authorization was created long +// enough ago in the past to be acceptably stale for accessing via the Boulder +// specific GET API. Since authorization creation date is not tracked directly +// the appropriate lifetime for the authz is subtracted from the expiry to find +// the creation date. +func (wfe *WebFrontEndImpl) staleEnoughToGETAuthz(authzPB *corepb.Authorization) *probs.ProblemDetails { + // If the authorization was deactivated we cannot reliably tell what the creation date was + // because we can't easily tell if it was pending or finalized before deactivation. + // As these authorizations can no longer be used for anything, just make them immediately + // available for access. + if core.AcmeStatus(authzPB.Status) == core.StatusDeactivated { + return nil + } + // We don't directly track authorization creation time. Instead subtract the + // pendingAuthorization lifetime from the expiry. This will be inaccurate if + // we change the pendingAuthorizationLifetime but is sufficient for the weak + // staleness requirements of the GET API. + createdTime := authzPB.Expires.AsTime().Add(-wfe.pendingAuthorizationLifetime) + // if the authz is valid then we need to subtract the authorizationLifetime + // instead of the pendingAuthorizationLifetime. + if core.AcmeStatus(authzPB.Status) == core.StatusValid { + createdTime = authzPB.Expires.AsTime().Add(-wfe.authorizationLifetime) + } + return wfe.staleEnoughToGET("Authorization", createdTime) +} + +// staleEnoughToGET checks that the createDate for the given resource is at +// least wfe.staleTimeout in the past. If the resource is newer than the +// wfe.staleTimeout then an unauthorized problem is returned. +func (wfe *WebFrontEndImpl) staleEnoughToGET(resourceType string, createDate time.Time) *probs.ProblemDetails { + if wfe.clk.Since(createDate) < wfe.staleTimeout { + return probs.Unauthorized(fmt.Sprintf( + "%s is too new for GET API. "+ + "You should only use this non-standard API to access resources created more than %s ago", + resourceType, + wfe.staleTimeout)) + } + return nil +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/stale_test.go b/third-party/github.com/letsencrypt/boulder/wfe2/stale_test.go new file mode 100644 index 000000000..662ddbbdd --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/stale_test.go @@ -0,0 +1,78 @@ +package wfe2 + +import ( + "net/http" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/web" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestRequiredStale(t *testing.T) { + testCases := []struct { + name string + req *http.Request + logEvent *web.RequestEvent + expectRequired bool + }{ + { + name: "not GET", + req: &http.Request{Method: http.MethodPost}, + logEvent: &web.RequestEvent{}, + expectRequired: false, + }, + { + name: "GET, not getAPIPrefix", + req: &http.Request{Method: http.MethodGet}, + logEvent: &web.RequestEvent{}, + expectRequired: false, + }, + { + name: "GET, getAPIPrefix", + req: &http.Request{Method: http.MethodGet}, + logEvent: &web.RequestEvent{Endpoint: getAPIPrefix + "whatever"}, + expectRequired: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test.AssertEquals(t, requiredStale(tc.req, tc.logEvent), tc.expectRequired) + }) + } +} + +func TestSaleEnoughToGETOrder(t *testing.T) { + fc := clock.NewFake() + wfe := WebFrontEndImpl{clk: fc, staleTimeout: time.Minute * 30} + fc.Add(time.Hour * 24) + created := fc.Now() + fc.Add(time.Hour) + prob := wfe.staleEnoughToGETOrder(&corepb.Order{ + Created: timestamppb.New(created), + }) + test.Assert(t, prob == nil, "wfe.staleEnoughToGETOrder returned a non-nil problem") +} + +func TestStaleEnoughToGETAuthzDeactivated(t *testing.T) { + fc := clock.NewFake() + wfe := WebFrontEndImpl{ + clk: fc, + staleTimeout: time.Minute * 30, + pendingAuthorizationLifetime: 7 * 24 * time.Hour, + authorizationLifetime: 30 * 24 * time.Hour, + } + fc.Add(time.Hour * 24) + expires := fc.Now().Add(wfe.authorizationLifetime) + fc.Add(time.Hour) + prob := wfe.staleEnoughToGETAuthz(&corepb.Authorization{ + Status: string(core.StatusDeactivated), + Expires: timestamppb.New(expires), + }) + test.Assert(t, prob == nil, "wfe.staleEnoughToGETOrder returned a non-nil problem") +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/stats.go b/third-party/github.com/letsencrypt/boulder/wfe2/stats.go new file mode 100644 index 000000000..46f9bf9e7 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/stats.go @@ -0,0 +1,89 @@ +package wfe2 + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +type wfe2Stats struct { + // httpErrorCount counts client errors at the HTTP level + // e.g. failure to provide a Content-Length header, no POST body, etc + httpErrorCount *prometheus.CounterVec + // joseErrorCount counts client errors at the JOSE level + // e.g. bad JWS, broken JWS signature, invalid JWK, etc + joseErrorCount *prometheus.CounterVec + // csrSignatureAlgs counts the signature algorithms in use for order + // finalization CSRs + csrSignatureAlgs *prometheus.CounterVec + // improperECFieldLengths counts the number of ACME account EC JWKs we see + // with improper X and Y lengths for their curve + improperECFieldLengths prometheus.Counter + // nonceNoMatchingBackendCount counts the number of times we've received a nonce + // with a prefix that doesn't match a known backend. + nonceNoMatchingBackendCount prometheus.Counter + // ariReplacementOrders counts the number of new order requests that replace + // an existing order, labeled by: + // - isReplacement=[true|false] + // - limitsExempt=[true|false] + ariReplacementOrders *prometheus.CounterVec +} + +func initStats(stats prometheus.Registerer) wfe2Stats { + httpErrorCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_errors", + Help: "client request errors at the HTTP level", + }, + []string{"type"}) + stats.MustRegister(httpErrorCount) + + joseErrorCount := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "jose_errors", + Help: "client request errors at the JOSE level", + }, + []string{"type"}) + stats.MustRegister(joseErrorCount) + + csrSignatureAlgs := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "csr_signature_algs", + Help: "Number of CSR signatures by algorithm", + }, + []string{"type"}, + ) + stats.MustRegister(csrSignatureAlgs) + + improperECFieldLengths := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "improper_ec_field_lengths", + Help: "Number of account EC keys with improper X and Y lengths", + }, + ) + stats.MustRegister(improperECFieldLengths) + + nonceNoBackendCount := prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "nonce_no_backend_found", + Help: "Number of times we've received a nonce with a prefix that doesn't match a known backend", + }, + ) + stats.MustRegister(nonceNoBackendCount) + + ariReplacementOrders := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ari_replacements", + Help: "Number of new order requests that replace an existing order, labeled isReplacement=[true|false], limitsExempt=[true|false]", + }, + []string{"isReplacement", "limitsExempt"}, + ) + stats.MustRegister(ariReplacementOrders) + + return wfe2Stats{ + httpErrorCount: httpErrorCount, + joseErrorCount: joseErrorCount, + csrSignatureAlgs: csrSignatureAlgs, + improperECFieldLengths: improperECFieldLengths, + nonceNoMatchingBackendCount: nonceNoBackendCount, + ariReplacementOrders: ariReplacementOrders, + } +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/verify.go b/third-party/github.com/letsencrypt/boulder/wfe2/verify.go new file mode 100644 index 000000000..665048f15 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/verify.go @@ -0,0 +1,839 @@ +package wfe2 + +import ( + "context" + "crypto/ecdsa" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/go-jose/go-jose/v4" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc/status" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/goodkey" + "github.com/letsencrypt/boulder/grpc" + nb "github.com/letsencrypt/boulder/grpc/noncebalancer" + "github.com/letsencrypt/boulder/nonce" + noncepb "github.com/letsencrypt/boulder/nonce/proto" + "github.com/letsencrypt/boulder/probs" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/web" +) + +const ( + // POST requests with a JWS body must have the following Content-Type header + expectedJWSContentType = "application/jose+json" + + maxRequestSize = 50000 +) + +func sigAlgorithmForKey(key *jose.JSONWebKey) (jose.SignatureAlgorithm, error) { + switch k := key.Key.(type) { + case *rsa.PublicKey: + return jose.RS256, nil + case *ecdsa.PublicKey: + switch k.Params().Name { + case "P-256": + return jose.ES256, nil + case "P-384": + return jose.ES384, nil + case "P-521": + return jose.ES512, nil + } + } + return "", errors.New("JWK contains unsupported key type (expected RSA, or ECDSA P-256, P-384, or P-521)") +} + +// getSupportedAlgs returns a sorted slice of joseSignatureAlgorithm's from a +// map of boulder allowed signature algorithms. We use a function for this to +// ensure that the source-of-truth slice can never be modified. +func getSupportedAlgs() []jose.SignatureAlgorithm { + return []jose.SignatureAlgorithm{ + jose.RS256, + jose.ES256, + jose.ES384, + jose.ES512, + } +} + +// Check that (1) there is a suitable algorithm for the provided key based on its +// Golang type, (2) the Algorithm field on the JWK is either absent, or matches +// that algorithm, and (3) the Algorithm field on the JWK is present and matches +// that algorithm. +func checkAlgorithm(key *jose.JSONWebKey, header jose.Header) error { + sigHeaderAlg := jose.SignatureAlgorithm(header.Algorithm) + if !slices.Contains(getSupportedAlgs(), sigHeaderAlg) { + return fmt.Errorf( + "JWS signature header contains unsupported algorithm %q, expected one of %s", + header.Algorithm, getSupportedAlgs(), + ) + } + + expectedAlg, err := sigAlgorithmForKey(key) + if err != nil { + return err + } + if sigHeaderAlg != expectedAlg { + return fmt.Errorf("JWS signature header algorithm %q does not match expected algorithm %q for JWK", sigHeaderAlg, string(expectedAlg)) + } + if key.Algorithm != "" && key.Algorithm != string(expectedAlg) { + return fmt.Errorf("JWK key header algorithm %q does not match expected algorithm %q for JWK", key.Algorithm, string(expectedAlg)) + } + return nil +} + +// jwsAuthType represents whether a given POST request is authenticated using +// a JWS with an embedded JWK (v1 ACME style, new-account, revoke-cert) or an +// embedded Key ID (v2 AMCE style) or an unsupported/unknown auth type. +type jwsAuthType int + +const ( + embeddedJWK jwsAuthType = iota + embeddedKeyID + invalidAuthType +) + +// checkJWSAuthType examines the protected headers from a bJSONWebSignature to +// determine if the request being authenticated by the JWS is identified using +// an embedded JWK or an embedded key ID. If no signatures are present, or +// mutually exclusive authentication types are specified at the same time, a +// problem is returned. checkJWSAuthType is separate from enforceJWSAuthType so +// that endpoints that need to handle both embedded JWK and embedded key ID +// requests can determine which type of request they have and act accordingly +// (e.g. acme v2 cert revocation). +func checkJWSAuthType(header jose.Header) (jwsAuthType, *probs.ProblemDetails) { + // There must not be a Key ID *and* an embedded JWK + if header.KeyID != "" && header.JSONWebKey != nil { + return invalidAuthType, probs.Malformed( + "jwk and kid header fields are mutually exclusive") + } else if header.KeyID != "" { + return embeddedKeyID, nil + } else if header.JSONWebKey != nil { + return embeddedJWK, nil + } + + return invalidAuthType, nil +} + +// enforceJWSAuthType enforces that the protected headers from a +// bJSONWebSignature have the provided auth type. If there is an error +// determining the auth type or if it is not the expected auth type then a +// problem is returned. +func (wfe *WebFrontEndImpl) enforceJWSAuthType( + header jose.Header, + expectedAuthType jwsAuthType) *probs.ProblemDetails { + // Check the auth type for the provided JWS + authType, prob := checkJWSAuthType(header) + if prob != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSAuthTypeInvalid"}).Inc() + return prob + } + // If the auth type isn't the one expected return a sensible problem based on + // what was expected + if authType != expectedAuthType { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSAuthTypeWrong"}).Inc() + switch expectedAuthType { + case embeddedKeyID: + return probs.Malformed("No Key ID in JWS header") + case embeddedJWK: + return probs.Malformed("No embedded JWK in JWS header") + } + } + return nil +} + +// validPOSTRequest checks a *http.Request to ensure it has the headers +// a well-formed ACME POST request has, and to ensure there is a body to +// process. +func (wfe *WebFrontEndImpl) validPOSTRequest(request *http.Request) *probs.ProblemDetails { + // All POSTs should have an accompanying Content-Length header + if _, present := request.Header["Content-Length"]; !present { + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "ContentLengthRequired"}).Inc() + return probs.ContentLengthRequired() + } + + // Per 6.2 ALL POSTs should have the correct JWS Content-Type for flattened + // JSON serialization. + if _, present := request.Header["Content-Type"]; !present { + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "NoContentType"}).Inc() + return probs.InvalidContentType(fmt.Sprintf("No Content-Type header on POST. Content-Type must be %q", + expectedJWSContentType)) + } + if contentType := request.Header.Get("Content-Type"); contentType != expectedJWSContentType { + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "WrongContentType"}).Inc() + return probs.InvalidContentType(fmt.Sprintf("Invalid Content-Type header on POST. Content-Type must be %q", + expectedJWSContentType)) + } + + // Per 6.4.1 "Replay-Nonce" clients should not send a Replay-Nonce header in + // the HTTP request, it needs to be part of the signed JWS request body + if _, present := request.Header["Replay-Nonce"]; present { + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "ReplayNonceOutsideJWS"}).Inc() + return probs.Malformed("HTTP requests should NOT contain Replay-Nonce header. Use JWS nonce field") + } + + // All POSTs should have a non-nil body + if request.Body == nil { + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "NoPOSTBody"}).Inc() + return probs.Malformed("No body on POST") + } + + return nil +} + +// nonceWellFormed checks a JWS' Nonce header to ensure it is well-formed, +// otherwise a bad nonce problem is returned. This avoids unnecessary RPCs to +// the nonce redemption service. +func nonceWellFormed(nonceHeader string, prefixLen int) *probs.ProblemDetails { + errBadNonce := probs.BadNonce(fmt.Sprintf("JWS has an invalid anti-replay nonce: %q", nonceHeader)) + if len(nonceHeader) <= prefixLen { + // Nonce header was an unexpected length because there is either: + // 1) no nonce, or + // 2) no nonce material after the prefix. + return errBadNonce + } + body, err := base64.RawURLEncoding.DecodeString(nonceHeader[prefixLen:]) + if err != nil { + // Nonce was not valid base64url. + return errBadNonce + } + if len(body) != nonce.NonceLen { + // Nonce was an unexpected length. + return errBadNonce + } + return nil +} + +// validNonce checks a JWS' Nonce header to ensure it is one that the +// nonceService knows about, otherwise a bad nonce problem is returned. +// NOTE: this function assumes the JWS has already been verified with the +// correct public key. +func (wfe *WebFrontEndImpl) validNonce(ctx context.Context, header jose.Header) *probs.ProblemDetails { + if len(header.Nonce) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSMissingNonce"}).Inc() + return probs.BadNonce("JWS has no anti-replay nonce") + } + + prob := nonceWellFormed(header.Nonce, nonce.PrefixLen) + if prob != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSMalformedNonce"}).Inc() + return prob + } + + // Populate the context with the nonce prefix and HMAC key. These are + // used by a custom gRPC balancer, known as "noncebalancer", to route + // redemption RPCs to the backend that originally issued the nonce. + ctx = context.WithValue(ctx, nonce.PrefixCtxKey{}, header.Nonce[:nonce.PrefixLen]) + ctx = context.WithValue(ctx, nonce.HMACKeyCtxKey{}, wfe.rncKey) + + resp, err := wfe.rnc.Redeem(ctx, &noncepb.NonceMessage{Nonce: header.Nonce}) + if err != nil { + rpcStatus, ok := status.FromError(err) + if !ok || rpcStatus != nb.ErrNoBackendsMatchPrefix { + return web.ProblemDetailsForError(err, "failed to redeem nonce") + } + + // ErrNoBackendsMatchPrefix suggests that the nonce backend, which + // issued this nonce, is presently unreachable or unrecognized by + // this WFE. As this is a transient failure, the client should retry + // their request with a fresh nonce. + resp = &noncepb.ValidMessage{Valid: false} + wfe.stats.nonceNoMatchingBackendCount.Inc() + } + + if !resp.Valid { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSInvalidNonce"}).Inc() + return probs.BadNonce(fmt.Sprintf("JWS has an invalid anti-replay nonce: %q", header.Nonce)) + } + return nil +} + +// validPOSTURL checks the JWS' URL header against the expected URL based on the +// HTTP request. This prevents a JWS intended for one endpoint being replayed +// against a different endpoint. If the URL isn't present, is invalid, or +// doesn't match the HTTP request a problem is returned. +func (wfe *WebFrontEndImpl) validPOSTURL( + request *http.Request, + header jose.Header) *probs.ProblemDetails { + extraHeaders := header.ExtraHeaders + // Check that there is at least one Extra Header + if len(extraHeaders) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSNoExtraHeaders"}).Inc() + return probs.Malformed("JWS header parameter 'url' required") + } + // Try to read a 'url' Extra Header as a string + headerURL, ok := extraHeaders[jose.HeaderKey("url")].(string) + if !ok || len(headerURL) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSMissingURL"}).Inc() + return probs.Malformed("JWS header parameter 'url' required") + } + // Compute the URL we expect to be in the JWS based on the HTTP request + expectedURL := url.URL{ + Scheme: requestProto(request), + Host: request.Host, + Path: request.RequestURI, + } + // Check that the URL we expect is the one that was found in the signed JWS + // header + if expectedURL.String() != headerURL { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSMismatchedURL"}).Inc() + return probs.Malformed(fmt.Sprintf( + "JWS header parameter 'url' incorrect. Expected %q got %q", + expectedURL.String(), headerURL)) + } + return nil +} + +// matchJWSURLs checks two JWS' URL headers are equal. This is used during key +// rollover to check that the inner JWS URL matches the outer JWS URL. If the +// JWS URLs do not match a problem is returned. +func (wfe *WebFrontEndImpl) matchJWSURLs(outer, inner jose.Header) *probs.ProblemDetails { + // Verify that the outer JWS has a non-empty URL header. This is strictly + // defensive since the expectation is that endpoints using `matchJWSURLs` + // have received at least one of their JWS from calling validPOSTForAccount(), + // which checks the outer JWS has the expected URL header before processing + // the inner JWS. + outerURL, ok := outer.ExtraHeaders[jose.HeaderKey("url")].(string) + if !ok || len(outerURL) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverOuterJWSNoURL"}).Inc() + return probs.Malformed("Outer JWS header parameter 'url' required") + } + + // Verify the inner JWS has a non-empty URL header. + innerURL, ok := inner.ExtraHeaders[jose.HeaderKey("url")].(string) + if !ok || len(innerURL) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverInnerJWSNoURL"}).Inc() + return probs.Malformed("Inner JWS header parameter 'url' required") + } + + // Verify that the outer URL matches the inner URL + if outerURL != innerURL { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverMismatchedURLs"}).Inc() + return probs.Malformed(fmt.Sprintf( + "Outer JWS 'url' value %q does not match inner JWS 'url' value %q", + outerURL, innerURL)) + } + + return nil +} + +// bJSONWebSignature is a new distinct type which embeds the +// *jose.JSONWebSignature concrete type. Callers must never create their own +// bJSONWebSignature. Instead they should rely upon wfe.parseJWS instead. +type bJSONWebSignature struct { + *jose.JSONWebSignature +} + +// parseJWS extracts a JSONWebSignature from a byte slice. If there is an error +// reading the JWS or it is unacceptable (e.g. too many/too few signatures, +// presence of unprotected headers) a problem is returned, otherwise a +// *bJSONWebSignature is returned. +func (wfe *WebFrontEndImpl) parseJWS(body []byte) (*bJSONWebSignature, *probs.ProblemDetails) { + // Parse the raw JWS JSON to check that: + // * the unprotected Header field is not being used. + // * the "signatures" member isn't present, just "signature". + // + // This must be done prior to `jose.parseSigned` since it will strip away + // these headers. + var unprotected struct { + Header map[string]string + Signatures []interface{} + } + err := json.Unmarshal(body, &unprotected) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSUnmarshalFailed"}).Inc() + return nil, probs.Malformed("Parse error reading JWS") + } + + // ACME v2 never uses values from the unprotected JWS header. Reject JWS that + // include unprotected headers. + if unprotected.Header != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSUnprotectedHeaders"}).Inc() + return nil, probs.Malformed( + "JWS \"header\" field not allowed. All headers must be in \"protected\" field") + } + + // ACME v2 never uses the "signatures" array of JSON serialized JWS, just the + // mandatory "signature" field. Reject JWS that include the "signatures" array. + if len(unprotected.Signatures) > 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSMultiSig"}).Inc() + return nil, probs.Malformed( + "JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature") + } + + // Parse the JWS using go-jose and enforce that the expected one non-empty + // signature is present in the parsed JWS. + bodyStr := string(body) + parsedJWS, err := jose.ParseSigned(bodyStr, getSupportedAlgs()) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSParseError"}).Inc() + return nil, probs.Malformed("Parse error reading JWS") + } + if len(parsedJWS.Signatures) > 1 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSTooManySignatures"}).Inc() + return nil, probs.Malformed("Too many signatures in POST body") + } + if len(parsedJWS.Signatures) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSNoSignatures"}).Inc() + return nil, probs.Malformed("POST JWS not signed") + } + if len(parsedJWS.Signatures) == 1 && len(parsedJWS.Signatures[0].Signature) == 0 { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSEmptySignature"}).Inc() + return nil, probs.Malformed("POST JWS not signed") + } + + return &bJSONWebSignature{parsedJWS}, nil +} + +// parseJWSRequest extracts a bJSONWebSignature from an HTTP POST request's body using parseJWS. +func (wfe *WebFrontEndImpl) parseJWSRequest(request *http.Request) (*bJSONWebSignature, *probs.ProblemDetails) { + // Verify that the POST request has the expected headers + if prob := wfe.validPOSTRequest(request); prob != nil { + return nil, prob + } + + // Read the POST request body's bytes. validPOSTRequest has already checked + // that the body is non-nil + bodyBytes, err := io.ReadAll(http.MaxBytesReader(nil, request.Body, maxRequestSize)) + if err != nil { + if err.Error() == "http: request body too large" { + return nil, probs.Unauthorized("request body too large") + } + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": "UnableToReadReqBody"}).Inc() + return nil, probs.ServerInternal("unable to read request body") + } + + jws, prob := wfe.parseJWS(bodyBytes) + if prob != nil { + return nil, prob + } + + return jws, nil +} + +// extractJWK extracts a JWK from the protected headers of a bJSONWebSignature +// or returns a problem. It expects that the JWS is using the embedded JWK style +// of authentication and does not contain an embedded Key ID. Callers should +// have acquired the headers from a bJSONWebSignature returned by parseJWS to +// ensure it has the correct number of signatures present. +func (wfe *WebFrontEndImpl) extractJWK(header jose.Header) (*jose.JSONWebKey, *probs.ProblemDetails) { + // extractJWK expects the request to be using an embedded JWK auth type and + // to not contain the mutually exclusive KeyID. + if prob := wfe.enforceJWSAuthType(header, embeddedJWK); prob != nil { + return nil, prob + } + + // We can be sure that JSONWebKey is != nil because we have already called + // enforceJWSAuthType() + key := header.JSONWebKey + + // If the key isn't considered valid by go-jose return a problem immediately + if !key.Valid() { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWKInvalid"}).Inc() + return nil, probs.Malformed("Invalid JWK in JWS header") + } + + return key, nil +} + +// acctIDFromURL extracts the numeric int64 account ID from a ACMEv1 or ACMEv2 +// account URL. If the acctURL has an invalid URL or the account ID in the +// acctURL is non-numeric a MalformedProblem is returned. +func (wfe *WebFrontEndImpl) acctIDFromURL(acctURL string, request *http.Request) (int64, *probs.ProblemDetails) { + // For normal ACME v2 accounts we expect the account URL has a prefix composed + // of the Host header and the acctPath. + expectedURLPrefix := web.RelativeEndpoint(request, acctPath) + + // Process the acctURL to find only the trailing numeric account ID. Both the + // expected URL prefix and a legacy URL prefix are permitted in order to allow + // ACME v1 clients to use legacy accounts with unmodified account URLs for V2 + // requests. + var accountIDStr string + if strings.HasPrefix(acctURL, expectedURLPrefix) { + accountIDStr = strings.TrimPrefix(acctURL, expectedURLPrefix) + } else if strings.HasPrefix(acctURL, wfe.LegacyKeyIDPrefix) { + accountIDStr = strings.TrimPrefix(acctURL, wfe.LegacyKeyIDPrefix) + } else { + return 0, probs.Malformed( + fmt.Sprintf("KeyID header contained an invalid account URL: %q", acctURL)) + } + + // Convert the raw account ID string to an int64 for use with the SA's + // GetRegistration RPC + accountID, err := strconv.ParseInt(accountIDStr, 10, 64) + if err != nil { + return 0, probs.Malformed("Malformed account ID in KeyID header URL: %q", acctURL) + } + return accountID, nil +} + +// lookupJWK finds a JWK associated with the Key ID present in the provided +// headers, returning the JWK and a pointer to the associated account, or a +// problem. It expects that the JWS header is using the embedded Key ID style of +// authentication and does not contain an embedded JWK. Callers should have +// acquired headers from a bJSONWebSignature. +func (wfe *WebFrontEndImpl) lookupJWK( + header jose.Header, + ctx context.Context, + request *http.Request, + logEvent *web.RequestEvent) (*jose.JSONWebKey, *core.Registration, *probs.ProblemDetails) { + // We expect the request to be using an embedded Key ID auth type and to not + // contain the mutually exclusive embedded JWK. + if prob := wfe.enforceJWSAuthType(header, embeddedKeyID); prob != nil { + return nil, nil, prob + } + + accountURL := header.KeyID + accountID, prob := wfe.acctIDFromURL(accountURL, request) + if prob != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSInvalidKeyID"}).Inc() + return nil, nil, prob + } + + // Try to find the account for this account ID + account, err := wfe.accountGetter.GetRegistration(ctx, &sapb.RegistrationID{Id: accountID}) + if err != nil { + // If the account isn't found, return a suitable problem + if errors.Is(err, berrors.NotFound) { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSKeyIDNotFound"}).Inc() + return nil, nil, probs.AccountDoesNotExist(fmt.Sprintf( + "Account %q not found", accountURL)) + } + + // If there was an error and it isn't a "Not Found" error, return + // a ServerInternal problem since this is unexpected. + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSKeyIDLookupFailed"}).Inc() + // Add an error to the log event with the internal error message + logEvent.AddError("calling SA.GetRegistration: %s", err) + return nil, nil, web.ProblemDetailsForError(err, fmt.Sprintf("Error retrieving account %q", accountURL)) + } + + // Verify the account is not deactivated + if core.AcmeStatus(account.Status) != core.StatusValid { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSKeyIDAccountInvalid"}).Inc() + return nil, nil, probs.Unauthorized( + fmt.Sprintf("Account is not valid, has status %q", account.Status)) + } + + // Update the logEvent with the account information and return the JWK + logEvent.Requester = account.Id + + acct, err := grpc.PbToRegistration(account) + if err != nil { + return nil, nil, probs.ServerInternal(fmt.Sprintf( + "Error unmarshalling account %q", accountURL)) + } + return acct.Key, &acct, nil +} + +// validJWSForKey checks a provided JWS for a given HTTP request validates +// correctly using the provided JWK. If the JWS verifies the protected payload +// is returned. The key/JWS algorithms are verified and +// the JWK is checked against the keyPolicy before any signature validation is +// done. If the JWS signature validates correctly then the JWS nonce value +// and the JWS URL are verified to ensure that they are correct. +func (wfe *WebFrontEndImpl) validJWSForKey( + ctx context.Context, + jws *bJSONWebSignature, + jwk *jose.JSONWebKey, + request *http.Request) ([]byte, *probs.ProblemDetails) { + err := checkAlgorithm(jwk, jws.Signatures[0].Header) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSAlgorithmCheckFailed"}).Inc() + return nil, probs.BadSignatureAlgorithm(err.Error()) + } + + // Verify the JWS signature with the public key. + // NOTE: It might seem insecure for the WFE to be trusted to verify + // client requests, i.e., that the verification should be done at the + // RA. However the WFE is the RA's only view of the outside world + // *anyway*, so it could always lie about what key was used by faking + // the signature itself. + payload, err := jws.Verify(jwk) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSVerifyFailed"}).Inc() + return nil, probs.Malformed("JWS verification error") + } + + // Check that the JWS contains a correct Nonce header + if prob := wfe.validNonce(ctx, jws.Signatures[0].Header); prob != nil { + return nil, prob + } + + // Check that the HTTP request URL matches the URL in the signed JWS + if prob := wfe.validPOSTURL(request, jws.Signatures[0].Header); prob != nil { + return nil, prob + } + + // In the WFE1 package the check for the request URL required unmarshalling + // the payload JSON to check the "resource" field of the protected JWS body. + // This caught invalid JSON early and so we preserve this check by explicitly + // trying to unmarshal the payload (when it is non-empty to allow POST-as-GET + // behaviour) as part of the verification and failing early if it isn't valid JSON. + var parsedBody struct{} + err = json.Unmarshal(payload, &parsedBody) + if string(payload) != "" && err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWSBodyUnmarshalFailed"}).Inc() + return nil, probs.Malformed("Request payload did not parse as JSON") + } + + return payload, nil +} + +// validJWSForAccount checks that a given JWS is valid and verifies with the +// public key associated to a known account specified by the JWS Key ID. If the +// JWS is valid (e.g. the JWS is well formed, verifies with the JWK stored for the +// specified key ID, specifies the correct URL, and has a valid nonce) then +// `validJWSForAccount` returns the validated JWS body, the parsed +// JSONWebSignature, and a pointer to the JWK's associated account. If any of +// these conditions are not met or an error occurs only a problem is returned. +func (wfe *WebFrontEndImpl) validJWSForAccount( + jws *bJSONWebSignature, + request *http.Request, + ctx context.Context, + logEvent *web.RequestEvent) ([]byte, *bJSONWebSignature, *core.Registration, *probs.ProblemDetails) { + // Lookup the account and JWK for the key ID that authenticated the JWS + pubKey, account, prob := wfe.lookupJWK(jws.Signatures[0].Header, ctx, request, logEvent) + if prob != nil { + return nil, nil, nil, prob + } + + // Verify the JWS with the JWK from the SA + payload, prob := wfe.validJWSForKey(ctx, jws, pubKey, request) + if prob != nil { + return nil, nil, nil, prob + } + + return payload, jws, account, nil +} + +// validPOSTForAccount checks that a given POST request has a valid JWS +// using `validJWSForAccount`. If valid, the authenticated JWS body and the +// registration that authenticated the body are returned. Otherwise a problem is +// returned. The returned JWS body may be empty if the request is a POST-as-GET +// request. +func (wfe *WebFrontEndImpl) validPOSTForAccount( + request *http.Request, + ctx context.Context, + logEvent *web.RequestEvent) ([]byte, *bJSONWebSignature, *core.Registration, *probs.ProblemDetails) { + // Parse the JWS from the POST request + jws, prob := wfe.parseJWSRequest(request) + if prob != nil { + return nil, nil, nil, prob + } + return wfe.validJWSForAccount(jws, request, ctx, logEvent) +} + +// validPOSTAsGETForAccount checks that a given POST request is valid using +// `validPOSTForAccount`. It additionally validates that the JWS request payload +// is empty, indicating that it is a POST-as-GET request per ACME draft 15+ +// section 6.3 "GET and POST-as-GET requests". If a non empty payload is +// provided in the JWS the invalidPOSTAsGETErr problem is returned. This +// function is useful only for endpoints that do not need to handle both POSTs +// with a body and POST-as-GET requests (e.g. Order, Certificate). +func (wfe *WebFrontEndImpl) validPOSTAsGETForAccount( + request *http.Request, + ctx context.Context, + logEvent *web.RequestEvent) (*core.Registration, *probs.ProblemDetails) { + // Call validPOSTForAccount to verify the JWS and extract the body. + body, _, reg, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + if prob != nil { + return nil, prob + } + // Verify the POST-as-GET payload is empty + if string(body) != "" { + return nil, probs.Malformed("POST-as-GET requests must have an empty payload") + } + // To make log analysis easier we choose to elevate the pseudo ACME HTTP + // method "POST-as-GET" to the logEvent's Method, replacing the + // http.MethodPost value. + logEvent.Method = "POST-as-GET" + return reg, prob +} + +// validSelfAuthenticatedJWS checks that a given JWS verifies with the JWK +// embedded in the JWS itself (e.g. self-authenticated). This type of JWS +// is only used for creating new accounts or revoking a certificate by signing +// the request with the private key corresponding to the certificate's public +// key and embedding that public key in the JWS. All other request should be +// validated using `validJWSforAccount`. +// If the JWS validates (e.g. the JWS is well formed, verifies with the JWK +// embedded in it, has the correct URL, and includes a valid nonce) then +// `validSelfAuthenticatedJWS` returns the validated JWS body and the JWK that +// was embedded in the JWS. Otherwise if the valid JWS conditions are not met or +// an error occurs only a problem is returned. +// Note that this function does *not* enforce that the JWK abides by our goodkey +// policies. This is because this method is used by the RevokeCertificate path, +// which must allow JWKs which are signed by blocklisted (i.e. already revoked +// due to compromise) keys, in case multiple clients attempt to revoke the same +// cert. +func (wfe *WebFrontEndImpl) validSelfAuthenticatedJWS( + ctx context.Context, + jws *bJSONWebSignature, + request *http.Request) ([]byte, *jose.JSONWebKey, *probs.ProblemDetails) { + // Extract the embedded JWK from the parsed protected JWS' headers + pubKey, prob := wfe.extractJWK(jws.Signatures[0].Header) + if prob != nil { + return nil, nil, prob + } + + // Verify the JWS with the embedded JWK + payload, prob := wfe.validJWSForKey(ctx, jws, pubKey, request) + if prob != nil { + return nil, nil, prob + } + + return payload, pubKey, nil +} + +// validSelfAuthenticatedPOST checks that a given POST request has a valid JWS +// using `validSelfAuthenticatedJWS`. It enforces that the JWK abides by our +// goodkey policies (key algorithm, length, blocklist, etc). +func (wfe *WebFrontEndImpl) validSelfAuthenticatedPOST( + ctx context.Context, + request *http.Request) ([]byte, *jose.JSONWebKey, *probs.ProblemDetails) { + // Parse the JWS from the POST request + jws, prob := wfe.parseJWSRequest(request) + if prob != nil { + return nil, nil, prob + } + + // Extract and validate the embedded JWK from the parsed JWS + payload, pubKey, prob := wfe.validSelfAuthenticatedJWS(ctx, jws, request) + if prob != nil { + return nil, nil, prob + } + + // If the key doesn't meet the GoodKey policy return a problem + err := wfe.keyPolicy.GoodKey(ctx, pubKey.Key) + if err != nil { + if errors.Is(err, goodkey.ErrBadKey) { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "JWKRejectedByGoodKey"}).Inc() + return nil, nil, probs.BadPublicKey(err.Error()) + } + return nil, nil, probs.ServerInternal("error checking key quality") + } + + return payload, pubKey, nil +} + +// rolloverRequest is a client request to change the key for the account ID +// provided from the specified old key to a new key (the embedded JWK in the +// inner JWS). +type rolloverRequest struct { + OldKey jose.JSONWebKey + Account string +} + +// rolloverOperation is a struct representing a requested rollover operation +// from the specified old key to the new key for the given account ID. +type rolloverOperation struct { + rolloverRequest + NewKey jose.JSONWebKey +} + +// validKeyRollover checks if the innerJWS is a valid key rollover operation +// given the outer JWS that carried it. It is assumed that the outerJWS has +// already been validated per the normal ACME process using `validPOSTForAccount`. +// It is *critical* this is the case since `validKeyRollover` does not check the +// outerJWS signature. This function checks that: +// 1) the inner JWS is valid and well formed +// 2) the inner JWS has the same "url" header as the outer JWS +// 3) the inner JWS is self-authenticated with an embedded JWK +// +// This function verifies that the inner JWS' body is a rolloverRequest instance +// that specifies the correct oldKey. The returned rolloverOperation's NewKey +// field will be set to the JWK from the inner JWS. +// +// If the request is valid a *rolloverOperation object is returned, +// otherwise a problem is returned. The caller is left to verify +// whether the new key is appropriate (e.g. isn't being used by another existing +// account) and that the account field of the rollover object matches the +// account that verified the outer JWS. +func (wfe *WebFrontEndImpl) validKeyRollover( + ctx context.Context, + outerJWS *bJSONWebSignature, + innerJWS *bJSONWebSignature, + oldKey *jose.JSONWebKey) (*rolloverOperation, *probs.ProblemDetails) { + + // Extract the embedded JWK from the inner JWS' protected headers + innerJWK, prob := wfe.extractJWK(innerJWS.Signatures[0].Header) + if prob != nil { + return nil, prob + } + + // If the key doesn't meet the GoodKey policy return a problem immediately + err := wfe.keyPolicy.GoodKey(ctx, innerJWK.Key) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverJWKRejectedByGoodKey"}).Inc() + return nil, probs.BadPublicKey(err.Error()) + } + + // Check that the public key and JWS algorithms match expected + err = checkAlgorithm(innerJWK, innerJWS.Signatures[0].Header) + if err != nil { + return nil, probs.Malformed(err.Error()) + } + + // Verify the inner JWS signature with the public key from the embedded JWK. + // NOTE(@cpu): We do not use `wfe.validJWSForKey` here because the inner JWS + // of a key rollover operation is special (e.g. has no nonce, doesn't have an + // HTTP request to match the URL to) + innerPayload, err := innerJWS.Verify(innerJWK) + if err != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverJWSVerifyFailed"}).Inc() + return nil, probs.Malformed("Inner JWS does not verify with embedded JWK") + } + // NOTE(@cpu): we do not stomp the web.RequestEvent's payload here since that is set + // from the outerJWS in validPOSTForAccount and contains the inner JWS and inner + // payload already. + + // Verify that the outer and inner JWS protected URL headers match + if prob := wfe.matchJWSURLs(outerJWS.Signatures[0].Header, innerJWS.Signatures[0].Header); prob != nil { + return nil, prob + } + + var req rolloverRequest + if json.Unmarshal(innerPayload, &req) != nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverUnmarshalFailed"}).Inc() + return nil, probs.Malformed( + "Inner JWS payload did not parse as JSON key rollover object") + } + + // If there's no oldkey specified fail before trying to use + // core.PublicKeyEqual on a nil argument. + if req.OldKey.Key == nil { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverWrongOldKey"}).Inc() + return nil, probs.Malformed("Inner JWS does not contain old key field matching current account key") + } + + // We must validate that the inner JWS' rollover request specifies the correct + // oldKey. + if keysEqual, err := core.PublicKeysEqual(req.OldKey.Key, oldKey.Key); err != nil { + return nil, probs.Malformed("Unable to compare new and old keys: %s", err.Error()) + } else if !keysEqual { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverWrongOldKey"}).Inc() + return nil, probs.Malformed("Inner JWS does not contain old key field matching current account key") + } + + // Return a rolloverOperation populated with the validated old JWK, the + // requested account, and the new JWK extracted from the inner JWS. + return &rolloverOperation{ + rolloverRequest: rolloverRequest{ + OldKey: *oldKey, + Account: req.Account, + }, + NewKey: *innerJWK, + }, nil +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/verify_test.go b/third-party/github.com/letsencrypt/boulder/wfe2/verify_test.go new file mode 100644 index 000000000..bc74f8c35 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/verify_test.go @@ -0,0 +1,1775 @@ +package wfe2 + +import ( + "context" + "crypto" + "crypto/dsa" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/grpc/noncebalancer" + noncepb "github.com/letsencrypt/boulder/nonce/proto" + "github.com/letsencrypt/boulder/probs" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/web" + + "github.com/go-jose/go-jose/v4" + "google.golang.org/grpc" +) + +// sigAlgForKey uses `signatureAlgorithmForKey` but fails immediately using the +// testing object if the sig alg is unknown. +func sigAlgForKey(t *testing.T, key interface{}) jose.SignatureAlgorithm { + var sigAlg jose.SignatureAlgorithm + var err error + // Gracefully handle the case where a non-pointer public key is given where + // sigAlgorithmForKey always wants a pointer. It may be tempting to try and do + // `sigAlgorithmForKey(&jose.JSONWebKey{Key: &key})` without a type switch but this produces + // `*interface {}` and not the desired `*rsa.PublicKey` or `*ecdsa.PublicKey`. + switch k := key.(type) { + case rsa.PublicKey: + sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: &k}) + case ecdsa.PublicKey: + sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: &k}) + default: + sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: k}) + } + test.Assert(t, err == nil, fmt.Sprintf("Error getting signature algorithm for key %#v", key)) + return sigAlg +} + +// keyAlgForKey returns a JWK key algorithm based on the provided private key. +// Only ECDSA and RSA private keys are supported. +func keyAlgForKey(t *testing.T, key interface{}) string { + switch key.(type) { + case *rsa.PrivateKey, rsa.PrivateKey: + return "RSA" + case *ecdsa.PrivateKey, ecdsa.PrivateKey: + return "ECDSA" + } + t.Fatalf("Can't figure out keyAlgForKey: %#v", key) + return "" +} + +// pubKeyForKey returns the public key of an RSA/ECDSA private key provided as +// argument. +func pubKeyForKey(t *testing.T, privKey interface{}) interface{} { + switch k := privKey.(type) { + case *rsa.PrivateKey: + return k.PublicKey + case *ecdsa.PrivateKey: + return k.PublicKey + } + t.Fatalf("Unable to get public key for private key %#v", privKey) + return nil +} + +// requestSigner offers methods to sign requests that will be accepted by a +// specific WFE in unittests. It is only valid for the lifetime of a single +// unittest. +type requestSigner struct { + t *testing.T + nonceService jose.NonceSource +} + +// embeddedJWK creates a JWS for a given request body with an embedded JWK +// corresponding to the private key provided. The URL and nonce extra headers +// are set based on the additional arguments. A computed JWS, the corresponding +// embedded JWK and the JWS in serialized string form are returned. +func (rs requestSigner) embeddedJWK( + privateKey interface{}, + url string, + req string) (*jose.JSONWebSignature, *jose.JSONWebKey, string) { + // if no key is provided default to test1KeyPrivatePEM + var publicKey interface{} + if privateKey == nil { + signer := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + privateKey = signer + publicKey = signer.Public() + } else { + publicKey = pubKeyForKey(rs.t, privateKey) + } + + signerKey := jose.SigningKey{ + Key: privateKey, + Algorithm: sigAlgForKey(rs.t, publicKey), + } + + opts := &jose.SignerOptions{ + NonceSource: rs.nonceService, + EmbedJWK: true, + } + if url != "" { + opts.ExtraHeaders = map[jose.HeaderKey]interface{}{ + "url": url, + } + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + + jws, err := signer.Sign([]byte(req)) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS, parsedJWS.Signatures[0].Header.JSONWebKey, body +} + +// signRequestKeyID creates a JWS for a given request body with key ID specified +// based on the ID number provided. The URL and nonce extra headers +// are set based on the additional arguments. A computed JWS, the corresponding +// embedded JWK and the JWS in serialized string form are returned. +func (rs requestSigner) byKeyID( + keyID int64, + privateKey interface{}, + url string, + req string) (*jose.JSONWebSignature, *jose.JSONWebKey, string) { + // if no key is provided default to test1KeyPrivatePEM + if privateKey == nil { + privateKey = loadKey(rs.t, []byte(test1KeyPrivatePEM)) + } + + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: keyAlgForKey(rs.t, privateKey), + KeyID: fmt.Sprintf("http://localhost/acme/acct/%d", keyID), + } + + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + NonceSource: rs.nonceService, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": url, + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + jws, err := signer.Sign([]byte(req)) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS, jwk, body +} + +// missingNonce returns an otherwise well-signed request that is missing its +// nonce. +func (rs requestSigner) missingNonce() *jose.JSONWebSignature { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: keyAlgForKey(rs.t, privateKey), + KeyID: "http://localhost/acme/acct/1", + } + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": "https://example.com/acme/foo", + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + return jws +} + +// invalidNonce returns an otherwise well-signed request with an invalid nonce. +func (rs requestSigner) invalidNonce() *jose.JSONWebSignature { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: keyAlgForKey(rs.t, privateKey), + KeyID: "http://localhost/acme/acct/1", + } + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + NonceSource: badNonceProvider{}, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": "https://example.com/acme/foo", + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS +} + +// malformedNonce returns an otherwise well-signed request with a malformed +// nonce. +func (rs requestSigner) malformedNonce() *jose.JSONWebSignature { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: keyAlgForKey(rs.t, privateKey), + KeyID: "http://localhost/acme/acct/1", + } + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + NonceSource: badNonceProvider{malformed: true}, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": "https://example.com/acme/foo", + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS +} + +// shortNonce returns an otherwise well-signed request with a nonce shorter than +// the prefix length. +func (rs requestSigner) shortNonce() *jose.JSONWebSignature { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: keyAlgForKey(rs.t, privateKey), + KeyID: "http://localhost/acme/acct/1", + } + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + NonceSource: badNonceProvider{shortNonce: true}, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": "https://example.com/acme/foo", + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS +} + +func TestRejectsNone(t *testing.T) { + noneJWSBody := ` + { + "header": { + "alg": "none", + "jwk": { + "kty": "RSA", + "n": "vrjT", + "e": "AQAB" + } + }, + "payload": "aGkK", + "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" + } + ` + _, err := jose.ParseSigned(noneJWSBody, getSupportedAlgs()) + test.AssertError(t, err, "Should not have been able to parse 'none' algorithm") +} + +func TestRejectsHS256(t *testing.T) { + hs256JWSBody := ` + { + "header": { + "alg": "HS256", + "jwk": { + "kty": "RSA", + "n": "vrjT", + "e": "AQAB" + } + }, + "payload": "aGkK", + "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" + } + ` + + _, err := jose.ParseSigned(hs256JWSBody, getSupportedAlgs()) + fmt.Println(err) + test.AssertError(t, err, "Parsed hs256JWSBody, but should not have") +} + +func TestCheckAlgorithm(t *testing.T) { + testCases := []struct { + key jose.JSONWebKey + jws jose.JSONWebSignature + expectedErr string + }{ + { + jose.JSONWebKey{}, + jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "RS256", + }, + }, + }, + }, + "JWK contains unsupported key type (expected RSA, or ECDSA P-256, P-384, or P-521)", + }, + { + jose.JSONWebKey{ + Algorithm: "HS256", + Key: &rsa.PublicKey{}, + }, + jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "HS256", + }, + }, + }, + }, + "JWS signature header contains unsupported algorithm \"HS256\", expected one of [RS256 ES256 ES384 ES512]", + }, + { + jose.JSONWebKey{ + Algorithm: "ES256", + Key: &dsa.PublicKey{}, + }, + jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "ES512", + }, + }, + }, + }, + "JWK contains unsupported key type (expected RSA, or ECDSA P-256, P-384, or P-521)", + }, + { + jose.JSONWebKey{ + Algorithm: "RS256", + Key: &rsa.PublicKey{}, + }, + jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "ES512", + }, + }, + }, + }, + "JWS signature header algorithm \"ES512\" does not match expected algorithm \"RS256\" for JWK", + }, + { + jose.JSONWebKey{ + Algorithm: "HS256", + Key: &rsa.PublicKey{}, + }, + jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "RS256", + }, + }, + }, + }, + "JWK key header algorithm \"HS256\" does not match expected algorithm \"RS256\" for JWK", + }, + } + for i, tc := range testCases { + err := checkAlgorithm(&tc.key, tc.jws.Signatures[0].Header) + if tc.expectedErr != "" && err.Error() != tc.expectedErr { + t.Errorf("TestCheckAlgorithm %d: Expected %q, got %q", i, tc.expectedErr, err) + } + } +} + +func TestCheckAlgorithmSuccess(t *testing.T) { + jwsRS256 := &jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "RS256", + }, + }, + }, + } + goodJSONWebKeyRS256 := &jose.JSONWebKey{ + Algorithm: "RS256", + Key: &rsa.PublicKey{}, + } + err := checkAlgorithm(goodJSONWebKeyRS256, jwsRS256.Signatures[0].Header) + test.AssertNotError(t, err, "RS256 key: Expected nil error") + + badJSONWebKeyRS256 := &jose.JSONWebKey{ + Algorithm: "ObviouslyWrongButNotZeroValue", + Key: &rsa.PublicKey{}, + } + err = checkAlgorithm(badJSONWebKeyRS256, jwsRS256.Signatures[0].Header) + test.AssertError(t, err, "RS256 key: Expected nil error") + test.AssertContains(t, err.Error(), "JWK key header algorithm \"ObviouslyWrongButNotZeroValue\" does not match expected algorithm \"RS256\" for JWK") + + jwsES256 := &jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "ES256", + }, + }, + }, + } + goodJSONWebKeyES256 := &jose.JSONWebKey{ + Algorithm: "ES256", + Key: &ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + } + err = checkAlgorithm(goodJSONWebKeyES256, jwsES256.Signatures[0].Header) + test.AssertNotError(t, err, "ES256 key: Expected nil error") + + badJSONWebKeyES256 := &jose.JSONWebKey{ + Algorithm: "ObviouslyWrongButNotZeroValue", + Key: &ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + } + err = checkAlgorithm(badJSONWebKeyES256, jwsES256.Signatures[0].Header) + test.AssertError(t, err, "ES256 key: Expected nil error") + test.AssertContains(t, err.Error(), "JWK key header algorithm \"ObviouslyWrongButNotZeroValue\" does not match expected algorithm \"ES256\" for JWK") +} + +func TestValidPOSTRequest(t *testing.T) { + wfe, _, _ := setupWFE(t) + + dummyContentLength := []string{"pretty long, idk, maybe a nibble or two?"} + + testCases := []struct { + Name string + Headers map[string][]string + Body *string + HTTPStatus int + ProblemDetail string + ErrorStatType string + EnforceContentType bool + }{ + // POST requests without a Content-Length should produce a problem + { + Name: "POST without a Content-Length header", + Headers: nil, + HTTPStatus: http.StatusLengthRequired, + ProblemDetail: "missing Content-Length header", + ErrorStatType: "ContentLengthRequired", + }, + // POST requests with a Replay-Nonce header should produce a problem + { + Name: "POST with a Replay-Nonce HTTP header", + Headers: map[string][]string{ + "Content-Length": dummyContentLength, + "Replay-Nonce": {"ima-misplaced-nonce"}, + "Content-Type": {expectedJWSContentType}, + }, + HTTPStatus: http.StatusBadRequest, + ProblemDetail: "HTTP requests should NOT contain Replay-Nonce header. Use JWS nonce field", + ErrorStatType: "ReplayNonceOutsideJWS", + }, + // POST requests without a body should produce a problem + { + Name: "POST with an empty POST body", + Headers: map[string][]string{ + "Content-Length": dummyContentLength, + "Content-Type": {expectedJWSContentType}, + }, + HTTPStatus: http.StatusBadRequest, + ProblemDetail: "No body on POST", + ErrorStatType: "NoPOSTBody", + }, + { + Name: "POST without a Content-Type header", + Headers: map[string][]string{ + "Content-Length": dummyContentLength, + }, + HTTPStatus: http.StatusUnsupportedMediaType, + ProblemDetail: fmt.Sprintf( + "No Content-Type header on POST. Content-Type must be %q", + expectedJWSContentType), + ErrorStatType: "NoContentType", + EnforceContentType: true, + }, + { + Name: "POST with an invalid Content-Type header", + Headers: map[string][]string{ + "Content-Length": dummyContentLength, + "Content-Type": {"fresh.and.rare"}, + }, + HTTPStatus: http.StatusUnsupportedMediaType, + ProblemDetail: fmt.Sprintf( + "Invalid Content-Type header on POST. Content-Type must be %q", + expectedJWSContentType), + ErrorStatType: "WrongContentType", + EnforceContentType: true, + }, + } + + for _, tc := range testCases { + input := &http.Request{ + Method: "POST", + URL: mustParseURL("/"), + Header: tc.Headers, + } + t.Run(tc.Name, func(t *testing.T) { + prob := wfe.validPOSTRequest(input) + test.Assert(t, prob != nil, "No error returned for invalid POST") + test.AssertEquals(t, prob.Type, probs.MalformedProblem) + test.AssertEquals(t, prob.HTTPStatus, tc.HTTPStatus) + test.AssertEquals(t, prob.Detail, tc.ProblemDetail) + test.AssertMetricWithLabelsEquals( + t, wfe.stats.httpErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + }) + } +} + +func TestEnforceJWSAuthType(t *testing.T) { + wfe, _, signer := setupWFE(t) + + testKeyIDJWS, _, _ := signer.byKeyID(1, nil, "", "") + testEmbeddedJWS, _, _ := signer.embeddedJWK(nil, "", "") + + // A hand crafted JWS that has both a Key ID and an embedded JWK + conflictJWSBody := ` +{ + "header": { + "alg": "RS256", + "jwk": { + "e": "AQAB", + "kty": "RSA", + "n": "ppbqGaMFnnq9TeMUryR6WW4Lr5WMgp46KlBXZkNaGDNQoifWt6LheeR5j9MgYkIFU7Z8Jw5-bpJzuBeEVwb-yHGh4Umwo_qKtvAJd44iLjBmhBSxq-OSe6P5hX1LGCByEZlYCyoy98zOtio8VK_XyS5VoOXqchCzBXYf32ksVUTrtH1jSlamKHGz0Q0pRKIsA2fLqkE_MD3jP6wUDD6ExMw_tKYLx21lGcK41WSrRpDH-kcZo1QdgCy2ceNzaliBX1eHmKG0-H8tY4tPQudk-oHQmWTdvUIiHO6gSKMGDZNWv6bq74VTCsRfUEAkuWhqUhgRSGzlvlZ24wjHv5Qdlw" + } + }, + "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", + "payload": "Zm9v", + "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" +} +` + + conflictJWS, err := jose.ParseSigned(conflictJWSBody, getSupportedAlgs()) + if err != nil { + t.Fatal("Unable to parse conflict JWS") + } + + testCases := []struct { + Name string + JWS *jose.JSONWebSignature + ExpectedAuthType jwsAuthType + ExpectedResult *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "Key ID and embedded JWS", + JWS: conflictJWS, + ExpectedAuthType: invalidAuthType, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "jwk and kid header fields are mutually exclusive", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeInvalid", + }, + { + Name: "Key ID when expected is embedded JWK", + JWS: testKeyIDJWS, + ExpectedAuthType: embeddedJWK, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No embedded JWK in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeWrong", + }, + { + Name: "Embedded JWK when expected is Key ID", + JWS: testEmbeddedJWS, + ExpectedAuthType: embeddedKeyID, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No Key ID in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeWrong", + }, + { + Name: "Key ID when expected is KeyID", + JWS: testKeyIDJWS, + ExpectedAuthType: embeddedKeyID, + ExpectedResult: nil, + }, + { + Name: "Embedded JWK when expected is embedded JWK", + JWS: testEmbeddedJWS, + ExpectedAuthType: embeddedJWK, + ExpectedResult: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + prob := wfe.enforceJWSAuthType(tc.JWS.Signatures[0].Header, tc.ExpectedAuthType) + if tc.ExpectedResult == nil && prob != nil { + t.Fatalf("Expected nil result, got %#v", prob) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedResult) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +type badNonceProvider struct { + malformed bool + shortNonce bool +} + +func (b badNonceProvider) Nonce() (string, error) { + if b.malformed { + return "im-a-nonce", nil + } + if b.shortNonce { + // A nonce length of 4 is considered "short" because there is no nonce + // material to be redeemed after the prefix. Derived prefixes are 8 + // characters and static prefixes are 4 characters. + return "woww", nil + } + return "mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA", nil +} + +func TestValidNonce(t *testing.T) { + wfe, _, signer := setupWFE(t) + + goodJWS, _, _ := signer.embeddedJWK(nil, "", "") + + testCases := []struct { + Name string + JWS *jose.JSONWebSignature + ExpectedResult *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "No nonce in JWS", + JWS: signer.missingNonce(), + ExpectedResult: &probs.ProblemDetails{ + Type: probs.BadNonceProblem, + Detail: "JWS has no anti-replay nonce", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMissingNonce", + }, + { + Name: "Malformed nonce in JWS", + JWS: signer.malformedNonce(), + ExpectedResult: &probs.ProblemDetails{ + Type: probs.BadNonceProblem, + Detail: "JWS has an invalid anti-replay nonce: \"im-a-nonce\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMalformedNonce", + }, + { + Name: "Canned nonce shorter than prefixLength in JWS", + JWS: signer.shortNonce(), + ExpectedResult: &probs.ProblemDetails{ + Type: probs.BadNonceProblem, + Detail: "JWS has an invalid anti-replay nonce: \"woww\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMalformedNonce", + }, + { + Name: "Invalid nonce in JWS (test/config-next)", + JWS: signer.invalidNonce(), + ExpectedResult: &probs.ProblemDetails{ + Type: probs.BadNonceProblem, + Detail: "JWS has an invalid anti-replay nonce: \"mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSInvalidNonce", + }, + { + Name: "Valid nonce in JWS", + JWS: goodJWS, + ExpectedResult: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + prob := wfe.validNonce(context.Background(), tc.JWS.Signatures[0].Header) + if tc.ExpectedResult == nil && prob != nil { + t.Fatalf("Expected nil result, got %#v", prob) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedResult) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +// noBackendsNonceRedeemer is a nonce redeemer that always returns an error +// indicating that the prefix matches no known nonce provider. +type noBackendsNonceRedeemer struct{} + +func (n noBackendsNonceRedeemer) Redeem(ctx context.Context, _ *noncepb.NonceMessage, opts ...grpc.CallOption) (*noncepb.ValidMessage, error) { + return nil, noncebalancer.ErrNoBackendsMatchPrefix.Err() +} + +func TestValidNonce_NoMatchingBackendFound(t *testing.T) { + wfe, _, signer := setupWFE(t) + goodJWS, _, _ := signer.embeddedJWK(nil, "", "") + wfe.rnc = noBackendsNonceRedeemer{} + + // A valid JWS with a nonce whose prefix matches no known nonce provider should + // result in a BadNonceProblem. + prob := wfe.validNonce(context.Background(), goodJWS.Signatures[0].Header) + test.Assert(t, prob != nil, "Expected error for valid nonce with no backend") + test.AssertEquals(t, prob.Type, probs.BadNonceProblem) + test.AssertEquals(t, prob.HTTPStatus, http.StatusBadRequest) + test.AssertContains(t, prob.Detail, "JWS has an invalid anti-replay nonce") + test.AssertMetricWithLabelsEquals(t, wfe.stats.nonceNoMatchingBackendCount, prometheus.Labels{}, 1) +} + +func (rs requestSigner) signExtraHeaders( + headers map[jose.HeaderKey]interface{}) (*jose.JSONWebSignature, string) { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + + signerKey := jose.SigningKey{ + Key: privateKey, + Algorithm: sigAlgForKey(rs.t, privateKey.Public()), + } + + opts := &jose.SignerOptions{ + NonceSource: rs.nonceService, + EmbedJWK: true, + ExtraHeaders: headers, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS, body +} + +func TestValidPOSTURL(t *testing.T) { + wfe, _, signer := setupWFE(t) + + // A JWS and HTTP request with no extra headers + noHeadersJWS, noHeadersJWSBody := signer.signExtraHeaders(nil) + noHeadersRequest := makePostRequestWithPath("test-path", noHeadersJWSBody) + + // A JWS and HTTP request with extra headers, but no "url" extra header + noURLHeaders := map[jose.HeaderKey]interface{}{ + "nifty": "swell", + } + noURLHeaderJWS, noURLHeaderJWSBody := signer.signExtraHeaders(noURLHeaders) + noURLHeaderRequest := makePostRequestWithPath("test-path", noURLHeaderJWSBody) + + // A JWS and HTTP request with a mismatched HTTP URL to JWS "url" header + wrongURLHeaders := map[jose.HeaderKey]interface{}{ + "url": "foobar", + } + wrongURLHeaderJWS, wrongURLHeaderJWSBody := signer.signExtraHeaders(wrongURLHeaders) + wrongURLHeaderRequest := makePostRequestWithPath("test-path", wrongURLHeaderJWSBody) + + correctURLHeaderJWS, _, correctURLHeaderJWSBody := signer.embeddedJWK(nil, "http://localhost/test-path", "") + correctURLHeaderRequest := makePostRequestWithPath("test-path", correctURLHeaderJWSBody) + + testCases := []struct { + Name string + JWS *jose.JSONWebSignature + Request *http.Request + ExpectedResult *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "No extra headers in JWS", + JWS: noHeadersJWS, + Request: noHeadersRequest, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS header parameter 'url' required", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSNoExtraHeaders", + }, + { + Name: "No URL header in JWS", + JWS: noURLHeaderJWS, + Request: noURLHeaderRequest, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS header parameter 'url' required", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMissingURL", + }, + { + Name: "Wrong URL header in JWS", + JWS: wrongURLHeaderJWS, + Request: wrongURLHeaderRequest, + ExpectedResult: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS header parameter 'url' incorrect. Expected \"http://localhost/test-path\" got \"foobar\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMismatchedURL", + }, + { + Name: "Correct URL header in JWS", + JWS: correctURLHeaderJWS, + Request: correctURLHeaderRequest, + ExpectedResult: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + tc.Request.Header.Add("Content-Type", expectedJWSContentType) + wfe.stats.joseErrorCount.Reset() + prob := wfe.validPOSTURL(tc.Request, tc.JWS.Signatures[0].Header) + if tc.ExpectedResult == nil && prob != nil { + t.Fatalf("Expected nil result, got %#v", prob) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedResult) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func (rs requestSigner) multiSigJWS() (*jose.JSONWebSignature, string) { + privateKeyA := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + privateKeyB := loadKey(rs.t, []byte(test2KeyPrivatePEM)) + + signerKeyA := jose.SigningKey{ + Key: privateKeyA, + Algorithm: sigAlgForKey(rs.t, privateKeyA.Public()), + } + + signerKeyB := jose.SigningKey{ + Key: privateKeyB, + Algorithm: sigAlgForKey(rs.t, privateKeyB.Public()), + } + + opts := &jose.SignerOptions{ + NonceSource: rs.nonceService, + EmbedJWK: true, + } + + signer, err := jose.NewMultiSigner([]jose.SigningKey{signerKeyA, signerKeyB}, opts) + test.AssertNotError(rs.t, err, "Failed to make multi signer") + + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS, body +} + +func TestParseJWSRequest(t *testing.T) { + wfe, _, signer := setupWFE(t) + + _, tooManySigsJWSBody := signer.multiSigJWS() + + _, _, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test-path", "") + validJWSRequest := makePostRequestWithPath("test-path", validJWSBody) + + missingSigsJWSBody := `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ"}` + missingSigsJWSRequest := makePostRequestWithPath("test-path", missingSigsJWSBody) + + unprotectedHeadersJWSBody := ` +{ + "header": { + "alg": "RS256", + "kid": "unprotected key id" + }, + "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", + "payload": "Zm9v", + "signature": "PKWWclRsiHF4bm-nmpxDez6Y_3Mdtu263YeYklbGYt1EiMOLiKY_dr_EqhUUKAKEWysFLO-hQLXVU7kVkHeYWQFFOA18oFgcZgkSF2Pr3DNZrVj9e2gl0eZ2i2jk6X5GYPt1lIfok_DrL92wrxEKGcrmxqXXGm0JgP6Al2VGapKZK2HaYbCHoGvtzNmzUX9rC21sKewq5CquJRvTmvQp5bmU7Q9KeafGibFr0jl6IA3W5LBGgf6xftuUtEVEbKmKaKtaG7tXsQH1mIVOPUZZoLWz9sWJSFLmV0QSXm3ZHV0DrOhLfcADbOCoQBMeGdseBQZuUO541A3BEKGv2Aikjw" +} +` + + wrongSignaturesFieldJWSBody := ` +{ + "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", + "payload": "Zm9v", + "signatures": ["PKWWclRsiHF4bm-nmpxDez6Y_3Mdtu263YeYklbGYt1EiMOLiKY_dr_EqhUUKAKEWysFLO-hQLXVU7kVkHeYWQFFOA18oFgcZgkSF2Pr3DNZrVj9e2gl0eZ2i2jk6X5GYPt1lIfok_DrL92wrxEKGcrmxqXXGm0JgP6Al2VGapKZK2HaYbCHoGvtzNmzUX9rC21sKewq5CquJRvTmvQp5bmU7Q9KeafGibFr0jl6IA3W5LBGgf6xftuUtEVEbKmKaKtaG7tXsQH1mIVOPUZZoLWz9sWJSFLmV0QSXm3ZHV0DrOhLfcADbOCoQBMeGdseBQZuUO541A3BEKGv2Aikjw"] +} +` + + testCases := []struct { + Name string + Request *http.Request + ExpectedProblem *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "Invalid POST request", + // No Content-Length, something that validPOSTRequest should be flagging + Request: &http.Request{ + Method: "POST", + URL: mustParseURL("/"), + }, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "missing Content-Length header", + HTTPStatus: http.StatusLengthRequired, + }, + }, + { + Name: "Invalid JWS in POST body", + Request: makePostRequestWithPath("test-path", `{`), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Parse error reading JWS", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSUnmarshalFailed", + }, + { + Name: "Too few signatures in JWS", + Request: missingSigsJWSRequest, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "POST JWS not signed", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSEmptySignature", + }, + { + Name: "Too many signatures in JWS", + Request: makePostRequestWithPath("test-path", tooManySigsJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMultiSig", + }, + { + Name: "Unprotected JWS headers", + Request: makePostRequestWithPath("test-path", unprotectedHeadersJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS \"header\" field not allowed. All headers must be in \"protected\" field", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSUnprotectedHeaders", + }, + { + Name: "Unsupported signatures field in JWS", + Request: makePostRequestWithPath("test-path", wrongSignaturesFieldJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMultiSig", + }, + { + Name: "Valid JWS in POST request", + Request: validJWSRequest, + ExpectedProblem: nil, + }, + { + Name: "POST body too large", + Request: makePostRequestWithPath("test-path", + fmt.Sprintf(`{"a":"%s"}`, strings.Repeat("a", 50000))), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.UnauthorizedProblem, + Detail: "request body too large", + HTTPStatus: http.StatusForbidden, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + _, prob := wfe.parseJWSRequest(tc.Request) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func TestExtractJWK(t *testing.T) { + wfe, _, signer := setupWFE(t) + + keyIDJWS, _, _ := signer.byKeyID(1, nil, "", "") + goodJWS, goodJWK, _ := signer.embeddedJWK(nil, "", "") + + testCases := []struct { + Name string + JWS *jose.JSONWebSignature + ExpectedKey *jose.JSONWebKey + ExpectedProblem *probs.ProblemDetails + }{ + { + Name: "JWS with wrong auth type (Key ID vs embedded JWK)", + JWS: keyIDJWS, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No embedded JWK in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + }, + { + Name: "Valid JWS with embedded JWK", + JWS: goodJWS, + ExpectedKey: goodJWK, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + jwkHeader, prob := wfe.extractJWK(tc.JWS.Signatures[0].Header) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem == nil { + test.AssertMarshaledEquals(t, jwkHeader, tc.ExpectedKey) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + }) + } +} + +func (rs requestSigner) specifyKeyID(keyID string) (*jose.JSONWebSignature, string) { + privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) + + if keyID == "" { + keyID = "this is an invalid non-numeric key ID" + } + + jwk := &jose.JSONWebKey{ + Key: privateKey, + Algorithm: "RSA", + KeyID: keyID, + } + + signerKey := jose.SigningKey{ + Key: jwk, + Algorithm: jose.RS256, + } + + opts := &jose.SignerOptions{ + NonceSource: rs.nonceService, + ExtraHeaders: map[jose.HeaderKey]interface{}{ + "url": "http://localhost", + }, + } + + signer, err := jose.NewSigner(signerKey, opts) + test.AssertNotError(rs.t, err, "Failed to make signer") + + jws, err := signer.Sign([]byte("")) + test.AssertNotError(rs.t, err, "Failed to sign req") + + body := jws.FullSerialize() + parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) + test.AssertNotError(rs.t, err, "Failed to parse generated JWS") + + return parsedJWS, body +} + +func TestLookupJWK(t *testing.T) { + wfe, _, signer := setupWFE(t) + + embeddedJWS, _, embeddedJWSBody := signer.embeddedJWK(nil, "", "") + invalidKeyIDJWS, invalidKeyIDJWSBody := signer.specifyKeyID("https://acme-99.lettuceencrypt.org/acme/reg/1") + // ID 100 is mocked to return a non-missing error from sa.GetRegistration + errorIDJWS, _, errorIDJWSBody := signer.byKeyID(100, nil, "", "") + // ID 102 is mocked to return an account does not exist error from sa.GetRegistration + missingIDJWS, _, missingIDJWSBody := signer.byKeyID(102, nil, "", "") + // ID 3 is mocked to return a deactivated account from sa.GetRegistration + deactivatedIDJWS, _, deactivatedIDJWSBody := signer.byKeyID(3, nil, "", "") + + wfe.LegacyKeyIDPrefix = "https://acme-v00.lettuceencrypt.org/acme/reg/" + legacyKeyIDJWS, legacyKeyIDJWSBody := signer.specifyKeyID(wfe.LegacyKeyIDPrefix + "1") + + nonNumericKeyIDJWS, nonNumericKeyIDJWSBody := signer.specifyKeyID(wfe.LegacyKeyIDPrefix + "abcd") + + validJWS, validKey, validJWSBody := signer.byKeyID(1, nil, "", "") + validAccountPB, _ := wfe.sa.GetRegistration(context.Background(), &sapb.RegistrationID{Id: 1}) + validAccount, _ := bgrpc.PbToRegistration(validAccountPB) + + // good key, log event requester is set + + testCases := []struct { + Name string + JWS *jose.JSONWebSignature + Request *http.Request + ExpectedProblem *probs.ProblemDetails + ExpectedKey *jose.JSONWebKey + ExpectedAccount *core.Registration + ErrorStatType string + }{ + { + Name: "JWS with wrong auth type (embedded JWK vs Key ID)", + JWS: embeddedJWS, + Request: makePostRequestWithPath("test-path", embeddedJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No Key ID in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeWrong", + }, + { + Name: "JWS with invalid key ID URL", + JWS: invalidKeyIDJWS, + Request: makePostRequestWithPath("test-path", invalidKeyIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "KeyID header contained an invalid account URL: \"https://acme-99.lettuceencrypt.org/acme/reg/1\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSInvalidKeyID", + }, + { + Name: "JWS with non-numeric account ID in key ID URL", + JWS: nonNumericKeyIDJWS, + Request: makePostRequestWithPath("test-path", nonNumericKeyIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Malformed account ID in KeyID header URL: \"https://acme-v00.lettuceencrypt.org/acme/reg/abcd\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSInvalidKeyID", + }, + { + Name: "JWS with account ID that causes GetRegistration error", + JWS: errorIDJWS, + Request: makePostRequestWithPath("test-path", errorIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.ServerInternalProblem, + Detail: "Error retrieving account \"http://localhost/acme/acct/100\"", + HTTPStatus: http.StatusInternalServerError, + }, + ErrorStatType: "JWSKeyIDLookupFailed", + }, + { + Name: "JWS with account ID that doesn't exist", + JWS: missingIDJWS, + Request: makePostRequestWithPath("test-path", missingIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.AccountDoesNotExistProblem, + Detail: "Account \"http://localhost/acme/acct/102\" not found", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSKeyIDNotFound", + }, + { + Name: "JWS with account ID that is deactivated", + JWS: deactivatedIDJWS, + Request: makePostRequestWithPath("test-path", deactivatedIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.UnauthorizedProblem, + Detail: "Account is not valid, has status \"deactivated\"", + HTTPStatus: http.StatusForbidden, + }, + ErrorStatType: "JWSKeyIDAccountInvalid", + }, + { + Name: "Valid JWS with legacy account ID", + JWS: legacyKeyIDJWS, + Request: makePostRequestWithPath("test-path", legacyKeyIDJWSBody), + ExpectedKey: validKey, + ExpectedAccount: &validAccount, + }, + { + Name: "Valid JWS with valid account ID", + JWS: validJWS, + Request: makePostRequestWithPath("test-path", validJWSBody), + ExpectedKey: validKey, + ExpectedAccount: &validAccount, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + inputLogEvent := newRequestEvent() + jwkHeader, acct, prob := wfe.lookupJWK(tc.JWS.Signatures[0].Header, context.Background(), tc.Request, inputLogEvent) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem == nil { + inThumb, _ := tc.ExpectedKey.Thumbprint(crypto.SHA256) + outThumb, _ := jwkHeader.Thumbprint(crypto.SHA256) + test.AssertDeepEquals(t, inThumb, outThumb) + test.AssertMarshaledEquals(t, acct, tc.ExpectedAccount) + test.AssertEquals(t, inputLogEvent.Requester, acct.ID) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func TestValidJWSForKey(t *testing.T) { + wfe, _, signer := setupWFE(t) + + payload := `{ "test": "payload" }` + testURL := "http://localhost/test" + goodJWS, goodJWK, _ := signer.embeddedJWK(nil, testURL, payload) + + // badSigJWSBody is a JWS that has had the payload changed by 1 byte to break the signature + badSigJWSBody := `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ","signature":"jcTdxSygm_cvD7KbXqsxgnoPApCTSkV4jolToSOd2ciRkg5W7Yl0ZKEEKwOc-dYIbQiwGiDzisyPCicwWsOUA1WSqHylKvZ3nxSMc6KtwJCW2DaOqcf0EEjy5VjiZJUrOt2c-r6b07tbn8sfOJKwlF2lsOeGi4s-rtvvkeQpAU-AWauzl9G4bv2nDUeCviAZjHx_PoUC-f9GmZhYrbDzAvXZ859ktM6RmMeD0OqPN7bhAeju2j9Gl0lnryZMtq2m0J2m1ucenQBL1g4ZkP1JiJvzd2cAz5G7Ftl2YeJJyWhqNd3qq0GVOt1P11s8PTGNaSoM0iR9QfUxT9A6jxARtg"}` + badJWS, err := jose.ParseSigned(badSigJWSBody, getSupportedAlgs()) + test.AssertNotError(t, err, "error loading badSigJWS body") + + // wrongAlgJWS is a JWS that has an invalid "HS256" algorithm in its header + wrongAlgJWS := &jose.JSONWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.Header{ + Algorithm: "HS256", + }, + }, + }, + } + + // A JWS and HTTP request with a mismatched HTTP URL to JWS "url" header + wrongURLHeaders := map[jose.HeaderKey]interface{}{ + "url": "foobar", + } + wrongURLHeaderJWS, _ := signer.signExtraHeaders(wrongURLHeaders) + + // badJSONJWS has a valid signature over a body that is not valid JSON + badJSONJWS, _, _ := signer.embeddedJWK(nil, testURL, `{`) + + testCases := []struct { + Name string + JWS bJSONWebSignature + JWK *jose.JSONWebKey + Body string + ExpectedProblem *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "JWS with an invalid algorithm", + JWS: bJSONWebSignature{wrongAlgJWS}, + JWK: goodJWK, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.BadSignatureAlgorithmProblem, + Detail: "JWS signature header contains unsupported algorithm \"HS256\", expected one of [RS256 ES256 ES384 ES512]", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAlgorithmCheckFailed", + }, + { + Name: "JWS with an invalid nonce (test/config-next)", + JWS: bJSONWebSignature{signer.invalidNonce()}, + JWK: goodJWK, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.BadNonceProblem, + Detail: "JWS has an invalid anti-replay nonce: \"mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSInvalidNonce", + }, + { + Name: "JWS with broken signature", + JWS: bJSONWebSignature{badJWS}, + JWK: badJWS.Signatures[0].Header.JSONWebKey, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS verification error", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSVerifyFailed", + }, + { + Name: "JWS with incorrect URL", + JWS: bJSONWebSignature{wrongURLHeaderJWS}, + JWK: wrongURLHeaderJWS.Signatures[0].Header.JSONWebKey, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "JWS header parameter 'url' incorrect. Expected \"http://localhost/test\" got \"foobar\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSMismatchedURL", + }, + { + Name: "Valid JWS with invalid JSON in the protected body", + JWS: bJSONWebSignature{badJSONJWS}, + JWK: goodJWK, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Request payload did not parse as JSON", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSBodyUnmarshalFailed", + }, + { + Name: "Good JWS and JWK", + JWS: bJSONWebSignature{goodJWS}, + JWK: goodJWK, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + request := makePostRequestWithPath("test", tc.Body) + outPayload, prob := wfe.validJWSForKey(context.Background(), &tc.JWS, tc.JWK, request) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem == nil { + test.AssertEquals(t, string(outPayload), payload) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func TestValidPOSTForAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + + validJWS, _, validJWSBody := signer.byKeyID(1, nil, "http://localhost/test", `{"test":"passed"}`) + validAccountPB, _ := wfe.sa.GetRegistration(context.Background(), &sapb.RegistrationID{Id: 1}) + validAccount, _ := bgrpc.PbToRegistration(validAccountPB) + + // ID 102 is mocked to return missing + _, _, missingJWSBody := signer.byKeyID(102, nil, "http://localhost/test", "{}") + + // ID 3 is mocked to return deactivated + key3 := loadKey(t, []byte(test3KeyPrivatePEM)) + _, _, deactivatedJWSBody := signer.byKeyID(3, key3, "http://localhost/test", "{}") + + _, _, embeddedJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) + + testCases := []struct { + Name string + Request *http.Request + ExpectedProblem *probs.ProblemDetails + ExpectedPayload string + ExpectedAcct *core.Registration + ExpectedJWS *jose.JSONWebSignature + ErrorStatType string + }{ + { + Name: "Invalid JWS", + Request: makePostRequestWithPath("test", "foo"), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Parse error reading JWS", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSUnmarshalFailed", + }, + { + Name: "Embedded Key JWS", + Request: makePostRequestWithPath("test", embeddedJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No Key ID in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeWrong", + }, + { + Name: "JWS signed by account that doesn't exist", + Request: makePostRequestWithPath("test", missingJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.AccountDoesNotExistProblem, + Detail: "Account \"http://localhost/acme/acct/102\" not found", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSKeyIDNotFound", + }, + { + Name: "JWS signed by account that's deactivated", + Request: makePostRequestWithPath("test", deactivatedJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.UnauthorizedProblem, + Detail: "Account is not valid, has status \"deactivated\"", + HTTPStatus: http.StatusForbidden, + }, + ErrorStatType: "JWSKeyIDAccountInvalid", + }, + { + Name: "Valid JWS for account", + Request: makePostRequestWithPath("test", validJWSBody), + ExpectedPayload: `{"test":"passed"}`, + ExpectedAcct: &validAccount, + ExpectedJWS: validJWS, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + inputLogEvent := newRequestEvent() + outPayload, jws, acct, prob := wfe.validPOSTForAccount(tc.Request, context.Background(), inputLogEvent) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem == nil { + test.AssertEquals(t, string(outPayload), tc.ExpectedPayload) + test.AssertMarshaledEquals(t, acct, tc.ExpectedAcct) + test.AssertMarshaledEquals(t, jws, tc.ExpectedJWS) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +// TestValidPOSTAsGETForAccount tests POST-as-GET processing. Because +// wfe.validPOSTAsGETForAccount calls `wfe.validPOSTForAccount` to do all +// processing except the empty body test we do not duplicate the +// `TestValidPOSTForAccount` testcases here. +func TestValidPOSTAsGETForAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + + // an invalid POST-as-GET request contains a non-empty payload. In this case + // we test with the empty JSON payload ("{}") + _, _, invalidPayloadRequest := signer.byKeyID(1, nil, "http://localhost/test", "{}") + // a valid POST-as-GET request contains an empty payload. + _, _, validRequest := signer.byKeyID(1, nil, "http://localhost/test", "") + + testCases := []struct { + Name string + Request *http.Request + ExpectedProblem *probs.ProblemDetails + ExpectedLogEvent web.RequestEvent + }{ + { + Name: "Non-empty JWS payload", + Request: makePostRequestWithPath("test", invalidPayloadRequest), + ExpectedProblem: probs.Malformed("POST-as-GET requests must have an empty payload"), + ExpectedLogEvent: web.RequestEvent{}, + }, + { + Name: "Valid POST-as-GET", + Request: makePostRequestWithPath("test", validRequest), + ExpectedLogEvent: web.RequestEvent{ + Method: "POST-as-GET", + }, + }, + } + + for _, tc := range testCases { + ev := newRequestEvent() + _, prob := wfe.validPOSTAsGETForAccount( + tc.Request, + context.Background(), + ev) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem != nil { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + test.AssertMarshaledEquals(t, *ev, tc.ExpectedLogEvent) + } +} + +type mockSADifferentStoredKey struct { + sapb.StorageAuthorityReadOnlyClient +} + +// mockSADifferentStoredKey has a GetRegistration that will always return an +// account with the test 2 key, no matter the provided ID +func (sa mockSADifferentStoredKey) GetRegistration(_ context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { + return &corepb.Registration{ + Key: []byte(test2KeyPublicJSON), + Status: string(core.StatusValid), + }, nil +} + +func TestValidPOSTForAccountSwappedKey(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = &mockSADifferentStoredKey{} + wfe.accountGetter = wfe.sa + event := newRequestEvent() + + payload := `{"resource":"ima-payload"}` + // Sign a request using test1key + _, _, body := signer.byKeyID(1, nil, "http://localhost:4001/test", payload) + request := makePostRequestWithPath("test", body) + + // Ensure that ValidPOSTForAccount produces an error since the + // mockSADifferentStoredKey will return a different key than the one we used to + // sign the request + _, _, _, prob := wfe.validPOSTForAccount(request, ctx, event) + test.Assert(t, prob != nil, "No error returned for request signed by wrong key") + test.AssertEquals(t, prob.Type, probs.MalformedProblem) + test.AssertEquals(t, prob.Detail, "JWS verification error") +} + +func TestValidSelfAuthenticatedPOSTGoodKeyErrors(t *testing.T) { + wfe, _, signer := setupWFE(t) + + timeoutErrCheckFunc := func(ctx context.Context, keyHash []byte) (bool, error) { + return false, context.DeadlineExceeded + } + + kp, err := goodkey.NewPolicy(nil, timeoutErrCheckFunc) + test.AssertNotError(t, err, "making key policy") + + wfe.keyPolicy = kp + + _, _, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) + request := makePostRequestWithPath("test", validJWSBody) + + _, _, prob := wfe.validSelfAuthenticatedPOST(context.Background(), request) + test.AssertEquals(t, prob.Type, probs.ServerInternalProblem) + + badKeyCheckFunc := func(ctx context.Context, keyHash []byte) (bool, error) { + return false, fmt.Errorf("oh no: %w", goodkey.ErrBadKey) + } + + kp, err = goodkey.NewPolicy(nil, badKeyCheckFunc) + test.AssertNotError(t, err, "making key policy") + + wfe.keyPolicy = kp + + _, _, validJWSBody = signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) + request = makePostRequestWithPath("test", validJWSBody) + + _, _, prob = wfe.validSelfAuthenticatedPOST(context.Background(), request) + test.AssertEquals(t, prob.Type, probs.BadPublicKeyProblem) +} + +func TestValidSelfAuthenticatedPOST(t *testing.T) { + wfe, _, signer := setupWFE(t) + + _, validKey, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) + + _, _, keyIDJWSBody := signer.byKeyID(1, nil, "http://localhost/test", `{"test":"passed"}`) + + testCases := []struct { + Name string + Request *http.Request + ExpectedProblem *probs.ProblemDetails + ExpectedPayload string + ExpectedJWK *jose.JSONWebKey + ErrorStatType string + }{ + { + Name: "Invalid JWS", + Request: makePostRequestWithPath("test", "foo"), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Parse error reading JWS", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSUnmarshalFailed", + }, + { + Name: "JWS with key ID", + Request: makePostRequestWithPath("test", keyIDJWSBody), + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "No embedded JWK in JWS header", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "JWSAuthTypeWrong", + }, + { + Name: "Valid JWS", + Request: makePostRequestWithPath("test", validJWSBody), + ExpectedPayload: `{"test":"passed"}`, + ExpectedJWK: validKey, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + outPayload, jwk, prob := wfe.validSelfAuthenticatedPOST(context.Background(), tc.Request) + if tc.ExpectedProblem == nil && prob != nil { + t.Fatalf("Expected nil problem, got %#v\n", prob) + } else if tc.ExpectedProblem == nil { + inThumb, _ := tc.ExpectedJWK.Thumbprint(crypto.SHA256) + outThumb, _ := jwk.Thumbprint(crypto.SHA256) + test.AssertDeepEquals(t, inThumb, outThumb) + test.AssertEquals(t, string(outPayload), tc.ExpectedPayload) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func TestMatchJWSURLs(t *testing.T) { + wfe, _, signer := setupWFE(t) + + noURLJWS, _, _ := signer.embeddedJWK(nil, "", "") + urlAJWS, _, _ := signer.embeddedJWK(nil, "example.com", "") + urlBJWS, _, _ := signer.embeddedJWK(nil, "example.org", "") + + testCases := []struct { + Name string + Outer *jose.JSONWebSignature + Inner *jose.JSONWebSignature + ExpectedProblem *probs.ProblemDetails + ErrorStatType string + }{ + { + Name: "Outer JWS without URL", + Outer: noURLJWS, + Inner: urlAJWS, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Outer JWS header parameter 'url' required", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "KeyRolloverOuterJWSNoURL", + }, + { + Name: "Inner JWS without URL", + Outer: urlAJWS, + Inner: noURLJWS, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Inner JWS header parameter 'url' required", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "KeyRolloverInnerJWSNoURL", + }, + { + Name: "Inner and outer JWS without URL", + Outer: noURLJWS, + Inner: noURLJWS, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + // The Outer JWS is validated first + Detail: "Outer JWS header parameter 'url' required", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "KeyRolloverOuterJWSNoURL", + }, + { + Name: "Mismatched inner and outer JWS URLs", + Outer: urlAJWS, + Inner: urlBJWS, + ExpectedProblem: &probs.ProblemDetails{ + Type: probs.MalformedProblem, + Detail: "Outer JWS 'url' value \"example.com\" does not match inner JWS 'url' value \"example.org\"", + HTTPStatus: http.StatusBadRequest, + }, + ErrorStatType: "KeyRolloverMismatchedURLs", + }, + { + Name: "Matching inner and outer JWS URLs", + Outer: urlAJWS, + Inner: urlAJWS, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + prob := wfe.matchJWSURLs(tc.Outer.Signatures[0].Header, tc.Inner.Signatures[0].Header) + if prob != nil && tc.ExpectedProblem == nil { + t.Errorf("matchJWSURLs failed. Expected no problem, got %#v", prob) + } else { + test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem) + } + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/wfe.go b/third-party/github.com/letsencrypt/boulder/wfe2/wfe.go new file mode 100644 index 000000000..1b3cc0b15 --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/wfe.go @@ -0,0 +1,2736 @@ +package wfe2 + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/policy" + "github.com/letsencrypt/boulder/ratelimits" + + // 'grpc/noncebalancer' is imported for its init function. + _ "github.com/letsencrypt/boulder/grpc/noncebalancer" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics/measured_http" + "github.com/letsencrypt/boulder/nonce" + "github.com/letsencrypt/boulder/probs" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/web" +) + +// Paths are the ACME-spec identified URL path-segments for various methods. +// NOTE: In metrics/measured_http we make the assumption that these are all +// lowercase plus hyphens. If you violate that assumption you should update +// measured_http. +const ( + directoryPath = "/directory" + newAcctPath = "/acme/new-acct" + acctPath = "/acme/acct/" + // When we moved to authzv2, we used a "-v3" suffix to avoid confusion + // regarding ACMEv2. + authzPath = "/acme/authz-v3/" + challengePath = "/acme/chall-v3/" + certPath = "/acme/cert/" + revokeCertPath = "/acme/revoke-cert" + buildIDPath = "/build" + rolloverPath = "/acme/key-change" + newNoncePath = "/acme/new-nonce" + newOrderPath = "/acme/new-order" + orderPath = "/acme/order/" + finalizeOrderPath = "/acme/finalize/" + + getAPIPrefix = "/get/" + getOrderPath = getAPIPrefix + "order/" + getAuthzPath = getAPIPrefix + "authz-v3/" + getChallengePath = getAPIPrefix + "chall-v3/" + getCertPath = getAPIPrefix + "cert/" + + // Draft or likely-to-change paths + renewalInfoPath = "/draft-ietf-acme-ari-03/renewalInfo/" +) + +const ( + headerRetryAfter = "Retry-After" + // Our 99th percentile finalize latency is 2.3s. Asking clients to wait 3s + // before polling the order to get an updated status means that >99% of + // clients will fetch the updated order object exactly once,. + orderRetryAfter = 3 +) + +var errIncompleteGRPCResponse = errors.New("incomplete gRPC response message") + +// WebFrontEndImpl provides all the logic for Boulder's web-facing interface, +// i.e., ACME. Its members configure the paths for various ACME functions, +// plus a few other data items used in ACME. Its methods are primarily handlers +// for HTTPS requests for the various ACME functions. +type WebFrontEndImpl struct { + ra rapb.RegistrationAuthorityClient + sa sapb.StorageAuthorityReadOnlyClient + // gnc is a nonce-service client used exclusively for the issuance of + // nonces. It's configured to route requests to backends colocated with the + // WFE. + gnc nonce.Getter + // rnc is a nonce-service client used exclusively for the redemption of + // nonces. It uses a custom RPC load balancer which is configured to route + // requests to backends based on the prefix and HMAC key passed as in the + // context of the request. The HMAC and prefix are passed using context keys + // `nonce.HMACKeyCtxKey` and `nonce.PrefixCtxKey`. + rnc nonce.Redeemer + // rncKey is the HMAC key used to derive the prefix of nonce backends used + // for nonce redemption. + rncKey string + accountGetter AccountGetter + log blog.Logger + clk clock.Clock + stats wfe2Stats + + // certificateChains maps IssuerNameIDs to slice of []byte containing a leading + // newline and one or more PEM encoded certificates separated by a newline, + // sorted from leaf to root. The first []byte is the default certificate chain, + // and any subsequent []byte is an alternate certificate chain. + certificateChains map[issuance.NameID][][]byte + + // issuerCertificates is a map of IssuerNameIDs to issuer certificates built with the + // first entry from each of the certificateChains. These certificates are used + // to verify the signature of certificates provided in revocation requests. + issuerCertificates map[issuance.NameID]*issuance.Certificate + + // URL to the current subscriber agreement (should contain some version identifier) + SubscriberAgreementURL string + + // DirectoryCAAIdentity is used for the /directory response's "meta" + // element's "caaIdentities" field. It should match the VA's issuerDomain + // field value. + DirectoryCAAIdentity string + + // DirectoryWebsite is used for the /directory response's "meta" element's + // "website" field. + DirectoryWebsite string + + // Allowed prefix for legacy accounts used by verify.go's `lookupJWK`. + // See `cmd/boulder-wfe2/main.go`'s comment on the configuration field + // `LegacyKeyIDPrefix` for more information. + LegacyKeyIDPrefix string + + // Key policy. + keyPolicy goodkey.KeyPolicy + + // CORS settings + AllowOrigins []string + + // requestTimeout is the per-request overall timeout. + requestTimeout time.Duration + + // StaleTimeout determines the required staleness for resources allowed to be + // accessed via Boulder-specific GET-able APIs. Resources newer than + // staleTimeout must be accessed via POST-as-GET and the RFC 8555 ACME API. We + // do this to incentivize client developers to use the standard API. + staleTimeout time.Duration + + // How long before authorizations and pending authorizations expire. The + // Boulder specific GET-able API uses these values to find the creation date + // of authorizations to determine if they are stale enough. The values should + // match the ones used by the RA. + authorizationLifetime time.Duration + pendingAuthorizationLifetime time.Duration + limiter *ratelimits.Limiter + txnBuilder *ratelimits.TransactionBuilder + maxNames int + + // certificateProfileNames is a list of profile names that are allowed to be + // passed to the newOrder endpoint. If a profile name is not in this list, + // the request will be rejected as malformed. + certificateProfileNames []string +} + +// NewWebFrontEndImpl constructs a web service for Boulder +func NewWebFrontEndImpl( + stats prometheus.Registerer, + clk clock.Clock, + keyPolicy goodkey.KeyPolicy, + certificateChains map[issuance.NameID][][]byte, + issuerCertificates map[issuance.NameID]*issuance.Certificate, + logger blog.Logger, + requestTimeout time.Duration, + staleTimeout time.Duration, + authorizationLifetime time.Duration, + pendingAuthorizationLifetime time.Duration, + rac rapb.RegistrationAuthorityClient, + sac sapb.StorageAuthorityReadOnlyClient, + gnc nonce.Getter, + rnc nonce.Redeemer, + rncKey string, + accountGetter AccountGetter, + limiter *ratelimits.Limiter, + txnBuilder *ratelimits.TransactionBuilder, + maxNames int, + certificateProfileNames []string, +) (WebFrontEndImpl, error) { + if len(issuerCertificates) == 0 { + return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate") + } + + if len(certificateChains) == 0 { + return WebFrontEndImpl{}, errors.New("must provide at least one certificate chain") + } + + if gnc == nil { + return WebFrontEndImpl{}, errors.New("must provide a service for nonce issuance") + } + + if rnc == nil { + return WebFrontEndImpl{}, errors.New("must provide a service for nonce redemption") + } + + wfe := WebFrontEndImpl{ + log: logger, + clk: clk, + keyPolicy: keyPolicy, + certificateChains: certificateChains, + issuerCertificates: issuerCertificates, + stats: initStats(stats), + requestTimeout: requestTimeout, + staleTimeout: staleTimeout, + authorizationLifetime: authorizationLifetime, + pendingAuthorizationLifetime: pendingAuthorizationLifetime, + ra: rac, + sa: sac, + gnc: gnc, + rnc: rnc, + rncKey: rncKey, + accountGetter: accountGetter, + limiter: limiter, + txnBuilder: txnBuilder, + maxNames: maxNames, + certificateProfileNames: certificateProfileNames, + } + + return wfe, nil +} + +// HandleFunc registers a handler at the given path. It's +// http.HandleFunc(), but with a wrapper around the handler that +// provides some generic per-request functionality: +// +// * Set a Replay-Nonce header. +// +// * Respond to OPTIONS requests, including CORS preflight requests. +// +// * Set a no cache header +// +// * Respond http.StatusMethodNotAllowed for HTTP methods other than +// those listed. +// +// * Set CORS headers when responding to CORS "actual" requests. +// +// * Never send a body in response to a HEAD request. Anything +// written by the handler will be discarded if the method is HEAD. +// Also, all handlers that accept GET automatically accept HEAD. +func (wfe *WebFrontEndImpl) HandleFunc(mux *http.ServeMux, pattern string, h web.WFEHandlerFunc, methods ...string) { + methodsMap := make(map[string]bool) + for _, m := range methods { + methodsMap[m] = true + } + if methodsMap["GET"] && !methodsMap["HEAD"] { + // Allow HEAD for any resource that allows GET + methods = append(methods, "HEAD") + methodsMap["HEAD"] = true + } + methodsStr := strings.Join(methods, ", ") + handler := http.StripPrefix(pattern, web.NewTopHandler(wfe.log, + web.WFEHandlerFunc(func(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + span := trace.SpanFromContext(ctx) + span.SetName(pattern) + + logEvent.Endpoint = pattern + if request.URL != nil { + logEvent.Slug = request.URL.Path + } + tls := request.Header.Get("TLS-Version") + if tls == "TLSv1" || tls == "TLSv1.1" { + wfe.sendError(response, logEvent, probs.Malformed("upgrade your ACME client to support TLSv1.2 or better"), nil) + return + } + if request.Method != "GET" || pattern == newNoncePath { + nonceMsg, err := wfe.gnc.Nonce(ctx, &emptypb.Empty{}) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "unable to get nonce"), err) + return + } + response.Header().Set("Replay-Nonce", nonceMsg.Nonce) + } + // Per section 7.1 "Resources": + // The "index" link relation is present on all resources other than the + // directory and indicates the URL of the directory. + if pattern != directoryPath { + directoryURL := web.RelativeEndpoint(request, directoryPath) + response.Header().Add("Link", link(directoryURL, "index")) + } + + switch request.Method { + case "HEAD": + // Go's net/http (and httptest) servers will strip out the body + // of responses for us. This keeps the Content-Length for HEAD + // requests as the same as GET requests per the spec. + case "OPTIONS": + wfe.Options(response, request, methodsStr, methodsMap) + return + } + + // No cache header is set for all requests, succeed or fail. + addNoCacheHeader(response) + + if !methodsMap[request.Method] { + response.Header().Set("Allow", methodsStr) + wfe.sendError(response, logEvent, probs.MethodNotAllowed(), nil) + return + } + + wfe.setCORSHeaders(response, request, "") + + timeout := wfe.requestTimeout + if timeout == 0 { + timeout = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + + // Call the wrapped handler. + h(ctx, logEvent, response, request) + cancel() + }), + )) + mux.Handle(pattern, handler) +} + +func marshalIndent(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +func (wfe *WebFrontEndImpl) writeJsonResponse(response http.ResponseWriter, logEvent *web.RequestEvent, status int, v interface{}) error { + jsonReply, err := marshalIndent(v) + if err != nil { + return err // All callers are responsible for handling this error + } + + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(status) + _, err = response.Write(jsonReply) + if err != nil { + // Don't worry about returning this error because the caller will + // never handle it. + wfe.log.Warningf("Could not write response: %s", err) + logEvent.AddError("failed to write response: %s", err) + } + return nil +} + +// requestProto returns "http" for HTTP requests and "https" for HTTPS +// requests. It supports the use of "X-Forwarded-Proto" to override the protocol. +func requestProto(request *http.Request) string { + proto := "http" + + // If the request was received via TLS, use `https://` for the protocol + if request.TLS != nil { + proto = "https" + } + + // Allow upstream proxies to specify the forwarded protocol. Allow this value + // to override our own guess. + if specifiedProto := request.Header.Get("X-Forwarded-Proto"); specifiedProto != "" { + proto = specifiedProto + } + + return proto +} + +const randomDirKeyExplanationLink = "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417" + +func (wfe *WebFrontEndImpl) relativeDirectory(request *http.Request, directory map[string]interface{}) ([]byte, error) { + // Create an empty map sized equal to the provided directory to store the + // relative-ized result + relativeDir := make(map[string]interface{}, len(directory)) + + // Copy each entry of the provided directory into the new relative map, + // prefixing it with the request protocol and host. + for k, v := range directory { + if v == randomDirKeyExplanationLink { + relativeDir[k] = v + continue + } + switch v := v.(type) { + case string: + // Only relative-ize top level string values, e.g. not the "meta" element + relativeDir[k] = web.RelativeEndpoint(request, v) + default: + // If it isn't a string, put it into the results unmodified + relativeDir[k] = v + } + } + + directoryJSON, err := marshalIndent(relativeDir) + // This should never happen since we are just marshalling known strings + if err != nil { + return nil, err + } + + return directoryJSON, nil +} + +// Handler returns an http.Handler that uses various functions for +// various ACME-specified paths. +func (wfe *WebFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler { + m := http.NewServeMux() + // Boulder specific endpoints + wfe.HandleFunc(m, buildIDPath, wfe.BuildID, "GET") + + // POSTable ACME endpoints + wfe.HandleFunc(m, newAcctPath, wfe.NewAccount, "POST") + wfe.HandleFunc(m, acctPath, wfe.Account, "POST") + wfe.HandleFunc(m, revokeCertPath, wfe.RevokeCertificate, "POST") + wfe.HandleFunc(m, rolloverPath, wfe.KeyRollover, "POST") + wfe.HandleFunc(m, newOrderPath, wfe.NewOrder, "POST") + wfe.HandleFunc(m, finalizeOrderPath, wfe.FinalizeOrder, "POST") + + // GETable and POST-as-GETable ACME endpoints + wfe.HandleFunc(m, directoryPath, wfe.Directory, "GET", "POST") + wfe.HandleFunc(m, newNoncePath, wfe.Nonce, "GET", "POST") + // POST-as-GETable ACME endpoints + // TODO(@cpu): After November 1st, 2020 support for "GET" to the following + // endpoints will be removed, leaving only POST-as-GET support. + wfe.HandleFunc(m, orderPath, wfe.GetOrder, "GET", "POST") + wfe.HandleFunc(m, authzPath, wfe.Authorization, "GET", "POST") + wfe.HandleFunc(m, challengePath, wfe.Challenge, "GET", "POST") + wfe.HandleFunc(m, certPath, wfe.Certificate, "GET", "POST") + // Boulder-specific GET-able resource endpoints + wfe.HandleFunc(m, getOrderPath, wfe.GetOrder, "GET") + wfe.HandleFunc(m, getAuthzPath, wfe.Authorization, "GET") + wfe.HandleFunc(m, getChallengePath, wfe.Challenge, "GET") + wfe.HandleFunc(m, getCertPath, wfe.Certificate, "GET") + + // Endpoint for draft-ietf-acme-ari + if features.Get().ServeRenewalInfo { + wfe.HandleFunc(m, renewalInfoPath, wfe.RenewalInfo, "GET", "POST") + } + + // We don't use our special HandleFunc for "/" because it matches everything, + // meaning we can wind up returning 405 when we mean to return 404. See + // https://github.com/letsencrypt/boulder/issues/717 + m.Handle("/", web.NewTopHandler(wfe.log, web.WFEHandlerFunc(wfe.Index))) + return measured_http.New(m, wfe.clk, stats, oTelHTTPOptions...) +} + +// Method implementations + +// Index serves a simple identification page. It is not part of the ACME spec. +func (wfe *WebFrontEndImpl) Index(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + // All requests that are not handled by our ACME endpoints ends up + // here. Set the our logEvent endpoint to "/" and the slug to the path + // minus "/" to make sure that we properly set log information about + // the request, even in the case of a 404 + logEvent.Endpoint = "/" + logEvent.Slug = request.URL.Path[1:] + + // http://golang.org/pkg/net/http/#example_ServeMux_Handle + // The "/" pattern matches everything, so we need to check + // that we're at the root here. + if request.URL.Path != "/" { + logEvent.AddError("Resource not found") + http.NotFound(response, request) + response.Header().Set("Content-Type", "application/problem+json") + return + } + + if request.Method != "GET" { + response.Header().Set("Allow", "GET") + wfe.sendError(response, logEvent, probs.MethodNotAllowed(), errors.New("Bad method")) + return + } + + addNoCacheHeader(response) + response.Header().Set("Content-Type", "text/html") + fmt.Fprintf(response, ` + + This is an ACME + Certificate Authority running Boulder. + JSON directory is available at %s. + + + `, directoryPath, directoryPath) +} + +func addNoCacheHeader(w http.ResponseWriter) { + w.Header().Add("Cache-Control", "public, max-age=0, no-cache") +} + +func addRequesterHeader(w http.ResponseWriter, requester int64) { + if requester > 0 { + w.Header().Set("Boulder-Requester", strconv.FormatInt(requester, 10)) + } +} + +// Directory is an HTTP request handler that provides the directory +// object stored in the WFE's DirectoryEndpoints member with paths prefixed +// using the `request.Host` of the HTTP request. +func (wfe *WebFrontEndImpl) Directory( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + directoryEndpoints := map[string]interface{}{ + "newAccount": newAcctPath, + "newNonce": newNoncePath, + "revokeCert": revokeCertPath, + "newOrder": newOrderPath, + "keyChange": rolloverPath, + } + + if features.Get().ServeRenewalInfo { + // ARI-capable clients are expected to add the trailing slash per the + // draft. We explicitly strip the trailing slash here so that clients + // don't need to add trailing slash handling in their own code, saving + // them minimal amounts of complexity. + directoryEndpoints["renewalInfo"] = strings.TrimRight(renewalInfoPath, "/") + } + + if request.Method == http.MethodPost { + acct, prob := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + logEvent.Requester = acct.ID + } + + // Add a random key to the directory in order to make sure that clients don't hardcode an + // expected set of keys. This ensures that we can properly extend the directory when we + // need to add a new endpoint or meta element. + directoryEndpoints[core.RandomString(8)] = randomDirKeyExplanationLink + + // ACME since draft-02 describes an optional "meta" directory entry. The + // meta entry may optionally contain a "termsOfService" URI for the + // current ToS. + metaMap := map[string]interface{}{ + "termsOfService": wfe.SubscriberAgreementURL, + } + // The "meta" directory entry may also include a []string of CAA identities + if wfe.DirectoryCAAIdentity != "" { + // The specification says caaIdentities is an array of strings. In + // practice Boulder's VA only allows configuring ONE CAA identity. Given + // that constraint it doesn't make sense to allow multiple directory CAA + // identities so we use just the `wfe.DirectoryCAAIdentity` alone. + metaMap["caaIdentities"] = []string{ + wfe.DirectoryCAAIdentity, + } + } + // The "meta" directory entry may also include a string with a website URL + if wfe.DirectoryWebsite != "" { + metaMap["website"] = wfe.DirectoryWebsite + } + directoryEndpoints["meta"] = metaMap + + response.Header().Set("Content-Type", "application/json") + + relDir, err := wfe.relativeDirectory(request, directoryEndpoints) + if err != nil { + marshalProb := probs.ServerInternal("unable to marshal JSON directory") + wfe.sendError(response, logEvent, marshalProb, nil) + return + } + + logEvent.Suppress() + response.Write(relDir) +} + +// Nonce is an endpoint for getting a fresh nonce with an HTTP GET or HEAD +// request. This endpoint only returns a status code header - the `HandleFunc` +// wrapper ensures that a nonce is written in the correct response header. +func (wfe *WebFrontEndImpl) Nonce( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + if request.Method == http.MethodPost { + acct, prob := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + logEvent.Requester = acct.ID + } + + statusCode := http.StatusNoContent + // The ACME specification says GET requests should receive http.StatusNoContent + // and HEAD/POST-as-GET requests should receive http.StatusOK. + if request.Method != "GET" { + statusCode = http.StatusOK + } + response.WriteHeader(statusCode) + + // The ACME specification says the server MUST include a Cache-Control header + // field with the "no-store" directive in responses for the newNonce resource, + // in order to prevent caching of this resource. + response.Header().Set("Cache-Control", "no-store") +} + +// sendError wraps web.SendError +func (wfe *WebFrontEndImpl) sendError(response http.ResponseWriter, logEvent *web.RequestEvent, prob *probs.ProblemDetails, ierr error) { + var bErr *berrors.BoulderError + if errors.As(ierr, &bErr) { + retryAfterSeconds := int(bErr.RetryAfter.Round(time.Second).Seconds()) + if retryAfterSeconds > 0 { + response.Header().Add(headerRetryAfter, strconv.Itoa(retryAfterSeconds)) + if bErr.Type == berrors.RateLimit { + response.Header().Add("Link", link("https://letsencrypt.org/docs/rate-limits", "help")) + } + } + } + wfe.stats.httpErrorCount.With(prometheus.Labels{"type": string(prob.Type)}).Inc() + web.SendError(wfe.log, response, logEvent, prob, ierr) +} + +func link(url, relation string) string { + return fmt.Sprintf("<%s>;rel=\"%s\"", url, relation) +} + +func (wfe *WebFrontEndImpl) newNewAccountLimitTransactions(ip net.IP) []ratelimits.Transaction { + if wfe.limiter == nil && wfe.txnBuilder == nil { + // Limiter is disabled. + return nil + } + + warn := func(err error, limit ratelimits.Name) { + // TODO(#5545): Once key-value rate limits are authoritative this log + // line should be removed in favor of returning the error. + wfe.log.Warningf("checking %s rate limit: %s", limit, err) + } + + var transactions []ratelimits.Transaction + txn, err := wfe.txnBuilder.RegistrationsPerIPAddressTransaction(ip) + if err != nil { + warn(err, ratelimits.NewRegistrationsPerIPAddress) + return nil + } + transactions = append(transactions, txn) + + if ip.To4() != nil { + // This request was made from an IPv4 address. + return transactions + } + + txn, err = wfe.txnBuilder.RegistrationsPerIPv6RangeTransaction(ip) + if err != nil { + warn(err, ratelimits.NewRegistrationsPerIPv6Range) + return nil + } + return append(transactions, txn) +} + +// checkNewAccountLimits checks whether sufficient limit quota exists for the +// creation of a new account. If so, that quota is spent. If an error is +// encountered during the check, it is logged but not returned. +// +// TODO(#5545): For now we're simply exercising the new rate limiter codepath. +// This should eventually return a berrors.RateLimit error containing the retry +// after duration among other information available in the ratelimits.Decision. +func (wfe *WebFrontEndImpl) checkNewAccountLimits(ctx context.Context, transactions []ratelimits.Transaction) { + if wfe.limiter == nil && wfe.txnBuilder == nil { + // Limiter is disabled. + return + } + + _, err := wfe.limiter.BatchSpend(ctx, transactions) + if err != nil { + wfe.log.Errf("checking newAccount limits: %s", err) + } +} + +// refundNewAccountLimits is typically called when a new account creation fails. +// It refunds the limit quota consumed by the request, allowing the caller to +// retry immediately. If an error is encountered during the refund, it is logged +// but not returned. +func (wfe *WebFrontEndImpl) refundNewAccountLimits(ctx context.Context, transactions []ratelimits.Transaction) { + if wfe.limiter == nil && wfe.txnBuilder == nil { + // Limiter is disabled. + return + } + + _, err := wfe.limiter.BatchRefund(ctx, transactions) + if err != nil { + wfe.log.Errf("refunding newAccount limits: %s", err) + } +} + +// NewAccount is used by clients to submit a new account +func (wfe *WebFrontEndImpl) NewAccount( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + + // NewAccount uses `validSelfAuthenticatedPOST` instead of + // `validPOSTforAccount` because there is no account to authenticate against + // until after it is created! + body, key, prob := wfe.validSelfAuthenticatedPOST(ctx, request) + if prob != nil { + // validSelfAuthenticatedPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + var accountCreateRequest struct { + Contact *[]string `json:"contact"` + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + OnlyReturnExisting bool `json:"onlyReturnExisting"` + } + + err := json.Unmarshal(body, &accountCreateRequest) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err) + return + } + + returnExistingAcct := func(acctPB *corepb.Registration) { + if core.AcmeStatus(acctPB.Status) == core.StatusDeactivated { + // If there is an existing, but deactivated account, then return an unauthorized + // problem informing the user that this account was deactivated + wfe.sendError(response, logEvent, probs.Unauthorized( + "An account with the provided public key exists but is deactivated"), nil) + return + } + + response.Header().Set("Location", + web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, acctPB.Id))) + logEvent.Requester = acctPB.Id + addRequesterHeader(response, acctPB.Id) + + acct, err := bgrpc.PbToRegistration(acctPB) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err) + return + } + prepAccountForDisplay(&acct) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, acct) + if err != nil { + // ServerInternal because we just created this account, and it + // should be OK. + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err) + return + } + } + + keyBytes, err := key.MarshalJSON() + if err != nil { + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Error creating new account"), err) + return + } + existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: keyBytes}) + if err == nil { + returnExistingAcct(existingAcct) + return + } else if !errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "failed check for existing account"), err) + return + } + + // If the request included a true "OnlyReturnExisting" field and we did not + // find an existing registration with the key specified then we must return an + // error and not create a new account. + if accountCreateRequest.OnlyReturnExisting { + wfe.sendError(response, logEvent, probs.AccountDoesNotExist( + "No account exists with the provided key"), nil) + return + } + + if !accountCreateRequest.TermsOfServiceAgreed { + wfe.sendError(response, logEvent, probs.Malformed("must agree to terms of service"), nil) + return + } + + ip, err := extractRequesterIP(request) + if err != nil { + wfe.sendError( + response, + logEvent, + probs.ServerInternal("couldn't parse the remote (that is, the client's) address"), + fmt.Errorf("Couldn't parse RemoteAddr: %s", request.RemoteAddr), + ) + return + } + + // Prepare account information to create corepb.Registration + ipBytes, err := ip.MarshalText() + if err != nil { + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Error creating new account"), err) + return + } + var contacts []string + var contactsPresent bool + if accountCreateRequest.Contact != nil { + contactsPresent = true + contacts = *accountCreateRequest.Contact + } + + // Create corepb.Registration from provided account information + reg := corepb.Registration{ + Contact: contacts, + ContactsPresent: contactsPresent, + Agreement: wfe.SubscriberAgreementURL, + Key: keyBytes, + InitialIP: ipBytes, + } + + // TODO(#5545): Spending and Refunding can be async until these rate limits + // are authoritative. This saves us from adding latency to each request. + // Goroutines spun out below will respect a context deadline set by the + // ratelimits package and cannot be prematurely canceled by the requester. + txns := wfe.newNewAccountLimitTransactions(ip) + go wfe.checkNewAccountLimits(ctx, txns) + + var newRegistrationSuccessful bool + var errIsRateLimit bool + defer func() { + if !newRegistrationSuccessful && !errIsRateLimit { + // This can be a little racy, but we're not going to worry about it + // for now. If the check hasn't completed yet, we can pretty safely + // assume that the refund will be similarly delayed. + go wfe.refundNewAccountLimits(ctx, txns) + } + }() + + // Send the registration to the RA via grpc + acctPB, err := wfe.ra.NewRegistration(ctx, ®) + if err != nil { + if errors.Is(err, berrors.RateLimit) { + // Request was denied by a legacy rate limit. In this error case we + // do not want to refund the quota consumed by the request because + // repeated requests would result in unearned refunds. + // + // TODO(#5545): Once key-value rate limits are authoritative this + // can be removed. + errIsRateLimit = true + } + if errors.Is(err, berrors.Duplicate) { + existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: keyBytes}) + if err == nil { + returnExistingAcct(existingAcct) + return + } + // return error even if berrors.NotFound, as the duplicate key error we got from + // ra.NewRegistration indicates it _does_ already exist. + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "checking for existing account"), err) + return + } + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Error creating new account"), err) + return + } + + registrationValid := func(reg *corepb.Registration) bool { + return !(len(reg.Key) == 0 || len(reg.InitialIP) == 0) && reg.Id != 0 + } + + if acctPB == nil || !registrationValid(acctPB) { + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Error creating new account"), err) + return + } + acct, err := bgrpc.PbToRegistration(acctPB) + if err != nil { + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Error creating new account"), err) + return + } + logEvent.Requester = acct.ID + addRequesterHeader(response, acct.ID) + + acctURL := web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, acct.ID)) + + response.Header().Add("Location", acctURL) + if len(wfe.SubscriberAgreementURL) > 0 { + response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service")) + } + + prepAccountForDisplay(&acct) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, acct) + if err != nil { + // ServerInternal because we just created this account, and it + // should be OK. + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err) + return + } + newRegistrationSuccessful = true +} + +// parseRevocation accepts the payload for a revocation request and parses it +// into both the certificate to be revoked and the requested revocation reason +// (if any). Returns an error if any of the parsing fails, or if the given cert +// or revocation reason don't pass simple static checks. Also populates some +// metadata fields on the given logEvent. +func (wfe *WebFrontEndImpl) parseRevocation( + jwsBody []byte, logEvent *web.RequestEvent) (*x509.Certificate, revocation.Reason, *probs.ProblemDetails) { + // Read the revoke request from the JWS payload + var revokeRequest struct { + CertificateDER core.JSONBuffer `json:"certificate"` + Reason *revocation.Reason `json:"reason"` + } + err := json.Unmarshal(jwsBody, &revokeRequest) + if err != nil { + return nil, 0, probs.Malformed("Unable to JSON parse revoke request") + } + + // Parse the provided certificate + parsedCertificate, err := x509.ParseCertificate(revokeRequest.CertificateDER) + if err != nil { + return nil, 0, probs.Malformed("Unable to parse certificate DER") + } + + // Compute and record the serial number of the provided certificate + serial := core.SerialToString(parsedCertificate.SerialNumber) + logEvent.Extra["CertificateSerial"] = serial + if revokeRequest.Reason != nil { + logEvent.Extra["RevocationReason"] = *revokeRequest.Reason + } + + // Try to validate the signature on the provided cert using its corresponding + // issuer certificate. + issuerCert, ok := wfe.issuerCertificates[issuance.IssuerNameID(parsedCertificate)] + if !ok || issuerCert == nil { + return nil, 0, probs.NotFound("Certificate from unrecognized issuer") + } + err = parsedCertificate.CheckSignatureFrom(issuerCert.Certificate) + if err != nil { + return nil, 0, probs.NotFound("No such certificate") + } + logEvent.DNSNames = parsedCertificate.DNSNames + + if parsedCertificate.NotAfter.Before(wfe.clk.Now()) { + return nil, 0, probs.Unauthorized("Certificate is expired") + } + + // Verify the revocation reason supplied is allowed + reason := revocation.Reason(0) + if revokeRequest.Reason != nil { + if _, present := revocation.UserAllowedReasons[*revokeRequest.Reason]; !present { + reasonStr, ok := revocation.ReasonToString[*revokeRequest.Reason] + if !ok { + reasonStr = "unknown" + } + return nil, 0, probs.BadRevocationReason( + "unsupported revocation reason code provided: %s (%d). Supported reasons: %s", + reasonStr, + *revokeRequest.Reason, + revocation.UserAllowedReasonsMessage) + } + reason = *revokeRequest.Reason + } + + return parsedCertificate, reason, nil +} + +type revocationEvidence struct { + Serial string + Reason revocation.Reason + RegID int64 + Method string +} + +// revokeCertBySubscriberKey processes an outer JWS as a revocation request that +// is authenticated by a KeyID and the associated account. +func (wfe *WebFrontEndImpl) revokeCertBySubscriberKey( + ctx context.Context, + outerJWS *bJSONWebSignature, + request *http.Request, + logEvent *web.RequestEvent) error { + // For Key ID revocations we authenticate the outer JWS by using + // `validJWSForAccount` similar to other WFE endpoints + jwsBody, _, acct, prob := wfe.validJWSForAccount(outerJWS, request, ctx, logEvent) + if prob != nil { + return prob + } + + cert, reason, prob := wfe.parseRevocation(jwsBody, logEvent) + if prob != nil { + return prob + } + + wfe.log.AuditObject("Authenticated revocation", revocationEvidence{ + Serial: core.SerialToString(cert.SerialNumber), + Reason: reason, + RegID: acct.ID, + Method: "applicant", + }) + + // The RA will confirm that the authenticated account either originally + // issued the certificate, or has demonstrated control over all identifiers + // in the certificate. + _, err := wfe.ra.RevokeCertByApplicant(ctx, &rapb.RevokeCertByApplicantRequest{ + Cert: cert.Raw, + Code: int64(reason), + RegID: acct.ID, + }) + if err != nil { + return err + } + + return nil +} + +// revokeCertByCertKey processes an outer JWS as a revocation request that is +// authenticated by an embedded JWK. E.g. in the case where someone is +// requesting a revocation by using the keypair associated with the certificate +// to be revoked +func (wfe *WebFrontEndImpl) revokeCertByCertKey( + ctx context.Context, + outerJWS *bJSONWebSignature, + request *http.Request, + logEvent *web.RequestEvent) error { + // For embedded JWK revocations we authenticate the outer JWS by using + // `validSelfAuthenticatedJWS` similar to new-reg and key rollover. + // We do *not* use `validSelfAuthenticatedPOST` here because we've already + // read the HTTP request body in `parseJWSRequest` and it is now empty. + jwsBody, jwk, prob := wfe.validSelfAuthenticatedJWS(ctx, outerJWS, request) + if prob != nil { + return prob + } + + cert, reason, prob := wfe.parseRevocation(jwsBody, logEvent) + if prob != nil { + return prob + } + + // For embedded JWK revocations we decide if a requester is able to revoke a specific + // certificate by checking that to-be-revoked certificate has the same public + // key as the JWK that was used to authenticate the request + if !core.KeyDigestEquals(jwk, cert.PublicKey) { + return probs.Unauthorized( + "JWK embedded in revocation request must be the same public key as the cert to be revoked") + } + + wfe.log.AuditObject("Authenticated revocation", revocationEvidence{ + Serial: core.SerialToString(cert.SerialNumber), + Reason: reason, + RegID: 0, + Method: "privkey", + }) + + // The RA assumes here that the WFE2 has validated the JWS as proving + // control of the private key corresponding to this certificate. + _, err := wfe.ra.RevokeCertByKey(ctx, &rapb.RevokeCertByKeyRequest{ + Cert: cert.Raw, + }) + if err != nil { + return err + } + + return nil +} + +// RevokeCertificate is used by clients to request the revocation of a cert. The +// revocation request is handled uniquely based on the method of authentication +// used. +func (wfe *WebFrontEndImpl) RevokeCertificate( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + + // The ACME specification handles the verification of revocation requests + // differently from other endpoints. For this reason we do *not* immediately + // call `wfe.validPOSTForAccount` like all of the other endpoints. + // For this endpoint we need to accept a JWS with an embedded JWK, or a JWS + // with an embedded key ID, handling each case differently in terms of which + // certificates are authorized to be revoked by the requester + + // Parse the JWS from the HTTP Request + jws, prob := wfe.parseJWSRequest(request) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Figure out which type of authentication this JWS uses + authType, prob := checkJWSAuthType(jws.Signatures[0].Header) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Handle the revocation request according to how it is authenticated, or if + // the authentication type is unknown, error immediately + var err error + switch authType { + case embeddedKeyID: + err = wfe.revokeCertBySubscriberKey(ctx, jws, request, logEvent) + case embeddedJWK: + err = wfe.revokeCertByCertKey(ctx, jws, request, logEvent) + default: + err = berrors.MalformedError("Malformed JWS, no KeyID or embedded JWK") + } + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "unable to revoke"), nil) + return + } + + response.WriteHeader(http.StatusOK) +} + +// Challenge handles POST requests to challenge URLs. +// Such requests are clients' responses to the server's challenges. +func (wfe *WebFrontEndImpl) Challenge( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + notFound := func() { + wfe.sendError(response, logEvent, probs.NotFound("No such challenge"), nil) + } + slug := strings.Split(request.URL.Path, "/") + if len(slug) != 2 { + notFound() + return + } + authorizationID, err := strconv.ParseInt(slug[0], 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid authorization ID"), nil) + return + } + challengeID := slug[1] + authzPB, err := wfe.sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authorizationID}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + notFound() + } else { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Problem getting authorization"), err) + } + return + } + + // Ensure gRPC response is complete. + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if authzPB.Id == "" || authzPB.Identifier == "" || authzPB.Status == "" || core.IsAnyNilOrZero(authzPB.Expires) { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), errIncompleteGRPCResponse) + return + } + + authz, err := bgrpc.PBToAuthz(authzPB) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), err) + return + } + challengeIndex := authz.FindChallengeByStringID(challengeID) + if challengeIndex == -1 { + notFound() + return + } + + if authz.Expires == nil || authz.Expires.Before(wfe.clk.Now()) { + wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil) + return + } + + if requiredStale(request, logEvent) { + if prob := wfe.staleEnoughToGETAuthz(authzPB); prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + } + + if authz.Identifier.Type == identifier.DNS { + logEvent.DNSName = authz.Identifier.Value + } + logEvent.Status = string(authz.Status) + + challenge := authz.Challenges[challengeIndex] + switch request.Method { + case "GET", "HEAD": + wfe.getChallenge(response, request, authz, &challenge, logEvent) + + case "POST": + logEvent.ChallengeType = string(challenge.Type) + wfe.postChallenge(ctx, response, request, authz, challengeIndex, logEvent) + } +} + +// prepAccountForDisplay takes a core.Registration and mutates it to be ready +// for display in a JSON response. Primarily it papers over legacy ACME v1 +// features or non-standard details internal to Boulder we don't want clients to +// rely on. +func prepAccountForDisplay(acct *core.Registration) { + // Zero out the account ID so that it isn't marshalled. RFC 8555 specifies + // using the Location header for learning the account ID. + acct.ID = 0 + + // We populate the account Agreement field when creating a new response to + // track which terms-of-service URL was in effect when an account with + // "termsOfServiceAgreed":"true" is created. That said, we don't want to send + // this value back to a V2 client. The "Agreement" field of an + // account/registration is a V1 notion so we strip it here in the WFE2 before + // returning the account. + acct.Agreement = "" +} + +// prepChallengeForDisplay takes a core.Challenge and prepares it for display to +// the client by filling in its URL field and clearing several unnecessary +// fields. +func (wfe *WebFrontEndImpl) prepChallengeForDisplay(request *http.Request, authz core.Authorization, challenge *core.Challenge) { + // Update the challenge URL to be relative to the HTTP request Host + challenge.URL = web.RelativeEndpoint(request, fmt.Sprintf("%s%s/%s", challengePath, authz.ID, challenge.StringID())) + + // ACMEv2 never sends the KeyAuthorization back in a challenge object. + challenge.ProvidedKeyAuthorization = "" + + // Internally, we store challenge error problems with just the short form + // (e.g. "CAA") of the problem type. But for external display, we need to + // prefix the error type with the RFC8555 ACME Error namespace. + if challenge.Error != nil { + challenge.Error.Type = probs.ErrorNS + challenge.Error.Type + } + + // If the authz has been marked invalid, consider all challenges on that authz + // to be invalid as well. + if authz.Status == core.StatusInvalid { + challenge.Status = authz.Status + } + + // This field is not useful for the client, only internal debugging, + for idx := range challenge.ValidationRecord { + challenge.ValidationRecord[idx].ResolverAddrs = nil + } +} + +// prepAuthorizationForDisplay takes a core.Authorization and prepares it for +// display to the client by clearing its ID and RegistrationID fields, and +// preparing all its challenges. +func (wfe *WebFrontEndImpl) prepAuthorizationForDisplay(request *http.Request, authz *core.Authorization) { + for i := range authz.Challenges { + wfe.prepChallengeForDisplay(request, *authz, &authz.Challenges[i]) + } + authz.ID = "" + authz.RegistrationID = 0 + + // The ACME spec forbids allowing "*" in authorization identifiers. Boulder + // allows this internally as a means of tracking when an authorization + // corresponds to a wildcard request (e.g. to handle CAA properly). We strip + // the "*." prefix from the Authz's Identifier's Value here to respect the law + // of the protocol. + if strings.HasPrefix(authz.Identifier.Value, "*.") { + authz.Identifier.Value = strings.TrimPrefix(authz.Identifier.Value, "*.") + // Mark that the authorization corresponds to a wildcard request since we've + // now removed the wildcard prefix from the identifier. + authz.Wildcard = true + } +} + +func (wfe *WebFrontEndImpl) getChallenge( + response http.ResponseWriter, + request *http.Request, + authz core.Authorization, + challenge *core.Challenge, + logEvent *web.RequestEvent) { + + wfe.prepChallengeForDisplay(request, authz, challenge) + + authzURL := urlForAuthz(authz, request) + response.Header().Add("Location", challenge.URL) + response.Header().Add("Link", link(authzURL, "up")) + + err := wfe.writeJsonResponse(response, logEvent, http.StatusOK, challenge) + if err != nil { + // InternalServerError because this is a failure to decode data passed in + // by the caller, which got it from the DB. + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err) + return + } +} + +func (wfe *WebFrontEndImpl) postChallenge( + ctx context.Context, + response http.ResponseWriter, + request *http.Request, + authz core.Authorization, + challengeIndex int, + logEvent *web.RequestEvent) { + body, _, currAcct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // validPOSTForAccount handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Check that the account ID matching the key used matches + // the account ID on the authz object + if currAcct.ID != authz.RegistrationID { + wfe.sendError(response, + logEvent, + probs.Unauthorized("User account ID doesn't match account ID in authorization"), + nil, + ) + return + } + + // If the JWS body is empty then this POST is a POST-as-GET to retrieve + // challenge details, not a POST to initiate a challenge + if string(body) == "" { + challenge := authz.Challenges[challengeIndex] + wfe.getChallenge(response, request, authz, &challenge, logEvent) + return + } + + // We can expect some clients to try and update a challenge for an authorization + // that is already valid. In this case we don't need to process the challenge + // update. It wouldn't be helpful, the overall authorization is already good! + var returnAuthz core.Authorization + if authz.Status == core.StatusValid { + returnAuthz = authz + } else { + + // NOTE(@cpu): Historically a challenge update needed to include + // a KeyAuthorization field. This is no longer the case, since both sides can + // calculate the key authorization as needed. We unmarshal here only to check + // that the POST body is valid JSON. Any data/fields included are ignored to + // be kind to ACMEv2 implementations that still send a key authorization. + var challengeUpdate struct{} + err := json.Unmarshal(body, &challengeUpdate) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling challenge response"), err) + return + } + + authzPB, err := bgrpc.AuthzToPB(authz) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to serialize authz"), err) + return + } + + authzPB, err = wfe.ra.PerformValidation(ctx, &rapb.PerformValidationRequest{ + Authz: authzPB, + ChallengeIndex: int64(challengeIndex), + }) + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if err != nil || authzPB == nil || authzPB.Id == "" || authzPB.Identifier == "" || authzPB.Status == "" || core.IsAnyNilOrZero(authzPB.Expires) { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to update challenge"), err) + return + } + + updatedAuthz, err := bgrpc.PBToAuthz(authzPB) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to deserialize authz"), err) + return + } + returnAuthz = updatedAuthz + } + + // assumption: PerformValidation does not modify order of challenges + challenge := returnAuthz.Challenges[challengeIndex] + wfe.prepChallengeForDisplay(request, authz, &challenge) + + authzURL := urlForAuthz(authz, request) + response.Header().Add("Location", challenge.URL) + response.Header().Add("Link", link(authzURL, "up")) + + err := wfe.writeJsonResponse(response, logEvent, http.StatusOK, challenge) + if err != nil { + // ServerInternal because we made the challenges, they should be OK + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err) + return + } +} + +// Account is used by a client to submit an update to their account. +func (wfe *WebFrontEndImpl) Account( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + body, _, currAcct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // validPOSTForAccount handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Requests to this handler should have a path that leads to a known + // account + idStr := request.URL.Path + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Account ID must be an integer"), err) + return + } else if id <= 0 { + msg := fmt.Sprintf("Account ID must be a positive non-zero integer, was %d", id) + wfe.sendError(response, logEvent, probs.Malformed(msg), nil) + return + } else if id != currAcct.ID { + wfe.sendError(response, logEvent, + probs.Unauthorized("Request signing key did not match account key"), nil) + return + } + + // If the body was not empty, then this is an account update request. + if string(body) != "" { + currAcct, prob = wfe.updateAccount(ctx, body, currAcct) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + } + + if len(wfe.SubscriberAgreementURL) > 0 { + response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service")) + } + + prepAccountForDisplay(currAcct) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, currAcct) + if err != nil { + // ServerInternal because we just generated the account, it should be OK + wfe.sendError(response, logEvent, + probs.ServerInternal("Failed to marshal account"), err) + return + } +} + +// updateAccount unmarshals an account update request from the provided +// requestBody to update the given registration. Important: It is assumed the +// request has already been authenticated by the caller. If the request is +// a valid update the resulting updated account is returned, otherwise a problem +// is returned. +func (wfe *WebFrontEndImpl) updateAccount( + ctx context.Context, + requestBody []byte, + currAcct *core.Registration) (*core.Registration, *probs.ProblemDetails) { + // Only the Contact and Status fields of an account may be updated this way. + // For key updates clients should be using the key change endpoint. + var accountUpdateRequest struct { + Contact *[]string `json:"contact"` + Status core.AcmeStatus `json:"status"` + } + + err := json.Unmarshal(requestBody, &accountUpdateRequest) + if err != nil { + return nil, probs.Malformed("Error unmarshaling account") + } + + // Convert existing account to corepb.Registration + basePb, err := bgrpc.RegistrationToPB(*currAcct) + if err != nil { + return nil, probs.ServerInternal("Error updating account") + } + + var contacts []string + var contactsPresent bool + if accountUpdateRequest.Contact != nil { + contactsPresent = true + contacts = *accountUpdateRequest.Contact + } + + // Copy over the fields from the request to the registration object used for + // the RA updates. + // Create corepb.Registration from provided account information + updatePb := &corepb.Registration{ + Contact: contacts, + ContactsPresent: contactsPresent, + Status: string(accountUpdateRequest.Status), + } + + // People *will* POST their full accounts to this endpoint, including + // the 'valid' status, to avoid always failing out when that happens only + // attempt to deactivate if the provided status is different from their current + // status. + // + // If a user tries to send both a deactivation request and an update to their + // contacts or subscriber agreement URL the deactivation will take place and + // return before an update would be performed. + if updatePb.Status != "" && updatePb.Status != basePb.Status { + if updatePb.Status != string(core.StatusDeactivated) { + return nil, probs.Malformed("Invalid value provided for status field") + } + _, err := wfe.ra.DeactivateRegistration(ctx, basePb) + if err != nil { + return nil, web.ProblemDetailsForError(err, "Unable to deactivate account") + } + currAcct.Status = core.StatusDeactivated + return currAcct, nil + } + + // Account objects contain a JWK object which are merged in UpdateRegistration + // if it is different from the existing account key. Since this isn't how you + // update the key we just copy the existing one into the update object here. This + // ensures the key isn't changed and that we can cleanly serialize the update as + // JSON to send via RPC to the RA. + updatePb.Key = basePb.Key + + updatedAcct, err := wfe.ra.UpdateRegistration(ctx, &rapb.UpdateRegistrationRequest{Base: basePb, Update: updatePb}) + if err != nil { + return nil, web.ProblemDetailsForError(err, "Unable to update account") + } + + // Convert proto to core.Registration for return + updatedReg, err := bgrpc.PbToRegistration(updatedAcct) + if err != nil { + return nil, probs.ServerInternal("Error updating account") + } + + return &updatedReg, nil +} + +// deactivateAuthorization processes the given JWS POST body as a request to +// deactivate the provided authorization. If an error occurs it is written to +// the response writer. Important: `deactivateAuthorization` does not check that +// the requester is authorized to deactivate the given authorization. It is +// assumed that this check is performed prior to calling deactivateAuthorzation. +func (wfe *WebFrontEndImpl) deactivateAuthorization( + ctx context.Context, + authzPB *corepb.Authorization, + logEvent *web.RequestEvent, + response http.ResponseWriter, + body []byte) bool { + var req struct { + Status core.AcmeStatus + } + err := json.Unmarshal(body, &req) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err) + return false + } + if req.Status != core.StatusDeactivated { + wfe.sendError(response, logEvent, probs.Malformed("Invalid status value"), err) + return false + } + _, err = wfe.ra.DeactivateAuthorization(ctx, authzPB) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error deactivating authorization"), err) + return false + } + // Since the authorization passed to DeactivateAuthorization isn't + // mutated locally by the function we must manually set the status + // here before displaying the authorization to the user + authzPB.Status = string(core.StatusDeactivated) + return true +} + +func (wfe *WebFrontEndImpl) Authorization( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + var requestAccount *core.Registration + var requestBody []byte + // If the request is a POST it is either: + // A) an update to an authorization to deactivate it + // B) a POST-as-GET to query the authorization details + if request.Method == "POST" { + // Both POST options need to be authenticated by an account + body, _, acct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + requestAccount = acct + requestBody = body + } + + authzID, err := strconv.ParseInt(request.URL.Path, 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid authorization ID"), nil) + return + } + + authzPB, err := wfe.sa.GetAuthorization2(ctx, &sapb.AuthorizationID2{Id: authzID}) + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound("No such authorization"), nil) + return + } else if errors.Is(err, berrors.Malformed) { + wfe.sendError(response, logEvent, probs.Malformed(err.Error()), nil) + return + } else if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Problem getting authorization"), err) + return + } + + // Ensure gRPC response is complete. + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if authzPB.Id == "" || authzPB.Identifier == "" || authzPB.Status == "" || core.IsAnyNilOrZero(authzPB.Expires) { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), errIncompleteGRPCResponse) + return + } + + if identifier.IdentifierType(authzPB.Identifier) == identifier.DNS { + logEvent.DNSName = authzPB.Identifier + } + logEvent.Status = authzPB.Status + + // After expiring, authorizations are inaccessible + if authzPB.Expires.AsTime().Before(wfe.clk.Now()) { + wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil) + return + } + + if requiredStale(request, logEvent) { + if prob := wfe.staleEnoughToGETAuthz(authzPB); prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + } + + // If this was a POST that has an associated requestAccount and that account + // doesn't own the authorization, abort before trying to deactivate the authz + // or return its details + if requestAccount != nil && requestAccount.ID != authzPB.RegistrationID { + wfe.sendError(response, logEvent, + probs.Unauthorized("Account ID doesn't match ID for authorization"), nil) + return + } + + // If the body isn't empty we know it isn't a POST-as-GET and must be an + // attempt to deactivate an authorization. + if string(requestBody) != "" { + // If the deactivation fails return early as errors and return codes + // have already been set. Otherwise continue so that the user gets + // sent the deactivated authorization. + if !wfe.deactivateAuthorization(ctx, authzPB, logEvent, response, requestBody) { + return + } + } + + authz, err := bgrpc.PBToAuthz(authzPB) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), err) + return + } + + wfe.prepAuthorizationForDisplay(request, &authz) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, authz) + if err != nil { + // InternalServerError because this is a failure to decode from our DB. + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to JSON marshal authz"), err) + return + } +} + +// Certificate is used by clients to request a copy of their current certificate, or to +// request a reissuance of the certificate. +func (wfe *WebFrontEndImpl) Certificate(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + var requesterAccount *core.Registration + // Any POSTs to the Certificate endpoint should be POST-as-GET requests. There are + // no POSTs with a body allowed for this endpoint. + if request.Method == "POST" { + acct, prob := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + requesterAccount = acct + } + + requestedChain := 0 + serial := request.URL.Path + + // An alternate chain may be requested with the request path {serial}/{chain}, where chain + // is a number - an index into the slice of chains for the issuer. If a specific chain is + // not requested, then it defaults to zero - the default certificate chain for the issuer. + serialAndChain := strings.SplitN(serial, "/", 2) + if len(serialAndChain) == 2 { + idx, err := strconv.Atoi(serialAndChain[1]) + if err != nil || idx < 0 { + wfe.sendError(response, logEvent, probs.Malformed("Chain ID must be a non-negative integer"), + fmt.Errorf("certificate chain id provided was not valid: %s", serialAndChain[1])) + return + } + serial = serialAndChain[0] + requestedChain = idx + } + + // Certificate paths consist of the CertBase path, plus exactly sixteen hex + // digits. + if !core.ValidSerial(serial) { + wfe.sendError( + response, + logEvent, + probs.NotFound("Certificate not found"), + fmt.Errorf("certificate serial provided was not valid: %s", serial), + ) + return + } + logEvent.Extra["RequestedSerial"] = serial + + cert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound("Certificate not found"), nil) + } else { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Failed to retrieve certificate"), err) + } + return + } + + if requiredStale(request, logEvent) { + if prob := wfe.staleEnoughToGETCert(cert); prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + } + + // If there was a requesterAccount (e.g. because it was a POST-as-GET request) + // then the requesting account must be the owner of the certificate, otherwise + // return an unauthorized error. + if requesterAccount != nil && requesterAccount.ID != cert.RegistrationID { + wfe.sendError(response, logEvent, probs.Unauthorized("Account in use did not issue specified certificate"), nil) + return + } + + responsePEM, prob := func() ([]byte, *probs.ProblemDetails) { + leafPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Der, + }) + + parsedCert, err := x509.ParseCertificate(cert.Der) + if err != nil { + // If we can't parse one of our own certs there's a serious problem + return nil, probs.ServerInternal( + fmt.Sprintf( + "unable to parse Boulder issued certificate with serial %#v: %s", + serial, + err), + ) + } + + issuerNameID := issuance.IssuerNameID(parsedCert) + availableChains, ok := wfe.certificateChains[issuerNameID] + if !ok || len(availableChains) == 0 { + // If there is no wfe.certificateChains entry for the IssuerNameID then + // we can't provide a chain for this cert. If the certificate is expired, + // just return the bare cert. If the cert is still valid, then there is + // a misconfiguration and we should treat it as an internal server error. + if parsedCert.NotAfter.Before(wfe.clk.Now()) { + return leafPEM, nil + } + return nil, probs.ServerInternal( + fmt.Sprintf( + "Certificate serial %#v has an unknown IssuerNameID %d - no PEM certificate chain associated.", + serial, + issuerNameID), + ) + } + + // If the requested chain is outside the bounds of the available chains, + // then it is an error by the client - not found. + if requestedChain < 0 || requestedChain >= len(availableChains) { + return nil, probs.NotFound("Unknown issuance chain") + } + + // Double check that the signature validates. + err = parsedCert.CheckSignatureFrom(wfe.issuerCertificates[issuerNameID].Certificate) + if err != nil { + return nil, probs.ServerInternal( + fmt.Sprintf( + "Certificate serial %#v has a signature which cannot be verified from issuer %d.", + serial, + issuerNameID), + ) + } + + // Add rel="alternate" links for every chain available for this issuer, + // excluding the currently requested chain. + for chainID := range availableChains { + if chainID == requestedChain { + continue + } + chainURL := web.RelativeEndpoint(request, + fmt.Sprintf("%s%s/%d", certPath, serial, chainID)) + response.Header().Add("Link", link(chainURL, "alternate")) + } + + // Prepend the chain with the leaf certificate + return append(leafPEM, availableChains[requestedChain]...), nil + }() + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // NOTE(@cpu): We must explicitly set the Content-Length header here. The Go + // HTTP library will only add this header if the body is below a certain size + // and with the addition of a PEM encoded certificate chain the body size of + // this endpoint will exceed this threshold. Since we know the length we can + // reliably set it ourselves and not worry. + response.Header().Set("Content-Length", strconv.Itoa(len(responsePEM))) + response.Header().Set("Content-Type", "application/pem-certificate-chain") + response.WriteHeader(http.StatusOK) + if _, err = response.Write(responsePEM); err != nil { + wfe.log.Warningf("Could not write response: %s", err) + } +} + +// BuildID tells the requester what build we're running. +func (wfe *WebFrontEndImpl) BuildID(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "text/plain") + response.WriteHeader(http.StatusOK) + detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime()) + if _, err := fmt.Fprintln(response, detailsString); err != nil { + wfe.log.Warningf("Could not write response: %s", err) + } +} + +// Options responds to an HTTP OPTIONS request. +func (wfe *WebFrontEndImpl) Options(response http.ResponseWriter, request *http.Request, methodsStr string, methodsMap map[string]bool) { + // Every OPTIONS request gets an Allow header with a list of supported methods. + response.Header().Set("Allow", methodsStr) + + // CORS preflight requests get additional headers. See + // http://www.w3.org/TR/cors/#resource-preflight-requests + reqMethod := request.Header.Get("Access-Control-Request-Method") + if reqMethod == "" { + reqMethod = "GET" + } + if methodsMap[reqMethod] { + wfe.setCORSHeaders(response, request, methodsStr) + } +} + +// setCORSHeaders() tells the client that CORS is acceptable for this +// request. If allowMethods == "" the request is assumed to be a CORS +// actual request and no Access-Control-Allow-Methods header will be +// sent. +func (wfe *WebFrontEndImpl) setCORSHeaders(response http.ResponseWriter, request *http.Request, allowMethods string) { + reqOrigin := request.Header.Get("Origin") + if reqOrigin == "" { + // This is not a CORS request. + return + } + + // Allow CORS if the current origin (or "*") is listed as an + // allowed origin in config. Otherwise, disallow by returning + // without setting any CORS headers. + allow := false + for _, ao := range wfe.AllowOrigins { + if ao == "*" { + response.Header().Set("Access-Control-Allow-Origin", "*") + allow = true + break + } else if ao == reqOrigin { + response.Header().Set("Vary", "Origin") + response.Header().Set("Access-Control-Allow-Origin", ao) + allow = true + break + } + } + if !allow { + return + } + + if allowMethods != "" { + // For an OPTIONS request: allow all methods handled at this URL. + response.Header().Set("Access-Control-Allow-Methods", allowMethods) + } + // NOTE(@cpu): "Content-Type" is considered a 'simple header' that doesn't + // need to be explicitly allowed in 'access-control-allow-headers', but only + // when the value is one of: `application/x-www-form-urlencoded`, + // `multipart/form-data`, or `text/plain`. Since `application/jose+json` is + // not one of these values we must be explicit in saying that `Content-Type` + // is an allowed header. See MDN for more details: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers + response.Header().Set("Access-Control-Allow-Headers", "Content-Type") + response.Header().Set("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location") + response.Header().Set("Access-Control-Max-Age", "86400") +} + +// KeyRollover allows a user to change their signing key +func (wfe *WebFrontEndImpl) KeyRollover( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + // Validate the outer JWS on the key rollover in standard fashion using + // validPOSTForAccount + outerBody, outerJWS, acct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + oldKey := acct.Key + + // Parse the inner JWS from the validated outer JWS body + innerJWS, prob := wfe.parseJWS(outerBody) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Validate the inner JWS as a key rollover request for the outer JWS + rolloverOperation, prob := wfe.validKeyRollover(ctx, outerJWS, innerJWS, oldKey) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + newKey := rolloverOperation.NewKey + + // Check that the rollover request's account URL matches the account URL used + // to validate the outer JWS + header := outerJWS.Signatures[0].Header + if rolloverOperation.Account != header.KeyID { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverMismatchedAccount"}).Inc() + wfe.sendError(response, logEvent, probs.Malformed( + fmt.Sprintf("Inner key rollover request specified Account %q, but outer JWS has Key ID %q", + rolloverOperation.Account, header.KeyID)), nil) + return + } + + // Check that the new key isn't the same as the old key. This would fail as + // part of the subsequent `wfe.SA.GetRegistrationByKey` check since the new key + // will find the old account if its equal to the old account key. We + // check new key against old key explicitly to save an RPC round trip and a DB + // query for this easy rejection case + keysEqual, err := core.PublicKeysEqual(newKey.Key, oldKey.Key) + if err != nil { + // This should not happen - both the old and new key have been validated by now + wfe.sendError(response, logEvent, probs.ServerInternal("Unable to compare new and old keys"), err) + return + } + if keysEqual { + wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverUnchangedKey"}).Inc() + wfe.sendError(response, logEvent, probs.Malformed( + "New key specified by rollover request is the same as the old key"), nil) + return + } + + // Marshal key to bytes + newKeyBytes, err := newKey.MarshalJSON() + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling new key"), err) + } + // Check that the new key isn't already being used for an existing account + existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: newKeyBytes}) + if err == nil { + response.Header().Set("Location", + web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, existingAcct.Id))) + wfe.sendError(response, logEvent, + probs.Conflict("New key is already in use for a different account"), err) + return + } else if !errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Failed to lookup existing keys"), err) + return + } + // Convert account to proto for grpc + regPb, err := bgrpc.RegistrationToPB(*acct) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling Registration to proto"), err) + return + } + + // Copy new key into an empty registration to provide as the update + updatePb := &corepb.Registration{Key: newKeyBytes} + + // Update the account key to the new key + updatedAcctPb, err := wfe.ra.UpdateRegistration(ctx, &rapb.UpdateRegistrationRequest{Base: regPb, Update: updatePb}) + if err != nil { + if errors.Is(err, berrors.Duplicate) { + // It is possible that between checking for the existing key, and performing the update + // a parallel update or new account request happened and claimed the key. In this case + // just retrieve the account again, and return an error as we would above with a Location + // header + existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: newKeyBytes}) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "looking up account by key"), err) + return + } + response.Header().Set("Location", + web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, existingAcct.Id))) + wfe.sendError(response, logEvent, + probs.Conflict("New key is already in use for a different account"), err) + return + } + wfe.sendError(response, logEvent, + web.ProblemDetailsForError(err, "Unable to update account with new key"), err) + return + } + // Convert proto to registration for display + updatedAcct, err := bgrpc.PbToRegistration(updatedAcctPb) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling proto to registration"), err) + return + } + prepAccountForDisplay(&updatedAcct) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, updatedAcct) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal updated account"), err) + } +} + +type orderJSON struct { + Status core.AcmeStatus `json:"status"` + Expires time.Time `json:"expires"` + Identifiers []identifier.ACMEIdentifier `json:"identifiers"` + Authorizations []string `json:"authorizations"` + Finalize string `json:"finalize"` + Profile string `json:"profile,omitempty"` + Certificate string `json:"certificate,omitempty"` + Error *probs.ProblemDetails `json:"error,omitempty"` +} + +// orderToOrderJSON converts a *corepb.Order instance into an orderJSON struct +// that is returned in HTTP API responses. It will convert the order names to +// DNS type identifiers and additionally create absolute URLs for the finalize +// URL and the ceritificate URL as appropriate. +func (wfe *WebFrontEndImpl) orderToOrderJSON(request *http.Request, order *corepb.Order) orderJSON { + idents := make([]identifier.ACMEIdentifier, len(order.Names)) + for i, name := range order.Names { + idents[i] = identifier.ACMEIdentifier{Type: identifier.DNS, Value: name} + } + finalizeURL := web.RelativeEndpoint(request, + fmt.Sprintf("%s%d/%d", finalizeOrderPath, order.RegistrationID, order.Id)) + respObj := orderJSON{ + Status: core.AcmeStatus(order.Status), + Expires: order.Expires.AsTime(), + Identifiers: idents, + Finalize: finalizeURL, + } + // If there is an order error, prefix its type with the V2 namespace + if order.Error != nil { + prob, err := bgrpc.PBToProblemDetails(order.Error) + if err != nil { + wfe.log.AuditErrf("Internal error converting order ID %d "+ + "proto buf prob to problem details: %q", order.Id, err) + } + respObj.Error = prob + respObj.Error.Type = probs.ErrorNS + respObj.Error.Type + } + for _, v2ID := range order.V2Authorizations { + respObj.Authorizations = append(respObj.Authorizations, web.RelativeEndpoint(request, fmt.Sprintf("%s%d", authzPath, v2ID))) + } + if respObj.Status == core.StatusValid { + certURL := web.RelativeEndpoint(request, + fmt.Sprintf("%s%s", certPath, order.CertificateSerial)) + respObj.Certificate = certURL + } + return respObj +} + +// newNewOrderLimitTransactions constructs a set of rate limit transactions to +// evaluate for a new-order request. +// +// Precondition: names must be a list of DNS names that all pass +// policy.WellFormedDomainNames. +func (wfe *WebFrontEndImpl) newNewOrderLimitTransactions(regId int64, names []string) []ratelimits.Transaction { + if wfe.limiter == nil && wfe.txnBuilder == nil { + // Limiter is disabled. + return nil + } + + logTxnErr := func(err error, limit ratelimits.Name) { + // TODO(#5545): Once key-value rate limits are authoritative this log + // line should be removed in favor of returning the error. + wfe.log.Infof("error constructing rate limit transaction for %s rate limit: %s", limit, err) + } + + var transactions []ratelimits.Transaction + txn, err := wfe.txnBuilder.OrdersPerAccountTransaction(regId) + if err != nil { + logTxnErr(err, ratelimits.NewOrdersPerAccount) + return nil + } + transactions = append(transactions, txn) + + failedAuthzTxns, err := wfe.txnBuilder.FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(regId, names, wfe.maxNames) + if err != nil { + logTxnErr(err, ratelimits.FailedAuthorizationsPerDomainPerAccount) + return nil + } + transactions = append(transactions, failedAuthzTxns...) + + certsPerDomainTxns, err := wfe.txnBuilder.CertificatesPerDomainTransactions(regId, names, wfe.maxNames) + if err != nil { + logTxnErr(err, ratelimits.CertificatesPerDomain) + return nil + } + transactions = append(transactions, certsPerDomainTxns...) + + txn, err = wfe.txnBuilder.CertificatesPerFQDNSetTransaction(names) + if err != nil { + logTxnErr(err, ratelimits.CertificatesPerFQDNSet) + return nil + } + return append(transactions, txn) +} + +// checkNewOrderLimits checks whether sufficient limit quota exists for the +// creation of a new order. If so, that quota is spent. If an error is +// encountered during the check, it is logged but not returned. +// +// TODO(#5545): For now we're simply exercising the new rate limiter codepath. +// This should eventually return a berrors.RateLimit error containing the retry +// after duration among other information available in the ratelimits.Decision. +func (wfe *WebFrontEndImpl) checkNewOrderLimits(ctx context.Context, transactions []ratelimits.Transaction) { + if wfe.limiter == nil && wfe.txnBuilder == nil { + // Limiter is disabled. + return + } + + _, err := wfe.limiter.BatchSpend(ctx, transactions) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + wfe.log.Errf("checking newOrder limits: %s", err) + } +} + +func (wfe *WebFrontEndImpl) refundNewOrderLimits(ctx context.Context, transactions []ratelimits.Transaction) { + if wfe.limiter == nil || wfe.txnBuilder == nil { + return + } + + _, err := wfe.limiter.BatchRefund(ctx, transactions) + if err != nil { + wfe.log.Errf("refunding newOrder limits: %s", err) + } +} + +// orderMatchesReplacement checks if the order matches the provided certificate +// as identified by the provided ARI CertID. This function ensures that: +// - the certificate being replaced exists, +// - the requesting account owns that certificate, and +// - a name in this new order matches a name in the certificate being +// replaced. +func (wfe *WebFrontEndImpl) orderMatchesReplacement(ctx context.Context, acct *core.Registration, names []string, serial string) error { + // It's okay to use GetCertificate (vs trying to get a precertificate), + // because we don't intend to serve ARI for certs that never made it past + // the precert stage. + oldCert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + return berrors.NotFoundError("request included `replaces` field, but no current certificate with serial %q exists", serial) + } + return errors.New("failed to retrieve existing certificate") + } + + if oldCert.RegistrationID != acct.ID { + return berrors.UnauthorizedError("requester account did not request the certificate being replaced by this order") + } + parsedCert, err := x509.ParseCertificate(oldCert.Der) + if err != nil { + return fmt.Errorf("error parsing certificate replaced by this order: %w", err) + } + + var nameMatch bool + for _, name := range names { + if parsedCert.VerifyHostname(name) == nil { + // At least one name in the new order matches a name in the + // predecessor certificate. + nameMatch = true + break + } + } + if !nameMatch { + return berrors.MalformedError("identifiers in this order do not match any names in the certificate being replaced") + } + return nil +} + +func (wfe *WebFrontEndImpl) determineARIWindow(ctx context.Context, serial string) (core.RenewalInfo, error) { + // Check if the serial is impacted by an incident. + result, err := wfe.sa.IncidentsForSerial(ctx, &sapb.Serial{Serial: serial}) + if err != nil { + return core.RenewalInfo{}, fmt.Errorf("checking if existing certificate is impacted by an incident: %w", err) + } + + if len(result.Incidents) > 0 { + // The existing cert is impacted by an incident, renew immediately. + return core.RenewalInfoImmediate(wfe.clk.Now()), nil + } + + // Check if the serial is revoked. + status, err := wfe.sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial}) + if err != nil { + return core.RenewalInfo{}, fmt.Errorf("checking if existing certificate has been revoked: %w", err) + } + + if status.Status == string(core.OCSPStatusRevoked) { + // The existing certificate is revoked, renew immediately. + return core.RenewalInfoImmediate(wfe.clk.Now()), nil + } + + // It's okay to use GetCertificate (vs trying to get a precertificate), + // because we don't intend to serve ARI for certs that never made it past + // the precert stage. + cert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + return core.RenewalInfo{}, err + } + return core.RenewalInfo{}, fmt.Errorf("failed to retrieve existing certificate: %w", err) + } + + return core.RenewalInfoSimple(cert.Issued.AsTime(), cert.Expires.AsTime()), nil +} + +// validateReplacementOrder implements draft-ietf-acme-ari-03. For a new order +// to be considered a replacement for an existing certificate, the existing +// certificate: +// 1. MUST NOT have been replaced by another finalized order, +// 2. MUST be associated with the same ACME account as this request, and +// 3. MUST have at least one identifier in common with this request. +// +// There are three values returned by this function: +// - The first return value is the serial number of the certificate being +// replaced. If the order is not a replacement, this value is an empty +// string. +// - The second return value is a boolean indicating whether the order is +// exempt from rate limits. If the order is a replacement and the request +// is made within the suggested renewal window, this value is true. +// Otherwise, this value is false. +// - The last value is an error, this is non-nil unless the order is not a +// replacement or there was an error while validating the replacement. +func (wfe *WebFrontEndImpl) validateReplacementOrder(ctx context.Context, acct *core.Registration, names []string, replaces string) (string, bool, error) { + if replaces == "" { + // No replacement indicated. + return "", false, nil + } + + decodedSerial, err := parseARICertID(replaces, wfe.issuerCertificates) + if err != nil { + return "", false, fmt.Errorf("while parsing ARI CertID an error occurred: %w", err) + } + + exists, err := wfe.sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: decodedSerial}) + if err != nil { + return "", false, fmt.Errorf("checking replacement status of existing certificate: %w", err) + } + if exists.Exists { + return "", false, berrors.ConflictError( + "cannot indicate an order replaces certificate with serial %q, which already has a replacement order", + decodedSerial, + ) + } + + err = wfe.orderMatchesReplacement(ctx, acct, names, decodedSerial) + if err != nil { + // The provided replacement field value failed to meet the required + // criteria. We're going to return the error to the caller instead + // of trying to create a regular (non-replacement) order. + return "", false, fmt.Errorf("while checking that this order is a replacement: %w", err) + } + // This order is a replacement for an existing certificate. + replaces = decodedSerial + + // For an order to be exempt from rate limits, it must be a replacement + // and the request must be made within the suggested renewal window. + renewalInfo, err := wfe.determineARIWindow(ctx, replaces) + if err != nil { + return "", false, fmt.Errorf("while determining the current ARI renewal window: %w", err) + } + + return replaces, renewalInfo.SuggestedWindow.IsWithin(wfe.clk.Now()), nil +} + +func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error { + if profile == "" { + // No profile name is specified. + return nil + } + if !slices.Contains(wfe.certificateProfileNames, profile) { + // The profile name is not in the list of configured profiles. + return errors.New("not a recognized profile name") + } + + return nil +} + +// NewOrder is used by clients to create a new order object and a set of +// authorizations to fulfill for issuance. +func (wfe *WebFrontEndImpl) NewOrder( + ctx context.Context, + logEvent *web.RequestEvent, + response http.ResponseWriter, + request *http.Request) { + body, _, acct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // validPOSTForAccount handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + // newOrderRequest is the JSON structure of the request body. We only + // support the identifiers and replaces fields. If notBefore or notAfter are + // sent we return a probs.Malformed as we do not support them. + var newOrderRequest struct { + Identifiers []identifier.ACMEIdentifier `json:"identifiers"` + NotBefore string + NotAfter string + Replaces string + Profile string + } + err := json.Unmarshal(body, &newOrderRequest) + if err != nil { + wfe.sendError(response, logEvent, + probs.Malformed("Unable to unmarshal NewOrder request body"), err) + return + } + + if len(newOrderRequest.Identifiers) == 0 { + wfe.sendError(response, logEvent, + probs.Malformed("NewOrder request did not specify any identifiers"), nil) + return + } + if newOrderRequest.NotBefore != "" || newOrderRequest.NotAfter != "" { + wfe.sendError(response, logEvent, probs.Malformed("NotBefore and NotAfter are not supported"), nil) + return + } + + // Collect up all of the DNS identifier values into a []string for + // subsequent layers to process. We reject anything with a non-DNS + // type identifier here. Check to make sure one of the strings is + // short enough to meet the max CN bytes requirement. + names := make([]string, len(newOrderRequest.Identifiers)) + for i, ident := range newOrderRequest.Identifiers { + if ident.Type != identifier.DNS { + wfe.sendError(response, logEvent, + probs.UnsupportedIdentifier("NewOrder request included invalid non-DNS type identifier: type %q, value %q", + ident.Type, ident.Value), + nil) + return + } + if ident.Value == "" { + wfe.sendError(response, logEvent, probs.Malformed("NewOrder request included empty domain name"), nil) + return + } + names[i] = ident.Value + } + + names = core.UniqueLowerNames(names) + err = policy.WellFormedDomainNames(names) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Invalid identifiers requested"), nil) + return + } + if len(names) > wfe.maxNames { + wfe.sendError(response, logEvent, probs.Malformed("Order cannot contain more than %d DNS names", wfe.maxNames), nil) + return + } + + logEvent.DNSNames = names + + var replaces string + var limitsExempt bool + if features.Get().TrackReplacementCertificatesARI { + replaces, limitsExempt, err = wfe.validateReplacementOrder(ctx, acct, names, newOrderRequest.Replaces) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While validating order as a replacement an error occurred"), err) + return + } + } + + err = wfe.validateCertificateProfileName(newOrderRequest.Profile) + if err != nil { + // TODO(#7392) Provide link to profile documentation. + wfe.sendError(response, logEvent, probs.Malformed("Invalid certificate profile, %q: %s", newOrderRequest.Profile, err), err) + return + } + + // TODO(#5545): Spending and Refunding can be async until these rate limits + // are authoritative. This saves us from adding latency to each request. + // Goroutines spun out below will respect a context deadline set by the + // ratelimits package and cannot be prematurely canceled by the requester. + var txns []ratelimits.Transaction + if !limitsExempt { + txns = wfe.newNewOrderLimitTransactions(acct.ID, names) + go wfe.checkNewOrderLimits(ctx, txns) + } + + var newOrderSuccessful bool + var errIsRateLimit bool + defer func() { + if features.Get().TrackReplacementCertificatesARI { + wfe.stats.ariReplacementOrders.With(prometheus.Labels{ + "isReplacement": fmt.Sprintf("%t", replaces != ""), + "limitsExempt": fmt.Sprintf("%t", limitsExempt), + }).Inc() + } + + if !newOrderSuccessful && !errIsRateLimit { + // This can be a little racy, but we're not going to worry about it + // for now. If the check hasn't completed yet, we can pretty safely + // assume that the refund will be similarly delayed. + go wfe.refundNewOrderLimits(ctx, txns) + } + }() + + order, err := wfe.ra.NewOrder(ctx, &rapb.NewOrderRequest{ + RegistrationID: acct.ID, + Names: names, + ReplacesSerial: replaces, + LimitsExempt: limitsExempt, + CertificateProfileName: newOrderRequest.Profile, + }) + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if err != nil || order == nil || order.Id == 0 || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error creating new order"), err) + if errors.Is(err, berrors.RateLimit) { + // Request was denied by a legacy rate limit. In this error case we + // do not want to refund the quota consumed by the request because + // repeated requests would result in unearned refunds. + // + // TODO(#5545): Once key-value rate limits are authoritative this + // can be removed. + errIsRateLimit = true + } + return + } + logEvent.Created = fmt.Sprintf("%d", order.Id) + + orderURL := web.RelativeEndpoint(request, + fmt.Sprintf("%s%d/%d", orderPath, acct.ID, order.Id)) + response.Header().Set("Location", orderURL) + + respObj := wfe.orderToOrderJSON(request, order) + err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, respObj) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling order"), err) + return + } + newOrderSuccessful = true +} + +// GetOrder is used to retrieve a existing order object +func (wfe *WebFrontEndImpl) GetOrder(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + var requesterAccount *core.Registration + // Any POSTs to the Order endpoint should be POST-as-GET requests. There are + // no POSTs with a body allowed for this endpoint. + if request.Method == http.MethodPost { + acct, prob := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + requesterAccount = acct + } + + // Path prefix is stripped, so this should be like "/" + fields := strings.SplitN(request.URL.Path, "/", 2) + if len(fields) != 2 { + wfe.sendError(response, logEvent, probs.NotFound("Invalid request path"), nil) + return + } + acctID, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid account ID"), err) + return + } + orderID, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid order ID"), err) + return + } + + order, err := wfe.sa.GetOrder(ctx, &sapb.OrderRequest{Id: orderID}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", orderID)), nil) + return + } + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, + fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), err) + return + } + + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if order.Id == 0 || order.Status == "" || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) { + wfe.sendError(response, logEvent, probs.ServerInternal(fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), errIncompleteGRPCResponse) + return + } + + if requiredStale(request, logEvent) { + if prob := wfe.staleEnoughToGETOrder(order); prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + } + + if order.RegistrationID != acctID { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acctID)), nil) + return + } + + // If the requesterAccount is not nil then this was an authenticated + // POST-as-GET request and we need to verify the requesterAccount is the + // order's owner. + if requesterAccount != nil && order.RegistrationID != requesterAccount.ID { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acctID)), nil) + return + } + + respObj := wfe.orderToOrderJSON(request, order) + + if respObj.Status == core.StatusProcessing { + response.Header().Set(headerRetryAfter, strconv.Itoa(orderRetryAfter)) + } + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, respObj) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling order"), err) + return + } +} + +// FinalizeOrder is used to request issuance for a existing order object. +// Most processing of the order details is handled by the RA but +// we do attempt to throw away requests with invalid CSRs here. +func (wfe *WebFrontEndImpl) FinalizeOrder(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + // Validate the POST body signature and get the authenticated account for this + // finalize order request + body, _, acct, prob := wfe.validPOSTForAccount(request, ctx, logEvent) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Order URLs are like: /acme/finalize///. The prefix is + // stripped by the time we get here. + fields := strings.SplitN(request.URL.Path, "/", 2) + if len(fields) != 2 { + wfe.sendError(response, logEvent, probs.NotFound("Invalid request path"), nil) + return + } + acctID, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid account ID"), nil) + return + } + orderID, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Invalid order ID"), nil) + return + } + + order, err := wfe.sa.GetOrder(ctx, &sapb.OrderRequest{Id: orderID}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", orderID)), nil) + return + } + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, + fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), err) + return + } + + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if order.Id == 0 || order.Status == "" || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) { + wfe.sendError(response, logEvent, probs.ServerInternal(fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), errIncompleteGRPCResponse) + return + } + + if order.RegistrationID != acctID { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acctID)), nil) + return + } + + // If the authenticated account ID doesn't match the order's registration ID + // pretend it doesn't exist and abort. + if acct.ID != order.RegistrationID { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acct.ID)), nil) + return + } + + // Only ready orders can be finalized. + if order.Status != string(core.StatusReady) { + wfe.sendError(response, logEvent, + probs.OrderNotReady( + "Order's status (%q) is not acceptable for finalization", + order.Status), + nil) + return + } + + // If the order is expired we can not finalize it and must return an error + orderExpiry := order.Expires.AsTime() + if orderExpiry.Before(wfe.clk.Now()) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("Order %d is expired", order.Id)), nil) + return + } + + // The authenticated finalize message body should be an encoded CSR + var rawCSR core.RawCertificateRequest + err = json.Unmarshal(body, &rawCSR) + if err != nil { + wfe.sendError(response, logEvent, + probs.Malformed("Error unmarshaling finalize order request"), err) + return + } + + // Check for a malformed CSR early to avoid unnecessary RPCs + csr, err := x509.ParseCertificateRequest(rawCSR.CSR) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error parsing certificate request: %s", err), err) + return + } + + logEvent.DNSNames = order.Names + logEvent.Extra["KeyType"] = web.KeyTypeToString(csr.PublicKey) + + updatedOrder, err := wfe.ra.FinalizeOrder(ctx, &rapb.FinalizeOrderRequest{ + Csr: rawCSR.CSR, + Order: order, + }) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error finalizing order"), err) + return + } + // TODO(#7153): Check each value via core.IsAnyNilOrZero + if updatedOrder == nil || order.Id == 0 || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error validating order"), errIncompleteGRPCResponse) + return + } + + // Inc CSR signature algorithm counter + wfe.stats.csrSignatureAlgs.With(prometheus.Labels{"type": csr.SignatureAlgorithm.String()}).Inc() + + orderURL := web.RelativeEndpoint(request, + fmt.Sprintf("%s%d/%d", orderPath, acct.ID, updatedOrder.Id)) + response.Header().Set("Location", orderURL) + + respObj := wfe.orderToOrderJSON(request, updatedOrder) + + if respObj.Status == core.StatusProcessing { + response.Header().Set(headerRetryAfter, strconv.Itoa(orderRetryAfter)) + } + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, respObj) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Unable to write finalize order response"), err) + return + } +} + +// parseARICertID parses the "certID", a unique identifier specified in +// draft-ietf-acme-ari-03. It takes the composite string as input returns a +// extracted and decoded certificate serial. If the decoded AKID does not match +// any known issuer or the serial number is not valid, an error is returned. For +// more details see: +// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1. +func parseARICertID(path string, issuerCertificates map[issuance.NameID]*issuance.Certificate) (string, error) { + parts := strings.Split(path, ".") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", berrors.MalformedError("Invalid path") + } + + akid, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", berrors.MalformedError("Authority Key Identifier was not base64url-encoded or contained padding: %s", err) + } + + var found bool + for _, issuer := range issuerCertificates { + if bytes.Equal(issuer.SubjectKeyId, akid) { + found = true + break + } + } + if !found { + return "", berrors.NotFoundError("path contained an Authority Key Identifier that did not match a known issuer") + } + + serialNumber, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", berrors.NotFoundError("serial number was not base64url-encoded or contained padding: %s", err) + } + + return core.SerialToString(new(big.Int).SetBytes(serialNumber)), nil +} + +// RenewalInfo is used to get information about the suggested renewal window +// for the given certificate. It only accepts unauthenticated GET requests. +func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + if !features.Get().ServeRenewalInfo { + wfe.sendError(response, logEvent, probs.NotFound("Feature not enabled"), nil) + return + } + + if len(request.URL.Path) == 0 { + wfe.sendError(response, logEvent, probs.NotFound("Must specify a request path"), nil) + return + } + + decodedSerial, err := parseARICertID(request.URL.Path, wfe.issuerCertificates) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While parsing ARI CertID an error occurred"), err) + return + } + + // We can do all of our processing based just on the serial, because Boulder + // does not re-use the same serial across multiple issuers. + logEvent.Extra["RequestedSerial"] = decodedSerial + + renewalInfo, err := wfe.determineARIWindow(ctx, decodedSerial) + if err != nil { + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound("Certificate replaced by this order was not found"), nil) + return + } + wfe.sendError(response, logEvent, probs.ServerInternal("Error determining renewal window"), err) + return + } + + response.Header().Set(headerRetryAfter, fmt.Sprintf("%d", int(6*time.Hour/time.Second))) + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, renewalInfo) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshalling renewalInfo"), err) + return + } +} + +func extractRequesterIP(req *http.Request) (net.IP, error) { + ip := net.ParseIP(req.Header.Get("X-Real-IP")) + if ip != nil { + return ip, nil + } + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return nil, err + } + return net.ParseIP(host), nil +} + +func urlForAuthz(authz core.Authorization, request *http.Request) string { + return web.RelativeEndpoint(request, authzPath+authz.ID) +} diff --git a/third-party/github.com/letsencrypt/boulder/wfe2/wfe_test.go b/third-party/github.com/letsencrypt/boulder/wfe2/wfe_test.go new file mode 100644 index 000000000..754c7562d --- /dev/null +++ b/third-party/github.com/letsencrypt/boulder/wfe2/wfe_test.go @@ -0,0 +1,3947 @@ +package wfe2 + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/jmhodges/clock" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/ocsp" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + capb "github.com/letsencrypt/boulder/ca/proto" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + corepb "github.com/letsencrypt/boulder/core/proto" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/issuance" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/must" + "github.com/letsencrypt/boulder/nonce" + noncepb "github.com/letsencrypt/boulder/nonce/proto" + "github.com/letsencrypt/boulder/probs" + rapb "github.com/letsencrypt/boulder/ra/proto" + "github.com/letsencrypt/boulder/ratelimits" + bredis "github.com/letsencrypt/boulder/redis" + "github.com/letsencrypt/boulder/revocation" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" + inmemnonce "github.com/letsencrypt/boulder/test/inmem/nonce" + "github.com/letsencrypt/boulder/web" +) + +const ( + agreementURL = "http://example.invalid/terms" + + test1KeyPublicJSON = ` + { + "kty":"RSA", + "n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e":"AQAB" + }` + + test1KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAyNWVhtYEKJR21y9xsHV+PD/bYwbXSeNuFal46xYxVfRL5mqh +a7vttvjB/vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K/klBYN8oYvTwwmeSkAz +6ut7ZxPv+nZaT5TJhGk0NT2kh/zSpdriEJ/3vW+mqxYbbBmpvHqsa1/zx9fSuHYc +tAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV+mzfMyboQjujPh7aNJxAWS +q4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF+w8hOTI3XXohUdu +29Se26k2B0PolDSuj0GIQU6+W9TdLXSjBb2SpQIDAQABAoIBAHw58SXYV/Yp72Cn +jjFSW+U0sqWMY7rmnP91NsBjl9zNIe3C41pagm39bTIjB2vkBNR8ZRG7pDEB/QAc +Cn9Keo094+lmTArjL407ien7Ld+koW7YS8TyKADYikZo0vAK3qOy14JfQNiFAF9r +Bw61hG5/E58cK5YwQZe+YcyBK6/erM8fLrJEyw4CV49wWdq/QqmNYU1dx4OExAkl +KMfvYXpjzpvyyTnZuS4RONfHsO8+JTyJVm+lUv2x+bTce6R4W++UhQY38HakJ0x3 +XRfXooRv1Bletu5OFlpXfTSGz/5gqsfemLSr5UHncsCcFMgoFBsk2t/5BVukBgC7 +PnHrAjkCgYEA887PRr7zu3OnaXKxylW5U5t4LzdMQLpslVW7cLPD4Y08Rye6fF5s +O/jK1DNFXIoUB7iS30qR7HtaOnveW6H8/kTmMv/YAhLO7PAbRPCKxxcKtniEmP1x +ADH0tF2g5uHB/zeZhCo9qJiF0QaJynvSyvSyJFmY6lLvYZsAW+C+PesCgYEA0uCi +Q8rXLzLpfH2NKlLwlJTi5JjE+xjbabgja0YySwsKzSlmvYJqdnE2Xk+FHj7TCnSK +KUzQKR7+rEk5flwEAf+aCCNh3W4+Hp9MmrdAcCn8ZsKmEW/o7oDzwiAkRCmLw/ck +RSFJZpvFoxEg15riT37EjOJ4LBZ6SwedsoGA/a8CgYEA2Ve4sdGSR73/NOKZGc23 +q4/B4R2DrYRDPhEySnMGoPCeFrSU6z/lbsUIU4jtQWSaHJPu4n2AfncsZUx9WeSb +OzTCnh4zOw33R4N4W8mvfXHODAJ9+kCc1tax1YRN5uTEYzb2dLqPQtfNGxygA1DF +BkaC9CKnTeTnH3TlKgK8tUcCgYB7J1lcgh+9ntwhKinBKAL8ox8HJfkUM+YgDbwR +sEM69E3wl1c7IekPFvsLhSFXEpWpq3nsuMFw4nsVHwaGtzJYAHByhEdpTDLXK21P +heoKF1sioFbgJB1C/Ohe3OqRLDpFzhXOkawOUrbPjvdBM2Erz/r11GUeSlpNazs7 +vsoYXQKBgFwFM1IHmqOf8a2wEFa/a++2y/WT7ZG9nNw1W36S3P04K4lGRNRS2Y/S +snYiqxD9nL7pVqQP2Qbqbn0yD6d3G5/7r86F7Wu2pihM8g6oyMZ3qZvvRIBvKfWo +eROL1ve1vmQF3kjrMPhhK2kr6qdWnTE5XlPllVSZFQenSTzj98AO +-----END RSA PRIVATE KEY----- +` + + test2KeyPublicJSON = `{ + "kty":"RSA", + "n":"qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw", + "e":"AQAB" + }` + + test2KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqnARLrT7Xz4gRcKyLdydmCr+ey9OuPImX4X40thk3on26FkM +znR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBr +hR6uIoO4jAzJZR+ChzZuSDt7iHN+3xUVspu5XGwXU/MVJZshTwp4TaFx5elHIT/O +bnTvTOU3Xhish07AbgZKmWsVbXh5s+CrIicU4OexJPgunWZ/YJJueOKmTvnLlTV4 +MzKR2oZlBKZ27S0+SfdV/QDx/ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY/2Uzi5 +eX0lTc7MPRwz6qR1kip+i59VcGcUQgqHV6FyqwIDAQABAoIBAG5m8Xpj2YC0aYtG +tsxmX9812mpJFqFOmfS+f5N0gMJ2c+3F4TnKz6vE/ZMYkFnehAT0GErC4WrOiw68 +F/hLdtJM74gQ0LGh9dKeJmz67bKqngcAHWW5nerVkDGIBtzuMEsNwxofDcIxrjkr +G0b7AHMRwXqrt0MI3eapTYxby7+08Yxm40mxpSsW87FSaI61LDxUDpeVkn7kolSN +WifVat7CpZb/D2BfGAQDxiU79YzgztpKhbynPdGc/OyyU+CNgk9S5MgUX2m9Elh3 +aXrWh2bT2xzF+3KgZdNkJQcdIYVoGq/YRBxlGXPYcG4Do3xKhBmH79Io2BizevZv +nHkbUGECgYEAydjb4rl7wYrElDqAYpoVwKDCZAgC6o3AKSGXfPX1Jd2CXgGR5Hkl +ywP0jdSLbn2v/jgKQSAdRbYuEiP7VdroMb5M6BkBhSY619cH8etoRoLzFo1GxcE8 +Y7B598VXMq8TT+TQqw/XRvM18aL3YDZ3LSsR7Gl2jF/sl6VwQAaZToUCgYEA2Cn4 +fG58ME+M4IzlZLgAIJ83PlLb9ip6MeHEhUq2Dd0In89nss7Acu0IVg8ES88glJZy +4SjDLGSiuQuoQVo9UBq/E5YghdMJFp5ovwVfEaJ+ruWqOeujvWzzzPVyIWSLXRQa +N4kedtfrlqldMIXywxVru66Q1NOGvhDHm/Q8+28CgYEAkhLCbn3VNed7A9qidrkT +7OdqRoIVujEDU8DfpKtK0jBP3EA+mJ2j4Bvoq4uZrEiBSPS9VwwqovyIstAfX66g +Qv95IK6YDwfvpawUL9sxB3ZU/YkYIp0JWwun+Mtzo1ZYH4V0DZfVL59q9of9hj9k +V+fHfNOF22jAC67KYUtlPxECgYEAwF6hj4L3rDqvQYrB/p8tJdrrW+B7dhgZRNkJ +fiGd4LqLGUWHoH4UkHJXT9bvWNPMx88YDz6qapBoq8svAnHfTLFwyGp7KP1FAkcZ +Kp4KG/SDTvx+QCtvPX1/fjAUUJlc2QmxxyiU3uiK9Tpl/2/FOk2O4aiZpX1VVUIz +kZuKxasCgYBiVRkEBk2W4Ia0B7dDkr2VBrz4m23Y7B9cQLpNAapiijz/0uHrrCl8 +TkLlEeVOuQfxTadw05gzKX0jKkMC4igGxvEeilYc6NR6a4nvRulG84Q8VV9Sy9Ie +wk6Oiadty3eQqSBJv0HnpmiEdQVffIK5Pg4M8Dd+aOBnEkbopAJOuA== +-----END RSA PRIVATE KEY----- +` + test3KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0h +Sx2mPP7gBvis2lizZ9r+y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq+XhHZb +FrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx+3uSvgZOuQA5ffEn5L3 +8Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W+nV +0WL17o7v8aDgV/t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9+g6rWnTqPbY +3knffhp4m0scLD6e33k8MtzxDX/D7vHsg0/X1wIDAQABAoIBAQCnFJpX3lhiuH5G +1uqHmmdVxpRVv9oKn/eJ63cRSzvZfgg0bE/A6Hq0xGtvXqDySttvck4zsGqqHnQr +86G4lfE53D1jnv4qvS5bUKnARwmFKIxU4EHE9s1QM8uMNTaV2nMqIX7TkVP6QHuw +yB70R2inq15dS7EBWVGFKNX6HwAAdj8pFuF6o2vIwmAfee20aFzpWWf81jOH9Ai6 +hyJyV3NqrU1JzIwlXaeX67R1VroFdhN/lapp+2b0ZEcJJtFlcYFl99NjkQeVZyik +izNv0GZZNWizc57wU0/8cv+jQ2f26ltvyrPz3QNK61bFfzy+/tfMvLq7sdCmztKJ +tMxCBJOBAoGBAPKnIVQIS2nTvC/qZ8ajw1FP1rkvYblIiixegjgfFhM32HehQ+nu +3TELi3I3LngLYi9o6YSqtNBmdBJB+DUAzIXp0TdOihOweGiv5dAEWwY9rjCzMT5S +GP7dCWiJwoMUHrOs1Po3dwcjj/YsoAW+FC0jSvach2Ln2CvPgr5FP0ARAoGBAMNj +64qUCzgeXiSyPKK69bCCGtHlTYUndwHQAZmABjbmxAXZNYgp/kBezFpKOwmICE8R +kK8YALRrL0VWXl/yj85b0HAZGkquNFHPUDd1e6iiP5TrY+Hy4oqtlYApjH6f85CE +lWjQ1iyUL7aT6fcSgzq65ZWD2hUzvNtWbTt6zQFnAoGAWS/EuDY0QblpOdNWQVR/ +vasyqO4ZZRiccKJsCmSioH2uOoozhBAfjJ9JqblOgyDr/bD546E6xD5j+zH0IMci +ZTYDh+h+J659Ez1Topl3O1wAYjX6q4VRWpuzkZDQxYznm/KydSVdwmn3x+uvBW1P +zSdjrjDqMhg1BCVJUNXy4YECgYEAjX1z+dwO68qB3gz7/9NnSzRL+6cTJdNYSIW6 +QtAEsAkX9iw+qaXPKgn77X5HljVd3vQXU9QL3pqnloxetxhNrt+p5yMmeOIBnSSF +MEPxEkK7zDlRETPzfP0Kf86WoLNviz2XfFmOXqXIj2w5RuOvB/6DdmwOpr/aiPLj +EulwPw0CgYAMSzsWOt6vU+y/G5NyhUCHvY50TdnGOj2btBk9rYVwWGWxCpg2QF0R +pcKXgGzXEVZKFAqB8V1c/mmCo8ojPgmqGM+GzX2Bj4seVBW7PsTeZUjrHpADshjV +F7o5b7y92NlxO5kwQzRKEAhwS5PbKJdx90iCuG+JlI1YgWlA1VcJMw== +-----END RSA PRIVATE KEY----- +` + + testE1KeyPrivatePEM = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIH+p32RUnqT/iICBEGKrLIWFcyButv0S0lU/BLPOyHn2oAoGCCqGSM49 +AwEHoUQDQgAEFwvSZpu06i3frSk/mz9HcD9nETn4wf3mQ+zDtG21GapLytH7R1Zr +ycBzDV9u6cX9qNLc9Bn5DAumz7Zp2AuA+Q== +-----END EC PRIVATE KEY----- +` + + testE2KeyPrivatePEM = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFRcPxQ989AY6se2RyIoF1ll9O6gHev4oY15SWJ+Jf5eoAoGCCqGSM49 +AwEHoUQDQgAES8FOmrZ3ywj4yyFqt0etAD90U+EnkNaOBSLfQmf7pNi8y+kPKoUN +EeMZ9nWyIM6bktLrE11HnFOnKhAYsM5fZA== +-----END EC PRIVATE KEY-----` +) + +type MockRegistrationAuthority struct { + lastRevocationReason revocation.Reason +} + +func (ra *MockRegistrationAuthority) NewRegistration(ctx context.Context, in *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) { + in.Id = 1 + created := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + in.CreatedAt = timestamppb.New(created) + return in, nil +} + +func (ra *MockRegistrationAuthority) UpdateRegistration(ctx context.Context, in *rapb.UpdateRegistrationRequest, _ ...grpc.CallOption) (*corepb.Registration, error) { + if !bytes.Equal(in.Base.Key, in.Update.Key) { + in.Base.Key = in.Update.Key + } + return in.Base, nil +} + +func (ra *MockRegistrationAuthority) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) { + return &corepb.Authorization{}, nil +} + +func (ra *MockRegistrationAuthority) RevokeCertByApplicant(ctx context.Context, in *rapb.RevokeCertByApplicantRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + ra.lastRevocationReason = revocation.Reason(in.Code) + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) RevokeCertByKey(ctx context.Context, in *rapb.RevokeCertByKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + ra.lastRevocationReason = revocation.Reason(ocsp.KeyCompromise) + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) GenerateOCSP(ctx context.Context, req *rapb.GenerateOCSPRequest, _ ...grpc.CallOption) (*capb.OCSPResponse, error) { + return nil, nil +} + +func (ra *MockRegistrationAuthority) AdministrativelyRevokeCertificate(context.Context, *rapb.AdministrativelyRevokeCertificateRequest, ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) OnValidationUpdate(context.Context, core.Authorization, ...grpc.CallOption) error { + return nil +} + +func (ra *MockRegistrationAuthority) DeactivateAuthorization(context.Context, *corepb.Authorization, ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) DeactivateRegistration(context.Context, *corepb.Registration, ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) UnpauseAccount(context.Context, *rapb.UnpauseAccountRequest, ...grpc.CallOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (ra *MockRegistrationAuthority) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + created := time.Date(2021, 1, 1, 1, 1, 1, 0, time.UTC) + expires := time.Date(2021, 2, 1, 1, 1, 1, 0, time.UTC) + + return &corepb.Order{ + Id: 1, + RegistrationID: in.RegistrationID, + Created: timestamppb.New(created), + Expires: timestamppb.New(expires), + Names: in.Names, + Status: string(core.StatusPending), + V2Authorizations: []int64{1}, + }, nil +} + +func (ra *MockRegistrationAuthority) FinalizeOrder(ctx context.Context, in *rapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + in.Order.Status = string(core.StatusProcessing) + return in.Order, nil +} + +func makeBody(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} + +// loadKey loads a private key from PEM/DER-encoded data and returns +// a `crypto.Signer`. +func loadKey(t *testing.T, keyBytes []byte) crypto.Signer { + // pem.Decode does not return an error as its 2nd arg, but instead the "rest" + // that was leftover from parsing the PEM block. We only care if the decoded + // PEM block was empty for this test function. + block, _ := pem.Decode(keyBytes) + if block == nil { + t.Fatal("Unable to decode private key PEM bytes") + } + + // Try decoding as an RSA private key + if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return rsaKey + } + + // Try decoding as a PKCS8 private key + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + // Determine the key's true type and return it as a crypto.Signer + switch k := key.(type) { + case *rsa.PrivateKey: + return k + case *ecdsa.PrivateKey: + return k + } + } + + // Try as an ECDSA private key + if ecdsaKey, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return ecdsaKey + } + + // Nothing worked! Fail hard. + t.Fatalf("Unable to decode private key PEM bytes") + // NOOP - the t.Fatal() call will abort before this return + return nil +} + +var ctx = context.Background() + +func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { + features.Reset() + + fc := clock.NewFake() + stats := metrics.NoopRegisterer + + testKeyPolicy, err := goodkey.NewPolicy(nil, nil) + test.AssertNotError(t, err, "creating test keypolicy") + + certChains := map[issuance.NameID][][]byte{} + issuerCertificates := map[issuance.NameID]*issuance.Certificate{} + for _, files := range [][]string{ + { + "../test/hierarchy/int-r3.cert.pem", + "../test/hierarchy/root-x1.cert.pem", + }, + { + "../test/hierarchy/int-r3-cross.cert.pem", + "../test/hierarchy/root-dst.cert.pem", + }, + { + "../test/hierarchy/int-e1.cert.pem", + "../test/hierarchy/root-x2.cert.pem", + }, + { + "../test/hierarchy/int-e1.cert.pem", + "../test/hierarchy/root-x2-cross.cert.pem", + "../test/hierarchy/root-x1-cross.cert.pem", + "../test/hierarchy/root-dst.cert.pem", + }, + } { + certs, err := issuance.LoadChain(files) + test.AssertNotError(t, err, "Unable to load chain") + var buf bytes.Buffer + for _, cert := range certs { + buf.Write([]byte("\n")) + buf.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) + } + id := certs[0].NameID() + certChains[id] = append(certChains[id], buf.Bytes()) + issuerCertificates[id] = certs[0] + } + + mockSA := mocks.NewStorageAuthorityReadOnly(fc) + + log := blog.NewMock() + + // Use derived nonces. + noncePrefix := nonce.DerivePrefix("192.168.1.1:8080", "b8c758dd85e113ea340ce0b3a99f389d40a308548af94d1730a7692c1874f1f") + nonceService, err := nonce.NewNonceService(metrics.NoopRegisterer, 100, noncePrefix) + test.AssertNotError(t, err, "making nonceService") + + inmemNonceService := &inmemnonce.Service{NonceService: nonceService} + gnc := inmemNonceService + rnc := inmemNonceService + + // Setup rate limiting. + rc := bredis.Config{ + Username: "unittest-rw", + TLS: cmd.TLSConfig{ + CACertFile: "../test/certs/ipki/minica.pem", + CertFile: "../test/certs/ipki/localhost/cert.pem", + KeyFile: "../test/certs/ipki/localhost/key.pem", + }, + Lookups: []cmd.ServiceDomain{ + { + Service: "redisratelimits", + Domain: "service.consul", + }, + }, + LookupDNSAuthority: "consul.service.consul", + } + rc.PasswordConfig = cmd.PasswordConfig{ + PasswordFile: "../test/secrets/ratelimits_redis_password", + } + ring, err := bredis.NewRingFromConfig(rc, stats, log) + test.AssertNotError(t, err, "making redis ring client") + source := ratelimits.NewRedisSource(ring.Ring, fc, stats) + test.AssertNotNil(t, source, "source should not be nil") + limiter, err := ratelimits.NewLimiter(fc, source, stats) + test.AssertNotError(t, err, "making limiter") + txnBuilder, err := ratelimits.NewTransactionBuilder("../test/config-next/wfe2-ratelimit-defaults.yml", "") + test.AssertNotError(t, err, "making transaction composer") + + wfe, err := NewWebFrontEndImpl( + stats, + fc, + testKeyPolicy, + certChains, + issuerCertificates, + blog.NewMock(), + 10*time.Second, + 10*time.Second, + 30*24*time.Hour, + 7*24*time.Hour, + &MockRegistrationAuthority{}, + mockSA, + gnc, + rnc, + "rncKey", + mockSA, + limiter, + txnBuilder, + 100, + []string{""}, + ) + test.AssertNotError(t, err, "Unable to create WFE") + + wfe.SubscriberAgreementURL = agreementURL + + return wfe, fc, requestSigner{t, inmemNonceService.AsSource()} +} + +// makePostRequestWithPath creates an http.Request for localhost with method +// POST, the provided body, and the correct Content-Length. The path provided +// will be parsed as a URL and used to populate the request URL and RequestURI +func makePostRequestWithPath(path string, body string) *http.Request { + request := &http.Request{ + Method: "POST", + RemoteAddr: "1.1.1.1:7882", + Header: map[string][]string{ + "Content-Length": {strconv.Itoa(len(body))}, + "Content-Type": {expectedJWSContentType}, + }, + Body: makeBody(body), + Host: "localhost", + } + url := mustParseURL(path) + request.URL = url + request.RequestURI = url.Path + return request +} + +// signAndPost constructs a JWS signed by the account with ID 1, over the given +// payload, with the protected URL set to the provided signedURL. An HTTP +// request constructed to the provided path with the encoded JWS body as the +// POST body is returned. +func signAndPost(signer requestSigner, path, signedURL, payload string) *http.Request { + _, _, body := signer.byKeyID(1, nil, signedURL, payload) + return makePostRequestWithPath(path, body) +} + +func mustParseURL(s string) *url.URL { + return must.Do(url.Parse(s)) +} + +func sortHeader(s string) string { + a := strings.Split(s, ", ") + sort.Strings(a) + return strings.Join(a, ", ") +} + +func addHeadIfGet(s []string) []string { + for _, a := range s { + if a == "GET" { + return append(s, "HEAD") + } + } + return s +} + +func TestHandleFunc(t *testing.T) { + wfe, _, _ := setupWFE(t) + var mux *http.ServeMux + var rw *httptest.ResponseRecorder + var stubCalled bool + runWrappedHandler := func(req *http.Request, pattern string, allowed ...string) { + mux = http.NewServeMux() + rw = httptest.NewRecorder() + stubCalled = false + wfe.HandleFunc(mux, pattern, func(context.Context, *web.RequestEvent, http.ResponseWriter, *http.Request) { + stubCalled = true + }, allowed...) + req.URL = mustParseURL(pattern) + mux.ServeHTTP(rw, req) + } + + // Plain requests (no CORS) + type testCase struct { + allowed []string + reqMethod string + shouldCallStub bool + shouldSucceed bool + pattern string + } + var lastNonce string + for _, c := range []testCase{ + {[]string{"GET", "POST"}, "GET", true, true, "/test"}, + {[]string{"GET", "POST"}, "GET", true, true, newNoncePath}, + {[]string{"GET", "POST"}, "POST", true, true, "/test"}, + {[]string{"GET"}, "", false, false, "/test"}, + {[]string{"GET"}, "POST", false, false, "/test"}, + {[]string{"GET"}, "OPTIONS", false, true, "/test"}, + {[]string{"GET"}, "MAKE-COFFEE", false, false, "/test"}, // 405, or 418? + {[]string{"GET"}, "GET", true, true, directoryPath}, + } { + runWrappedHandler(&http.Request{Method: c.reqMethod}, c.pattern, c.allowed...) + test.AssertEquals(t, stubCalled, c.shouldCallStub) + if c.shouldSucceed { + test.AssertEquals(t, rw.Code, http.StatusOK) + } else { + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), sortHeader(strings.Join(addHeadIfGet(c.allowed), ", "))) + test.AssertUnmarshaledEquals(t, + rw.Body.String(), + `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) + } + if c.reqMethod == "GET" && c.pattern != newNoncePath { + nonce := rw.Header().Get("Replay-Nonce") + test.AssertEquals(t, nonce, "") + } else { + nonce := rw.Header().Get("Replay-Nonce") + test.AssertNotEquals(t, nonce, lastNonce) + test.AssertNotEquals(t, nonce, "") + lastNonce = nonce + } + linkHeader := rw.Header().Get("Link") + if c.pattern != directoryPath { + // If the pattern wasn't the directory there should be a Link header for the index + test.AssertEquals(t, linkHeader, `;rel="index"`) + } else { + // The directory resource shouldn't get a link header + test.AssertEquals(t, linkHeader, "") + } + } + + // Disallowed method returns error JSON in body + runWrappedHandler(&http.Request{Method: "PUT"}, "/test", "GET", "POST") + test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") + test.AssertUnmarshaledEquals(t, rw.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") + + // Disallowed method special case: response to HEAD has got no body + runWrappedHandler(&http.Request{Method: "HEAD"}, "/test", "GET", "POST") + test.AssertEquals(t, stubCalled, true) + test.AssertEquals(t, rw.Body.String(), "") + + // HEAD doesn't work with POST-only endpoints + runWrappedHandler(&http.Request{Method: "HEAD"}, "/test", "POST") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") + test.AssertEquals(t, rw.Header().Get("Allow"), "POST") + test.AssertUnmarshaledEquals(t, rw.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) + + wfe.AllowOrigins = []string{"*"} + testOrigin := "https://example.com" + + // CORS "actual" request for disallowed method + runWrappedHandler(&http.Request{ + Method: "POST", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, "/test", "GET") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + + // CORS "actual" request for allowed method + runWrappedHandler(&http.Request{ + Method: "GET", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, "/test", "GET", "POST") + test.AssertEquals(t, stubCalled, true) + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Location, Replay-Nonce") + + // CORS preflight request for disallowed method + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "/test", "GET") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Allow"), "GET, HEAD") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") + + // CORS preflight request for allowed method + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + "Access-Control-Request-Headers": {"X-Accept-Header1, X-Accept-Header2", "X-Accept-Header3"}, + }, + }, "/test", "GET", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + test.AssertEquals(t, rw.Header().Get("Access-Control-Max-Age"), "86400") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Allow-Methods")), "GET, HEAD, POST") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Location, Replay-Nonce") + + // OPTIONS request without an Origin header (i.e., not a CORS + // preflight request) + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Access-Control-Request-Method": {"POST"}, + }, + }, "/test", "GET", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") + + // CORS preflight request missing optional Request-Method + // header. The "actual" request will be GET. + for _, allowedMethod := range []string{"GET", "POST"} { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, "/test", allowedMethod) + test.AssertEquals(t, rw.Code, http.StatusOK) + if allowedMethod == "GET" { + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "GET, HEAD") + } else { + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") + } + } + + // No CORS headers are given when configuration does not list + // "*" or the client-provided origin. + for _, wfe.AllowOrigins = range [][]string{ + {}, + {"http://example.com", "https://other.example"}, + {""}, // Invalid origin is never matched + } { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "/test", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + for _, h := range []string{ + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Headers", + "Access-Control-Expose-Headers", + "Access-Control-Request-Headers", + } { + test.AssertEquals(t, rw.Header().Get(h), "") + } + } + + // CORS headers are offered when configuration lists "*" or + // the client-provided origin. + for _, wfe.AllowOrigins = range [][]string{ + {testOrigin, "http://example.org", "*"}, + {"", "http://example.org", testOrigin}, // Invalid origin is harmless + } { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "/test", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), testOrigin) + // http://www.w3.org/TR/cors/ section 6.4: + test.AssertEquals(t, rw.Header().Get("Vary"), "Origin") + } +} + +func TestPOST404(t *testing.T) { + wfe, _, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + url, _ := url.Parse("/foobar") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "POST", + URL: url, + }) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) +} + +func TestIndex(t *testing.T) { + wfe, _, _ := setupWFE(t) + + responseWriter := httptest.NewRecorder() + + url, _ := url.Parse("/") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: url, + }) + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + test.AssertNotEquals(t, responseWriter.Body.String(), "404 page not found\n") + test.Assert(t, strings.Contains(responseWriter.Body.String(), directoryPath), + "directory path not found") + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + + responseWriter.Body.Reset() + responseWriter.Header().Del("Cache-Control") + url, _ = url.Parse("/foo") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + URL: url, + }) + //test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + test.AssertEquals(t, responseWriter.Body.String(), "404 page not found\n") + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "") +} + +// randomDirectoryKeyPresent unmarshals the given buf of JSON and returns true +// if `randomDirKeyExplanationLink` appears as the value of a key in the directory +// object. +func randomDirectoryKeyPresent(t *testing.T, buf []byte) bool { + var dir map[string]interface{} + err := json.Unmarshal(buf, &dir) + if err != nil { + t.Errorf("Failed to unmarshal directory: %s", err) + } + for _, v := range dir { + if v == randomDirKeyExplanationLink { + return true + } + } + return false +} + +type fakeRand struct{} + +func (fr fakeRand) Read(p []byte) (int, error) { + return len(p), nil +} + +func TestDirectory(t *testing.T) { + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + core.RandReader = fakeRand{} + defer func() { core.RandReader = rand.Reader }() + + dirURL, _ := url.Parse("/directory") + + getReq := &http.Request{ + Method: http.MethodGet, + URL: dirURL, + Host: "localhost:4300", + } + + _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/directory", "") + postAsGetReq := makePostRequestWithPath("/directory", jwsBody) + + testCases := []struct { + name string + caaIdent string + website string + expectedJSON string + request *http.Request + }{ + { + name: "standard GET, no CAA ident/website meta", + request: getReq, + expectedJSON: `{ + "keyChange": "http://localhost:4300/acme/key-change", + "meta": { + "termsOfService": "http://example.invalid/terms" + }, + "newNonce": "http://localhost:4300/acme/new-nonce", + "newAccount": "http://localhost:4300/acme/new-acct", + "newOrder": "http://localhost:4300/acme/new-order", + "revokeCert": "http://localhost:4300/acme/revoke-cert", + "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417" +}`, + }, + { + name: "standard GET, CAA ident/website meta", + caaIdent: "Radiant Lock", + website: "zombo.com", + request: getReq, + expectedJSON: `{ + "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", + "keyChange": "http://localhost:4300/acme/key-change", + "meta": { + "caaIdentities": [ + "Radiant Lock" + ], + "termsOfService": "http://example.invalid/terms", + "website": "zombo.com" + }, + "newAccount": "http://localhost:4300/acme/new-acct", + "newNonce": "http://localhost:4300/acme/new-nonce", + "newOrder": "http://localhost:4300/acme/new-order", + "revokeCert": "http://localhost:4300/acme/revoke-cert" +}`, + }, + { + name: "POST-as-GET, CAA ident/website meta", + caaIdent: "Radiant Lock", + website: "zombo.com", + request: postAsGetReq, + expectedJSON: `{ + "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", + "keyChange": "http://localhost/acme/key-change", + "meta": { + "caaIdentities": [ + "Radiant Lock" + ], + "termsOfService": "http://example.invalid/terms", + "website": "zombo.com" + }, + "newAccount": "http://localhost/acme/new-acct", + "newNonce": "http://localhost/acme/new-nonce", + "newOrder": "http://localhost/acme/new-order", + "revokeCert": "http://localhost/acme/revoke-cert" +}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Configure a caaIdentity and website for the /directory meta based on the tc + wfe.DirectoryCAAIdentity = tc.caaIdent // "Radiant Lock" + wfe.DirectoryWebsite = tc.website //"zombo.com" + responseWriter := httptest.NewRecorder() + // Serve the /directory response for this request into a recorder + mux.ServeHTTP(responseWriter, tc.request) + // We expect all directory requests to return a json object with a good HTTP status + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + // We expect all requests to return status OK + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + // The response should match expected + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.expectedJSON) + // Check that the random directory key is present + test.AssertEquals(t, + randomDirectoryKeyPresent(t, responseWriter.Body.Bytes()), + true) + }) + } +} + +func TestRelativeDirectory(t *testing.T) { + wfe, _, _ := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + core.RandReader = fakeRand{} + defer func() { core.RandReader = rand.Reader }() + + expectedDirectory := func(hostname string) string { + expected := new(bytes.Buffer) + + fmt.Fprintf(expected, "{") + fmt.Fprintf(expected, `"keyChange":"%s/acme/key-change",`, hostname) + fmt.Fprintf(expected, `"newNonce":"%s/acme/new-nonce",`, hostname) + fmt.Fprintf(expected, `"newAccount":"%s/acme/new-acct",`, hostname) + fmt.Fprintf(expected, `"newOrder":"%s/acme/new-order",`, hostname) + fmt.Fprintf(expected, `"revokeCert":"%s/acme/revoke-cert",`, hostname) + fmt.Fprintf(expected, `"AAAAAAAAAAA":"https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",`) + fmt.Fprintf(expected, `"meta":{"termsOfService":"http://example.invalid/terms"}`) + fmt.Fprintf(expected, "}") + return expected.String() + } + + dirTests := []struct { + host string + protoHeader string + result string + }{ + // Test '' (No host header) with no proto header + {"", "", expectedDirectory("http://localhost")}, + // Test localhost:4300 with no proto header + {"localhost:4300", "", expectedDirectory("http://localhost:4300")}, + // Test 127.0.0.1:4300 with no proto header + {"127.0.0.1:4300", "", expectedDirectory("http://127.0.0.1:4300")}, + // Test localhost:4300 with HTTP proto header + {"localhost:4300", "http", expectedDirectory("http://localhost:4300")}, + // Test localhost:4300 with HTTPS proto header + {"localhost:4300", "https", expectedDirectory("https://localhost:4300")}, + } + + for _, tt := range dirTests { + var headers map[string][]string + responseWriter := httptest.NewRecorder() + + if tt.protoHeader != "" { + headers = map[string][]string{ + "X-Forwarded-Proto": {tt.protoHeader}, + } + } + + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + Host: tt.host, + URL: mustParseURL(directoryPath), + Header: headers, + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tt.result) + } +} + +// TestNonceEndpoint tests requests to the WFE2's new-nonce endpoint +func TestNonceEndpoint(t *testing.T) { + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + + getReq := &http.Request{ + Method: http.MethodGet, + URL: mustParseURL(newNoncePath), + } + headReq := &http.Request{ + Method: http.MethodHead, + URL: mustParseURL(newNoncePath), + } + + _, _, jwsBody := signer.byKeyID(1, nil, fmt.Sprintf("http://localhost%s", newNoncePath), "") + postAsGetReq := makePostRequestWithPath(newNoncePath, jwsBody) + + testCases := []struct { + name string + request *http.Request + expectedStatus int + }{ + { + name: "GET new-nonce request", + request: getReq, + expectedStatus: http.StatusNoContent, + }, + { + name: "HEAD new-nonce request", + request: headReq, + expectedStatus: http.StatusOK, + }, + { + name: "POST-as-GET new-nonce request", + request: postAsGetReq, + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + responseWriter := httptest.NewRecorder() + mux.ServeHTTP(responseWriter, tc.request) + // The response should have the expected HTTP status code + test.AssertEquals(t, responseWriter.Code, tc.expectedStatus) + // And the response should contain a valid nonce in the Replay-Nonce header + nonce := responseWriter.Header().Get("Replay-Nonce") + redeemResp, err := wfe.rnc.Redeem(context.Background(), &noncepb.NonceMessage{Nonce: nonce}) + test.AssertNotError(t, err, "redeeming nonce") + test.AssertEquals(t, redeemResp.Valid, true) + // The server MUST include a Cache-Control header field with the "no-store" + // directive in responses for the newNonce resource, in order to prevent + // caching of this resource. + cacheControl := responseWriter.Header().Get("Cache-Control") + test.AssertEquals(t, cacheControl, "no-store") + }) + } +} + +func TestHTTPMethods(t *testing.T) { + wfe, _, _ := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + + // NOTE: Boulder's muxer treats HEAD as implicitly allowed if GET is specified + // so we include both here in `getOnly` + getOnly := map[string]bool{http.MethodGet: true, http.MethodHead: true} + postOnly := map[string]bool{http.MethodPost: true} + getOrPost := map[string]bool{http.MethodGet: true, http.MethodHead: true, http.MethodPost: true} + + testCases := []struct { + Name string + Path string + Allowed map[string]bool + }{ + { + Name: "Index path should be GET only", + Path: "/", + Allowed: getOnly, + }, + { + Name: "Directory path should be GET or POST only", + Path: directoryPath, + Allowed: getOrPost, + }, + { + Name: "NewAcct path should be POST only", + Path: newAcctPath, + Allowed: postOnly, + }, + { + Name: "Acct path should be POST only", + Path: acctPath, + Allowed: postOnly, + }, + // TODO(@cpu): Remove GET authz support, support only POST-as-GET + { + Name: "Authz path should be GET or POST only", + Path: authzPath, + Allowed: getOrPost, + }, + // TODO(@cpu): Remove GET challenge support, support only POST-as-GET + { + Name: "Challenge path should be GET or POST only", + Path: challengePath, + Allowed: getOrPost, + }, + // TODO(@cpu): Remove GET certificate support, support only POST-as-GET + { + Name: "Certificate path should be GET or POST only", + Path: certPath, + Allowed: getOrPost, + }, + { + Name: "RevokeCert path should be POST only", + Path: revokeCertPath, + Allowed: postOnly, + }, + { + Name: "Build ID path should be GET only", + Path: buildIDPath, + Allowed: getOnly, + }, + { + Name: "Rollover path should be POST only", + Path: rolloverPath, + Allowed: postOnly, + }, + { + Name: "New order path should be POST only", + Path: newOrderPath, + Allowed: postOnly, + }, + // TODO(@cpu): Remove GET order support, support only POST-as-GET + { + Name: "Order path should be GET or POST only", + Path: orderPath, + Allowed: getOrPost, + }, + { + Name: "Nonce path should be GET or POST only", + Path: newNoncePath, + Allowed: getOrPost, + }, + } + + // NOTE: We omit http.MethodOptions because all requests with this method are + // redirected to a special endpoint for CORS headers + allMethods := []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodTrace, + } + + responseWriter := httptest.NewRecorder() + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // For every possible HTTP method check what the mux serves for the test + // case path + for _, method := range allMethods { + responseWriter.Body.Reset() + mux.ServeHTTP(responseWriter, &http.Request{ + Method: method, + URL: mustParseURL(tc.Path), + }) + // If the method isn't one that is intended to be allowed by the path, + // check that the response was the not allowed response + if _, ok := tc.Allowed[method]; !ok { + var prob probs.ProblemDetails + // Unmarshal the body into a problem + body := responseWriter.Body.String() + err := json.Unmarshal([]byte(body), &prob) + test.AssertNotError(t, err, fmt.Sprintf("Error unmarshalling resp body: %q", body)) + // TODO(@cpu): It seems like the mux should be returning + // http.StatusMethodNotAllowed here, but instead it returns StatusOK + // with a problem that has a StatusMethodNotAllowed HTTPStatus. Is + // this a bug? + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + test.AssertEquals(t, prob.HTTPStatus, http.StatusMethodNotAllowed) + test.AssertEquals(t, prob.Detail, "Method not allowed") + } else { + // Otherwise if it was an allowed method, ensure that the response was + // *not* StatusMethodNotAllowed + test.AssertNotEquals(t, responseWriter.Code, http.StatusMethodNotAllowed) + } + } + }) + } +} + +func TestGetChallenge(t *testing.T) { + wfe, _, _ := setupWFE(t) + + challengeURL := "http://localhost/acme/chall-v3/1/-ZfxEw" + + for _, method := range []string{"GET", "HEAD"} { + resp := httptest.NewRecorder() + + req, err := http.NewRequest(method, challengeURL, nil) + req.URL.Path = "1/-ZfxEw" + test.AssertNotError(t, err, "Could not make NewRequest") + + wfe.Challenge(ctx, newRequestEvent(), resp, req) + test.AssertEquals(t, + resp.Code, + http.StatusOK) + test.AssertEquals(t, + resp.Header().Get("Location"), + challengeURL) + test.AssertEquals(t, + resp.Header().Get("Content-Type"), + "application/json") + test.AssertEquals(t, + resp.Header().Get("Link"), + `;rel="up"`) + // Body is only relevant for GET. For HEAD, body will + // be discarded by HandleFunc() anyway, so it doesn't + // matter what Challenge() writes to it. + if method == "GET" { + test.AssertUnmarshaledEquals( + t, resp.Body.String(), + `{"status": "pending", "type":"dns","token":"token","url":"http://localhost/acme/chall-v3/1/-ZfxEw"}`) + } + } +} + +func TestChallenge(t *testing.T) { + wfe, _, signer := setupWFE(t) + + post := func(path string) *http.Request { + signedURL := fmt.Sprintf("http://localhost/%s", path) + _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) + return makePostRequestWithPath(path, jwsBody) + } + postAsGet := func(keyID int64, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost/%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + testCases := []struct { + Name string + Request *http.Request + ExpectedStatus int + ExpectedHeaders map[string]string + ExpectedBody string + }{ + { + Name: "Valid challenge", + Request: post("1/-ZfxEw"), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Location": "http://localhost/acme/chall-v3/1/-ZfxEw", + "Link": `;rel="up"`, + }, + ExpectedBody: `{"status": "pending", "type":"dns","token":"token","url":"http://localhost/acme/chall-v3/1/-ZfxEw"}`, + }, + { + Name: "Expired challenge", + Request: post("3/-ZfxEw"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Expired authorization","status":404}`, + }, + { + Name: "Missing challenge", + Request: post("1/"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No such challenge","status":404}`, + }, + { + Name: "Unspecified database error", + Request: post("4/-ZfxEw"), + ExpectedStatus: http.StatusInternalServerError, + ExpectedBody: `{"type":"` + probs.ErrorNS + `serverInternal","detail":"Problem getting authorization","status":500}`, + }, + { + Name: "POST-as-GET, wrong owner", + Request: postAsGet(1, "5/-ZfxEw", ""), + ExpectedStatus: http.StatusForbidden, + ExpectedBody: `{"type":"` + probs.ErrorNS + `unauthorized","detail":"User account ID doesn't match account ID in authorization","status":403}`, + }, + { + Name: "Valid POST-as-GET", + Request: postAsGet(1, "1/-ZfxEw", ""), + ExpectedStatus: http.StatusOK, + ExpectedBody: `{"status": "pending", "type":"dns", "token":"token", "url": "http://localhost/acme/chall-v3/1/-ZfxEw"}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe.Challenge(ctx, newRequestEvent(), responseWriter, tc.Request) + // Check the response code, headers and body match expected + headers := responseWriter.Header() + body := responseWriter.Body.String() + test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) + for h, v := range tc.ExpectedHeaders { + test.AssertEquals(t, headers.Get(h), v) + } + test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) + }) + } +} + +// MockRAPerformValidationError is a mock RA that just returns an error on +// PerformValidation. +type MockRAPerformValidationError struct { + MockRegistrationAuthority +} + +func (ra *MockRAPerformValidationError) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) { + return nil, errors.New("broken on purpose") +} + +// TestUpdateChallengeFinalizedAuthz tests that POSTing a challenge associated +// with an already valid authorization just returns the challenge without calling +// the RA. +func TestUpdateChallengeFinalizedAuthz(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.ra = &MockRAPerformValidationError{} + responseWriter := httptest.NewRecorder() + + signedURL := "http://localhost/1/-ZfxEw" + _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) + request := makePostRequestWithPath("1/-ZfxEw", jwsBody) + wfe.Challenge(ctx, newRequestEvent(), responseWriter, request) + + body := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, body, `{ + "status": "pending", + "type": "dns", + "token":"token", + "url": "http://localhost/acme/chall-v3/1/-ZfxEw" + }`) +} + +// TestUpdateChallengeRAError tests that when the RA returns an error from +// PerformValidation that the WFE returns an internal server error as expected +// and does not panic or otherwise bug out. +func TestUpdateChallengeRAError(t *testing.T) { + wfe, _, signer := setupWFE(t) + // Mock the RA to always fail PerformValidation + wfe.ra = &MockRAPerformValidationError{} + + // Update a pending challenge + signedURL := "http://localhost/2/-ZfxEw" + _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) + responseWriter := httptest.NewRecorder() + request := makePostRequestWithPath("2/-ZfxEw", jwsBody) + + wfe.Challenge(ctx, newRequestEvent(), responseWriter, request) + + // The result should be an internal server error problem. + body := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, body, `{ + "type": "urn:ietf:params:acme:error:serverInternal", + "detail": "Unable to update challenge", + "status": 500 + }`) +} + +func TestBadNonce(t *testing.T) { + wfe, _, _ := setupWFE(t) + + key := loadKey(t, []byte(test2KeyPrivatePEM)) + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + // NOTE: We deliberately do not set the NonceSource in the jose.SignerOptions + // for this test in order to provoke a bad nonce error + noNonceSigner, err := jose.NewSigner(jose.SigningKey{ + Key: rsaKey, + Algorithm: jose.RS256, + }, &jose.SignerOptions{ + EmbedJWK: true, + }) + test.AssertNotError(t, err, "Failed to make signer") + + responseWriter := httptest.NewRecorder() + result, err := noNonceSigner.Sign([]byte(`{"contact":["mailto:person@mail.com"]}`)) + test.AssertNotError(t, err, "Failed to sign body") + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("nonce", result.FullSerialize())) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`badNonce","detail":"JWS has no anti-replay nonce","status":400}`) +} + +func TestNewECDSAAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + + // E1 always exists; E2 never exists + key := loadKey(t, []byte(testE2KeyPrivatePEM)) + _, ok := key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + + payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` + path := newAcctPath + signedURL := fmt.Sprintf("http://localhost%s", path) + _, _, body := signer.embeddedJWK(key, signedURL, payload) + request := makePostRequestWithPath(path, body) + + responseWriter := httptest.NewRecorder() + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + + var acct core.Registration + responseBody := responseWriter.Body.String() + err := json.Unmarshal([]byte(responseBody), &acct) + test.AssertNotError(t, err, "Couldn't unmarshal returned account object") + test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") + test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, acct.Agreement, "") + test.AssertEquals(t, acct.InitialIP.String(), "1.1.1.1") + + test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/1") + + key = loadKey(t, []byte(testE1KeyPrivatePEM)) + _, ok = key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + + _, _, body = signer.embeddedJWK(key, signedURL, payload) + request = makePostRequestWithPath(path, body) + + // Reset the body and status code + responseWriter = httptest.NewRecorder() + // POST, Valid JSON, Key already in use + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + `{ + "key": { + "kty": "EC", + "crv": "P-256", + "x": "FwvSZpu06i3frSk_mz9HcD9nETn4wf3mQ-zDtG21Gao", + "y": "S8rR-0dWa8nAcw1fbunF_ajS3PQZ-QwLps-2adgLgPk" + }, + "initialIp": "", + "status": "" + }`) + test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/3") + test.AssertEquals(t, responseWriter.Code, 200) + + // test3KeyPrivatePEM is a private key corresponding to a deactivated account in the mock SA's GetRegistration test data. + key = loadKey(t, []byte(test3KeyPrivatePEM)) + _, ok = key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test3 key") + + // Reset the body and status code + responseWriter = httptest.NewRecorder() + + // Test POST valid JSON with deactivated account + payload = `{}` + path = "1" + signedURL = "http://localhost/1" + _, _, body = signer.embeddedJWK(key, signedURL, payload) + request = makePostRequestWithPath(path, body) + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) +} + +// Test that the WFE handling of the "empty update" POST is correct. The ACME +// spec describes how when clients wish to query the server for information +// about an account an empty account update should be sent, and +// a populated acct object will be returned. +func TestEmptyAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + responseWriter := httptest.NewRecorder() + + // Test Key 1 is mocked in the mock StorageAuthority used in setupWFE to + // return a populated account for GetRegistrationByKey when test key 1 is + // used. + key := loadKey(t, []byte(test1KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + + payload := `{}` + path := "1" + signedURL := "http://localhost/1" + _, _, body := signer.byKeyID(1, key, signedURL, payload) + request := makePostRequestWithPath(path, body) + + // Send an account update with the trivial body + wfe.Account( + ctx, + newRequestEvent(), + responseWriter, + request) + + responseBody := responseWriter.Body.String() + // There should be no error + test.AssertNotContains(t, responseBody, probs.ErrorNS) + + // We should get back a populated Account + var acct core.Registration + err := json.Unmarshal([]byte(responseBody), &acct) + test.AssertNotError(t, err, "Couldn't unmarshal returned account object") + test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") + test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, acct.Agreement, "") + responseWriter.Body.Reset() +} + +func TestNewAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + key := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test2 key") + + path := newAcctPath + signedURL := fmt.Sprintf("http://localhost%s", path) + + wrongAgreementAcct := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":false}` + // An acct with the terms not agreed to + _, _, wrongAgreementBody := signer.embeddedJWK(key, signedURL, wrongAgreementAcct) + + // A non-JSON payload + _, _, fooBody := signer.embeddedJWK(key, signedURL, `foo`) + + type newAcctErrorTest struct { + r *http.Request + respBody string + } + + acctErrTests := []newAcctErrorTest{ + // POST, but no body. + { + &http.Request{ + Method: "POST", + URL: mustParseURL(newAcctPath), + Header: map[string][]string{ + "Content-Length": {"0"}, + "Content-Type": {expectedJWSContentType}, + }, + }, + `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, + }, + + // POST, but body that isn't valid JWS + { + makePostRequestWithPath(newAcctPath, "hi"), + `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, + }, + + // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. + { + makePostRequestWithPath(newAcctPath, fooBody), + `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, + }, + + // Same signed body, but payload modified by one byte, breaking signature. + // should fail JWS verification. + { + makePostRequestWithPath(newAcctPath, + `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ","signature":"jcTdxSygm_cvD7KbXqsxgnoPApCTSkV4jolToSOd2ciRkg5W7Yl0ZKEEKwOc-dYIbQiwGiDzisyPCicwWsOUA1WSqHylKvZ3nxSMc6KtwJCW2DaOqcf0EEjy5VjiZJUrOt2c-r6b07tbn8sfOJKwlF2lsOeGi4s-rtvvkeQpAU-AWauzl9G4bv2nDUeCviAZjHx_PoUC-f9GmZhYrbDzAvXZ859ktM6RmMeD0OqPN7bhAeju2j9Gl0lnryZMtq2m0J2m1ucenQBL1g4ZkP1JiJvzd2cAz5G7Ftl2YeJJyWhqNd3qq0GVOt1P11s8PTGNaSoM0iR9QfUxT9A6jxARtg"}`), + `{"type":"` + probs.ErrorNS + `malformed","detail":"JWS verification error","status":400}`, + }, + { + makePostRequestWithPath(newAcctPath, wrongAgreementBody), + `{"type":"` + probs.ErrorNS + `malformed","detail":"must agree to terms of service","status":400}`, + }, + } + for _, rt := range acctErrTests { + responseWriter := httptest.NewRecorder() + mux.ServeHTTP(responseWriter, rt.r) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), rt.respBody) + } + + responseWriter := httptest.NewRecorder() + + payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` + _, _, body := signer.embeddedJWK(key, signedURL, payload) + request := makePostRequestWithPath(path, body) + + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + + var acct core.Registration + responseBody := responseWriter.Body.String() + err := json.Unmarshal([]byte(responseBody), &acct) + test.AssertNotError(t, err, "Couldn't unmarshal returned account object") + test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") + test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, acct.InitialIP.String(), "1.1.1.1") + // Agreement is an ACMEv1 field and should not be present + test.AssertEquals(t, acct.Agreement, "") + + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/acct/1") + + // Load an existing key + key = loadKey(t, []byte(test1KeyPrivatePEM)) + _, ok = key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test1 key") + + // Reset the body and status code + responseWriter = httptest.NewRecorder() + // POST, Valid JSON, Key already in use + _, _, body = signer.embeddedJWK(key, signedURL, payload) + request = makePostRequestWithPath(path, body) + // POST the NewAccount request + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + // We expect a Location header and a 200 response with an empty body + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/acct/1") + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + `{ + "key": { + "kty": "RSA", + "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "initialIp": "", + "status": "valid" + }`) +} + +func TestNewAccountWhenAccountHasBeenDeactivated(t *testing.T) { + wfe, _, signer := setupWFE(t) + signedURL := fmt.Sprintf("http://localhost%s", newAcctPath) + // test3KeyPrivatePEM is a private key corresponding to a deactivated account in the mock SA's GetRegistration test data. + k := loadKey(t, []byte(test3KeyPrivatePEM)) + _, ok := k.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test3 key") + + payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` + _, _, body := signer.embeddedJWK(k, signedURL, payload) + request := makePostRequestWithPath(newAcctPath, body) + + responseWriter := httptest.NewRecorder() + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + + test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) +} + +func TestNewAccountNoID(t *testing.T) { + wfe, _, signer := setupWFE(t) + key := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test2 key") + path := newAcctPath + signedURL := fmt.Sprintf("http://localhost%s", path) + + payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` + _, _, body := signer.embeddedJWK(key, signedURL, payload) + request := makePostRequestWithPath(path, body) + + responseWriter := httptest.NewRecorder() + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) + + responseBody := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, responseBody, `{ + "key": { + "kty": "RSA", + "n": "qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "initialIp": "1.1.1.1", + "createdAt": "2021-01-01T00:00:00Z", + "status": "" + }`) +} + +func TestGetAuthorization(t *testing.T) { + wfe, _, signer := setupWFE(t) + + // Expired authorizations should be inaccessible + authzURL := "3" + responseWriter := httptest.NewRecorder() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(authzURL), + }) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`malformed","detail":"Expired authorization","status":404}`) + responseWriter.Body.Reset() + + // Ensure that a valid authorization can't be reached with an invalid URL + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + URL: mustParseURL("1d"), + Method: "GET", + }) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`malformed","detail":"Invalid authorization ID","status":400}`) + + _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/1", "") + postAsGet := makePostRequestWithPath("1", jwsBody) + + responseWriter = httptest.NewRecorder() + // Ensure that a POST-as-GET to an authorization works + wfe.Authorization(ctx, newRequestEvent(), responseWriter, postAsGet) + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + body := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, body, ` + { + "identifier": { + "type": "dns", + "value": "not-an-example.com" + }, + "status": "valid", + "expires": "2070-01-01T00:00:00Z", + "challenges": [ + { + "status": "pending", + "type": "dns", + "token":"token", + "url": "http://localhost/acme/chall-v3/1/-ZfxEw" + } + ] + }`) +} + +// TestAuthorization500 tests that internal errors on GetAuthorization result in +// a 500. +func TestAuthorization500(t *testing.T) { + wfe, _, _ := setupWFE(t) + + responseWriter := httptest.NewRecorder() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL("4"), + }) + expected := `{ + "type": "urn:ietf:params:acme:error:serverInternal", + "detail": "Problem getting authorization", + "status": 500 + }` + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), expected) +} + +// SAWithFailedChallenges is a mocks.StorageAuthority that has +// a `GetAuthorization` implementation that can return authorizations with +// failed challenges. +type SAWithFailedChallenges struct { + sapb.StorageAuthorityReadOnlyClient + Clk clock.FakeClock +} + +func (sa *SAWithFailedChallenges) GetAuthorization2(ctx context.Context, id *sapb.AuthorizationID2, _ ...grpc.CallOption) (*corepb.Authorization, error) { + authz := core.Authorization{ + ID: "55", + Status: core.StatusValid, + RegistrationID: 1, + Identifier: identifier.DNSIdentifier("not-an-example.com"), + Challenges: []core.Challenge{ + { + Status: core.StatusInvalid, + Type: "dns", + Token: "exampleToken", + Error: &probs.ProblemDetails{ + Type: "things:are:whack", + Detail: "whack attack", + HTTPStatus: 555, + }, + }, + }, + } + exp := sa.Clk.Now().AddDate(100, 0, 0) + authz.Expires = &exp + return bgrpc.AuthzToPB(authz) +} + +// TestAuthorizationChallengeNamespace tests that the runtime prefixing of +// Challenge Problem Types works as expected +func TestAuthorizationChallengeNamespace(t *testing.T) { + wfe, clk, _ := setupWFE(t) + + wfe.sa = &SAWithFailedChallenges{Clk: clk} + + responseWriter := httptest.NewRecorder() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL("55"), + }) + + var authz core.Authorization + err := json.Unmarshal(responseWriter.Body.Bytes(), &authz) + test.AssertNotError(t, err, "Couldn't unmarshal returned authorization object") + test.AssertEquals(t, len(authz.Challenges), 1) + // The Challenge Error Type should have had the probs.ErrorNS prefix added + test.AssertEquals(t, string(authz.Challenges[0].Error.Type), probs.ErrorNS+"things:are:whack") + responseWriter.Body.Reset() +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func TestAccount(t *testing.T) { + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + responseWriter := httptest.NewRecorder() + + // Test GET proper entry returns 405 + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(acctPath), + }) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) + responseWriter.Body.Reset() + + // Test POST invalid JSON + wfe.Account(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("2", "invalid")) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`malformed","detail":"Parse error reading JWS","status":400}`) + responseWriter.Body.Reset() + + key := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + + signedURL := fmt.Sprintf("http://localhost%s%d", acctPath, 102) + path := fmt.Sprintf("%s%d", acctPath, 102) + payload := `{}` + // ID 102 is used by the mock for missing acct + _, _, body := signer.byKeyID(102, nil, signedURL, payload) + request := makePostRequestWithPath(path, body) + + // Test POST valid JSON but key is not registered + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`accountDoesNotExist","detail":"Account \"http://localhost/acme/acct/102\" not found","status":400}`) + responseWriter.Body.Reset() + + key = loadKey(t, []byte(test1KeyPrivatePEM)) + _, ok = key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + + // Test POST valid JSON with account up in the mock + payload = `{}` + path = "1" + signedURL = "http://localhost/1" + _, _, body = signer.byKeyID(1, nil, signedURL, payload) + request = makePostRequestWithPath(path, body) + + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertNotContains(t, responseWriter.Body.String(), probs.ErrorNS) + links := responseWriter.Header()["Link"] + test.AssertEquals(t, contains(links, "<"+agreementURL+">;rel=\"terms-of-service\""), true) + responseWriter.Body.Reset() + + // Test POST valid JSON with garbage in URL but valid account ID + payload = `{}` + signedURL = "http://localhost/a/bunch/of/garbage/1" + _, _, body = signer.byKeyID(1, nil, signedURL, payload) + request = makePostRequestWithPath("/a/bunch/of/garbage/1", body) + + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertContains(t, responseWriter.Body.String(), "400") + test.AssertContains(t, responseWriter.Body.String(), probs.ErrorNS+"malformed") + responseWriter.Body.Reset() + + // Test valid POST-as-GET request + responseWriter = httptest.NewRecorder() + _, _, body = signer.byKeyID(1, nil, "http://localhost/1", "") + request = makePostRequestWithPath("1", body) + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + // It should not error + test.AssertNotContains(t, responseWriter.Body.String(), probs.ErrorNS) + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + + altKey := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok = altKey.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load altKey RSA key") + + // Test POST-as-GET request signed with wrong account key + responseWriter = httptest.NewRecorder() + _, _, body = signer.byKeyID(2, altKey, "http://localhost/1", "") + request = makePostRequestWithPath("1", body) + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + // It should error + test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ + "type": "urn:ietf:params:acme:error:unauthorized", + "detail": "Request signing key did not match account key", + "status": 403 + }`) +} + +type mockSAWithCert struct { + sapb.StorageAuthorityReadOnlyClient + cert *x509.Certificate + status core.OCSPStatus +} + +func newMockSAWithCert(t *testing.T, sa sapb.StorageAuthorityReadOnlyClient) *mockSAWithCert { + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "Failed to load test cert") + return &mockSAWithCert{sa, cert, core.OCSPStatusGood} +} + +// GetCertificate returns the mock SA's hard-coded certificate, issued by the +// account with regID 1, if the given serial matches. Otherwise, returns not found. +func (sa *mockSAWithCert) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + if req.Serial != core.SerialToString(sa.cert.SerialNumber) { + return nil, berrors.NotFoundError("Certificate with serial %q not found", req.Serial) + } + + return &corepb.Certificate{ + RegistrationID: 1, + Serial: core.SerialToString(sa.cert.SerialNumber), + Issued: timestamppb.New(sa.cert.NotBefore), + Expires: timestamppb.New(sa.cert.NotAfter), + Der: sa.cert.Raw, + }, nil +} + +// GetCertificateStatus returns the mock SA's status, if the given serial matches. +// Otherwise, returns not found. +func (sa *mockSAWithCert) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { + if req.Serial != core.SerialToString(sa.cert.SerialNumber) { + return nil, berrors.NotFoundError("Status for certificate with serial %q not found", req.Serial) + } + + return &corepb.CertificateStatus{ + Serial: core.SerialToString(sa.cert.SerialNumber), + Status: string(sa.status), + }, nil +} + +type mockSAWithIncident struct { + sapb.StorageAuthorityReadOnlyClient + incidents map[string]*sapb.Incidents +} + +// newMockSAWithIncident returns a mock SA with an enabled (ongoing) incident +// for each of the provided serials. +func newMockSAWithIncident(sa sapb.StorageAuthorityReadOnlyClient, serial []string) *mockSAWithIncident { + incidents := make(map[string]*sapb.Incidents) + for _, s := range serial { + incidents[s] = &sapb.Incidents{ + Incidents: []*sapb.Incident{ + { + Id: 0, + SerialTable: "incident_foo", + Url: agreementURL, + RenewBy: nil, + Enabled: true, + }, + }, + } + } + return &mockSAWithIncident{sa, incidents} +} + +func (sa *mockSAWithIncident) IncidentsForSerial(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.Incidents, error) { + incidents, ok := sa.incidents[req.Serial] + if ok { + return incidents, nil + } + return &sapb.Incidents{}, nil +} + +func TestGetCertificate(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + mux := wfe.Handler(metrics.NoopRegisterer) + + makeGet := func(path string) *http.Request { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} + } + + makePost := func(keyID int64, key interface{}, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, key, fmt.Sprintf("http://localhost%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + altKey := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok := altKey.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + + certPemBytes, _ := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "failed to load test certificate") + + chainPemBytes, err := os.ReadFile("../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3.cert.pem") + + chainCrossPemBytes, err := os.ReadFile("../test/hierarchy/int-r3-cross.cert.pem") + test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3-cross.cert.pem") + + reqPath := fmt.Sprintf("/acme/cert/%s", core.SerialToString(cert.SerialNumber)) + pkixContent := "application/pem-certificate-chain" + noCache := "public, max-age=0, no-cache" + notFound := `{"type":"` + probs.ErrorNS + `malformed","detail":"Certificate not found","status":404}` + + testCases := []struct { + Name string + Request *http.Request + ExpectedStatus int + ExpectedHeaders map[string]string + ExpectedLink string + ExpectedBody string + ExpectedCert []byte + AnyCert bool + }{ + { + Name: "Valid serial", + Request: makeGet(reqPath), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), + ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), + }, + { + Name: "Valid serial, POST-as-GET", + Request: makePost(1, nil, reqPath, ""), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), + }, + { + Name: "Valid serial, bad POST-as-GET", + Request: makePost(1, nil, reqPath, "{}"), + ExpectedStatus: http.StatusBadRequest, + ExpectedBody: `{ + "type": "urn:ietf:params:acme:error:malformed", + "status": 400, + "detail": "POST-as-GET requests must have an empty payload" + }`, + }, + { + Name: "Valid serial, POST-as-GET from wrong account", + Request: makePost(2, altKey, reqPath, ""), + ExpectedStatus: http.StatusForbidden, + ExpectedBody: `{ + "type": "urn:ietf:params:acme:error:unauthorized", + "status": 403, + "detail": "Account in use did not issue specified certificate" + }`, + }, + { + Name: "Unused serial, no cache", + Request: makeGet("/acme/cert/000000000000000000000000000000000001"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: notFound, + }, + { + Name: "Invalid serial, no cache", + Request: makeGet("/acme/cert/nothex"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: notFound, + }, + { + Name: "Another invalid serial, no cache", + Request: makeGet("/acme/cert/00000000000000"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: notFound, + }, + { + Name: "Valid serial (explicit default chain)", + Request: makeGet(reqPath + "/0"), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), + ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), + }, + { + Name: "Valid serial (explicit alternate chain)", + Request: makeGet(reqPath + "/1"), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), + ExpectedCert: append(certPemBytes, append([]byte("\n"), chainCrossPemBytes...)...), + }, + { + Name: "Valid serial (explicit non-existent alternate chain)", + Request: makeGet(reqPath + "/2"), + ExpectedStatus: http.StatusNotFound, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Unknown issuance chain","status":404}`, + }, + { + Name: "Valid serial (explicit negative alternate chain)", + Request: makeGet(reqPath + "/-1"), + ExpectedStatus: http.StatusBadRequest, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Chain ID must be a non-negative integer","status":400}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter := httptest.NewRecorder() + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + // Mux a request for a certificate + mux.ServeHTTP(responseWriter, tc.Request) + headers := responseWriter.Header() + + // Assert that the status code written is as expected + test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) + + // All of the responses should have the correct cache control header + test.AssertEquals(t, headers.Get("Cache-Control"), noCache) + + // If the test cases expects additional headers, check those too + for h, v := range tc.ExpectedHeaders { + test.AssertEquals(t, headers.Get(h), v) + } + + if tc.ExpectedLink != "" { + found := false + links := headers["Link"] + for _, link := range links { + if link == tc.ExpectedLink { + found = true + break + } + } + if !found { + t.Errorf("Expected link '%s', but did not find it in (%v)", + tc.ExpectedLink, links) + } + } + + if tc.AnyCert { // Certificate is randomly generated, don't match it + return + } + + if len(tc.ExpectedCert) > 0 { + // If the expectation was to return a certificate, check that it was the one expected + bodyBytes := responseWriter.Body.Bytes() + test.Assert(t, bytes.Equal(bodyBytes, tc.ExpectedCert), "Certificates don't match") + + // Successful requests should be logged as such + reqlogs := mockLog.GetAllMatching(`INFO: [^ ]+ [^ ]+ [^ ]+ 200 .*`) + if len(reqlogs) != 1 { + t.Errorf("Didn't find info logs with code 200. Instead got:\n%s\n", + strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } + } else { + // Otherwise if the expectation wasn't a certificate, check that the body matches the expected + body := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) + + // Unsuccessful requests should be logged as such + reqlogs := mockLog.GetAllMatching(fmt.Sprintf(`INFO: [^ ]+ [^ ]+ [^ ]+ %d .*`, tc.ExpectedStatus)) + if len(reqlogs) != 1 { + t.Errorf("Didn't find info logs with code %d. Instead got:\n%s\n", + tc.ExpectedStatus, strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } + } + }) + } +} + +type mockSAWithNewCert struct { + sapb.StorageAuthorityReadOnlyClient + clk clock.Clock +} + +func (sa *mockSAWithNewCert) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + issuer, err := core.LoadCert("../test/hierarchy/int-e1.cert.pem") + if err != nil { + return nil, fmt.Errorf("failed to load test issuer cert: %w", err) + } + + issuerKeyPem, err := os.ReadFile("../test/hierarchy/int-e1.key.pem") + if err != nil { + return nil, fmt.Errorf("failed to load test issuer key: %w", err) + } + issuerKey := loadKey(&testing.T{}, issuerKeyPem) + + newKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to create test key: %w", err) + } + + sn, err := core.StringToSerial(req.Serial) + if err != nil { + return nil, fmt.Errorf("failed to parse test serial: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: sn, + DNSNames: []string{"new.ee.boulder.test"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, issuer, &newKey.PublicKey, issuerKey) + if err != nil { + return nil, fmt.Errorf("failed to issue test cert: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, fmt.Errorf("failed to parse test cert: %w", err) + } + + return &corepb.Certificate{ + RegistrationID: 1, + Serial: core.SerialToString(cert.SerialNumber), + Issued: timestamppb.New(sa.clk.Now().Add(-1 * time.Second)), + Der: cert.Raw, + }, nil +} + +// TestGetCertificateNew tests for the case when the certificate is new (by +// dynamically generating it at test time), and therefore isn't served by the +// GET api. +func TestGetCertificateNew(t *testing.T) { + wfe, fc, signer := setupWFE(t) + wfe.sa = &mockSAWithNewCert{wfe.sa, fc} + mux := wfe.Handler(metrics.NoopRegisterer) + + makeGet := func(path string) *http.Request { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} + } + + makePost := func(keyID int64, key interface{}, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, key, fmt.Sprintf("http://localhost%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + altKey := loadKey(t, []byte(test2KeyPrivatePEM)) + _, ok := altKey.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + + pkixContent := "application/pem-certificate-chain" + noCache := "public, max-age=0, no-cache" + + testCases := []struct { + Name string + Request *http.Request + ExpectedStatus int + ExpectedHeaders map[string]string + ExpectedBody string + }{ + { + Name: "Get", + Request: makeGet("/get/cert/000000000000000000000000000000000001"), + ExpectedStatus: http.StatusForbidden, + ExpectedBody: `{ + "type": "` + probs.ErrorNS + `unauthorized", + "detail": "Certificate is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago", + "status": 403 + }`, + }, + { + Name: "ACME Get", + Request: makeGet("/acme/cert/000000000000000000000000000000000002"), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + }, + { + Name: "ACME POST-as-GET", + Request: makePost(1, nil, "/acme/cert/000000000000000000000000000000000003", ""), + ExpectedStatus: http.StatusOK, + ExpectedHeaders: map[string]string{ + "Content-Type": pkixContent, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter := httptest.NewRecorder() + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + // Mux a request for a certificate + mux.ServeHTTP(responseWriter, tc.Request) + headers := responseWriter.Header() + + // Assert that the status code written is as expected + test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) + + // All of the responses should have the correct cache control header + test.AssertEquals(t, headers.Get("Cache-Control"), noCache) + + // If the test cases expects additional headers, check those too + for h, v := range tc.ExpectedHeaders { + test.AssertEquals(t, headers.Get(h), v) + } + + // If we're expecting a particular body (because of an error), check that. + if tc.ExpectedBody != "" { + body := responseWriter.Body.String() + test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) + + // Unsuccessful requests should be logged as such + reqlogs := mockLog.GetAllMatching(fmt.Sprintf(`INFO: [^ ]+ [^ ]+ [^ ]+ %d .*`, tc.ExpectedStatus)) + if len(reqlogs) != 1 { + t.Errorf("Didn't find info logs with code %d. Instead got:\n%s\n", + tc.ExpectedStatus, strings.Join(mockLog.GetAllMatching(`.*`), "\n")) + } + } + }) + } +} + +// This uses httptest.NewServer because ServeMux.ServeHTTP won't prevent the +// body from being sent like the net/http Server's actually do. +func TestGetCertificateHEADHasCorrectBodyLength(t *testing.T) { + wfe, _, _ := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + certPemBytes, _ := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "failed to load test certificate") + + chainPemBytes, err := os.ReadFile("../test/hierarchy/int-r3.cert.pem") + test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3.cert.pem") + chain := fmt.Sprintf("%s\n%s", string(certPemBytes), string(chainPemBytes)) + chainLen := strconv.Itoa(len(chain)) + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + mux := wfe.Handler(metrics.NoopRegisterer) + s := httptest.NewServer(mux) + defer s.Close() + req, _ := http.NewRequest( + "HEAD", fmt.Sprintf("%s/acme/cert/%s", s.URL, core.SerialToString(cert.SerialNumber)), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + test.AssertNotError(t, err, "do error") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + test.AssertNotEquals(t, err, "readall error") + } + err = resp.Body.Close() + if err != nil { + test.AssertNotEquals(t, err, "readall error") + } + test.AssertEquals(t, resp.StatusCode, 200) + test.AssertEquals(t, chainLen, resp.Header.Get("Content-Length")) + test.AssertEquals(t, 0, len(body)) +} + +type mockSAWithError struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (sa *mockSAWithError) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + return nil, errors.New("Oops") +} + +func TestGetCertificateServerError(t *testing.T) { + // TODO: add tests for failure to parse the retrieved cert, a cert whose + // IssuerNameID is unknown, and a cert whose signature can't be verified. + wfe, _, _ := setupWFE(t) + wfe.sa = &mockSAWithError{wfe.sa} + mux := wfe.Handler(metrics.NoopRegisterer) + + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "failed to load test certificate") + + reqPath := fmt.Sprintf("/acme/cert/%s", core.SerialToString(cert.SerialNumber)) + req := &http.Request{URL: &url.URL{Path: reqPath}, Method: "GET"} + + // Mux a request for a certificate + responseWriter := httptest.NewRecorder() + mux.ServeHTTP(responseWriter, req) + + test.AssertEquals(t, responseWriter.Code, http.StatusInternalServerError) + + noCache := "public, max-age=0, no-cache" + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), noCache) + + body := `{ + "type": "urn:ietf:params:acme:error:serverInternal", + "status": 500, + "detail": "Failed to retrieve certificate" + }` + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), body) +} + +func newRequestEvent() *web.RequestEvent { + return &web.RequestEvent{Extra: make(map[string]interface{})} +} + +func TestHeaderBoulderRequester(t *testing.T) { + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + responseWriter := httptest.NewRecorder() + + key := loadKey(t, []byte(test1KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Failed to load test 1 RSA key") + + payload := `{}` + path := fmt.Sprintf("%s%d", acctPath, 1) + signedURL := fmt.Sprintf("http://localhost%s", path) + _, _, body := signer.byKeyID(1, nil, signedURL, payload) + request := makePostRequestWithPath(path, body) + + mux.ServeHTTP(responseWriter, request) + test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") + + // requests that do call sendError() also should have the requester header + payload = `{"agreement":"https://letsencrypt.org/im-bad"}` + _, _, body = signer.byKeyID(1, nil, signedURL, payload) + request = makePostRequestWithPath(path, body) + mux.ServeHTTP(responseWriter, request) + test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") +} + +func TestDeactivateAuthorization(t *testing.T) { + wfe, _, signer := setupWFE(t) + responseWriter := httptest.NewRecorder() + + responseWriter.Body.Reset() + + payload := `{"status":""}` + _, _, body := signer.byKeyID(1, nil, "http://localhost/1", payload) + request := makePostRequestWithPath("1", body) + + wfe.Authorization(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type": "`+probs.ErrorNS+`malformed","detail": "Invalid status value","status": 400}`) + + responseWriter.Body.Reset() + payload = `{"status":"deactivated"}` + _, _, body = signer.byKeyID(1, nil, "http://localhost/1", payload) + request = makePostRequestWithPath("1", body) + + wfe.Authorization(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{ + "identifier": { + "type": "dns", + "value": "not-an-example.com" + }, + "status": "deactivated", + "expires": "2070-01-01T00:00:00Z", + "challenges": [ + { + "status": "pending", + "type": "dns", + "token":"token", + "url": "http://localhost/acme/chall-v3/1/-ZfxEw" + } + ] + }`) +} + +func TestDeactivateAccount(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe, _, signer := setupWFE(t) + + responseWriter.Body.Reset() + payload := `{"status":"asd"}` + signedURL := "http://localhost/1" + path := "1" + _, _, body := signer.byKeyID(1, nil, signedURL, payload) + request := makePostRequestWithPath(path, body) + + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type": "`+probs.ErrorNS+`malformed","detail": "Invalid value provided for status field","status": 400}`) + + responseWriter.Body.Reset() + payload = `{"status":"deactivated"}` + _, _, body = signer.byKeyID(1, nil, signedURL, payload) + request = makePostRequestWithPath(path, body) + + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{ + "key": { + "kty": "RSA", + "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "initialIp": "", + "status": "deactivated" + }`) + + responseWriter.Body.Reset() + payload = `{"status":"deactivated", "contact":[]}` + _, _, body = signer.byKeyID(1, nil, signedURL, payload) + request = makePostRequestWithPath(path, body) + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{ + "key": { + "kty": "RSA", + "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "initialIp": "", + "status": "deactivated" + }`) + + responseWriter.Body.Reset() + key := loadKey(t, []byte(test3KeyPrivatePEM)) + _, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load test3 RSA key") + + payload = `{"status":"deactivated"}` + path = "3" + signedURL = "http://localhost/3" + _, _, body = signer.byKeyID(3, key, signedURL, payload) + request = makePostRequestWithPath(path, body) + + wfe.Account(ctx, newRequestEvent(), responseWriter, request) + + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{ + "type": "`+probs.ErrorNS+`unauthorized", + "detail": "Account is not valid, has status \"deactivated\"", + "status": 403 + }`) +} + +func TestNewOrder(t *testing.T) { + wfe, _, signer := setupWFE(t) + responseWriter := httptest.NewRecorder() + + targetHost := "localhost" + targetPath := "new-order" + signedURL := fmt.Sprintf("http://%s/%s", targetHost, targetPath) + + nonDNSIdentifierBody := ` + { + "Identifiers": [ + {"type": "dns", "value": "not-example.com"}, + {"type": "dns", "value": "www.not-example.com"}, + {"type": "fakeID", "value": "www.i-am-21.com"} + ] + } + ` + + validOrderBody := ` + { + "Identifiers": [ + {"type": "dns", "value": "not-example.com"}, + {"type": "dns", "value": "www.not-example.com"} + ] + }` + + validOrderBodyWithMixedCaseIdentifiers := ` + { + "Identifiers": [ + {"type": "dns", "value": "Not-Example.com"}, + {"type": "dns", "value": "WWW.Not-example.com"} + ] + }` + + // Body with a SAN that is longer than 64 bytes. This one is 65 bytes. + tooLongCNBody := ` + { + "Identifiers": [ + { + "type": "dns", + "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com" + } + ] + }` + + oneLongOneShortCNBody := ` + { + "Identifiers": [ + { + "type": "dns", + "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com" + }, + { + "type": "dns", + "value": "not-example.com" + } + ] + }` + + testCases := []struct { + Name string + Request *http.Request + ExpectedBody string + ExpectedHeaders map[string]string + }{ + { + Name: "POST, but no body", + Request: &http.Request{ + Method: "POST", + Header: map[string][]string{ + "Content-Length": {"0"}, + "Content-Type": {expectedJWSContentType}, + }, + }, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, + }, + { + Name: "POST, with an invalid JWS body", + Request: makePostRequestWithPath("hi", "hi"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, + }, + { + Name: "POST, properly signed JWS, payload isn't valid", + Request: signAndPost(signer, targetPath, signedURL, "foo"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, + }, + { + Name: "POST, empty domain name identifier", + Request: signAndPost(signer, targetPath, signedURL, `{"identifiers":[{"type":"dns","value":""}]}`), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NewOrder request included empty domain name","status":400}`, + }, + { + Name: "POST, invalid domain name identifier", + Request: signAndPost(signer, targetPath, signedURL, `{"identifiers":[{"type":"dns","value":"example.invalid"}]}`), + ExpectedBody: `{"type":"` + probs.ErrorNS + `rejectedIdentifier","detail":"Invalid identifiers requested :: Cannot issue for \"example.invalid\": Domain name does not end with a valid public suffix (TLD)","status":400}`, + }, + { + Name: "POST, no identifiers in payload", + Request: signAndPost(signer, targetPath, signedURL, "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NewOrder request did not specify any identifiers","status":400}`, + }, + { + Name: "POST, non-DNS identifier in payload", + Request: signAndPost(signer, targetPath, signedURL, nonDNSIdentifierBody), + ExpectedBody: `{"type":"` + probs.ErrorNS + `unsupportedIdentifier","detail":"NewOrder request included invalid non-DNS type identifier: type \"fakeID\", value \"www.i-am-21.com\"","status":400}`, + }, + { + Name: "POST, notAfter and notBefore in payload", + Request: signAndPost(signer, targetPath, signedURL, `{"identifiers":[{"type": "dns", "value": "not-example.com"}], "notBefore":"now", "notAfter": "later"}`), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NotBefore and NotAfter are not supported","status":400}`, + }, + { + Name: "POST, good payload, all names too long to fit in CN", + Request: signAndPost(signer, targetPath, signedURL, tooLongCNBody), + ExpectedBody: ` + { + "status": "pending", + "expires": "2021-02-01T01:01:01Z", + "identifiers": [ + { "type": "dns", "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com"} + ], + "authorizations": [ + "http://localhost/acme/authz-v3/1" + ], + "finalize": "http://localhost/acme/finalize/1/1" + }`, + }, + { + Name: "POST, good payload, one potential CNs less than 64 bytes and one longer", + Request: signAndPost(signer, targetPath, signedURL, oneLongOneShortCNBody), + ExpectedBody: ` + { + "status": "pending", + "expires": "2021-02-01T01:01:01Z", + "identifiers": [ + { "type": "dns", "value": "not-example.com"}, + { "type": "dns", "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com"} + ], + "authorizations": [ + "http://localhost/acme/authz-v3/1" + ], + "finalize": "http://localhost/acme/finalize/1/1" + }`, + }, + { + Name: "POST, good payload", + Request: signAndPost(signer, targetPath, signedURL, validOrderBody), + ExpectedBody: ` + { + "status": "pending", + "expires": "2021-02-01T01:01:01Z", + "identifiers": [ + { "type": "dns", "value": "not-example.com"}, + { "type": "dns", "value": "www.not-example.com"} + ], + "authorizations": [ + "http://localhost/acme/authz-v3/1" + ], + "finalize": "http://localhost/acme/finalize/1/1" + }`, + }, + { + Name: "POST, good payload, but when the input had mixed case", + Request: signAndPost(signer, targetPath, signedURL, validOrderBodyWithMixedCaseIdentifiers), + ExpectedBody: ` + { + "status": "pending", + "expires": "2021-02-01T01:01:01Z", + "identifiers": [ + { "type": "dns", "value": "not-example.com"}, + { "type": "dns", "value": "www.not-example.com"} + ], + "authorizations": [ + "http://localhost/acme/authz-v3/1" + ], + "finalize": "http://localhost/acme/finalize/1/1" + }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter.Body.Reset() + + wfe.NewOrder(ctx, newRequestEvent(), responseWriter, tc.Request) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedBody) + + headers := responseWriter.Header() + for k, v := range tc.ExpectedHeaders { + test.AssertEquals(t, headers.Get(k), v) + } + }) + } + + // Test that we log the "Created" field. + responseWriter.Body.Reset() + request := signAndPost(signer, targetPath, signedURL, validOrderBody) + requestEvent := newRequestEvent() + wfe.NewOrder(ctx, requestEvent, responseWriter, request) + + if requestEvent.Created != "1" { + t.Errorf("Expected to log Created field when creating Order: %#v", requestEvent) + } +} + +func TestFinalizeOrder(t *testing.T) { + wfe, _, signer := setupWFE(t) + responseWriter := httptest.NewRecorder() + + targetHost := "localhost" + targetPath := "1/1" + signedURL := fmt.Sprintf("http://%s/%s", targetHost, targetPath) + + // This example is a well-formed CSR for the name "example.com". + goodCertCSRPayload := `{ + "csr": "MIHRMHgCAQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ2hlvArQl5k0L1eF1vF5dwr7ASm2iKqibmauund-z3QJpuudnNEjlyOXi-IY1rxyhehRrtbm_bbcNCtZLgbkPvoAAwCgYIKoZIzj0EAwIDSQAwRgIhAJ8z2EDll2BvoNRotAknEfrqeP6K5CN1NeVMB4QOu0G1AiEAqAVpiGwNyV7SEZ67vV5vyuGsKPAGnqrisZh5Vg5JKHE=" + }` + + egUrl := mustParseURL("1/1") + + testCases := []struct { + Name string + Request *http.Request + ExpectedHeaders map[string]string + ExpectedBody string + }{ + { + Name: "POST, but no body", + Request: &http.Request{ + URL: egUrl, + RequestURI: targetPath, + Method: "POST", + Header: map[string][]string{ + "Content-Length": {"0"}, + "Content-Type": {expectedJWSContentType}, + }, + }, + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, + }, + { + Name: "POST, with an invalid JWS body", + Request: makePostRequestWithPath(targetPath, "hi"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, + }, + { + Name: "POST, properly signed JWS, payload isn't valid", + Request: signAndPost(signer, targetPath, signedURL, "foo"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, + }, + { + Name: "Invalid path", + Request: signAndPost(signer, "1", "http://localhost/1", "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid request path","status":404}`, + }, + { + Name: "Bad acct ID in path", + Request: signAndPost(signer, "a/1", "http://localhost/a/1", "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid account ID","status":400}`, + }, + { + Name: "Mismatched acct ID in path/JWS", + // Note(@cpu): We use "http://localhost/2/1" here not + // "http://localhost/order/2/1" because we are calling the Order + // handler directly and it normally has the initial path component + // stripped by the global WFE2 handler. We need the JWS URL to match the request + // URL so we fudge both such that the finalize-order prefix has been removed. + Request: signAndPost(signer, "2/1", "http://localhost/2/1", "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2","status":404}`, + }, + { + Name: "Order ID is invalid", + Request: signAndPost(signer, "1/okwhatever/finalize-order", "http://localhost/1/okwhatever/finalize-order", "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid order ID","status":400}`, + }, + { + Name: "Order doesn't exist", + // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 2 as missing + Request: signAndPost(signer, "1/2", "http://localhost/1/2", "{}"), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order for ID 2","status":404}`, + }, + { + Name: "Order is already finalized", + // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 1 as an Order with a Serial + Request: signAndPost(signer, "1/1", "http://localhost/1/1", goodCertCSRPayload), + ExpectedBody: `{"type":"` + probs.ErrorNS + `orderNotReady","detail":"Order's status (\"valid\") is not acceptable for finalization","status":403}`, + }, + { + Name: "Order is expired", + // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 7 as an Order that has already expired + Request: signAndPost(signer, "1/7", "http://localhost/1/7", goodCertCSRPayload), + ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Order 7 is expired","status":404}`, + }, + { + Name: "Good CSR, Pending Order", + Request: signAndPost(signer, "1/4", "http://localhost/1/4", goodCertCSRPayload), + ExpectedBody: `{"type":"` + probs.ErrorNS + `orderNotReady","detail":"Order's status (\"pending\") is not acceptable for finalization","status":403}`, + }, + { + Name: "Good CSR, Ready Order", + Request: signAndPost(signer, "1/8", "http://localhost/1/8", goodCertCSRPayload), + ExpectedHeaders: map[string]string{ + "Location": "http://localhost/acme/order/1/8", + "Retry-After": "3", + }, + ExpectedBody: ` +{ + "status": "processing", + "expires": "2000-01-01T00:00:00Z", + "identifiers": [ + {"type":"dns","value":"example.com"} + ], + "authorizations": [ + "http://localhost/acme/authz-v3/1" + ], + "finalize": "http://localhost/acme/finalize/1/8" +}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter.Body.Reset() + wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, tc.Request) + for k, v := range tc.ExpectedHeaders { + got := responseWriter.Header().Get(k) + if v != got { + t.Errorf("Header %q: Expected %q, got %q", k, v, got) + } + } + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + tc.ExpectedBody) + }) + } + + // Check a bad CSR request separately from the above testcases. We don't want + // to match the whole response body because the "detail" of a bad CSR problem + // contains a verbose Go error message that can change between versions (e.g. + // Go 1.10.4 to 1.11 changed the expected format) + badCSRReq := signAndPost(signer, "1/8", "http://localhost/1/8", `{"CSR": "ABCD"}`) + responseWriter.Body.Reset() + wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, badCSRReq) + responseBody := responseWriter.Body.String() + test.AssertContains(t, responseBody, "Error parsing certificate request") +} + +func TestKeyRollover(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe, _, signer := setupWFE(t) + + existingKey, err := rsa.GenerateKey(rand.Reader, 2048) + test.AssertNotError(t, err, "Error creating random 2048 RSA key") + + newKeyBytes, err := os.ReadFile("../test/test-key-5.der") + test.AssertNotError(t, err, "Failed to read ../test/test-key-5.der") + newKeyPriv, err := x509.ParsePKCS1PrivateKey(newKeyBytes) + test.AssertNotError(t, err, "Failed parsing private key") + newJWKJSON, err := jose.JSONWebKey{Key: newKeyPriv.Public()}.MarshalJSON() + test.AssertNotError(t, err, "Failed to marshal JWK JSON") + + wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("", "{}")) + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{ + "type": "`+probs.ErrorNS+`malformed", + "detail": "Parse error reading JWS", + "status": 400 + }`) + + testCases := []struct { + Name string + Payload string + ExpectedResponse string + NewKey crypto.Signer + ErrorStatType string + }{ + { + Name: "Missing account URL", + Payload: `{"oldKey":` + test1KeyPublicJSON + `}`, + ExpectedResponse: `{ + "type": "` + probs.ErrorNS + `malformed", + "detail": "Inner key rollover request specified Account \"\", but outer JWS has Key ID \"http://localhost/acme/acct/1\"", + "status": 400 + }`, + NewKey: newKeyPriv, + ErrorStatType: "KeyRolloverMismatchedAccount", + }, + { + Name: "incorrect old key", + Payload: `{"oldKey":` + string(newJWKJSON) + `,"account":"http://localhost/acme/acct/1"}`, + ExpectedResponse: `{ + "type": "` + probs.ErrorNS + `malformed", + "detail": "Inner JWS does not contain old key field matching current account key", + "status": 400 + }`, + NewKey: newKeyPriv, + ErrorStatType: "KeyRolloverWrongOldKey", + }, + { + Name: "Valid key rollover request, key exists", + Payload: `{"oldKey":` + test1KeyPublicJSON + `,"account":"http://localhost/acme/acct/1"}`, + ExpectedResponse: `{ + "type": "urn:ietf:params:acme:error:conflict", + "detail": "New key is already in use for a different account", + "status": 409 + }`, + NewKey: existingKey, + }, + { + Name: "Valid key rollover request", + Payload: `{"oldKey":` + test1KeyPublicJSON + `,"account":"http://localhost/acme/acct/1"}`, + ExpectedResponse: `{ + "key": ` + string(newJWKJSON) + `, + "contact": [ + "mailto:person@mail.com" + ], + "initialIp": "", + "status": "valid" + }`, + NewKey: newKeyPriv, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + wfe.stats.joseErrorCount.Reset() + responseWriter.Body.Reset() + _, _, inner := signer.embeddedJWK(tc.NewKey, "http://localhost/key-change", tc.Payload) + _, _, outer := signer.byKeyID(1, nil, "http://localhost/key-change", inner) + wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("key-change", outer)) + t.Log(responseWriter.Body.String()) + t.Log(tc.ExpectedResponse) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedResponse) + if tc.ErrorStatType != "" { + test.AssertMetricWithLabelsEquals( + t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) + } + }) + } +} + +func TestKeyRolloverMismatchedJWSURLs(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe, _, signer := setupWFE(t) + + newKeyBytes, err := os.ReadFile("../test/test-key-5.der") + test.AssertNotError(t, err, "Failed to read ../test/test-key-5.der") + newKeyPriv, err := x509.ParsePKCS1PrivateKey(newKeyBytes) + test.AssertNotError(t, err, "Failed parsing private key") + + _, _, inner := signer.embeddedJWK(newKeyPriv, "http://localhost/wrong-url", "{}") + _, _, outer := signer.byKeyID(1, nil, "http://localhost/key-change", inner) + wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("key-change", outer)) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), ` + { + "type": "urn:ietf:params:acme:error:malformed", + "detail": "Outer JWS 'url' value \"http://localhost/key-change\" does not match inner JWS 'url' value \"http://localhost/wrong-url\"", + "status": 400 + }`) +} + +func TestGetOrder(t *testing.T) { + wfe, _, signer := setupWFE(t) + + makeGet := func(path string) *http.Request { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} + } + + makePost := func(keyID int64, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost/%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + testCases := []struct { + Name string + Request *http.Request + Response string + Headers map[string]string + Endpoint string + }{ + { + Name: "Good request", + Request: makeGet("1/1"), + Response: `{"status": "valid","expires": "2000-01-01T00:00:00Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/1","certificate":"http://localhost/acme/cert/serial"}`, + }, + { + Name: "404 request", + Request: makeGet("1/2"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order for ID 2", "status":404}`, + }, + { + Name: "Invalid request path", + Request: makeGet("asd"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid request path","status":404}`, + }, + { + Name: "Invalid account ID", + Request: makeGet("asd/asd"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid account ID","status":400}`, + }, + { + Name: "Invalid order ID", + Request: makeGet("1/asd"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid order ID","status":400}`, + }, + { + Name: "Real request, wrong account", + Request: makeGet("2/1"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2", "status":404}`, + }, + { + Name: "Internal error request", + Request: makeGet("1/3"), + Response: `{"type":"` + probs.ErrorNS + `serverInternal","detail":"Failed to retrieve order for ID 3","status":500}`, + }, + { + Name: "Invalid POST-as-GET", + Request: makePost(1, "1/1", "{}"), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"POST-as-GET requests must have an empty payload", "status":400}`, + }, + { + Name: "Valid POST-as-GET, wrong account", + Request: makePost(1, "2/1", ""), + Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2", "status":404}`, + }, + { + Name: "Valid POST-as-GET", + Request: makePost(1, "1/1", ""), + Response: `{"status": "valid","expires": "2000-01-01T00:00:00Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/1","certificate":"http://localhost/acme/cert/serial"}`, + }, + { + Name: "GET new order", + Request: makeGet("1/9"), + Response: `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Order is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}`, + Endpoint: "/get/order/", + }, + { + Name: "GET new order from old endpoint", + Request: makeGet("1/9"), + Response: `{"status": "valid","expires": "2000-01-01T00:00:00Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/9","certificate":"http://localhost/acme/cert/serial"}`, + }, + { + Name: "POST-as-GET new order", + Request: makePost(1, "1/9", ""), + Response: `{"status": "valid","expires": "2000-01-01T00:00:00Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/9","certificate":"http://localhost/acme/cert/serial"}`, + }, + { + Name: "POST-as-GET processing order", + Request: makePost(1, "1/10", ""), + Response: `{"status": "processing","expires": "2000-01-01T00:00:00Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/10"}`, + Headers: map[string]string{"Retry-After": "3"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + responseWriter := httptest.NewRecorder() + if tc.Endpoint != "" { + wfe.GetOrder(ctx, &web.RequestEvent{Extra: make(map[string]interface{}), Endpoint: tc.Endpoint}, responseWriter, tc.Request) + } else { + wfe.GetOrder(ctx, newRequestEvent(), responseWriter, tc.Request) + } + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.Response) + for k, v := range tc.Headers { + test.AssertEquals(t, responseWriter.Header().Get(k), v) + } + }) + } +} + +func makeRevokeRequestJSON(reason *revocation.Reason) ([]byte, error) { + certPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") + if err != nil { + return nil, err + } + certBlock, _ := pem.Decode(certPemBytes) + return makeRevokeRequestJSONForCert(certBlock.Bytes, reason) +} + +func makeRevokeRequestJSONForCert(der []byte, reason *revocation.Reason) ([]byte, error) { + revokeRequest := struct { + CertificateDER core.JSONBuffer `json:"certificate"` + Reason *revocation.Reason `json:"reason"` + }{ + CertificateDER: der, + Reason: reason, + } + revokeRequestJSON, err := json.Marshal(revokeRequest) + if err != nil { + return nil, err + } + return revokeRequestJSON, nil +} + +// Valid revocation request for existing, non-revoked cert, signed using the +// issuing account key. +func TestRevokeCertificateByApplicantValid(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") + test.AssertDeepEquals(t, mockLog.GetAllMatching("Authenticated revocation"), []string{ + `INFO: [AUDIT] Authenticated revocation JSON={"Serial":"000000000000000000001d72443db5189821","Reason":0,"RegID":1,"Method":"applicant"}`, + }) +} + +// Valid revocation request for existing, non-revoked cert, signed using the +// certificate private key. +func TestRevokeCertificateByKeyValid(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") + test.AssertNotError(t, err, "Failed to load key") + key := loadKey(t, keyPemBytes) + + revocationReason := revocation.Reason(ocsp.KeyCompromise) + revokeRequestJSON, err := makeRevokeRequestJSON(&revocationReason) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") + test.AssertDeepEquals(t, mockLog.GetAllMatching("Authenticated revocation"), []string{ + `INFO: [AUDIT] Authenticated revocation JSON={"Serial":"000000000000000000001d72443db5189821","Reason":1,"RegID":0,"Method":"privkey"}`, + }) +} + +// Invalid revocation request: although signed with the cert key, the cert +// wasn't issued by any issuer the Boulder is aware of. +func TestRevokeCertificateNotIssued(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + // Make a self-signed junk certificate + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "unexpected error making random private key") + // Use a known serial from the mockSAWithValidCert mock. + // This ensures that any failures here are due to the certificate's issuer + // not matching up with issuers known by the mock, rather than due to the + // certificate's serial not matching up with serials known by the mock. + knownCert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "Unexpected error loading test cert") + template := &x509.Certificate{ + SerialNumber: knownCert.SerialNumber, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, k.Public(), k) + test.AssertNotError(t, err, "Unexpected error creating self-signed junk cert") + + keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") + test.AssertNotError(t, err, "Failed to load key") + key := loadKey(t, keyPemBytes) + + revokeRequestJSON, err := makeRevokeRequestJSONForCert(certDER, nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON for certDER") + _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + // It should result in a 404 response with a problem body + test.AssertEquals(t, responseWriter.Code, 404) + test.AssertEquals(t, responseWriter.Body.String(), "{\n \"type\": \"urn:ietf:params:acme:error:malformed\",\n \"detail\": \"Certificate from unrecognized issuer\",\n \"status\": 404\n}") +} + +func TestRevokeCertificateExpired(t *testing.T) { + wfe, fc, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") + test.AssertNotError(t, err, "Failed to load key") + key := loadKey(t, keyPemBytes) + + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "Failed to load test certificate") + + fc.Set(cert.NotAfter.Add(time.Hour)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + test.AssertEquals(t, responseWriter.Code, 403) + test.AssertEquals(t, responseWriter.Body.String(), "{\n \"type\": \"urn:ietf:params:acme:error:unauthorized\",\n \"detail\": \"Certificate is expired\",\n \"status\": 403\n}") +} + +func TestRevokeCertificateReasons(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + ra := wfe.ra.(*MockRegistrationAuthority) + + reason0 := revocation.Reason(ocsp.Unspecified) + reason1 := revocation.Reason(ocsp.KeyCompromise) + reason2 := revocation.Reason(ocsp.CACompromise) + reason100 := revocation.Reason(100) + + testCases := []struct { + Name string + Reason *revocation.Reason + ExpectedHTTPCode int + ExpectedBody string + ExpectedReason *revocation.Reason + }{ + { + Name: "Valid reason", + Reason: &reason1, + ExpectedHTTPCode: http.StatusOK, + ExpectedReason: &reason1, + }, + { + Name: "No reason", + ExpectedHTTPCode: http.StatusOK, + ExpectedReason: &reason0, + }, + { + Name: "Unsupported reason", + Reason: &reason2, + ExpectedHTTPCode: http.StatusBadRequest, + ExpectedBody: `{"type":"` + probs.ErrorNS + `badRevocationReason","detail":"unsupported revocation reason code provided: cACompromise (2). Supported reasons: unspecified (0), keyCompromise (1), superseded (4), cessationOfOperation (5)","status":400}`, + }, + { + Name: "Non-existent reason", + Reason: &reason100, + ExpectedHTTPCode: http.StatusBadRequest, + ExpectedBody: `{"type":"` + probs.ErrorNS + `badRevocationReason","detail":"unsupported revocation reason code provided: unknown (100). Supported reasons: unspecified (0), keyCompromise (1), superseded (4), cessationOfOperation (5)","status":400}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + revokeRequestJSON, err := makeRevokeRequestJSON(tc.Reason) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + + test.AssertEquals(t, responseWriter.Code, tc.ExpectedHTTPCode) + if tc.ExpectedBody != "" { + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedBody) + } else { + test.AssertEquals(t, responseWriter.Body.String(), tc.ExpectedBody) + } + if tc.ExpectedReason != nil { + test.AssertEquals(t, ra.lastRevocationReason, *tc.ExpectedReason) + } + }) + } +} + +// A revocation request signed by an incorrect certificate private key. +func TestRevokeCertificateWrongCertificateKey(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = newMockSAWithCert(t, wfe.sa) + + keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-e1.key.pem") + test.AssertNotError(t, err, "Failed to load key") + key := loadKey(t, keyPemBytes) + + revocationReason := revocation.Reason(ocsp.KeyCompromise) + revokeRequestJSON, err := makeRevokeRequestJSON(&revocationReason) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) + + responseWriter := httptest.NewRecorder() + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("revoke-cert", jwsBody)) + test.AssertEquals(t, responseWriter.Code, 403) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`unauthorized","detail":"JWK embedded in revocation request must be the same public key as the cert to be revoked","status":403}`) +} + +type mockSAGetRegByKeyFails struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (sa *mockSAGetRegByKeyFails) GetRegistrationByKey(_ context.Context, req *sapb.JSONWebKey, _ ...grpc.CallOption) (*corepb.Registration, error) { + return nil, fmt.Errorf("whoops") +} + +// When SA.GetRegistrationByKey errors (e.g. gRPC timeout), NewAccount should +// return internal server errors. +func TestNewAccountWhenGetRegByKeyFails(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = &mockSAGetRegByKeyFails{wfe.sa} + key := loadKey(t, []byte(testE2KeyPrivatePEM)) + _, ok := key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + payload := `{"contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}` + responseWriter := httptest.NewRecorder() + _, _, body := signer.embeddedJWK(key, "http://localhost/new-account", payload) + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) + if responseWriter.Code != 500 { + t.Fatalf("Wrong response code %d for NewAccount with failing GetRegByKey (wanted 500)", responseWriter.Code) + } + var prob probs.ProblemDetails + err := json.Unmarshal(responseWriter.Body.Bytes(), &prob) + test.AssertNotError(t, err, "unmarshalling response") + if prob.Type != probs.ErrorNS+probs.ServerInternalProblem { + t.Errorf("Wrong type for returned problem: %#v", prob.Type) + } +} + +type mockSAGetRegByKeyNotFound struct { + sapb.StorageAuthorityReadOnlyClient +} + +func (sa *mockSAGetRegByKeyNotFound) GetRegistrationByKey(_ context.Context, req *sapb.JSONWebKey, _ ...grpc.CallOption) (*corepb.Registration, error) { + return nil, berrors.NotFoundError("not found") +} + +func TestNewAccountWhenGetRegByKeyNotFound(t *testing.T) { + wfe, _, signer := setupWFE(t) + wfe.sa = &mockSAGetRegByKeyNotFound{wfe.sa} + key := loadKey(t, []byte(testE2KeyPrivatePEM)) + _, ok := key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + // When SA.GetRegistrationByKey returns NotFound, and no onlyReturnExisting + // field is sent, NewAccount should succeed. + payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` + signedURL := "http://localhost/new-account" + responseWriter := httptest.NewRecorder() + _, _, body := signer.embeddedJWK(key, signedURL, payload) + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) + if responseWriter.Code != http.StatusCreated { + t.Errorf("Bad response to NewRegistration: %d, %s", responseWriter.Code, responseWriter.Body) + } + + // When SA.GetRegistrationByKey returns NotFound, and onlyReturnExisting + // field **is** sent, NewAccount should fail with the expected error. + payload = `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true,"onlyReturnExisting":true}` + responseWriter = httptest.NewRecorder() + _, _, body = signer.embeddedJWK(key, signedURL, payload) + // Process the new account request + wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) + test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), ` + { + "type": "urn:ietf:params:acme:error:accountDoesNotExist", + "detail": "No account exists with the provided key", + "status": 400 + }`) +} + +func TestPrepAuthzForDisplay(t *testing.T) { + wfe, _, _ := setupWFE(t) + + // Make an authz for a wildcard identifier + authz := &core.Authorization{ + ID: "12345", + Status: core.StatusPending, + RegistrationID: 1, + Identifier: identifier.DNSIdentifier("*.example.com"), + Challenges: []core.Challenge{ + { + Type: "dns", + ProvidedKeyAuthorization: " 🔑", + }, + }, + } + + // Prep the wildcard authz for display + wfe.prepAuthorizationForDisplay(&http.Request{Host: "localhost"}, authz) + + // The authz should not have a wildcard prefix in the identifier value + test.AssertEquals(t, strings.HasPrefix(authz.Identifier.Value, "*."), false) + // The authz should be marked as corresponding to a wildcard name + test.AssertEquals(t, authz.Wildcard, true) + + // We expect the authz challenge has its URL set and the URI emptied. + authz.ID = "12345" + wfe.prepAuthorizationForDisplay(&http.Request{Host: "localhost"}, authz) + chal := authz.Challenges[0] + test.AssertEquals(t, chal.URL, "http://localhost/acme/chall-v3/12345/po1V2w") + test.AssertEquals(t, chal.ProvidedKeyAuthorization, "") +} + +// noSCTMockRA is a mock RA that always returns a `berrors.MissingSCTsError` from `FinalizeOrder` +type noSCTMockRA struct { + MockRegistrationAuthority +} + +func (ra *noSCTMockRA) FinalizeOrder(context.Context, *rapb.FinalizeOrderRequest, ...grpc.CallOption) (*corepb.Order, error) { + return nil, berrors.MissingSCTsError("noSCTMockRA missing scts error") +} + +func TestFinalizeSCTError(t *testing.T) { + wfe, _, signer := setupWFE(t) + + // Set up an RA mock that always returns a berrors.MissingSCTsError from + // `FinalizeOrder` + wfe.ra = &noSCTMockRA{} + + // Create a response writer to capture the WFE response + responseWriter := httptest.NewRecorder() + + // This example is a well-formed CSR for the name "example.com". + goodCertCSRPayload := `{ + "csr": "MIHRMHgCAQAwFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ2hlvArQl5k0L1eF1vF5dwr7ASm2iKqibmauund-z3QJpuudnNEjlyOXi-IY1rxyhehRrtbm_bbcNCtZLgbkPvoAAwCgYIKoZIzj0EAwIDSQAwRgIhAJ8z2EDll2BvoNRotAknEfrqeP6K5CN1NeVMB4QOu0G1AiEAqAVpiGwNyV7SEZ67vV5vyuGsKPAGnqrisZh5Vg5JKHE=" + }` + + // Create a finalization request with the above payload + request := signAndPost(signer, "1/8", "http://localhost/1/8", goodCertCSRPayload) + + // POST the finalize order request. + wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, request) + + // We expect the berrors.MissingSCTsError error to have been converted into + // a serverInternal error with the right message. + test.AssertUnmarshaledEquals(t, + responseWriter.Body.String(), + `{"type":"`+probs.ErrorNS+`serverInternal","detail":"Error finalizing order :: Unable to meet CA SCT embedding requirements","status":500}`) +} + +func TestOrderToOrderJSONV2Authorizations(t *testing.T) { + wfe, fc, _ := setupWFE(t) + expires := fc.Now() + orderJSON := wfe.orderToOrderJSON(&http.Request{}, &corepb.Order{ + Id: 1, + RegistrationID: 1, + Names: []string{"a"}, + Status: string(core.StatusPending), + Expires: timestamppb.New(expires), + V2Authorizations: []int64{1, 2}, + }) + test.AssertDeepEquals(t, orderJSON.Authorizations, []string{ + "http://localhost/acme/authz-v3/1", + "http://localhost/acme/authz-v3/2", + }) +} + +func TestGetChallengeUpRel(t *testing.T) { + wfe, _, _ := setupWFE(t) + + challengeURL := "http://localhost/acme/chall-v3/1/-ZfxEw" + resp := httptest.NewRecorder() + + req, err := http.NewRequest("GET", challengeURL, nil) + test.AssertNotError(t, err, "Could not make NewRequest") + req.URL.Path = "1/-ZfxEw" + + wfe.Challenge(ctx, newRequestEvent(), resp, req) + test.AssertEquals(t, + resp.Code, + http.StatusOK) + test.AssertEquals(t, + resp.Header().Get("Link"), + `;rel="up"`) +} + +func TestPrepAccountForDisplay(t *testing.T) { + acct := &core.Registration{ + ID: 1987, + Agreement: "disagreement", + } + + // Prep the account for display. + prepAccountForDisplay(acct) + + // The Agreement should always be cleared. + test.AssertEquals(t, acct.Agreement, "") + // The ID field should be zeroed. + test.AssertEquals(t, acct.ID, int64(0)) +} + +func TestGETAPIAuthz(t *testing.T) { + wfe, _, _ := setupWFE(t) + makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, + &web.RequestEvent{Endpoint: endpoint} + } + + testCases := []struct { + name string + path string + expectTooFreshErr bool + }{ + { + name: "fresh authz", + path: "1", + expectTooFreshErr: true, + }, + { + name: "old authz", + path: "2", + expectTooFreshErr: false, + }, + } + + tooFreshErr := `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Authorization is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}` + for _, tc := range testCases { + responseWriter := httptest.NewRecorder() + req, logEvent := makeGet(tc.path, getAuthzPath) + wfe.Authorization(context.Background(), logEvent, responseWriter, req) + + if responseWriter.Code == http.StatusOK && tc.expectTooFreshErr { + t.Errorf("expected too fresh error, got http.StatusOK") + } else { + test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tooFreshErr) + } + } +} + +func TestGETAPIChallenge(t *testing.T) { + wfe, _, _ := setupWFE(t) + makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, + &web.RequestEvent{Endpoint: endpoint} + } + + testCases := []struct { + name string + path string + expectTooFreshErr bool + }{ + { + name: "fresh authz challenge", + path: "1/-ZfxEw", + expectTooFreshErr: true, + }, + { + name: "old authz challenge", + path: "2/-ZfxEw", + expectTooFreshErr: false, + }, + } + + tooFreshErr := `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Authorization is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}` + for _, tc := range testCases { + responseWriter := httptest.NewRecorder() + req, logEvent := makeGet(tc.path, getAuthzPath) + wfe.Challenge(context.Background(), logEvent, responseWriter, req) + + if responseWriter.Code == http.StatusOK && tc.expectTooFreshErr { + t.Errorf("expected too fresh error, got http.StatusOK") + } else { + test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tooFreshErr) + } + } +} + +// TestGet404 tests that a 404 is served and that the expected endpoint of +// "/" is logged when an unknown path is requested. This will test the +// codepath to the wfe.Index() handler which handles "/" and all non-api +// endpoint requests to make sure the endpoint is set properly in the logs. +func TestIndexGet404(t *testing.T) { + // Setup + wfe, _, _ := setupWFE(t) + path := "/nopathhere/nope/nofilehere" + req := &http.Request{URL: &url.URL{Path: path}, Method: "GET"} + logEvent := &web.RequestEvent{} + responseWriter := httptest.NewRecorder() + + // Send a request to wfe.Index() + wfe.Index(context.Background(), logEvent, responseWriter, req) + + // Test that a 404 is received as expected + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + // Test that we logged the "/" endpoint + test.AssertEquals(t, logEvent.Endpoint, "/") + // Test that the rest of the path is logged as the slug + test.AssertEquals(t, logEvent.Slug, path[1:]) +} + +// TestARI tests that requests for real certs result in renewal info, while +// requests for certs that don't exist result in errors. +func TestARI(t *testing.T) { + wfe, _, _ := setupWFE(t) + msa := newMockSAWithCert(t, wfe.sa) + wfe.sa = msa + + features.Set(features.Config{ServeRenewalInfo: true}) + defer features.Reset() + + makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, + &web.RequestEvent{Endpoint: endpoint, Extra: map[string]interface{}{}} + } + + // Load the leaf certificate. + cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") + test.AssertNotError(t, err, "failed to load test certificate") + + // Ensure that a correct draft-ietf-acme-ari03 query results in a 200. + certID := fmt.Sprintf("%s.%s", + base64.RawURLEncoding.EncodeToString(cert.AuthorityKeyId), + base64.RawURLEncoding.EncodeToString(cert.SerialNumber.Bytes()), + ) + req, event := makeGet(certID, renewalInfoPath) + resp := httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, http.StatusOK) + test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") + var ri core.RenewalInfo + err = json.Unmarshal(resp.Body.Bytes(), &ri) + test.AssertNotError(t, err, "unmarshalling renewal info") + test.Assert(t, ri.SuggestedWindow.Start.After(cert.NotBefore), "suggested window begins before cert issuance") + test.Assert(t, ri.SuggestedWindow.End.Before(cert.NotAfter), "suggested window ends after cert expiry") + + // Ensure that a correct draft-ietf-acme-ari03 query for a revoked cert + // results in a renewal window in the past. + msa.status = core.OCSPStatusRevoked + req, event = makeGet(certID, renewalInfoPath) + resp = httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, http.StatusOK) + test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") + err = json.Unmarshal(resp.Body.Bytes(), &ri) + test.AssertNotError(t, err, "unmarshalling renewal info") + test.Assert(t, ri.SuggestedWindow.End.Before(wfe.clk.Now()), "suggested window should end in the past") + test.Assert(t, ri.SuggestedWindow.Start.Before(ri.SuggestedWindow.End), "suggested window should start before it ends") + + // Ensure that a draft-ietf-acme-ari03 query for a non-existent serial + // results in a 404. + certID = fmt.Sprintf("%s.%s", + base64.RawURLEncoding.EncodeToString(cert.AuthorityKeyId), + base64.RawURLEncoding.EncodeToString( + big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1)).Bytes(), + ), + ) + req, event = makeGet(certID, renewalInfoPath) + resp = httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, http.StatusNotFound) + test.AssertEquals(t, resp.Header().Get("Retry-After"), "") + + // Ensure that a query with a non-CertID path fails. + req, event = makeGet("lolwutsup", renewalInfoPath) + resp = httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, http.StatusBadRequest) + test.AssertContains(t, resp.Body.String(), "Invalid path") + + // Ensure that a query with no path slug at all bails out early. + req, event = makeGet("", renewalInfoPath) + resp = httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, http.StatusNotFound) + test.AssertContains(t, resp.Body.String(), "Must specify a request path") +} + +// TestIncidentARI tests that requests certs impacted by an ongoing revocation +// incident result in a 200 with a retry-after header and a suggested retry +// window in the past. +func TestIncidentARI(t *testing.T) { + wfe, _, _ := setupWFE(t) + expectSerial := big.NewInt(12345) + expectSerialString := core.SerialToString(big.NewInt(12345)) + wfe.sa = newMockSAWithIncident(wfe.sa, []string{expectSerialString}) + + features.Set(features.Config{ServeRenewalInfo: true}) + defer features.Reset() + + makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { + return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, + &web.RequestEvent{Endpoint: endpoint, Extra: map[string]interface{}{}} + } + + var issuer issuance.NameID + for k := range wfe.issuerCertificates { + // Grab the first known issuer. + issuer = k + break + } + certID := fmt.Sprintf("%s.%s", + base64.RawURLEncoding.EncodeToString(wfe.issuerCertificates[issuer].SubjectKeyId), + base64.RawURLEncoding.EncodeToString(expectSerial.Bytes()), + ) + req, event := makeGet(certID, renewalInfoPath) + resp := httptest.NewRecorder() + wfe.RenewalInfo(context.Background(), event, resp, req) + test.AssertEquals(t, resp.Code, 200) + test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") + var ri core.RenewalInfo + err := json.Unmarshal(resp.Body.Bytes(), &ri) + test.AssertNotError(t, err, "unmarshalling renewal info") + // The start of the window should be in the past. + test.AssertEquals(t, ri.SuggestedWindow.Start.Before(wfe.clk.Now()), true) + // The end of the window should be after the start. + test.AssertEquals(t, ri.SuggestedWindow.End.After(ri.SuggestedWindow.Start), true) + // The end of the window should also be in the past. + test.AssertEquals(t, ri.SuggestedWindow.End.Before(wfe.clk.Now()), true) +} + +func TestOldTLSInbound(t *testing.T) { + wfe, _, _ := setupWFE(t) + req := &http.Request{ + URL: &url.URL{Path: "/directory"}, + Method: "GET", + Header: http.Header(map[string][]string{ + http.CanonicalHeaderKey("TLS-Version"): {"TLSv1"}, + }), + } + + responseWriter := httptest.NewRecorder() + wfe.Handler(metrics.NoopRegisterer).ServeHTTP(responseWriter, req) + test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) +} + +func Test_sendError(t *testing.T) { + features.Reset() + wfe, _, _ := setupWFE(t) + testResponse := httptest.NewRecorder() + + testErr := berrors.RateLimitError(0, "test") + wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) + // Ensure a 0 value RetryAfter results in no Retry-After header. + test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "") + // Ensure the Link header isn't populatsed. + test.AssertEquals(t, testResponse.Header().Get("Link"), "") + + testErr = berrors.RateLimitError(time.Millisecond*500, "test") + wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) + // Ensure a 500ms RetryAfter is rounded up to a 1s Retry-After header. + test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "1") + // Ensure the Link header is populated. + test.AssertEquals(t, testResponse.Header().Get("Link"), ";rel=\"help\"") + + // Clear headers for the next test. + testResponse.Header().Del("Retry-After") + testResponse.Header().Del("Link") + + testErr = berrors.RateLimitError(time.Millisecond*499, "test") + wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) + // Ensure a 499ms RetryAfter results in no Retry-After header. + test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "") + // Ensure the Link header isn't populatsed. + test.AssertEquals(t, testResponse.Header().Get("Link"), "") +} + +type mockSA struct { + sapb.StorageAuthorityReadOnlyClient + cert *corepb.Certificate +} + +// GetCertificate returns the inner certificate if it matches the given serial. +func (sa *mockSA) GetCertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { + if req.Serial == sa.cert.Serial { + return sa.cert, nil + } + return nil, berrors.NotFoundError("certificate with serial %q not found", req.Serial) +} + +func (sa *mockSA) ReplacementOrderExists(ctx context.Context, in *sapb.Serial, opts ...grpc.CallOption) (*sapb.Exists, error) { + if in.Serial == sa.cert.Serial { + return &sapb.Exists{Exists: false}, nil + + } + return &sapb.Exists{Exists: true}, nil +} + +func (sa *mockSA) IncidentsForSerial(ctx context.Context, in *sapb.Serial, opts ...grpc.CallOption) (*sapb.Incidents, error) { + return &sapb.Incidents{}, nil +} + +func (sa *mockSA) GetCertificateStatus(ctx context.Context, in *sapb.Serial, opts ...grpc.CallOption) (*corepb.CertificateStatus, error) { + return &corepb.CertificateStatus{Serial: in.Serial, Status: string(core.OCSPStatusGood)}, nil +} + +func TestOrderMatchesReplacement(t *testing.T) { + wfe, _, _ := setupWFE(t) + + expectExpiry := time.Now().AddDate(0, 0, 1) + expectSerial := big.NewInt(1337) + testKey, _ := rsa.GenerateKey(rand.Reader, 1024) + rawCert := x509.Certificate{ + NotAfter: expectExpiry, + DNSNames: []string{"example.com", "example-a.com"}, + SerialNumber: expectSerial, + } + mockDer, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "failed to create test certificate") + + wfe.sa = &mockSA{ + cert: &corepb.Certificate{ + RegistrationID: 1, + Serial: expectSerial.String(), + Der: mockDer, + }, + } + + // Working with a single matching identifier. + err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, expectSerial.String()) + test.AssertNotError(t, err, "failed to check order is replacement") + + // Working with a different matching identifier. + err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example-a.com"}, expectSerial.String()) + test.AssertNotError(t, err, "failed to check order is replacement") + + // No matching identifiers. + err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example-b.com"}, expectSerial.String()) + test.AssertErrorIs(t, err, berrors.Malformed) + + // RegID for predecessor order does not match. + err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 2}, []string{"example.com"}, expectSerial.String()) + test.AssertErrorIs(t, err, berrors.Unauthorized) + + // Predecessor certificate not found. + err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, "1") + test.AssertErrorIs(t, err, berrors.NotFound) +} + +type mockRA struct { + rapb.RegistrationAuthorityClient + expectProfileName string +} + +// NewOrder returns an error if the "" +func (sa *mockRA) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, opts ...grpc.CallOption) (*corepb.Order, error) { + if in.CertificateProfileName != sa.expectProfileName { + return nil, errors.New("not expected profile name") + } + now := time.Now().UTC() + created := now.AddDate(-30, 0, 0) + exp := now.AddDate(30, 0, 0) + return &corepb.Order{ + Id: 123456789, + RegistrationID: 987654321, + Created: timestamppb.New(created), + Expires: timestamppb.New(exp), + Names: []string{"example.com"}, + Status: string(core.StatusValid), + V2Authorizations: []int64{1}, + CertificateSerial: "serial", + Error: nil, + CertificateProfileName: in.CertificateProfileName, + }, nil +} + +func TestNewOrderWithProfile(t *testing.T) { + wfe, _, signer := setupWFE(t) + expectProfileName := "test-profile" + wfe.ra = &mockRA{expectProfileName: expectProfileName} + mux := wfe.Handler(metrics.NoopRegisterer) + wfe.certificateProfileNames = []string{expectProfileName} + + // Test that the newOrder endpoint returns the proper error if an invalid + // profile is specified. + invalidOrderBody := ` + { + "Identifiers": [ + {"type": "dns", "value": "example.com"} + ], + "Profile": "bad-profile" + }` + + responseWriter := httptest.NewRecorder() + r := signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, invalidOrderBody) + mux.ServeHTTP(responseWriter, r) + test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) + var errorResp map[string]interface{} + err := json.Unmarshal(responseWriter.Body.Bytes(), &errorResp) + test.AssertNotError(t, err, "Failed to unmarshal error response") + test.AssertEquals(t, errorResp["type"], "urn:ietf:params:acme:error:malformed") + test.AssertEquals(t, errorResp["detail"], "Invalid certificate profile, \"bad-profile\": not a recognized profile name") + + // Test that the newOrder endpoint returns no error if the valid profile is specified. + validOrderBody := ` + { + "Identifiers": [ + {"type": "dns", "value": "example.com"} + ], + "Profile": "test-profile" + }` + responseWriter = httptest.NewRecorder() + r = signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, validOrderBody) + mux.ServeHTTP(responseWriter, r) + test.AssertEquals(t, responseWriter.Code, http.StatusCreated) + var errorResp1 map[string]interface{} + err = json.Unmarshal(responseWriter.Body.Bytes(), &errorResp1) + test.AssertNotError(t, err, "Failed to unmarshal order response") + test.AssertEquals(t, errorResp1["status"], "valid") + + // Set the acceptable profiles to an empty list, the WFE should no longer accept any profiles. + wfe.certificateProfileNames = []string{} + responseWriter = httptest.NewRecorder() + r = signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, validOrderBody) + mux.ServeHTTP(responseWriter, r) + test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) + var errorResp2 map[string]interface{} + err = json.Unmarshal(responseWriter.Body.Bytes(), &errorResp2) + test.AssertNotError(t, err, "Failed to unmarshal error response") + test.AssertEquals(t, errorResp2["type"], "urn:ietf:params:acme:error:malformed") + test.AssertEquals(t, errorResp2["detail"], "Invalid certificate profile, \"test-profile\": not a recognized profile name") +} + +func makeARICertID(leaf *x509.Certificate) (string, error) { + if leaf == nil { + return "", errors.New("leaf certificate is nil") + } + + // Marshal the Serial Number into DER. + der, err := asn1.Marshal(leaf.SerialNumber) + if err != nil { + return "", err + } + + // Check if the DER encoded bytes are sufficient (at least 3 bytes: tag, + // length, and value). + if len(der) < 3 { + return "", errors.New("invalid DER encoding of serial number") + } + + // Extract only the integer bytes from the DER encoded Serial Number + // Skipping the first 2 bytes (tag and length). The result is base64url + // encoded without padding. + serial := base64.RawURLEncoding.EncodeToString(der[2:]) + + // Convert the Authority Key Identifier to base64url encoding without + // padding. + aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId) + + // Construct the final identifier by concatenating AKI and Serial Number. + return fmt.Sprintf("%s.%s", aki, serial), nil +} + +func TestCountNewOrderWithReplaces(t *testing.T) { + wfe, _, signer := setupWFE(t) + features.Set(features.Config{TrackReplacementCertificatesARI: true}) + + expectExpiry := time.Now().AddDate(0, 0, 1) + var expectAKID []byte + for _, v := range wfe.issuerCertificates { + expectAKID = v.SubjectKeyId + break + } + testKey, _ := rsa.GenerateKey(rand.Reader, 1024) + expectSerial := big.NewInt(1337) + expectCert := &x509.Certificate{ + NotAfter: expectExpiry, + DNSNames: []string{"example.com"}, + SerialNumber: expectSerial, + AuthorityKeyId: expectAKID, + } + expectCertId, err := makeARICertID(expectCert) + test.AssertNotError(t, err, "failed to create test cert id") + expectDer, err := x509.CreateCertificate(rand.Reader, expectCert, expectCert, &testKey.PublicKey, testKey) + test.AssertNotError(t, err, "failed to create test certificate") + + // MockSA that returns the certificate with the expected serial. + wfe.sa = &mockSA{ + cert: &corepb.Certificate{ + RegistrationID: 1, + Serial: core.SerialToString(expectSerial), + Der: expectDer, + }, + } + mux := wfe.Handler(metrics.NoopRegisterer) + responseWriter := httptest.NewRecorder() + + body := fmt.Sprintf(` + { + "Identifiers": [ + {"type": "dns", "value": "example.com"} + ], + "Replaces": %q + }`, expectCertId) + + r := signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, body) + mux.ServeHTTP(responseWriter, r) + test.AssertEquals(t, responseWriter.Code, http.StatusCreated) + test.AssertMetricWithLabelsEquals(t, wfe.stats.ariReplacementOrders, prometheus.Labels{"isReplacement": "true", "limitsExempt": "true"}, 1) +} diff --git a/third-party/github.com/lucasb-eyer/go-colorful/LICENSE b/third-party/github.com/lucasb-eyer/go-colorful/LICENSE new file mode 100644 index 000000000..4e402a00e --- /dev/null +++ b/third-party/github.com/lucasb-eyer/go-colorful/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2013 Lucas Beyer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/mailru/easyjson/LICENSE b/third-party/github.com/mailru/easyjson/LICENSE new file mode 100644 index 000000000..fbff658f7 --- /dev/null +++ b/third-party/github.com/mailru/easyjson/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2016 Mail.Ru Group + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/mattn/go-colorable/LICENSE b/third-party/github.com/mattn/go-colorable/LICENSE new file mode 100644 index 000000000..91b5cef30 --- /dev/null +++ b/third-party/github.com/mattn/go-colorable/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/mattn/go-isatty/LICENSE b/third-party/github.com/mattn/go-isatty/LICENSE new file mode 100644 index 000000000..65dc692b6 --- /dev/null +++ b/third-party/github.com/mattn/go-isatty/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) Yasuhiro MATSUMOTO + +MIT License (Expat) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/mattn/go-runewidth/LICENSE b/third-party/github.com/mattn/go-runewidth/LICENSE new file mode 100644 index 000000000..91b5cef30 --- /dev/null +++ b/third-party/github.com/mattn/go-runewidth/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/mgutz/ansi/LICENSE b/third-party/github.com/mgutz/ansi/LICENSE new file mode 100644 index 000000000..06ce0c3b5 --- /dev/null +++ b/third-party/github.com/mgutz/ansi/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) +Copyright (c) 2013 Mario L. Gutierrez + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/third-party/github.com/microcosm-cc/bluemonday/LICENSE.md b/third-party/github.com/microcosm-cc/bluemonday/LICENSE.md new file mode 100644 index 000000000..f822458ed --- /dev/null +++ b/third-party/github.com/microcosm-cc/bluemonday/LICENSE.md @@ -0,0 +1,28 @@ +Copyright (c) 2014, David Kitchen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the organisation (Microcosm) nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/microsoft/dev-tunnels/go/tunnels/LICENSE b/third-party/github.com/microsoft/dev-tunnels/go/tunnels/LICENSE new file mode 100644 index 000000000..9e841e7a2 --- /dev/null +++ b/third-party/github.com/microsoft/dev-tunnels/go/tunnels/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/third-party/github.com/mitchellh/copystructure/LICENSE b/third-party/github.com/mitchellh/copystructure/LICENSE new file mode 100644 index 000000000..229851590 --- /dev/null +++ b/third-party/github.com/mitchellh/copystructure/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/mitchellh/go-homedir/LICENSE b/third-party/github.com/mitchellh/go-homedir/LICENSE new file mode 100644 index 000000000..f9c841a51 --- /dev/null +++ b/third-party/github.com/mitchellh/go-homedir/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/mitchellh/hashstructure/v2/LICENSE b/third-party/github.com/mitchellh/hashstructure/v2/LICENSE new file mode 100644 index 000000000..a3866a291 --- /dev/null +++ b/third-party/github.com/mitchellh/hashstructure/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/mitchellh/mapstructure/LICENSE b/third-party/github.com/mitchellh/mapstructure/LICENSE new file mode 100644 index 000000000..f9c841a51 --- /dev/null +++ b/third-party/github.com/mitchellh/mapstructure/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/mitchellh/reflectwalk/LICENSE b/third-party/github.com/mitchellh/reflectwalk/LICENSE new file mode 100644 index 000000000..f9c841a51 --- /dev/null +++ b/third-party/github.com/mitchellh/reflectwalk/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/muesli/ansi/LICENSE b/third-party/github.com/muesli/ansi/LICENSE new file mode 100644 index 000000000..bd9cdc6f7 --- /dev/null +++ b/third-party/github.com/muesli/ansi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/muesli/cancelreader/LICENSE b/third-party/github.com/muesli/cancelreader/LICENSE new file mode 100644 index 000000000..4b19b92d5 --- /dev/null +++ b/third-party/github.com/muesli/cancelreader/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/muesli/reflow/LICENSE b/third-party/github.com/muesli/reflow/LICENSE new file mode 100644 index 000000000..8532c45c9 --- /dev/null +++ b/third-party/github.com/muesli/reflow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/muesli/termenv/LICENSE b/third-party/github.com/muesli/termenv/LICENSE new file mode 100644 index 000000000..8532c45c9 --- /dev/null +++ b/third-party/github.com/muesli/termenv/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/muhammadmuzzammil1998/jsonc/LICENSE b/third-party/github.com/muhammadmuzzammil1998/jsonc/LICENSE new file mode 100644 index 000000000..1d48344cc --- /dev/null +++ b/third-party/github.com/muhammadmuzzammil1998/jsonc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Muhammad Muzzammil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/oklog/ulid/LICENSE b/third-party/github.com/oklog/ulid/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/github.com/oklog/ulid/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/opencontainers/go-digest/LICENSE b/third-party/github.com/opencontainers/go-digest/LICENSE new file mode 100644 index 000000000..3ac8ab648 --- /dev/null +++ b/third-party/github.com/opencontainers/go-digest/LICENSE @@ -0,0 +1,192 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2019, 2020 OCI Contributors + Copyright 2016 Docker, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/opencontainers/image-spec/specs-go/LICENSE b/third-party/github.com/opencontainers/image-spec/specs-go/LICENSE new file mode 100644 index 000000000..9fdc20fdb --- /dev/null +++ b/third-party/github.com/opencontainers/image-spec/specs-go/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2016 The Linux Foundation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/opentracing/opentracing-go/LICENSE b/third-party/github.com/opentracing/opentracing-go/LICENSE new file mode 100644 index 000000000..f0027349e --- /dev/null +++ b/third-party/github.com/opentracing/opentracing-go/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 The OpenTracing Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/pelletier/go-toml/v2/LICENSE b/third-party/github.com/pelletier/go-toml/v2/LICENSE new file mode 100644 index 000000000..991e2ae96 --- /dev/null +++ b/third-party/github.com/pelletier/go-toml/v2/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +go-toml v2 +Copyright (c) 2021 - 2023 Thomas Pelletier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/pkg/errors/LICENSE b/third-party/github.com/pkg/errors/LICENSE new file mode 100644 index 000000000..835ba3e75 --- /dev/null +++ b/third-party/github.com/pkg/errors/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2015, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/pmezard/go-difflib/difflib/LICENSE b/third-party/github.com/pmezard/go-difflib/difflib/LICENSE new file mode 100644 index 000000000..c67dad612 --- /dev/null +++ b/third-party/github.com/pmezard/go-difflib/difflib/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2013, Patrick Mezard +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + The names of its contributors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/rivo/tview/LICENSE.txt b/third-party/github.com/rivo/tview/LICENSE.txt new file mode 100644 index 000000000..9d6943073 --- /dev/null +++ b/third-party/github.com/rivo/tview/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Oliver Kuederle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/rivo/uniseg/LICENSE.txt b/third-party/github.com/rivo/uniseg/LICENSE.txt new file mode 100644 index 000000000..5040f1ef8 --- /dev/null +++ b/third-party/github.com/rivo/uniseg/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Oliver Kuederle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/rodaine/table/license b/third-party/github.com/rodaine/table/license new file mode 100644 index 000000000..4a1a5779e --- /dev/null +++ b/third-party/github.com/rodaine/table/license @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2015 Chris Roche (rodaine+github@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/github.com/russross/blackfriday/v2/LICENSE.txt b/third-party/github.com/russross/blackfriday/v2/LICENSE.txt new file mode 100644 index 000000000..2885af360 --- /dev/null +++ b/third-party/github.com/russross/blackfriday/v2/LICENSE.txt @@ -0,0 +1,29 @@ +Blackfriday is distributed under the Simplified BSD License: + +> Copyright © 2011 Russ Ross +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions +> are met: +> +> 1. Redistributions of source code must retain the above copyright +> notice, this list of conditions and the following disclaimer. +> +> 2. Redistributions in binary form must reproduce the above +> copyright notice, this list of conditions and the following +> disclaimer in the documentation and/or other materials provided with +> the distribution. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +> "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +> LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +> FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +> COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +> INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +> LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +> ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +> POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/sagikazarmark/locafero/LICENSE b/third-party/github.com/sagikazarmark/locafero/LICENSE new file mode 100644 index 000000000..a70b0f296 --- /dev/null +++ b/third-party/github.com/sagikazarmark/locafero/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Márk Sági-Kazár + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/sassoftware/relic/lib/LICENSE b/third-party/github.com/sassoftware/relic/lib/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/sassoftware/relic/lib/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/secure-systems-lab/go-securesystemslib/LICENSE b/third-party/github.com/secure-systems-lab/go-securesystemslib/LICENSE new file mode 100644 index 000000000..e51324f9b --- /dev/null +++ b/third-party/github.com/secure-systems-lab/go-securesystemslib/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 NYU Secure Systems Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/shibumi/go-pathspec/LICENSE b/third-party/github.com/shibumi/go-pathspec/LICENSE new file mode 100644 index 000000000..5c304d1a4 --- /dev/null +++ b/third-party/github.com/shibumi/go-pathspec/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/shopspring/decimal/LICENSE b/third-party/github.com/shopspring/decimal/LICENSE new file mode 100644 index 000000000..ad2148aaf --- /dev/null +++ b/third-party/github.com/shopspring/decimal/LICENSE @@ -0,0 +1,45 @@ +The MIT License (MIT) + +Copyright (c) 2015 Spring, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +- Based on https://github.com/oguzbilgic/fpd, which has the following license: +""" +The MIT License (MIT) + +Copyright (c) 2013 Oguz Bilgic + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" diff --git a/third-party/github.com/shurcooL/githubv4/LICENSE b/third-party/github.com/shurcooL/githubv4/LICENSE new file mode 100644 index 000000000..ca4c77642 --- /dev/null +++ b/third-party/github.com/shurcooL/githubv4/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Dmitri Shuralyov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/shurcooL/graphql/LICENSE b/third-party/github.com/shurcooL/graphql/LICENSE new file mode 100644 index 000000000..ca4c77642 --- /dev/null +++ b/third-party/github.com/shurcooL/graphql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Dmitri Shuralyov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/sigstore/protobuf-specs/gen/pb-go/LICENSE b/third-party/github.com/sigstore/protobuf-specs/gen/pb-go/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/sigstore/protobuf-specs/gen/pb-go/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/sigstore/rekor/pkg/LICENSE b/third-party/github.com/sigstore/rekor/pkg/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/sigstore/rekor/pkg/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/sigstore/sigstore-go/pkg/LICENSE b/third-party/github.com/sigstore/sigstore-go/pkg/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/github.com/sigstore/sigstore-go/pkg/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/sigstore/sigstore/pkg/LICENSE b/third-party/github.com/sigstore/sigstore/pkg/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/sigstore/sigstore/pkg/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/sigstore/timestamp-authority/pkg/verification/LICENSE b/third-party/github.com/sigstore/timestamp-authority/pkg/verification/LICENSE new file mode 100644 index 000000000..f49a4e16e --- /dev/null +++ b/third-party/github.com/sigstore/timestamp-authority/pkg/verification/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third-party/github.com/sirupsen/logrus/LICENSE b/third-party/github.com/sirupsen/logrus/LICENSE new file mode 100644 index 000000000..f090cb42f --- /dev/null +++ b/third-party/github.com/sirupsen/logrus/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Simon Eskildsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/sourcegraph/conc/LICENSE b/third-party/github.com/sourcegraph/conc/LICENSE new file mode 100644 index 000000000..1081f4ef4 --- /dev/null +++ b/third-party/github.com/sourcegraph/conc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Sourcegraph + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/spf13/afero/LICENSE.txt b/third-party/github.com/spf13/afero/LICENSE.txt new file mode 100644 index 000000000..298f0e266 --- /dev/null +++ b/third-party/github.com/spf13/afero/LICENSE.txt @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/third-party/github.com/spf13/cast/LICENSE b/third-party/github.com/spf13/cast/LICENSE new file mode 100644 index 000000000..4527efb9c --- /dev/null +++ b/third-party/github.com/spf13/cast/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Steve Francia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third-party/github.com/spf13/cobra/LICENSE.txt b/third-party/github.com/spf13/cobra/LICENSE.txt new file mode 100644 index 000000000..298f0e266 --- /dev/null +++ b/third-party/github.com/spf13/cobra/LICENSE.txt @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/third-party/github.com/spf13/pflag/LICENSE b/third-party/github.com/spf13/pflag/LICENSE new file mode 100644 index 000000000..63ed1cfea --- /dev/null +++ b/third-party/github.com/spf13/pflag/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2012 Alex Ogier. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/spf13/viper/LICENSE b/third-party/github.com/spf13/viper/LICENSE new file mode 100644 index 000000000..4527efb9c --- /dev/null +++ b/third-party/github.com/spf13/viper/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Steve Francia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third-party/github.com/stretchr/objx/LICENSE b/third-party/github.com/stretchr/objx/LICENSE new file mode 100644 index 000000000..44d4d9d5a --- /dev/null +++ b/third-party/github.com/stretchr/objx/LICENSE @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2014 Stretchr, Inc. +Copyright (c) 2017-2018 objx contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/stretchr/testify/LICENSE b/third-party/github.com/stretchr/testify/LICENSE new file mode 100644 index 000000000..4b0421cf9 --- /dev/null +++ b/third-party/github.com/stretchr/testify/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/subosito/gotenv/LICENSE b/third-party/github.com/subosito/gotenv/LICENSE new file mode 100644 index 000000000..f64ccaedc --- /dev/null +++ b/third-party/github.com/subosito/gotenv/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Alif Rachmawadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/github.com/theupdateframework/go-tuf/LICENSE b/third-party/github.com/theupdateframework/go-tuf/LICENSE new file mode 100644 index 000000000..38163dd4b --- /dev/null +++ b/third-party/github.com/theupdateframework/go-tuf/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014-2020 Prime Directive, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Prime Directive, Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/theupdateframework/go-tuf/v2/metadata/LICENSE b/third-party/github.com/theupdateframework/go-tuf/v2/metadata/LICENSE new file mode 100644 index 000000000..85541be2e --- /dev/null +++ b/third-party/github.com/theupdateframework/go-tuf/v2/metadata/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 The Update Framework Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third-party/github.com/theupdateframework/go-tuf/v2/metadata/NOTICE b/third-party/github.com/theupdateframework/go-tuf/v2/metadata/NOTICE new file mode 100644 index 000000000..090052199 --- /dev/null +++ b/third-party/github.com/theupdateframework/go-tuf/v2/metadata/NOTICE @@ -0,0 +1,9 @@ +Copyright 2024 The Update Framework Authors + +Apache 2.0 License +Copyright 2024 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (/). + +SPDX-License-Identifier: Apache-2.0 diff --git a/third-party/github.com/thlib/go-timezone-local/tzlocal/LICENSE b/third-party/github.com/thlib/go-timezone-local/tzlocal/LICENSE new file mode 100644 index 000000000..fdddb29aa --- /dev/null +++ b/third-party/github.com/thlib/go-timezone-local/tzlocal/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/third-party/github.com/titanous/rocacheck/LICENSE b/third-party/github.com/titanous/rocacheck/LICENSE new file mode 100644 index 000000000..7bdce481f --- /dev/null +++ b/third-party/github.com/titanous/rocacheck/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2017, Jonathan Rudenberg +Copyright (c) 2017, CRoCS, EnigmaBridge Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/transparency-dev/merkle/LICENSE b/third-party/github.com/transparency-dev/merkle/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/github.com/transparency-dev/merkle/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/github.com/vbatts/tar-split/archive/tar/LICENSE b/third-party/github.com/vbatts/tar-split/archive/tar/LICENSE new file mode 100644 index 000000000..ca03685b1 --- /dev/null +++ b/third-party/github.com/vbatts/tar-split/archive/tar/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2015 Vincent Batts, Raleigh, NC, USA + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/xo/terminfo/LICENSE b/third-party/github.com/xo/terminfo/LICENSE new file mode 100644 index 000000000..197dadb12 --- /dev/null +++ b/third-party/github.com/xo/terminfo/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Anmol Sethi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/yuin/goldmark-emoji/LICENSE b/third-party/github.com/yuin/goldmark-emoji/LICENSE new file mode 100644 index 000000000..829d18143 --- /dev/null +++ b/third-party/github.com/yuin/goldmark-emoji/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Yusuke Inuzuka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/yuin/goldmark/LICENSE b/third-party/github.com/yuin/goldmark/LICENSE new file mode 100644 index 000000000..dc5b2a690 --- /dev/null +++ b/third-party/github.com/yuin/goldmark/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Yusuke Inuzuka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/zalando/go-keyring/LICENSE b/third-party/github.com/zalando/go-keyring/LICENSE new file mode 100644 index 000000000..1c494f92f --- /dev/null +++ b/third-party/github.com/zalando/go-keyring/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Zalando SE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/go.mongodb.org/mongo-driver/LICENSE b/third-party/go.mongodb.org/mongo-driver/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/go.mongodb.org/mongo-driver/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/go.opentelemetry.io/auto/sdk/LICENSE b/third-party/go.opentelemetry.io/auto/sdk/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/go.opentelemetry.io/auto/sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/go.opentelemetry.io/otel/LICENSE b/third-party/go.opentelemetry.io/otel/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/go.opentelemetry.io/otel/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/go.opentelemetry.io/otel/metric/LICENSE b/third-party/go.opentelemetry.io/otel/metric/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/go.opentelemetry.io/otel/metric/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/go.opentelemetry.io/otel/trace/LICENSE b/third-party/go.opentelemetry.io/otel/trace/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/third-party/go.opentelemetry.io/otel/trace/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/go.uber.org/multierr/LICENSE.txt b/third-party/go.uber.org/multierr/LICENSE.txt new file mode 100644 index 000000000..413e30f7c --- /dev/null +++ b/third-party/go.uber.org/multierr/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2017-2021 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/go.uber.org/zap/LICENSE b/third-party/go.uber.org/zap/LICENSE new file mode 100644 index 000000000..6652bed45 --- /dev/null +++ b/third-party/go.uber.org/zap/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016-2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third-party/golang.org/x/crypto/LICENSE b/third-party/golang.org/x/crypto/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/crypto/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/exp/LICENSE b/third-party/golang.org/x/exp/LICENSE new file mode 100644 index 000000000..6a66aea5e --- /dev/null +++ b/third-party/golang.org/x/exp/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/mod/LICENSE b/third-party/golang.org/x/mod/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/mod/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/net/LICENSE b/third-party/golang.org/x/net/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/net/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/sync/errgroup/LICENSE b/third-party/golang.org/x/sync/errgroup/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/sync/errgroup/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/sys/LICENSE b/third-party/golang.org/x/sys/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/sys/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/term/LICENSE b/third-party/golang.org/x/term/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/term/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/golang.org/x/text/LICENSE b/third-party/golang.org/x/text/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/text/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/google.golang.org/genproto/googleapis/api/LICENSE b/third-party/google.golang.org/genproto/googleapis/api/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/google.golang.org/genproto/googleapis/api/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/google.golang.org/genproto/googleapis/rpc/status/LICENSE b/third-party/google.golang.org/genproto/googleapis/rpc/status/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/google.golang.org/genproto/googleapis/rpc/status/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/google.golang.org/grpc/LICENSE b/third-party/google.golang.org/grpc/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/third-party/google.golang.org/grpc/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/google.golang.org/grpc/NOTICE.txt b/third-party/google.golang.org/grpc/NOTICE.txt new file mode 100644 index 000000000..530197749 --- /dev/null +++ b/third-party/google.golang.org/grpc/NOTICE.txt @@ -0,0 +1,13 @@ +Copyright 2014 gRPC authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/google.golang.org/protobuf/LICENSE b/third-party/google.golang.org/protobuf/LICENSE new file mode 100644 index 000000000..49ea0f928 --- /dev/null +++ b/third-party/google.golang.org/protobuf/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/gopkg.in/yaml.v3/LICENSE b/third-party/gopkg.in/yaml.v3/LICENSE new file mode 100644 index 000000000..2683e4bb1 --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/LICENSE @@ -0,0 +1,50 @@ + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/gopkg.in/yaml.v3/NOTICE b/third-party/gopkg.in/yaml.v3/NOTICE new file mode 100644 index 000000000..866d74a7a --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/NOTICE @@ -0,0 +1,13 @@ +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/k8s.io/klog/v2/LICENSE b/third-party/k8s.io/klog/v2/LICENSE new file mode 100644 index 000000000..37ec93a14 --- /dev/null +++ b/third-party/k8s.io/klog/v2/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.