diff --git a/.github/ISSUE_TEMPLATE/add_issuer.yml b/.github/ISSUE_TEMPLATE/add_issuer.yml deleted file mode 100644 index be3a42f..0000000 --- a/.github/ISSUE_TEMPLATE/add_issuer.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Add Issuer -description: Use this template for request a new issuer be added to one or more of Let's Encrypt's Certificate Transparency logs -title: "[Add Issuer]: " -labels: ["triage"] -assignees: - - jcjones - - aarongable - - pgporada - -body: - - type: textarea - id: root-inclusion-url - attributes: - label: Mozilla Bugzilla Root Inclusion URL - description: Provide the URL to your submission to the [CA Certificate Root Program component](https://bugzilla.mozilla.org/show_bug.cgi?id=647959). Only certificate authorities that are progressing toward being included in the Mozilla Root Program will be accepted for Let's Encrypt's production log(s). Please also provide a relevant CCADB URL for your publicly trusted root(s). - placeholder: https:// - - - type: textarea - id: ca-website-url - attributes: - label: CA CP/CPS Website URL - description: Provide the website you use for policy information for your CA, such as your CP, CPS, and test certificates. - placeholder: https:// - - - type: checkboxes - id: logs - attributes: - label: CT Logs - description: Which CT logs are you requesting to submit to? - options: - - label: Oak (production) - - label: Sapling (testing) - validations: - required: true - - - type: textarea - id: rootcert - attributes: - label: Issuer Certificate - description: Paste in each of the root CA certificates to be considered here, in PEM format. - placeholder: | - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - validations: - required: true - - - type: checkboxes - id: ctpolicy - attributes: - label: CT Policy - description: Are you subscribed to the [Certificate Transparency Policy Google Group](https://groups.google.com/a/chromium.org/g/ct-policy) for Updates? - options: - - label: We are subscribed to the [Certificate Transparency Policy Google Group](https://groups.google.com/a/chromium.org/g/ct-policy) - required: True diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/PULL_REQUEST_TEMPLATE/root_inclusion.md b/.github/PULL_REQUEST_TEMPLATE/root_inclusion.md new file mode 100644 index 0000000..02722f3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/root_inclusion.md @@ -0,0 +1,11 @@ +### Checklist + +_NOTE: Let's Encrypt no longer accepts root inclusion requests for our +production CT logs. Instead, we keep our logs up-to-date with the CCADB. Please +only create pull requests for __testing__ roots._ + +- [ ] The roots added in this change are __testing-only__. +- [ ] I added my root certificate(s) to this PR using `./add-roots.sh roots/testing ` + +CI will automatically build and validate the accepted-roots bundles from your +change. A maintainer will review once checks pass. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d74786..1b3120e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ name: Linting -on: [push] +on: [push, pull_request] + +permissions: + contents: read jobs: @@ -8,9 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python 3.14 - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: 3.14 @@ -20,6 +25,7 @@ jobs: pip install --user pylint==4.0.8 pip install --user black==26.5.1 pip install --user flake8==7.3.0 + pip install --user zizmor==1.30.1 - name: Analysing the code with pylint run: | @@ -33,4 +39,30 @@ jobs: run: | python -m flake8 . + - name: Checking workflows with zizmor + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + export PATH="$HOME/.local/bin:$PATH" + zizmor .github/workflows + + validate-bundles: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: 3.14 + + - name: Build bundles + run: | + ./build_bundle.py -o bundle-production.pem + ./build_bundle.py --testing -o bundle-testing.pem + + - name: Validate bundles + run: ./validate-bundles.sh bundle-production.pem bundle-testing.pem diff --git a/.github/workflows/publish-bundles.yml b/.github/workflows/publish-bundles.yml new file mode 100644 index 0000000..b69137e --- /dev/null +++ b/.github/workflows/publish-bundles.yml @@ -0,0 +1,59 @@ +name: Publish accepted-roots bundles + +on: + push: + branches: [main] + paths: + - 'roots/**' + - 'build_bundle.py' + workflow_dispatch: {} + +concurrency: + group: publish-bundles + cancel-in-progress: false + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: 3.14 + + - name: Build bundles + run: | + ./build_bundle.py -o bundle-production.pem + ./build_bundle.py --testing -o bundle-testing.pem + + - name: Validate bundles + run: ./validate-bundles.sh bundle-production.pem bundle-testing.pem + + - name: Generate checksums + run: sha256sum bundle-production.pem bundle-testing.pem > SHA256SUMS + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + TAG="bundles-$(date -u +%Y.%m.%d)-$(git rev-parse --short HEAD)" + + if gh release view "${TAG}" > /dev/null 2>&1; then + echo "::error::Release ${TAG} already exists — delete it manually first if you intend to republish this commit." + exit 1 + fi + + gh release create "${TAG}" \ + bundle-production.pem bundle-testing.pem SHA256SUMS \ + --title "${TAG}" \ + --notes "Accepted roots bundle built from commit ${GITHUB_SHA}." diff --git a/README.md b/README.md index 08531b2..3acc72f 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Next, fork this GitHub repository and add your root certificate(s) to the store: ``` Create a [new pull -request](https://github.com/letsencrypt/ct-log-metadata/compare) and fill out -the provided template. All communication will be performed via responses to your -PR. +request](https://github.com/letsencrypt/ct-log-metadata/compare?template=root_inclusion.md) +and fill out the provided template. All communication will be performed via +responses to your PR. Upon approval, Let's Encrypt staff will merge your change, which publishes new accepted-roots bundles (see below). Rolling a new bundle out to our running CT @@ -39,3 +39,30 @@ mkdir /tmp/downloaded-roots/ The `build_bundle.py` script is used in CI by the release build process. You shouldn't need to use it directly for submitting a new root. +# Accepted-roots bundles + +Every merge to `main` that touches `roots/` or `build_bundle.py` triggers a CI +job that builds two bundles and publishes them as +[GitHub Release](https://github.com/letsencrypt/ct-log-metadata/releases) +assets, alongside a `SHA256SUMS` file: + +* `bundle-production.pem` — roots trusted by our production logs (`roots/common/` only). +* `bundle-testing.pem` — roots trusted by our testing logs (`roots/common/` + `roots/testing/`). + +The current bundles are always available at stable URLs, with no +authentication required: + +``` +https://github.com/letsencrypt/ct-log-metadata/releases/latest/download/bundle-production.pem +https://github.com/letsencrypt/ct-log-metadata/releases/latest/download/bundle-testing.pem +https://github.com/letsencrypt/ct-log-metadata/releases/latest/download/SHA256SUMS +``` + +Every release is also kept permanently under its own tag +(`bundles-YYYY.MM.DD-`), so a specific historical bundle can be +retrieved by substituting that tag for `latest` in the URLs above. + +**Fetching a bundle and verifying it against `SHA256SUMS`?** Resolve +`/releases/latest` to a concrete tag once, then download both files from that +same tag — don't fetch them as two separate `latest` requests, since a new +release could land in between and produce a spurious checksum mismatch. diff --git a/build_bundle.py b/build_bundle.py index c7b6750..385e5d4 100755 --- a/build_bundle.py +++ b/build_bundle.py @@ -2,6 +2,7 @@ import argparse import logging +import sys from ct_meta_py import CA @@ -69,7 +70,7 @@ def main(): logging.error( "The common roots dir does not appear to exist: %s", COMMON_ROOTS_DIR ) - return + sys.exit(1) bundler = RootsBundler() bundler.addRootsFrom(COMMON_ROOTS_DIR) @@ -80,7 +81,7 @@ def main(): "The testing roots dir does not appear to exist: %s", TESTING_ROOTS_DIR, ) - return + sys.exit(1) bundler.addRootsFrom(TESTING_ROOTS_DIR) bundler.write(args.output) diff --git a/validate-bundles.sh b/validate-bundles.sh new file mode 100755 index 0000000..2c13165 --- /dev/null +++ b/validate-bundles.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -euo pipefail + +function usage() { + echo -e "USAGE: + ./$(basename "${0}") + +Validates that both bundles are non-empty and contain only well-formed +certificates, and that every certificate in is also +present in . + " +} + +function validate_bundle() { + local bundle="${1}" + local prefix="${2}" + + if [ ! -s "${bundle}" ]; then + echo "::error::${bundle} is empty or missing" >&2 + exit 1 + fi + + csplit -s -z -f "${prefix}" -b '%03d.pem' "${bundle}" '/-----BEGIN CERTIFICATE-----/' '{*}' + + for cert in "${prefix}"*.pem; do + if ! openssl x509 -in "${cert}" -noout > /dev/null 2>&1; then + echo "::error::${bundle} contains a block that doesn't parse as a certificate (${cert})" >&2 + exit 1 + fi + done +} + +# Canonical per-cert fingerprints (DER hash), so we compare cert content +# rather than raw PEM text/line-wrapping. +function cert_fingerprints() { + local prefix="${1}" + for cert in "${prefix}"*.pem; do + openssl x509 -in "${cert}" -outform DER | sha256sum | cut -d' ' -f1 + done | sort -u +} + +if [ "${#}" -ne 2 ]; then + usage + exit 1 +fi + +PRODUCTION_BUNDLE="${1}" +TESTING_BUNDLE="${2}" + +for bundle in "${PRODUCTION_BUNDLE}" "${TESTING_BUNDLE}"; do + if [ ! -r "${bundle}" ]; then + echo "::error::Couldn't find bundle at '${bundle}'" >&2 + exit 1 + fi +done + +command -v openssl > /dev/null 2>&1 +if [ "${?}" -ne 0 ]; then + echo "::error::Missing openssl binary. Is it in your PATH?" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT + +validate_bundle "${PRODUCTION_BUNDLE}" "${WORKDIR}/prod-" +validate_bundle "${TESTING_BUNDLE}" "${WORKDIR}/test-" + +MISSING="$(comm -23 <(cert_fingerprints "${WORKDIR}/prod-") <(cert_fingerprints "${WORKDIR}/test-"))" +if [ -n "${MISSING}" ]; then + echo "::error::Certificate(s) in ${PRODUCTION_BUNDLE} are missing from ${TESTING_BUNDLE}" >&2 + exit 1 +fi + +echo "OK: '${PRODUCTION_BUNDLE}' and '${TESTING_BUNDLE}' are both valid, and every certificate in '${PRODUCTION_BUNDLE}' is present in '${TESTING_BUNDLE}'."