Compare commits

..

No commits in common. "main" and "release-2025-03-18" have entirely different histories.

1274 changed files with 56899 additions and 84390 deletions

View File

@ -14,8 +14,6 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
cooldown:
default-days: 30
- package-ecosystem: "github-actions"
directory: "/"
schedule:

View File

@ -36,7 +36,7 @@ jobs:
matrix:
# Add additional docker image tags here and all tests will be run with the additional image.
BOULDER_TOOLS_TAG:
- go1.24.4_2025-06-06
- go1.24.1_2025-03-10
# 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.
@ -71,7 +71,7 @@ jobs:
- name: Docker Login
# You may pin to the exact commit or the version.
# uses: docker/login-action@f3364599c6aa293cdc2b8391b1b56d0c30e45c8a
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
# Username used to log against the Docker registry
username: ${{ secrets.DOCKER_USERNAME}}

View File

@ -1,53 +0,0 @@
name: Check for IANA special-purpose address registry updates
on:
schedule:
- cron: "20 16 * * *"
workflow_dispatch:
jobs:
check-iana-registries:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout iana/data from main branch
uses: actions/checkout@v4
with:
sparse-checkout: iana/data
# If the branch already exists, this will fail, which will remind us about
# the outstanding PR.
- name: Create an iana-registries-gha branch
run: |
git checkout --track origin/main -b iana-registries-gha
- name: Retrieve the IANA special-purpose address registries
run: |
IANA_IPV4="https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry-1.csv"
IANA_IPV6="https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry-1.csv"
REPO_IPV4="iana/data/iana-ipv4-special-registry-1.csv"
REPO_IPV6="iana/data/iana-ipv6-special-registry-1.csv"
curl --fail --location --show-error --silent --output "${REPO_IPV4}" "${IANA_IPV4}"
curl --fail --location --show-error --silent --output "${REPO_IPV6}" "${IANA_IPV6}"
- name: Create a commit and pull request
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell:
bash
# `git diff --exit-code` returns an error code if there are any changes.
run: |
if ! git diff --exit-code; then
git add iana/data/
git config user.name "Irwin the IANA Bot"
git commit \
--message "Update IANA special-purpose address registries"
git push origin HEAD
gh pr create --fill
fi

View File

@ -1,17 +0,0 @@
# This GitHub Action runs only on pushes to main or a hotfix branch. It can
# be used by tag protection rules to ensure that tags may only be pushed if
# their corresponding commit was first pushed to one of those branches.
name: Merged to main (or hotfix)
on:
push:
branches:
- main
- release-branch-*
jobs:
merged-to-main:
name: Merged to main (or hotfix)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

View File

@ -1,13 +1,13 @@
# Build the Boulder Debian package on tag push, and attach it to a GitHub
# release.
#
# 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.
# 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:
@ -15,8 +15,8 @@ jobs:
fail-fast: false
matrix:
GO_VERSION:
- "1.24.4"
runs-on: ubuntu-24.04
- "1.24.1"
runs-on: ubuntu-20.04
permissions:
contents: write
packages: write
@ -24,16 +24,12 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: '0' # Needed for verify-release-ancestry.sh to see origin/main
- name: Verify release ancestry
run: ./tools/verify-release-ancestry.sh "$GITHUB_SHA"
- name: Build .deb
id: build
env:
GO_VERSION: ${{ matrix.GO_VERSION }}
run: docker run -v $PWD:/boulder -e GO_VERSION=$GO_VERSION -e COMMIT_ID="$(git rev-parse --short=8 HEAD)" ubuntu:24.04 bash -c 'apt update && apt -y install gnupg2 curl sudo git gcc && cd /boulder/ && ./tools/make-assets.sh'
run: ./tools/make-assets.sh
- name: Compute checksums
id: checksums

View File

@ -16,8 +16,8 @@ jobs:
fail-fast: false
matrix:
GO_VERSION:
- "1.24.4"
runs-on: ubuntu-24.04
- "1.24.1"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
@ -27,7 +27,7 @@ jobs:
id: build
env:
GO_VERSION: ${{ matrix.GO_VERSION }}
run: docker run -v $PWD:/boulder -e GO_VERSION=$GO_VERSION -e COMMIT_ID="$(git rev-parse --short=8 HEAD)" ubuntu:24.04 bash -c 'apt update && apt -y install gnupg2 curl sudo git gcc && cd /boulder/ && ./tools/make-assets.sh'
run: ./tools/make-assets.sh
- name: Compute checksums
id: checksums

View File

@ -1,89 +1,62 @@
version: "2"
linters:
default: none
disable-all: true
enable:
- asciicheck
- bidichk
- errcheck
- gofmt
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nolintlint
- spancheck
- sqlclosecheck
- staticcheck
- typecheck
- unconvert
- unparam
- unused
- wastedassign
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
govet:
disable:
- fieldalignment
- shadow
enable-all: true
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
- G104 # Errors unhandled
- G107 # Potential HTTP request made with variable url
- G201 # SQL string formatting
- G202 # SQL string concatenation
- G204 # Subprocess launched with variable
- G302 # Expect file permissions to be 0600 or less
- G306 # Expect WriteFile permissions to be 0600 or less
- G304 # Potential file inclusion via variable
- 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
nolintlint:
require-explanation: true
require-specific: true
allow-unused: false
staticcheck:
checks:
- all
# TODO: Identify, fix, and remove violations of most of these rules
- -S1029 # Range over the string directly
- -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
- -QF1001 # Could apply De Morgan's law
- -QF1003 # Could use tagged switch
- -QF1004 # Could use strings.Split instead
- -QF1007 # Could merge conditional assignment into variable declaration
- -QF1008 # Could remove embedded field from selector
- -QF1009 # Probably want to use time.Time.Equal
- -QF1012 # Use fmt.Fprintf(...) instead of Write(fmt.Sprintf(...))
exclusions:
presets:
- std-error-handling
formatters:
enable:
- gofmt
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)
nolintlint:
allow-unused: false
require-explanation: true
require-specific: true

View File

@ -6,7 +6,7 @@ VERSION ?= 1.0.0
EPOCH ?= 1
MAINTAINER ?= "Community"
CMDS = admin boulder ceremony ct-test-srv pardot-test-srv chall-test-srv
CMDS = admin boulder ceremony ct-test-srv pardot-test-srv
CMD_BINS = $(addprefix bin/, $(CMDS) )
OBJECTS = $(CMD_BINS)

View File

@ -3,10 +3,10 @@
[![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 subscribers to issue and revoke certificates for the identifiers they
control. Boulder is the software that runs [Let's
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

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc-gen-go v1.34.1
// protoc v3.20.1
// source: akamai.proto
@ -12,7 +12,6 @@ import (
emptypb "google.golang.org/protobuf/types/known/emptypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
@ -23,17 +22,20 @@ const (
)
type PurgeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Urls []string `protobuf:"bytes,1,rep,name=urls,proto3" json:"urls,omitempty"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_akamai_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_akamai_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PurgeRequest) String() string {
@ -44,7 +46,7 @@ func (*PurgeRequest) ProtoMessage() {}
func (x *PurgeRequest) ProtoReflect() protoreflect.Message {
mi := &file_akamai_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -68,7 +70,7 @@ func (x *PurgeRequest) GetUrls() []string {
var File_akamai_proto protoreflect.FileDescriptor
var file_akamai_proto_rawDesc = string([]byte{
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,
@ -83,22 +85,22 @@ var file_akamai_proto_rawDesc = string([]byte{
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 []byte
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(unsafe.Slice(unsafe.StringData(file_akamai_proto_rawDesc), len(file_akamai_proto_rawDesc)))
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 = []any{
var file_akamai_proto_goTypes = []interface{}{
(*PurgeRequest)(nil), // 0: akamai.PurgeRequest
(*emptypb.Empty)(nil), // 1: google.protobuf.Empty
}
@ -117,11 +119,25 @@ 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: unsafe.Slice(unsafe.StringData(file_akamai_proto_rawDesc), len(file_akamai_proto_rawDesc)),
RawDescriptor: file_akamai_proto_rawDesc,
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
@ -132,6 +148,7 @@ func file_akamai_proto_init() {
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
}

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.3.0
// - protoc v3.20.1
// source: akamai.proto
@ -50,24 +50,20 @@ func (c *akamaiPurgerClient) Purge(ctx context.Context, in *PurgeRequest, opts .
// AkamaiPurgerServer is the server API for AkamaiPurger service.
// All implementations must embed UnimplementedAkamaiPurgerServer
// for forward compatibility.
// for forward compatibility
type AkamaiPurgerServer interface {
Purge(context.Context, *PurgeRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedAkamaiPurgerServer()
}
// UnimplementedAkamaiPurgerServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAkamaiPurgerServer struct{}
// 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() {}
func (UnimplementedAkamaiPurgerServer) testEmbeddedByValue() {}
// 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
@ -77,13 +73,6 @@ type UnsafeAkamaiPurgerServer interface {
}
func RegisterAkamaiPurgerServer(s grpc.ServiceRegistrar, srv AkamaiPurgerServer) {
// If the following call pancis, it indicates UnimplementedAkamaiPurgerServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AkamaiPurger_ServiceDesc, srv)
}

View File

@ -9,7 +9,6 @@ import (
"io"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
"strconv"
@ -21,11 +20,88 @@ import (
"github.com/miekg/dns"
"github.com/prometheus/client_golang/prometheus"
"github.com/letsencrypt/boulder/iana"
"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 (
// TODO(#8040): Rebuild these as structs that track the structure of IANA's
// CSV files, for better automated handling.
//
// Private CIDRs to ignore. Sourced from:
// https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
privateV4Networks = []net.IPNet{
parseCidr("0.0.0.0/8", "RFC 791, Section 3.2: This network"),
parseCidr("0.0.0.0/32", "RFC 1122, Section 3.2.1.3: This host on this network"),
parseCidr("10.0.0.0/8", "RFC 1918: Private-Use"),
parseCidr("100.64.0.0/10", "RFC 6598: Shared Address Space"),
parseCidr("127.0.0.0/8", "RFC 1122, Section 3.2.1.3: Loopback"),
parseCidr("169.254.0.0/16", "RFC 3927: Link Local"),
parseCidr("172.16.0.0/12", "RFC 1918: Private-Use"),
parseCidr("192.0.0.0/24", "RFC 6890, Section 2.1: IETF Protocol Assignments"),
parseCidr("192.0.0.0/29", "RFC 7335: IPv4 Service Continuity Prefix"),
parseCidr("192.0.0.8/32", "RFC 7600: IPv4 dummy address"),
parseCidr("192.0.0.9/32", "RFC 7723: Port Control Protocol Anycast"),
parseCidr("192.0.0.10/32", "RFC 8155: Traversal Using Relays around NAT Anycast"),
parseCidr("192.0.0.170/32", "RFC 8880 & RFC 7050, Section 2.2: NAT64/DNS64 Discovery"),
parseCidr("192.0.0.171/32", "RFC 8880 & RFC 7050, Section 2.2: NAT64/DNS64 Discovery"),
parseCidr("192.0.2.0/24", "RFC 5737: Documentation (TEST-NET-1)"),
parseCidr("192.31.196.0/24", "RFC 7535: AS112-v4"),
parseCidr("192.52.193.0/24", "RFC 7450: AMT"),
parseCidr("192.88.99.0/24", "RFC 7526: Deprecated (6to4 Relay Anycast)"),
parseCidr("192.168.0.0/16", "RFC 1918: Private-Use"),
parseCidr("192.175.48.0/24", "RFC 7534: Direct Delegation AS112 Service"),
parseCidr("198.18.0.0/15", "RFC 2544: Benchmarking"),
parseCidr("198.51.100.0/24", "RFC 5737: Documentation (TEST-NET-2)"),
parseCidr("203.0.113.0/24", "RFC 5737: Documentation (TEST-NET-3)"),
parseCidr("240.0.0.0/4", "RFC1112, Section 4: Reserved"),
parseCidr("255.255.255.255/32", "RFC 8190 & RFC 919, Section 7: Limited Broadcast"),
// 224.0.0.0/4 are multicast addresses as per RFC 3171. They are not
// present in the IANA registry.
parseCidr("224.0.0.0/4", "RFC 3171: Multicast Addresses"),
}
// Sourced from:
// https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
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("64:ff9b::/96", "RFC 6052: IPv4-IPv6 Translat."),
parseCidr("64:ff9b:1::/48", "RFC 8215: IPv4-IPv6 Translat."),
parseCidr("100::/64", "RFC 6666: Discard-Only Address Block"),
parseCidr("2001::/23", "RFC 2928: IETF Protocol Assignments"),
parseCidr("2001::/32", "RFC 4380 & RFC 8190: TEREDO"),
parseCidr("2001:1::1/128", "RFC 7723: Port Control Protocol Anycast"),
parseCidr("2001:1::2/128", "RFC 8155: Traversal Using Relays around NAT Anycast"),
parseCidr("2001:1::3/128", "RFC-ietf-dnssd-srp-25: DNS-SD Service Registration Protocol Anycast"),
parseCidr("2001:2::/48", "RFC 5180 & RFC Errata 1752: Benchmarking"),
parseCidr("2001:3::/32", "RFC 7450: AMT"),
parseCidr("2001:4:112::/48", "RFC 7535: AS112-v6"),
parseCidr("2001:10::/28", "RFC 4843: Deprecated (previously ORCHID)"),
parseCidr("2001:20::/28", "RFC 7343: ORCHIDv2"),
parseCidr("2001:30::/28", "RFC 9374: Drone Remote ID Protocol Entity Tags (DETs) Prefix"),
parseCidr("2001:db8::/32", "RFC 3849: Documentation"),
parseCidr("2002::/16", "RFC 3056: 6to4"),
parseCidr("2620:4f:8000::/48", "RFC 7534: Direct Delegation AS112 Service"),
parseCidr("3fff::/20", "RFC 9637: Documentation"),
parseCidr("5f00::/16", "RFC 9602: Segment Routing (SRv6) SIDs"),
parseCidr("fc00::/7", "RFC 4193 & RFC 8190: Unique-Local"),
parseCidr("fe80::/10", "RFC 4291: Link-Local Unicast"),
// ff00::/8 are multicast addresses as per RFC 4291, Sections 2.4 & 2.7.
// They are not present in the IANA registry.
parseCidr("ff00::/8", "RFC 4291: Multicast Addresses"),
}
)
// 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
@ -35,7 +111,7 @@ 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) ([]netip.Addr, ResolverAddrs, error)
LookupHost(context.Context, string) ([]net.IP, ResolverAddrs, error)
LookupCAA(context.Context, string) ([]*dns.CAA, string, ResolverAddrs, error)
}
@ -71,28 +147,33 @@ func New(
stats prometheus.Registerer,
clk clock.Clock,
maxTries int,
userAgent string,
log blog.Logger,
tlsConfig *tls.Config,
) Client {
var client exchanger
// 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,
},
userAgent: userAgent,
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(
@ -149,11 +230,10 @@ func NewTest(
stats prometheus.Registerer,
clk clock.Clock,
maxTries int,
userAgent string,
log blog.Logger,
tlsConfig *tls.Config,
) Client {
resolver := New(readTimeout, servers, stats, clk, maxTries, userAgent, log, tlsConfig)
resolver := New(readTimeout, servers, stats, clk, maxTries, log, tlsConfig)
resolver.(*impl).allowRestrictedAddresses = true
return resolver
}
@ -273,10 +353,17 @@ func (dnsClient *impl) exchangeOne(ctx context.Context, hostname string, qtype u
case r := <-ch:
if r.err != nil {
var isRetryable bool
// 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()
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++
@ -301,6 +388,7 @@ func (dnsClient *impl) exchangeOne(ctx context.Context, hostname string, qtype u
return
}
}
}
// isTLD returns a simplified view of whether something is a TLD: does it have
@ -342,6 +430,24 @@ func (dnsClient *impl) LookupTXT(ctx context.Context, hostname string) ([]string
return txt, ResolverAddrs{resolver}, err
}
func isPrivateV4(ip net.IP) bool {
for _, net := range privateV4Networks {
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 {
@ -366,7 +472,7 @@ func (dnsClient *impl) lookupIP(ctx context.Context, hostname string, ipType uin
// 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) ([]netip.Addr, ResolverAddrs, error) {
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
@ -389,16 +495,13 @@ func (dnsClient *impl) LookupHost(ctx context.Context, hostname string) ([]netip
return a == ""
})
var addrsA []netip.Addr
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 {
netIP, ok := netip.AddrFromSlice(a.A)
if ok && (iana.IsReservedAddr(netIP) == nil || dnsClient.allowRestrictedAddresses) {
addrsA = append(addrsA, netIP)
}
if ok && a.A.To4() != nil && (!isPrivateV4(a.A) || dnsClient.allowRestrictedAddresses) {
addrsA = append(addrsA, a.A)
}
}
}
@ -407,16 +510,13 @@ func (dnsClient *impl) LookupHost(ctx context.Context, hostname string) ([]netip
}
}
var addrsAAAA []netip.Addr
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 {
netIP, ok := netip.AddrFromSlice(aaaa.AAAA)
if ok && (iana.IsReservedAddr(netIP) == nil || dnsClient.allowRestrictedAddresses) {
addrsAAAA = append(addrsAAAA, netIP)
}
if ok && aaaa.AAAA.To16() != nil && (!isPrivateV6(aaaa.AAAA) || dnsClient.allowRestrictedAddresses) {
addrsAAAA = append(addrsAAAA, aaaa.AAAA)
}
}
}
@ -536,9 +636,8 @@ func logDNSError(
}
type dohExchanger struct {
clk clock.Clock
hc http.Client
userAgent string
clk clock.Clock
hc http.Client
}
// Exchange sends a DoH query to the provided DoH server and returns the response.
@ -556,9 +655,6 @@ func (d *dohExchanger) Exchange(query *dns.Msg, server string) (*dns.Msg, time.D
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
if len(d.userAgent) > 0 {
req.Header.Set("User-Agent", d.userAgent)
}
start := d.clk.Now()
resp, err := d.hc.Do(req)
@ -584,3 +680,20 @@ func (d *dohExchanger) Exchange(query *dns.Msg, server string) (*dns.Msg, time.D
return response, d.clk.Since(start), nil
}
// IsReservedIP reports whether an IP address is part of a reserved range.
//
// TODO(#7311): Once we're fully ready to issue for IP address identifiers, dev
// environments should have a way to bypass this check for their own Private-Use
// IP addresses. Maybe plumb the DNSAllowLoopbackAddresses feature flag through
// to here.
//
// TODO(#8040): Move this and its dependencies into the policy package. As part
// of this, consider changing it to return an error and/or the description of
// the reserved network.
func IsReservedIP(ip net.IP) bool {
if ip.To4() == nil {
return isPrivateV6(ip)
}
return isPrivateV4(ip)
}

View File

@ -2,15 +2,10 @@ package bdns
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"regexp"
@ -24,6 +19,7 @@ import (
"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"
@ -31,30 +27,7 @@ import (
const dnsLoopbackAddr = "127.0.0.1:4053"
func mockDNSQuery(w http.ResponseWriter, httpReq *http.Request) {
if httpReq.Header.Get("Content-Type") != "application/dns-message" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "client didn't send Content-Type: application/dns-message")
}
if httpReq.Header.Get("Accept") != "application/dns-message" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "client didn't accept Content-Type: application/dns-message")
}
requestBody, err := io.ReadAll(httpReq.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "reading body: %s", err)
}
httpReq.Body.Close()
r := new(dns.Msg)
err = r.Unpack(requestBody)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "unpacking request: %s", err)
}
func mockDNSQuery(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false
@ -84,19 +57,19 @@ func mockDNSQuery(w http.ResponseWriter, httpReq *http.Request) {
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("2602:80a:6000:abad:cafe::1")
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("2602:80a:6000:abad:cafe::1")
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("2602:80a:6000:abad:cafe::1")
record.AAAA = net.ParseIP("::1")
appendAnswer(record)
}
if q.Name == "v6error.letsencrypt.org." {
@ -112,19 +85,19 @@ func mockDNSQuery(w http.ResponseWriter, httpReq *http.Request) {
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("64.112.117.1")
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("64.112.117.1")
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("64.112.117.1")
record.A = net.ParseIP("127.0.0.1")
appendAnswer(record)
}
if q.Name == "v4error.letsencrypt.org." {
@ -200,37 +173,45 @@ func mockDNSQuery(w http.ResponseWriter, httpReq *http.Request) {
}
}
body, err := m.Pack()
if err != nil {
fmt.Fprintf(os.Stderr, "packing reply: %s\n", err)
}
w.Header().Set("Content-Type", "application/dns-message")
_, err = w.Write(body)
err := w.WriteMsg(m)
if err != nil {
panic(err) // running tests, so panic is OK
}
}
func serveLoopResolver(stopChan chan bool) {
m := http.NewServeMux()
m.HandleFunc("/dns-query", mockDNSQuery)
httpServer := &http.Server{
dns.HandleFunc(".", mockDNSQuery)
tcpServer := &dns.Server{
Addr: dnsLoopbackAddr,
Handler: m,
Net: "tcp",
ReadTimeout: time.Second,
WriteTimeout: time.Second,
}
udpServer := &dns.Server{
Addr: dnsLoopbackAddr,
Net: "udp",
ReadTimeout: time.Second,
WriteTimeout: time.Second,
}
go func() {
cert := "../test/certs/ipki/localhost/cert.pem"
key := "../test/certs/ipki/localhost/key.pem"
err := httpServer.ListenAndServeTLS(cert, key)
err := tcpServer.ListenAndServe()
if err != nil {
fmt.Println(err)
}
}()
go func() {
err := udpServer.ListenAndServe()
if err != nil {
fmt.Println(err)
}
}()
go func() {
<-stopChan
err := httpServer.Shutdown(context.Background())
err := tcpServer.Shutdown()
if err != nil {
log.Fatal(err)
}
err = udpServer.Shutdown()
if err != nil {
log.Fatal(err)
}
@ -258,21 +239,7 @@ func pollServer() {
}
}
// tlsConfig is used for the TLS config of client instances that talk to the
// DoH server set up in TestMain.
var tlsConfig *tls.Config
func TestMain(m *testing.M) {
root, err := os.ReadFile("../test/certs/ipki/minica.pem")
if err != nil {
log.Fatal(err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(root)
tlsConfig = &tls.Config{
RootCAs: pool,
}
stop := make(chan bool, 1)
serveLoopResolver(stop)
pollServer()
@ -285,7 +252,7 @@ func TestDNSNoServers(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Hour, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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)
@ -302,7 +269,7 @@ func TestDNSOneServer(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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)
@ -315,7 +282,7 @@ func TestDNSDuplicateServers(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr, dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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)
@ -328,7 +295,7 @@ func TestDNSServFail(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil)
bad := "servfail.com"
_, _, err = obj.LookupTXT(context.Background(), bad)
@ -346,7 +313,7 @@ func TestDNSLookupTXT(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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)
@ -359,12 +326,11 @@ func TestDNSLookupTXT(t *testing.T) {
test.AssertEquals(t, a[0], "abc")
}
// TODO(#8213): Convert this to a table test.
func TestDNSLookupHost(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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)
@ -407,10 +373,10 @@ func TestDNSLookupHost(t *testing.T) {
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 := netip.MustParseAddr("64.112.117.1")
test.Assert(t, ip[0] == expected, "wrong ipv4 address")
expected = netip.MustParseAddr("2602:80a:6000:abad:cafe::1")
test.Assert(t, ip[1] == expected, "wrong ipv6 address")
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"})
@ -419,8 +385,8 @@ func TestDNSLookupHost(t *testing.T) {
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 = netip.MustParseAddr("64.112.117.1")
test.Assert(t, ip[0] == expected, "wrong ipv4 address")
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"})
@ -429,8 +395,8 @@ func TestDNSLookupHost(t *testing.T) {
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 = netip.MustParseAddr("2602:80a:6000:abad:cafe::1")
test.Assert(t, ip[0] == expected, "wrong ipv6 address")
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"})
@ -450,7 +416,7 @@ func TestDNSNXDOMAIN(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
obj := NewTest(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, blog.UseMock(), nil)
hostname := "nxdomain.letsencrypt.org"
_, _, err = obj.LookupHost(context.Background(), hostname)
@ -466,7 +432,7 @@ func TestDNSLookupCAA(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
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")
@ -521,6 +487,37 @@ caa.example.com. 0 IN CAA 1 issue "letsencrypt.org"
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
@ -545,9 +542,10 @@ func (te *testExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, time.Duration
}
func TestRetry(t *testing.T) {
isTempErr := &url.Error{Op: "read", Err: tempError(true)}
nonTempErr := &url.Error{Op: "read", Err: tempError(false)}
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
@ -598,7 +596,7 @@ func TestRetry(t *testing.T) {
isTempErr,
},
},
expected: servFailError,
expected: netError,
expectedCount: 3,
metricsAllRetries: 1,
},
@ -651,7 +649,7 @@ func TestRetry(t *testing.T) {
isTempErr,
},
},
expected: servFailError,
expected: netError,
expectedCount: 3,
metricsAllRetries: 1,
},
@ -665,7 +663,7 @@ func TestRetry(t *testing.T) {
nonTempErr,
},
},
expected: servFailError,
expected: netError,
expectedCount: 2,
},
}
@ -675,7 +673,7 @@ func TestRetry(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), tc.maxTries, "", blog.UseMock(), tlsConfig)
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")
@ -706,7 +704,7 @@ func TestRetry(t *testing.T) {
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
test.AssertNotError(t, err, "Got error creating StaticProvider")
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, "", blog.UseMock(), tlsConfig)
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())
@ -785,7 +783,7 @@ func (e *rotateFailureExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, time.
// If its a broken server, return a retryable error
if e.brokenAddresses[a] {
isTempErr := &url.Error{Op: "read", Err: tempError(true)}
isTempErr := &net.OpError{Op: "read", Err: tempError(true)}
return nil, 2 * time.Millisecond, isTempErr
}
@ -807,9 +805,10 @@ func TestRotateServerOnErr(t *testing.T) {
// working server
staticProvider, err := NewStaticProvider(dnsServers)
test.AssertNotError(t, err, "Got error creating StaticProvider")
fmt.Println(staticProvider.servers)
maxTries := 5
client := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), maxTries, "", blog.UseMock(), tlsConfig)
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
@ -873,10 +872,13 @@ func (dohE *dohAlwaysRetryExchanger) Exchange(m *dns.Msg, a string) (*dns.Msg, t
}
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 := New(time.Second*11, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 0, "", blog.UseMock(), tlsConfig)
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)}}

View File

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net"
"net/netip"
"os"
"github.com/miekg/dns"
@ -68,13 +67,13 @@ func (t timeoutError) Timeout() bool {
}
// LookupHost is a mock
func (mock *MockClient) LookupHost(_ context.Context, hostname string) ([]netip.Addr, ResolverAddrs, error) {
func (mock *MockClient) LookupHost(_ context.Context, hostname string) ([]net.IP, ResolverAddrs, error) {
if hostname == "always.invalid" ||
hostname == "invalid.invalid" {
return []netip.Addr{}, ResolverAddrs{"MockClient"}, nil
return []net.IP{}, ResolverAddrs{"MockClient"}, nil
}
if hostname == "always.timeout" {
return []netip.Addr{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, "always.timeout", makeTimeoutError(), -1, nil}
return []net.IP{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, "always.timeout", makeTimeoutError(), -1, nil}
}
if hostname == "always.error" {
err := &net.OpError{
@ -87,7 +86,7 @@ func (mock *MockClient) LookupHost(_ context.Context, hostname string) ([]netip.
m.AuthenticatedData = true
m.SetEdns0(4096, false)
logDNSError(mock.Log, "mock.server", hostname, m, nil, err)
return []netip.Addr{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, hostname, err, -1, nil}
return []net.IP{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, hostname, err, -1, nil}
}
if hostname == "id.mismatch" {
err := dns.ErrId
@ -101,21 +100,22 @@ func (mock *MockClient) LookupHost(_ context.Context, hostname string) ([]netip.
record.A = net.ParseIP("127.0.0.1")
r.Answer = append(r.Answer, record)
logDNSError(mock.Log, "mock.server", hostname, m, r, err)
return []netip.Addr{}, ResolverAddrs{"MockClient"}, &Error{dns.TypeA, hostname, err, -1, nil}
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 []netip.Addr{
netip.MustParseAddr("::1"),
netip.MustParseAddr("127.0.0.1"),
return []net.IP{
net.ParseIP("::1"),
net.ParseIP("127.0.0.1"),
}, ResolverAddrs{"MockClient"}, nil
}
if hostname == "ipv6.localhost" {
return []netip.Addr{
netip.MustParseAddr("::1"),
return []net.IP{
net.ParseIP("::1"),
}, ResolverAddrs{"MockClient"}, nil
}
return []netip.Addr{netip.MustParseAddr("127.0.0.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.

View File

@ -6,7 +6,6 @@ import (
"fmt"
"math/rand/v2"
"net"
"net/netip"
"strconv"
"sync"
"time"
@ -62,9 +61,10 @@ func validateServerAddress(address string) error {
}
// Ensure the `host` portion of `address` is a valid FQDN or IP address.
_, err = netip.ParseAddr(host)
IPv6 := net.ParseIP(host).To16()
IPv4 := net.ParseIP(host).To4()
FQDN := dns.IsFqdn(dns.Fqdn(host))
if err != nil && !FQDN {
if IPv6 == nil && IPv4 == nil && !FQDN {
return errors.New("host is not an FQDN or IP address")
}
return nil

201
ca/ca.go
View File

@ -32,10 +32,10 @@ import (
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/goodkey"
"github.com/letsencrypt/boulder/identifier"
"github.com/letsencrypt/boulder/issuance"
"github.com/letsencrypt/boulder/linter"
blog "github.com/letsencrypt/boulder/log"
@ -61,6 +61,7 @@ type issuanceEvent struct {
Issuer string
OrderID int64
Profile string
ProfileHash string
Requester int64
Result struct {
Precertificate string `json:",omitempty"`
@ -79,10 +80,20 @@ type issuerMaps struct {
type certProfileWithID struct {
// name is a human readable name used to refer to the certificate profile.
name string
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.
type certProfilesMaps struct {
profileByHash map[[32]byte]*certProfileWithID
profileByName map[string]*certProfileWithID
}
// caMetrics holds various metrics which are shared between caImpl, ocspImpl,
// and crlImpl.
type caMetrics struct {
@ -140,7 +151,7 @@ type certificateAuthorityImpl struct {
sctClient rapb.SCTProviderClient
pa core.PolicyAuthority
issuers issuerMaps
certProfiles map[string]*certProfileWithID
certProfiles certProfilesMaps
// The prefix is prepended to the serial number.
prefix byte
@ -180,27 +191,46 @@ func makeIssuerMaps(issuers []*issuance.Issuer) (issuerMaps, error) {
}
// makeCertificateProfilesMap processes a set of named certificate issuance
// profile configs into a map from name to profile.
func makeCertificateProfilesMap(profiles map[string]*issuance.ProfileConfig) (map[string]*certProfileWithID, error) {
// 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.
//
// 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(profiles map[string]*issuance.ProfileConfigNew) (certProfilesMaps, error) {
if len(profiles) <= 0 {
return nil, fmt.Errorf("must pass at least one certificate profile")
return certProfilesMaps{}, fmt.Errorf("must pass at least one certificate profile")
}
profilesByName := make(map[string]*certProfileWithID, len(profiles))
profilesByHash := make(map[[32]byte]*certProfileWithID, len(profiles))
for name, profileConfig := range profiles {
profile, err := issuance.NewProfile(profileConfig)
if err != nil {
return nil, err
return certProfilesMaps{}, err
}
profilesByName[name] = &certProfileWithID{
hash := profile.Hash()
withID := certProfileWithID{
name: name,
hash: hash,
profile: profile,
}
profilesByName[name] = &withID
_, found := profilesByHash[hash]
if found {
return certProfilesMaps{}, fmt.Errorf("duplicate certificate profile hash %d", hash)
}
profilesByHash[hash] = &withID
}
return profilesByName, nil
return certProfilesMaps{profilesByHash, profilesByName}, nil
}
// NewCertificateAuthorityImpl creates a CA instance that can sign certificates
@ -211,7 +241,7 @@ func NewCertificateAuthorityImpl(
sctService rapb.SCTProviderClient,
pa core.PolicyAuthority,
boulderIssuers []*issuance.Issuer,
certificateProfiles map[string]*issuance.ProfileConfig,
certificateProfiles map[string]*issuance.ProfileConfigNew,
serialPrefix byte,
maxNames int,
keyPolicy goodkey.KeyPolicy,
@ -265,18 +295,31 @@ var ocspStatusToCode = map[string]int{
"unknown": ocsp.Unknown,
}
// issuePrecertificate is the first step in the [issuance cycle]. It allocates and stores a serial number,
// 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.
//
// Returns precertificate DER.
// 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, certProfile *certProfileWithID, issueReq *capb.IssueCertificateRequest) ([]byte, error) {
func (ca *certificateAuthorityImpl) IssuePrecertificate(ctx context.Context, issueReq *capb.IssueCertificateRequest) (*capb.IssuePrecertificateResponse, error) {
// issueReq.orderID may be zero, for ACMEv1 requests.
if core.IsAnyNilOrZero(issueReq, issueReq.Csr, issueReq.RegistrationID, issueReq.CertProfileName) {
return nil, berrors.InternalServerError("Incomplete issue certificate request")
}
// The CA must check if it is capable of issuing for the given certificate
// profile name. We check the name here, because the RA is not able to
// precompute profile hashes. All issuance requests must come with a profile
// name, and the RA handles selecting the default.
certProfile, ok := ca.certProfiles.profileByName[issueReq.CertProfileName]
if !ok {
return nil, fmt.Errorf("the CA is incapable of using a profile named %s", issueReq.CertProfileName)
}
serialBigInt, err := ca.generateSerialNumber()
if err != nil {
return nil, err
@ -296,7 +339,7 @@ func (ca *certificateAuthorityImpl) issuePrecertificate(ctx context.Context, cer
return nil, err
}
precertDER, _, err := ca.issuePrecertificateInner(ctx, issueReq, certProfile, serialBigInt, notBefore, notAfter)
precertDER, cpwid, err := ca.issuePrecertificateInner(ctx, issueReq, certProfile, serialBigInt, notBefore, notAfter)
if err != nil {
return nil, err
}
@ -306,39 +349,39 @@ func (ca *certificateAuthorityImpl) issuePrecertificate(ctx context.Context, cer
return nil, err
}
return precertDER, nil
return &capb.IssuePrecertificateResponse{
DER: precertDER,
CertProfileName: cpwid.name,
CertProfileHash: cpwid.hash[:],
}, nil
}
func (ca *certificateAuthorityImpl) IssueCertificate(ctx context.Context, issueReq *capb.IssueCertificateRequest) (*capb.IssueCertificateResponse, error) {
if core.IsAnyNilOrZero(issueReq, issueReq.Csr, issueReq.RegistrationID, issueReq.OrderID) {
return nil, berrors.InternalServerError("Incomplete issue certificate request")
}
if ca.sctClient == nil {
return nil, errors.New("IssueCertificate called with a nil SCT service")
}
// All issuance requests must come with a profile name, and the RA handles selecting the default.
certProfile, ok := ca.certProfiles[issueReq.CertProfileName]
if !ok {
return nil, fmt.Errorf("the CA is incapable of using a profile named %s", issueReq.CertProfileName)
}
precertDER, err := ca.issuePrecertificate(ctx, certProfile, issueReq)
precert, err := ca.IssuePrecertificate(ctx, issueReq)
if err != nil {
return nil, err
}
scts, err := ca.sctClient.GetSCTs(ctx, &rapb.SCTRequest{PrecertDER: precertDER})
scts, err := ca.sctClient.GetSCTs(ctx, &rapb.SCTRequest{PrecertDER: precert.DER})
if err != nil {
return nil, err
}
certDER, err := ca.issueCertificateForPrecertificate(ctx, certProfile, precertDER, scts.SctDER, issueReq.RegistrationID, issueReq.OrderID)
cert, err := ca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{
DER: precert.DER,
SCTs: scts.SctDER,
RegistrationID: issueReq.RegistrationID,
OrderID: issueReq.OrderID,
CertProfileHash: precert.CertProfileHash,
})
if err != nil {
return nil, err
}
return &capb.IssueCertificateResponse{DER: certDER}, nil
return &capb.IssueCertificateResponse{DER: cert.Der}, nil
}
// issueCertificateForPrecertificate is final step in the [issuance cycle].
// 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.
@ -348,11 +391,12 @@ func (ca *certificateAuthorityImpl) IssueCertificate(ctx context.Context, issueR
//
// 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
// 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 once per call to `IssueCertificate`.
// If there is any error, the whole certificate issuance attempt fails and any subsequent
// 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
@ -360,17 +404,23 @@ func (ca *certificateAuthorityImpl) IssueCertificate(ctx context.Context, issueR
// there could be race conditions where two goroutines are issuing for the same
// serial number at the same time.
//
// Returns the final certificate's bytes as DER.
//
// [issuance cycle]: https://github.com/letsencrypt/boulder/blob/main/docs/ISSUANCE-CYCLE.md
func (ca *certificateAuthorityImpl) issueCertificateForPrecertificate(ctx context.Context,
certProfile *certProfileWithID,
precertDER []byte,
sctBytes [][]byte,
regID int64,
orderID int64,
) ([]byte, error) {
precert, err := x509.ParseCertificate(precertDER)
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
}
@ -384,9 +434,9 @@ func (ca *certificateAuthorityImpl) issueCertificateForPrecertificate(ctx contex
return nil, fmt.Errorf("error checking for duplicate issuance of %s: %s", serialHex, err)
}
var scts []ct.SignedCertificateTimestamp
for _, singleSCTBytes := range sctBytes {
for _, sctBytes := range req.SCTs {
var sct ct.SignedCertificateTimestamp
_, err = cttls.Unmarshal(singleSCTBytes, &sct)
_, err = cttls.Unmarshal(sctBytes, &sct)
if err != nil {
return nil, err
}
@ -412,23 +462,18 @@ func (ca *certificateAuthorityImpl) issueCertificateForPrecertificate(ctx contex
logEvent := issuanceEvent{
IssuanceRequest: issuanceReq,
Issuer: issuer.Name(),
OrderID: orderID,
OrderID: req.OrderID,
Profile: certProfile.name,
Requester: regID,
ProfileHash: hex.EncodeToString(certProfile.hash[:]),
Requester: req.RegistrationID,
}
ca.log.AuditObject("Signing cert", logEvent)
var ipStrings []string
for _, ip := range issuanceReq.IPAddresses {
ipStrings = append(ipStrings, ip.String())
}
_, span := ca.tracer.Start(ctx, "signing cert", trace.WithAttributes(
attribute.String("serial", serialHex),
attribute.String("issuer", issuer.Name()),
attribute.String("certProfileName", certProfile.name),
attribute.StringSlice("names", issuanceReq.DNSNames),
attribute.StringSlice("ipAddresses", ipStrings),
))
certDER, err := issuer.Issue(issuanceToken)
if err != nil {
@ -452,7 +497,7 @@ func (ca *certificateAuthorityImpl) issueCertificateForPrecertificate(ctx contex
_, err = ca.sa.AddCertificate(ctx, &sapb.AddCertificateRequest{
Der: certDER,
RegID: regID,
RegID: req.RegistrationID,
Issued: timestamppb.New(ca.clk.Now()),
})
if err != nil {
@ -460,7 +505,14 @@ func (ca *certificateAuthorityImpl) issueCertificateForPrecertificate(ctx contex
return nil, err
}
return certDER, nil
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
}
// generateSerialNumber produces a big.Int which has more than 64 bits of
@ -543,21 +595,17 @@ func (ca *certificateAuthorityImpl) issuePrecertificateInner(ctx context.Context
serialHex := core.SerialToString(serialBigInt)
dnsNames, ipAddresses, err := identifier.FromCSR(csr).ToValues()
if err != nil {
return nil, nil, err
}
names := csrlib.NamesFromCSR(csr)
req := &issuance.IssuanceRequest{
PublicKey: issuance.MarshalablePublicKey{PublicKey: csr.PublicKey},
SubjectKeyId: subjectKeyId,
Serial: serialBigInt.Bytes(),
DNSNames: dnsNames,
IPAddresses: ipAddresses,
CommonName: csrlib.CNFromCSR(csr),
IncludeCTPoison: true,
NotBefore: notBefore,
NotAfter: notAfter,
PublicKey: issuance.MarshalablePublicKey{PublicKey: csr.PublicKey},
SubjectKeyId: subjectKeyId,
Serial: serialBigInt.Bytes(),
DNSNames: names.SANs,
CommonName: names.CN,
IncludeCTPoison: true,
IncludeMustStaple: issuance.ContainsMustStaple(csr.Extensions),
NotBefore: notBefore,
NotAfter: notAfter,
}
lintCertBytes, issuanceToken, err := issuer.Prepare(certProfile.profile, req)
@ -590,22 +638,17 @@ func (ca *certificateAuthorityImpl) issuePrecertificateInner(ctx context.Context
IssuanceRequest: req,
Issuer: issuer.Name(),
Profile: certProfile.name,
ProfileHash: hex.EncodeToString(certProfile.hash[:]),
Requester: issueReq.RegistrationID,
OrderID: issueReq.OrderID,
}
ca.log.AuditObject("Signing precert", logEvent)
var ipStrings []string
for _, ip := range csr.IPAddresses {
ipStrings = append(ipStrings, ip.String())
}
_, span := ca.tracer.Start(ctx, "signing precert", trace.WithAttributes(
attribute.String("serial", serialHex),
attribute.String("issuer", issuer.Name()),
attribute.String("certProfileName", certProfile.name),
attribute.StringSlice("names", csr.DNSNames),
attribute.StringSlice("ipAddresses", ipStrings),
))
certDER, err := issuer.Issue(issuanceToken)
if err != nil {
@ -629,7 +672,7 @@ func (ca *certificateAuthorityImpl) issuePrecertificateInner(ctx context.Context
logEvent.CSR = ""
ca.log.AuditObject("Signing precert success", logEvent)
return certDER, &certProfileWithID{certProfile.name, nil}, nil
return certDER, &certProfileWithID{certProfile.name, certProfile.hash, nil}, nil
}
// verifyTBSCertIsDeterministic verifies that x509.CreateCertificate signing

View File

@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"math/big"
mrand "math/rand"
"os"
"strings"
"testing"
@ -33,7 +32,6 @@ import (
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/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
@ -94,6 +92,8 @@ var (
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))
}
@ -102,7 +102,7 @@ type testCtx struct {
pa core.PolicyAuthority
ocsp *ocspImpl
crl *crlImpl
certProfiles map[string]*issuance.ProfileConfig
certProfiles map[string]*issuance.ProfileConfigNew
serialPrefix byte
maxNames int
boulderIssuers []*issuance.Issuer
@ -148,27 +148,27 @@ func setup(t *testing.T) *testCtx {
fc := clock.NewFake()
fc.Add(1 * time.Hour)
pa, err := policy.New(map[identifier.IdentifierType]bool{"dns": true}, nil, blog.NewMock())
pa, err := policy.New(nil, blog.NewMock())
test.AssertNotError(t, err, "Couldn't create PA")
err = pa.LoadIdentPolicyFile("../test/ident-policy.yaml")
test.AssertNotError(t, err, "Couldn't set identifier policy")
err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml")
test.AssertNotError(t, err, "Couldn't set hostname policy")
certProfiles := make(map[string]*issuance.ProfileConfig, 0)
certProfiles["legacy"] = &issuance.ProfileConfig{
IncludeCRLDistributionPoints: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
MaxValidityBackdate: config.Duration{Duration: time.Hour},
IgnoredLints: []string{"w_subject_common_name_included"},
certProfiles := make(map[string]*issuance.ProfileConfigNew, 0)
certProfiles["legacy"] = &issuance.ProfileConfigNew{
AllowMustStaple: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
MaxValidityBackdate: config.Duration{Duration: time.Hour},
IgnoredLints: []string{"w_subject_common_name_included"},
}
certProfiles["modern"] = &issuance.ProfileConfig{
OmitCommonName: true,
OmitKeyEncipherment: true,
OmitClientAuth: true,
OmitSKID: true,
IncludeCRLDistributionPoints: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 6},
MaxValidityBackdate: config.Duration{Duration: time.Hour},
IgnoredLints: []string{"w_ext_subject_key_identifier_missing_sub_cert"},
certProfiles["modern"] = &issuance.ProfileConfigNew{
AllowMustStaple: true,
OmitCommonName: true,
OmitKeyEncipherment: true,
OmitClientAuth: true,
OmitSKID: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 6},
MaxValidityBackdate: config.Duration{Duration: time.Hour},
IgnoredLints: []string{"w_ext_subject_key_identifier_missing_sub_cert"},
}
test.AssertEquals(t, len(certProfiles), 2)
@ -179,7 +179,6 @@ func setup(t *testing.T) *testCtx {
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),
CRLShards: 10,
Location: issuance.IssuerLoc{
File: fmt.Sprintf("../test/hierarchy/%s.key.pem", name),
CertFile: fmt.Sprintf("../test/hierarchy/%s.cert.pem", name),
@ -316,6 +315,7 @@ func TestIssuePrecertificate(t *testing.T) {
{"IssuePrecertificate", CNandSANCSR, issueCertificateSubTestIssuePrecertificate},
{"ProfileSelectionRSA", CNandSANCSR, issueCertificateSubTestProfileSelectionRSA},
{"ProfileSelectionECDSA", ECDSACSR, issueCertificateSubTestProfileSelectionECDSA},
{"MustStaple", MustStapleCSR, issueCertificateSubTestMustStaple},
{"UnknownExtension", UnsupportedExtensionCSR, issueCertificateSubTestUnknownExtension},
{"CTPoisonExtension", CTPoisonExtensionCSR, issueCertificateSubTestCTPoisonExtension},
{"CTPoisonExtensionEmpty", CTPoisonExtensionEmptyCSR, issueCertificateSubTestCTPoisonExtension},
@ -332,11 +332,12 @@ func TestIssuePrecertificate(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: mrand.Int63(), OrderID: mrand.Int63()}
issueReq := &capb.IssueCertificateRequest{Csr: testCase.csr, RegistrationID: arbitraryRegID, CertProfileName: "legacy"}
profile := ca.certProfiles["legacy"]
certDER, err := ca.issuePrecertificate(ctx, profile, issueReq)
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")
@ -445,10 +446,9 @@ func TestMultipleIssuers(t *testing.T) {
test.AssertNotError(t, err, "Failed to remake CA")
// Test that an RSA CSR gets issuance from an RSA issuer.
profile := ca.certProfiles["legacy"]
issuedCertDER, err := ca.issuePrecertificate(ctx, profile, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63()})
issuedCert, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"})
test.AssertNotError(t, err, "Failed to issue certificate")
cert, err := x509.ParseCertificate(issuedCertDER)
cert, err := x509.ParseCertificate(issuedCert.DER)
test.AssertNotError(t, err, "Certificate failed to parse")
validated := false
for _, issuer := range ca.issuers.byAlg[x509.RSA] {
@ -462,9 +462,9 @@ func TestMultipleIssuers(t *testing.T) {
test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "precertificate", "status": "success"}, 1)
// Test that an ECDSA CSR gets issuance from an ECDSA issuer.
issuedCertDER, err = ca.issuePrecertificate(ctx, profile, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"})
issuedCert, err = ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"})
test.AssertNotError(t, err, "Failed to issue certificate")
cert, err = x509.ParseCertificate(issuedCertDER)
cert, err = x509.ParseCertificate(issuedCert.DER)
test.AssertNotError(t, err, "Certificate failed to parse")
validated = false
for _, issuer := range ca.issuers.byAlg[x509.ECDSA] {
@ -493,7 +493,6 @@ func TestUnpredictableIssuance(t *testing.T) {
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),
CRLShards: 10,
Location: issuance.IssuerLoc{
File: fmt.Sprintf("../test/hierarchy/%s.key.pem", name),
CertFile: fmt.Sprintf("../test/hierarchy/%s.cert.pem", name),
@ -528,14 +527,13 @@ func TestUnpredictableIssuance(t *testing.T) {
// 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: mrand.Int63(), OrderID: mrand.Int63()}
req := &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"}
seenE2 := false
seenR3 := false
profile := ca.certProfiles["legacy"]
for i := 0; i < 20; i++ {
precertDER, err := ca.issuePrecertificate(ctx, profile, req)
result, err := ca.IssuePrecertificate(ctx, req)
test.AssertNotError(t, err, "Failed to issue test certificate")
cert, err := x509.ParseCertificate(precertDER)
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")
@ -554,11 +552,22 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
testCtx := setup(t)
test.AssertEquals(t, len(testCtx.certProfiles), 2)
testProfile := issuance.ProfileConfigNew{
AllowMustStaple: false,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
MaxValidityBackdate: config.Duration{Duration: time.Hour},
}
type nameToHash struct {
name string
hash [32]byte
}
testCases := []struct {
name string
profileConfigs map[string]*issuance.ProfileConfig
profileConfigs map[string]*issuance.ProfileConfigNew
expectedErrSubstr string
expectedProfiles []string
expectedProfiles []nameToHash
}{
{
name: "nil profile map",
@ -567,27 +576,42 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
},
{
name: "no profiles",
profileConfigs: map[string]*issuance.ProfileConfig{},
profileConfigs: map[string]*issuance.ProfileConfigNew{},
expectedErrSubstr: "at least one certificate profile",
},
{
name: "duplicate hash",
profileConfigs: map[string]*issuance.ProfileConfigNew{
"default": &testProfile,
"default2": &testProfile,
},
expectedErrSubstr: "duplicate certificate profile hash",
},
{
name: "empty profile config",
profileConfigs: map[string]*issuance.ProfileConfig{
profileConfigs: map[string]*issuance.ProfileConfigNew{
"empty": {},
},
expectedErrSubstr: "at least one revocation mechanism must be included",
},
{
name: "minimal profile config",
profileConfigs: map[string]*issuance.ProfileConfig{
"empty": {IncludeCRLDistributionPoints: true},
expectedProfiles: []nameToHash{
{
name: "empty",
hash: [32]byte{0x25, 0x27, 0x72, 0xa1, 0xaf, 0x95, 0xfe, 0xc7, 0x32, 0x78, 0x38, 0x97, 0xd0, 0xf1, 0x83, 0x92, 0xc3, 0xac, 0x60, 0x91, 0x68, 0x4f, 0x22, 0xb6, 0x57, 0x2f, 0x89, 0x1a, 0x54, 0xe5, 0xd8, 0xa3},
},
},
expectedProfiles: []string{"empty"},
},
{
name: "default profiles from setup func",
profileConfigs: testCtx.certProfiles,
expectedProfiles: []string{"legacy", "modern"},
name: "default profiles from setup func",
profileConfigs: testCtx.certProfiles,
expectedProfiles: []nameToHash{
{
name: "legacy",
hash: [32]byte{0x44, 0xc5, 0xbc, 0x73, 0x8, 0x95, 0xba, 0x4c, 0x13, 0x12, 0xc4, 0xc, 0x5d, 0x77, 0x2f, 0x54, 0xf8, 0x54, 0x1, 0xb8, 0x84, 0xaf, 0x6c, 0x58, 0x74, 0x6, 0xac, 0xda, 0x3e, 0x37, 0xfc, 0x88},
},
{
name: "modern",
hash: [32]byte{0x58, 0x7, 0xea, 0x3a, 0x85, 0xcd, 0xf9, 0xd1, 0x7a, 0x9a, 0x59, 0x76, 0xfc, 0x92, 0xea, 0x1b, 0x69, 0x54, 0xe4, 0xbe, 0xcf, 0xe3, 0x91, 0xfa, 0x85, 0x4, 0xbf, 0x1f, 0x55, 0x97, 0x2c, 0x8b},
},
},
},
}
@ -604,14 +628,17 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
}
if tc.expectedProfiles != nil {
test.AssertEquals(t, len(profiles), len(tc.expectedProfiles))
test.AssertEquals(t, len(profiles.profileByName), len(tc.expectedProfiles))
}
for _, expected := range tc.expectedProfiles {
cpwid, ok := profiles[expected]
test.Assert(t, ok, fmt.Sprintf("expected profile %q not found", expected))
cpwid, ok := profiles.profileByName[expected.name]
test.Assert(t, ok, fmt.Sprintf("expected profile %q not found", expected.name))
test.AssertEquals(t, cpwid.hash, expected.hash)
test.AssertEquals(t, cpwid.name, expected)
cpwid, ok = profiles.profileByHash[expected.hash]
test.Assert(t, ok, fmt.Sprintf("expected profile %q not found", expected.hash))
test.AssertEquals(t, cpwid.name, expected.name)
}
})
}
@ -684,9 +711,8 @@ func TestInvalidCSRs(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
serializedCSR := mustRead(testCase.csrPath)
profile := ca.certProfiles["legacy"]
issueReq := &capb.IssueCertificateRequest{Csr: serializedCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"}
_, err = ca.issuePrecertificate(ctx, profile, issueReq)
issueReq := &capb.IssueCertificateRequest{Csr: serializedCSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"}
_, err = ca.IssuePrecertificate(ctx, issueReq)
test.AssertErrorIs(t, err, testCase.errorType)
test.AssertMetricWithLabelsEquals(t, ca.metrics.signatureCount, prometheus.Labels{"purpose": "cert"}, 0)
@ -722,8 +748,7 @@ func TestRejectValidityTooLong(t *testing.T) {
test.AssertNotError(t, err, "Failed to create CA")
// Test that the CA rejects CSRs that would expire after the intermediate cert
profile := ca.certProfiles["legacy"]
_, err = ca.issuePrecertificate(ctx, profile, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"})
_, err = ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"})
test.AssertError(t, err, "Cannot issue a certificate that expires after the intermediate certificate")
test.AssertErrorIs(t, err, berrors.InternalServer)
}
@ -742,12 +767,30 @@ func issueCertificateSubTestProfileSelectionECDSA(t *testing.T, i *TestCertifica
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 := 10
expectedExtensionCount := 9
test.AssertEquals(t, len(i.cert.Extensions), expectedExtensionCount)
}
@ -797,11 +840,10 @@ func TestIssueCertificateForPrecertificate(t *testing.T) {
testCtx.fc)
test.AssertNotError(t, err, "Failed to create CA")
profile := ca.certProfiles["legacy"]
issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"}
precertDER, err := ca.issuePrecertificate(ctx, profile, &issueReq)
issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, OrderID: 0, CertProfileName: "legacy"}
precert, err := ca.IssuePrecertificate(ctx, &issueReq)
test.AssertNotError(t, err, "Failed to issue precert")
parsedPrecert, err := x509.ParseCertificate(precertDER)
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)
@ -818,14 +860,15 @@ func TestIssueCertificateForPrecertificate(t *testing.T) {
}
test.AssertNotError(t, err, "Failed to marshal SCT")
certDER, err := ca.issueCertificateForPrecertificate(ctx,
profile,
precertDER,
sctBytes,
mrand.Int63(),
mrand.Int63())
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(certDER)
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)
@ -860,18 +903,18 @@ func TestIssueCertificateForPrecertificateWithSpecificCertificateProfile(t *test
test.AssertNotError(t, err, "Failed to create CA")
selectedProfile := "modern"
certProfile, ok := ca.certProfiles[selectedProfile]
certProfile, ok := ca.certProfiles.profileByName[selectedProfile]
test.Assert(t, ok, "Certificate profile was expected to exist")
issueReq := capb.IssueCertificateRequest{
Csr: CNandSANCSR,
RegistrationID: mrand.Int63(),
OrderID: mrand.Int63(),
RegistrationID: arbitraryRegID,
OrderID: 0,
CertProfileName: selectedProfile,
}
precertDER, err := ca.issuePrecertificate(ctx, certProfile, &issueReq)
precert, err := ca.IssuePrecertificate(ctx, &issueReq)
test.AssertNotError(t, err, "Failed to issue precert")
parsedPrecert, err := x509.ParseCertificate(precertDER)
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)
@ -888,14 +931,15 @@ func TestIssueCertificateForPrecertificateWithSpecificCertificateProfile(t *test
}
test.AssertNotError(t, err, "Failed to marshal SCT")
certDER, err := ca.issueCertificateForPrecertificate(ctx,
certProfile,
precertDER,
sctBytes,
mrand.Int63(),
mrand.Int63())
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(certDER)
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)
@ -979,17 +1023,17 @@ func TestIssueCertificateForPrecertificateDuplicateSerial(t *testing.T) {
t.Fatal(err)
}
profile := ca.certProfiles["legacy"]
issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"}
precertDER, err := ca.issuePrecertificate(ctx, profile, &issueReq)
issueReq := capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, OrderID: 0, CertProfileName: "legacy"}
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,
profile,
precertDER,
sctBytes,
mrand.Int63(),
mrand.Int63())
_, err = ca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{
DER: precert.DER,
SCTs: sctBytes,
RegistrationID: arbitraryRegID,
OrderID: 0,
CertProfileHash: ca.certProfiles.profileByName["legacy"].hash[:],
})
if err == nil {
t.Error("Expected error issuing duplicate serial but got none.")
}
@ -1017,12 +1061,13 @@ func TestIssueCertificateForPrecertificateDuplicateSerial(t *testing.T) {
testCtx.fc)
test.AssertNotError(t, err, "Failed to create CA")
_, err = errorca.issueCertificateForPrecertificate(ctx,
profile,
precertDER,
sctBytes,
mrand.Int63(),
mrand.Int63())
_, err = errorca.IssueCertificateForPrecertificate(ctx, &capb.IssueCertificateForPrecertificateRequest{
DER: precert.DER,
SCTs: sctBytes,
RegistrationID: arbitraryRegID,
OrderID: 0,
CertProfileHash: ca.certProfiles.profileByName["legacy"].hash[:],
})
if err == nil {
t.Fatal("Expected error issuing duplicate serial but got none.")
}

View File

@ -4,7 +4,6 @@ import (
"context"
"crypto/x509"
"encoding/hex"
mrand "math/rand"
"testing"
"time"
@ -45,12 +44,11 @@ func TestOCSP(t *testing.T) {
test.AssertNotError(t, err, "Failed to create CA")
ocspi := testCtx.ocsp
profile := ca.certProfiles["legacy"]
// Issue a certificate from an RSA issuer, request OCSP from the same issuer,
// and make sure it works.
rsaCertDER, err := ca.issuePrecertificate(ctx, profile, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"})
rsaCertPB, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: CNandSANCSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"})
test.AssertNotError(t, err, "Failed to issue certificate")
rsaCert, err := x509.ParseCertificate(rsaCertDER)
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{
@ -71,9 +69,9 @@ func TestOCSP(t *testing.T) {
// Issue a certificate from an ECDSA issuer, request OCSP from the same issuer,
// and make sure it works.
ecdsaCertDER, err := ca.issuePrecertificate(ctx, profile, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: mrand.Int63(), OrderID: mrand.Int63(), CertProfileName: "legacy"})
ecdsaCertPB, err := ca.IssuePrecertificate(ctx, &capb.IssueCertificateRequest{Csr: ECDSACSR, RegistrationID: arbitraryRegID, CertProfileName: "legacy"})
test.AssertNotError(t, err, "Failed to issue certificate")
ecdsaCert, err := x509.ParseCertificate(ecdsaCertDER)
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{

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc-gen-go v1.34.1
// protoc v3.20.1
// source: ca.proto
@ -13,7 +13,6 @@ import (
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
@ -24,7 +23,10 @@ const (
)
type IssueCertificateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
@ -34,15 +36,15 @@ type IssueCertificateRequest struct {
// 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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *IssueCertificateRequest) Reset() {
*x = IssueCertificateRequest{}
mi := &file_ca_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IssueCertificateRequest) String() string {
@ -53,7 +55,7 @@ func (*IssueCertificateRequest) ProtoMessage() {}
func (x *IssueCertificateRequest) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -97,17 +99,20 @@ func (x *IssueCertificateRequest) GetCertProfileName() string {
}
type IssueCertificateResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
DER []byte `protobuf:"bytes,1,opt,name=DER,proto3" json:"DER,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
DER []byte `protobuf:"bytes,1,opt,name=DER,proto3" json:"DER,omitempty"`
}
func (x *IssueCertificateResponse) Reset() {
*x = IssueCertificateResponse{}
mi := &file_ca_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IssueCertificateResponse) String() string {
@ -118,7 +123,7 @@ func (*IssueCertificateResponse) ProtoMessage() {}
func (x *IssueCertificateResponse) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -140,24 +145,180 @@ func (x *IssueCertificateResponse) GetDER() []byte {
return nil
}
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[2]
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[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 IssuePrecertificateResponse.ProtoReflect.Descriptor instead.
func (*IssuePrecertificateResponse) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{2}
}
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[3]
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[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 IssueCertificateForPrecertificateRequest.ProtoReflect.Descriptor instead.
func (*IssueCertificateForPrecertificateRequest) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{3}
}
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 `protogen:"open.v1"`
// 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"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_ca_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GenerateOCSPRequest) String() string {
@ -167,8 +328,8 @@ func (x *GenerateOCSPRequest) String() string {
func (*GenerateOCSPRequest) ProtoMessage() {}
func (x *GenerateOCSPRequest) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[2]
if x != nil {
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)
@ -180,7 +341,7 @@ func (x *GenerateOCSPRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GenerateOCSPRequest.ProtoReflect.Descriptor instead.
func (*GenerateOCSPRequest) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{2}
return file_ca_proto_rawDescGZIP(), []int{4}
}
func (x *GenerateOCSPRequest) GetStatus() string {
@ -219,17 +380,20 @@ func (x *GenerateOCSPRequest) GetIssuerID() int64 {
}
type OCSPResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Response []byte `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_ca_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *OCSPResponse) String() string {
@ -239,8 +403,8 @@ func (x *OCSPResponse) String() string {
func (*OCSPResponse) ProtoMessage() {}
func (x *OCSPResponse) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[3]
if x != nil {
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)
@ -252,7 +416,7 @@ func (x *OCSPResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use OCSPResponse.ProtoReflect.Descriptor instead.
func (*OCSPResponse) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{3}
return file_ca_proto_rawDescGZIP(), []int{5}
}
func (x *OCSPResponse) GetResponse() []byte {
@ -263,21 +427,24 @@ func (x *OCSPResponse) GetResponse() []byte {
}
type GenerateCRLRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
Payload isGenerateCRLRequest_Payload `protobuf_oneof:"payload"`
}
func (x *GenerateCRLRequest) Reset() {
*x = GenerateCRLRequest{}
mi := &file_ca_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GenerateCRLRequest) String() string {
@ -287,8 +454,8 @@ func (x *GenerateCRLRequest) String() string {
func (*GenerateCRLRequest) ProtoMessage() {}
func (x *GenerateCRLRequest) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[4]
if x != nil {
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)
@ -300,30 +467,26 @@ func (x *GenerateCRLRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GenerateCRLRequest.ProtoReflect.Descriptor instead.
func (*GenerateCRLRequest) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{4}
return file_ca_proto_rawDescGZIP(), []int{6}
}
func (x *GenerateCRLRequest) GetPayload() isGenerateCRLRequest_Payload {
if x != nil {
return x.Payload
func (m *GenerateCRLRequest) GetPayload() isGenerateCRLRequest_Payload {
if m != nil {
return m.Payload
}
return nil
}
func (x *GenerateCRLRequest) GetMetadata() *CRLMetadata {
if x != nil {
if x, ok := x.Payload.(*GenerateCRLRequest_Metadata); ok {
return x.Metadata
}
if x, ok := x.GetPayload().(*GenerateCRLRequest_Metadata); ok {
return x.Metadata
}
return nil
}
func (x *GenerateCRLRequest) GetEntry() *proto.CRLEntry {
if x != nil {
if x, ok := x.Payload.(*GenerateCRLRequest_Entry); ok {
return x.Entry
}
if x, ok := x.GetPayload().(*GenerateCRLRequest_Entry); ok {
return x.Entry
}
return nil
}
@ -345,20 +508,23 @@ func (*GenerateCRLRequest_Metadata) isGenerateCRLRequest_Payload() {}
func (*GenerateCRLRequest_Entry) isGenerateCRLRequest_Payload() {}
type CRLMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 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"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_ca_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CRLMetadata) String() string {
@ -368,8 +534,8 @@ func (x *CRLMetadata) String() string {
func (*CRLMetadata) ProtoMessage() {}
func (x *CRLMetadata) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[5]
if x != nil {
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)
@ -381,7 +547,7 @@ func (x *CRLMetadata) ProtoReflect() protoreflect.Message {
// Deprecated: Use CRLMetadata.ProtoReflect.Descriptor instead.
func (*CRLMetadata) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{5}
return file_ca_proto_rawDescGZIP(), []int{7}
}
func (x *CRLMetadata) GetIssuerNameID() int64 {
@ -406,17 +572,20 @@ func (x *CRLMetadata) GetShardIdx() int64 {
}
type GenerateCRLResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Chunk []byte `protobuf:"bytes,1,opt,name=chunk,proto3" json:"chunk,omitempty"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_ca_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_ca_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GenerateCRLResponse) String() string {
@ -426,8 +595,8 @@ func (x *GenerateCRLResponse) String() string {
func (*GenerateCRLResponse) ProtoMessage() {}
func (x *GenerateCRLResponse) ProtoReflect() protoreflect.Message {
mi := &file_ca_proto_msgTypes[6]
if x != nil {
mi := &file_ca_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -439,7 +608,7 @@ func (x *GenerateCRLResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GenerateCRLResponse.ProtoReflect.Descriptor instead.
func (*GenerateCRLResponse) Descriptor() ([]byte, []int) {
return file_ca_proto_rawDescGZIP(), []int{6}
return file_ca_proto_rawDescGZIP(), []int{8}
}
func (x *GenerateCRLResponse) GetChunk() []byte {
@ -451,7 +620,7 @@ func (x *GenerateCRLResponse) GetChunk() []byte {
var File_ca_proto protoreflect.FileDescriptor
var file_ca_proto_rawDesc = string([]byte{
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,
@ -469,103 +638,142 @@ var file_ca_proto_rawDesc = string([]byte{
0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x2c, 0x0a, 0x18, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43,
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, 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, 0x67, 0x0a, 0x14, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61,
0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x10, 0x49,
0x73, 0x73, 0x75, 0x65, 0x43, 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, 0x1c, 0x2e, 0x63,
0x61, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61,
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 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,
})
0x03, 0x44, 0x45, 0x52, 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, 0xa6, 0x02, 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,
0x12, 0x4f, 0x0a, 0x10, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 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, 0x1c, 0x2e, 0x63, 0x61, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x65, 0x72, 0x74,
0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 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 []byte
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(unsafe.Slice(unsafe.StringData(file_ca_proto_rawDesc), len(file_ca_proto_rawDesc)))
file_ca_proto_rawDescData = protoimpl.X.CompressGZIP(file_ca_proto_rawDescData)
})
return file_ca_proto_rawDescData
}
var file_ca_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_ca_proto_goTypes = []any{
(*IssueCertificateRequest)(nil), // 0: ca.IssueCertificateRequest
(*IssueCertificateResponse)(nil), // 1: ca.IssueCertificateResponse
(*GenerateOCSPRequest)(nil), // 2: ca.GenerateOCSPRequest
(*OCSPResponse)(nil), // 3: ca.OCSPResponse
(*GenerateCRLRequest)(nil), // 4: ca.GenerateCRLRequest
(*CRLMetadata)(nil), // 5: ca.CRLMetadata
(*GenerateCRLResponse)(nil), // 6: ca.GenerateCRLResponse
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
(*proto.CRLEntry)(nil), // 8: core.CRLEntry
var file_ca_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_ca_proto_goTypes = []interface{}{
(*IssueCertificateRequest)(nil), // 0: ca.IssueCertificateRequest
(*IssueCertificateResponse)(nil), // 1: ca.IssueCertificateResponse
(*IssuePrecertificateResponse)(nil), // 2: ca.IssuePrecertificateResponse
(*IssueCertificateForPrecertificateRequest)(nil), // 3: ca.IssueCertificateForPrecertificateRequest
(*GenerateOCSPRequest)(nil), // 4: ca.GenerateOCSPRequest
(*OCSPResponse)(nil), // 5: ca.OCSPResponse
(*GenerateCRLRequest)(nil), // 6: ca.GenerateCRLRequest
(*CRLMetadata)(nil), // 7: ca.CRLMetadata
(*GenerateCRLResponse)(nil), // 8: ca.GenerateCRLResponse
(*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp
(*proto.CRLEntry)(nil), // 10: core.CRLEntry
(*proto.Certificate)(nil), // 11: core.Certificate
}
var file_ca_proto_depIdxs = []int32{
7, // 0: ca.GenerateOCSPRequest.revokedAt:type_name -> google.protobuf.Timestamp
5, // 1: ca.GenerateCRLRequest.metadata:type_name -> ca.CRLMetadata
8, // 2: ca.GenerateCRLRequest.entry:type_name -> core.CRLEntry
7, // 3: ca.CRLMetadata.thisUpdate:type_name -> google.protobuf.Timestamp
0, // 4: ca.CertificateAuthority.IssueCertificate:input_type -> ca.IssueCertificateRequest
2, // 5: ca.OCSPGenerator.GenerateOCSP:input_type -> ca.GenerateOCSPRequest
4, // 6: ca.CRLGenerator.GenerateCRL:input_type -> ca.GenerateCRLRequest
1, // 7: ca.CertificateAuthority.IssueCertificate:output_type -> ca.IssueCertificateResponse
3, // 8: ca.OCSPGenerator.GenerateOCSP:output_type -> ca.OCSPResponse
6, // 9: ca.CRLGenerator.GenerateCRL:output_type -> ca.GenerateCRLResponse
7, // [7:10] is the sub-list for method output_type
4, // [4:7] 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
9, // 0: ca.GenerateOCSPRequest.revokedAt:type_name -> google.protobuf.Timestamp
7, // 1: ca.GenerateCRLRequest.metadata:type_name -> ca.CRLMetadata
10, // 2: ca.GenerateCRLRequest.entry:type_name -> core.CRLEntry
9, // 3: ca.CRLMetadata.thisUpdate:type_name -> google.protobuf.Timestamp
0, // 4: ca.CertificateAuthority.IssuePrecertificate:input_type -> ca.IssueCertificateRequest
3, // 5: ca.CertificateAuthority.IssueCertificateForPrecertificate:input_type -> ca.IssueCertificateForPrecertificateRequest
0, // 6: ca.CertificateAuthority.IssueCertificate:input_type -> ca.IssueCertificateRequest
4, // 7: ca.OCSPGenerator.GenerateOCSP:input_type -> ca.GenerateOCSPRequest
6, // 8: ca.CRLGenerator.GenerateCRL:input_type -> ca.GenerateCRLRequest
2, // 9: ca.CertificateAuthority.IssuePrecertificate:output_type -> ca.IssuePrecertificateResponse
11, // 10: ca.CertificateAuthority.IssueCertificateForPrecertificate:output_type -> core.Certificate
1, // 11: ca.CertificateAuthority.IssueCertificate:output_type -> ca.IssueCertificateResponse
5, // 12: ca.OCSPGenerator.GenerateOCSP:output_type -> ca.OCSPResponse
8, // 13: ca.CRLGenerator.GenerateCRL:output_type -> ca.GenerateCRLResponse
9, // [9:14] is the sub-list for method output_type
4, // [4:9] 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() }
@ -573,7 +781,117 @@ func file_ca_proto_init() {
if File_ca_proto != nil {
return
}
file_ca_proto_msgTypes[4].OneofWrappers = []any{
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.(*IssueCertificateResponse); 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.(*IssuePrecertificateResponse); 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.(*IssueCertificateForPrecertificateRequest); 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.(*GenerateOCSPRequest); 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.(*OCSPResponse); 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.(*GenerateCRLRequest); 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.(*CRLMetadata); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_ca_proto_msgTypes[8].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[6].OneofWrappers = []interface{}{
(*GenerateCRLRequest_Metadata)(nil),
(*GenerateCRLRequest_Entry)(nil),
}
@ -581,9 +899,9 @@ func file_ca_proto_init() {
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ca_proto_rawDesc), len(file_ca_proto_rawDesc)),
RawDescriptor: file_ca_proto_rawDesc,
NumEnums: 0,
NumMessages: 7,
NumMessages: 9,
NumExtensions: 0,
NumServices: 3,
},
@ -592,6 +910,7 @@ func file_ca_proto_init() {
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
}

View File

@ -8,6 +8,8 @@ import "google/protobuf/timestamp.proto";
// CertificateAuthority issues certificates.
service CertificateAuthority {
rpc IssuePrecertificate(IssueCertificateRequest) returns (IssuePrecertificateResponse) {}
rpc IssueCertificateForPrecertificate(IssueCertificateForPrecertificateRequest) returns (core.Certificate) {}
// IssueCertificate issues a precertificate, gets SCTs, issues a certificate, and returns that.
rpc IssueCertificate(IssueCertificateRequest) returns (IssueCertificateResponse) {}
}
@ -30,6 +32,34 @@ message IssueCertificateResponse {
bytes DER = 1;
}
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

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.3.0
// - protoc v3.20.1
// source: ca.proto
@ -8,6 +8,7 @@ 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"
@ -19,15 +20,17 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
CertificateAuthority_IssueCertificate_FullMethodName = "/ca.CertificateAuthority/IssueCertificate"
CertificateAuthority_IssuePrecertificate_FullMethodName = "/ca.CertificateAuthority/IssuePrecertificate"
CertificateAuthority_IssueCertificateForPrecertificate_FullMethodName = "/ca.CertificateAuthority/IssueCertificateForPrecertificate"
CertificateAuthority_IssueCertificate_FullMethodName = "/ca.CertificateAuthority/IssueCertificate"
)
// 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.
//
// CertificateAuthority issues certificates.
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)
// IssueCertificate issues a precertificate, gets SCTs, issues a certificate, and returns that.
IssueCertificate(ctx context.Context, in *IssueCertificateRequest, opts ...grpc.CallOption) (*IssueCertificateResponse, error)
}
@ -40,6 +43,26 @@ func NewCertificateAuthorityClient(cc grpc.ClientConnInterface) CertificateAutho
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
}
func (c *certificateAuthorityClient) IssueCertificate(ctx context.Context, in *IssueCertificateRequest, opts ...grpc.CallOption) (*IssueCertificateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IssueCertificateResponse)
@ -52,27 +75,29 @@ func (c *certificateAuthorityClient) IssueCertificate(ctx context.Context, in *I
// CertificateAuthorityServer is the server API for CertificateAuthority service.
// All implementations must embed UnimplementedCertificateAuthorityServer
// for forward compatibility.
//
// CertificateAuthority issues certificates.
// for forward compatibility
type CertificateAuthorityServer interface {
IssuePrecertificate(context.Context, *IssueCertificateRequest) (*IssuePrecertificateResponse, error)
IssueCertificateForPrecertificate(context.Context, *IssueCertificateForPrecertificateRequest) (*proto.Certificate, error)
// IssueCertificate issues a precertificate, gets SCTs, issues a certificate, and returns that.
IssueCertificate(context.Context, *IssueCertificateRequest) (*IssueCertificateResponse, error)
mustEmbedUnimplementedCertificateAuthorityServer()
}
// UnimplementedCertificateAuthorityServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCertificateAuthorityServer struct{}
// 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) IssueCertificate(context.Context, *IssueCertificateRequest) (*IssueCertificateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IssueCertificate not implemented")
}
func (UnimplementedCertificateAuthorityServer) mustEmbedUnimplementedCertificateAuthorityServer() {}
func (UnimplementedCertificateAuthorityServer) testEmbeddedByValue() {}
// 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
@ -82,16 +107,45 @@ type UnsafeCertificateAuthorityServer interface {
}
func RegisterCertificateAuthorityServer(s grpc.ServiceRegistrar, srv CertificateAuthorityServer) {
// If the following call pancis, it indicates UnimplementedCertificateAuthorityServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
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)
}
func _CertificateAuthority_IssueCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IssueCertificateRequest)
if err := dec(in); err != nil {
@ -117,6 +171,14 @@ 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,
},
{
MethodName: "IssueCertificate",
Handler: _CertificateAuthority_IssueCertificate_Handler,
@ -133,11 +195,6 @@ const (
// 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.
//
// 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.
type OCSPGeneratorClient interface {
GenerateOCSP(ctx context.Context, in *GenerateOCSPRequest, opts ...grpc.CallOption) (*OCSPResponse, error)
}
@ -162,29 +219,20 @@ func (c *oCSPGeneratorClient) GenerateOCSP(ctx context.Context, in *GenerateOCSP
// OCSPGeneratorServer is the server API for OCSPGenerator service.
// All implementations must embed UnimplementedOCSPGeneratorServer
// for forward compatibility.
//
// 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.
// for forward compatibility
type OCSPGeneratorServer interface {
GenerateOCSP(context.Context, *GenerateOCSPRequest) (*OCSPResponse, error)
mustEmbedUnimplementedOCSPGeneratorServer()
}
// UnimplementedOCSPGeneratorServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedOCSPGeneratorServer struct{}
// 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() {}
func (UnimplementedOCSPGeneratorServer) testEmbeddedByValue() {}
// 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
@ -194,13 +242,6 @@ type UnsafeOCSPGeneratorServer interface {
}
func RegisterOCSPGeneratorServer(s grpc.ServiceRegistrar, srv OCSPGeneratorServer) {
// If the following call pancis, it indicates UnimplementedOCSPGeneratorServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&OCSPGenerator_ServiceDesc, srv)
}
@ -245,8 +286,6 @@ const (
// 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.
//
// CRLGenerator signs CRLs. It is separated for the same reason as OCSPGenerator.
type CRLGeneratorClient interface {
GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[GenerateCRLRequest, GenerateCRLResponse], error)
}
@ -274,26 +313,20 @@ type CRLGenerator_GenerateCRLClient = grpc.BidiStreamingClient[GenerateCRLReques
// CRLGeneratorServer is the server API for CRLGenerator service.
// All implementations must embed UnimplementedCRLGeneratorServer
// for forward compatibility.
//
// CRLGenerator signs CRLs. It is separated for the same reason as OCSPGenerator.
// for forward compatibility
type CRLGeneratorServer interface {
GenerateCRL(grpc.BidiStreamingServer[GenerateCRLRequest, GenerateCRLResponse]) error
mustEmbedUnimplementedCRLGeneratorServer()
}
// UnimplementedCRLGeneratorServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCRLGeneratorServer struct{}
// 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() {}
func (UnimplementedCRLGeneratorServer) testEmbeddedByValue() {}
// 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
@ -303,13 +336,6 @@ type UnsafeCRLGeneratorServer interface {
}
func RegisterCRLGeneratorServer(s grpc.ServiceRegistrar, srv CRLGeneratorServer) {
// If the following call pancis, it indicates UnimplementedCRLGeneratorServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CRLGenerator_ServiceDesc, srv)
}

View File

@ -323,7 +323,7 @@ func (a *admin) revokeSerials(ctx context.Context, serials []string, reason revo
go func() {
defer wg.Done()
for serial := range work {
_, err := a.rac.AdministrativelyRevokeCertificate(
_, err = a.rac.AdministrativelyRevokeCertificate(
ctx,
&rapb.AdministrativelyRevokeCertificateRequest{
Serial: serial,

View File

@ -32,6 +32,10 @@ type dryRunSAC struct {
}
func (d dryRunSAC) AddBlockedKey(_ context.Context, req *sapb.AddBlockedKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) {
d.log.Infof("dry-run: Block SPKI hash %x by %s %s", req.KeyHash, req.Comment, req.Source)
b, err := prototext.Marshal(req)
if err != nil {
return nil, err
}
d.log.Infof("dry-run: %#v", string(b))
return &emptypb.Empty{}, nil
}

84
cmd/admin/email.go Normal file
View File

@ -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, &regIDs, "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
}

View File

@ -178,6 +178,6 @@ func TestBlockSPKIHash(t *testing.T) {
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: Block SPKI hash "+hex.EncodeToString(keyHash[:]))), 1)
test.AssertEquals(t, len(log.GetAllMatching("dry-run:")), 1)
test.AssertEquals(t, len(msa.blockRequests), 0)
}

View File

@ -70,6 +70,7 @@ func main() {
subcommands := map[string]subcommand{
"revoke-cert": &subcommandRevokeCert{},
"block-key": &subcommandBlockKey{},
"update-email": &subcommandUpdateEmail{},
"pause-identifier": &subcommandPauseIdentifier{},
"unpause-account": &subcommandUnpauseAccount{},
}

View File

@ -1,10 +1,15 @@
package notmain
import (
"bytes"
"context"
"crypto/x509"
"flag"
"fmt"
"html/template"
netmail "net/mail"
"os"
"strings"
"time"
"github.com/jmhodges/clock"
@ -19,6 +24,7 @@ import (
"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"
)
@ -37,6 +43,10 @@ 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
@ -46,17 +56,19 @@ type revoker interface {
}
type badKeyRevoker struct {
dbMap *db.WrappedMap
maxRevocations int
serialBatchSize int
raClient revoker
logger blog.Logger
clk clock.Clock
backoffIntervalBase time.Duration
backoffIntervalMax time.Duration
backoffFactor float64
backoffTicker int
maxExpectedReplicationLag time.Duration
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
@ -77,10 +89,8 @@ func (bkr *badKeyRevoker) countUncheckedKeys(ctx context.Context) (int, error) {
&count,
`SELECT COUNT(*)
FROM (SELECT 1 FROM blockedKeys
WHERE extantCertificatesChecked = false AND added < ? - INTERVAL ? SECOND
WHERE extantCertificatesChecked = false
LIMIT ?) AS a`,
bkr.clk.Now(),
bkr.maxExpectedReplicationLag.Seconds(),
blockedKeysGaugeLimit,
)
return count, err
@ -93,10 +103,8 @@ func (bkr *badKeyRevoker) selectUncheckedKey(ctx context.Context) (uncheckedBloc
&row,
`SELECT keyHash, revokedBy
FROM blockedKeys
WHERE extantCertificatesChecked = false AND added < ? - INTERVAL ? SECOND
WHERE extantCertificatesChecked = false
LIMIT 1`,
bkr.clk.Now(),
bkr.maxExpectedReplicationLag.Seconds(),
)
return row, err
}
@ -182,27 +190,109 @@ func (bkr *badKeyRevoker) markRowChecked(ctx context.Context, unchecked unchecke
return err
}
// revokeCerts revokes all the provided certificates. It uses reason
// keyCompromise and includes note indicating that they were revoked by
// bad-key-revoker.
func (bkr *badKeyRevoker) revokeCerts(certs []unrevokedCertificate) error {
for _, cert := range certs {
_, 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
// 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
}
certsRevoked.Inc()
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
}
// invoke exits early and returns true if there is no work to be done.
// Otherwise, it processes a single key in the blockedKeys table and returns false.
// 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: %s", 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)
@ -247,14 +337,47 @@ func (bkr *badKeyRevoker) invoke(ctx context.Context) (bool, error) {
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]...)
}
}
var serials []string
for _, cert := range unrevokedCerts {
serials = append(serials, cert.Serial)
}
bkr.logger.AuditInfo(fmt.Sprintf("revoking serials %v for key with hash %x", serials, unchecked.KeyHash))
bkr.logger.AuditInfo(fmt.Sprintf("revoking serials %v for key with hash %s", serials, unchecked.KeyHash))
// revoke each certificate
err = bkr.revokeCerts(unrevokedCerts)
// revoke each certificate and send emails to their owners
err = bkr.revokeCerts(idToEmails[unchecked.RevokedBy], emailsToCerts)
if err != nil {
return false, err
}
@ -280,7 +403,6 @@ type Config struct {
// 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"`
@ -295,12 +417,16 @@ type Config struct {
// or no work to do.
BackoffIntervalMax config.Duration `validate:"-"`
// MaxExpectedReplicationLag specifies the minimum duration
// bad-key-revoker should wait before searching for certificates
// matching a blockedKeys row. This should be just slightly greater than
// the database's maximum replication lag, and always well under 24
// hours.
MaxExpectedReplicationLag 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
@ -331,6 +457,7 @@ func main() {
scope.MustRegister(keysProcessed)
scope.MustRegister(certsRevoked)
scope.MustRegister(mailErrors)
dbMap, err := sa.InitWrappedDb(config.BadKeyRevoker.DB, scope, logger)
cmd.FailOnError(err, "While initializing dbMap")
@ -342,17 +469,55 @@ func main() {
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,
logger: logger,
clk: clk,
backoffIntervalMax: config.BadKeyRevoker.BackoffIntervalMax.Duration,
backoffIntervalBase: config.BadKeyRevoker.Interval.Duration,
backoffFactor: 1.3,
maxExpectedReplicationLag: config.BadKeyRevoker.MaxExpectedReplicationLag.Duration,
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
@ -368,14 +533,6 @@ func main() {
bkr.backoffIntervalBase = time.Second
}
// If `MaxExpectedReplicationLag` was not set via the config, then set
// `bkr.maxExpectedReplicationLag` to a default 22 seconds. This is based on
// ProxySQL's max_replication_lag for bad-key-revoker (10s), times two, plus
// two seconds.
if bkr.maxExpectedReplicationLag == 0 {
bkr.maxExpectedReplicationLag = time.Second * 22
}
// Run bad-key-revoker in a loop. Backoff if no work or errors.
for {
noWork, err := bkr.invoke(context.Background())

View File

@ -4,22 +4,24 @@ import (
"context"
"crypto/rand"
"fmt"
"html/template"
"strings"
"sync"
"testing"
"time"
"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/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 {
@ -45,12 +47,6 @@ func insertBlockedRow(t *testing.T, dbMap *db.WrappedMap, fc clock.Clock, hash [
test.AssertNotError(t, err, "failed to add test row")
}
func fcBeforeRepLag(clk clock.Clock, bkr *badKeyRevoker) clock.FakeClock {
fc := clock.NewFake()
fc.Set(clk.Now().Add(-bkr.maxExpectedReplicationLag - time.Second))
return fc
}
func TestSelectUncheckedRows(t *testing.T) {
ctx := context.Background()
@ -61,15 +57,12 @@ func TestSelectUncheckedRows(t *testing.T) {
fc := clock.NewFake()
bkr := &badKeyRevoker{
dbMap: dbMap,
logger: blog.NewMock(),
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
dbMap: dbMap,
logger: blog.NewMock(),
clk: fc,
}
hashA, hashB, hashC := randHash(t), randHash(t), randHash(t)
// insert a blocked key that's marked as already checked
insertBlockedRow(t, dbMap, fc, hashA, 1, true)
count, err := bkr.countUncheckedKeys(ctx)
test.AssertNotError(t, err, "countUncheckedKeys failed")
@ -77,33 +70,40 @@ func TestSelectUncheckedRows(t *testing.T) {
_, 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")
// insert a blocked key that's due to be checked
insertBlockedRow(t, dbMap, fcBeforeRepLag(fc, bkr), hashB, 1, false)
// insert a freshly blocked key, so it's not yet due to be checked
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, 1)
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) int64 {
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, agreement, createdAt, status) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO registrations (jwk, jwk_sha256, contact, agreement, createdAt, status, LockCol) VALUES (?, ?, ?, ?, ?, ?, ?)",
[]byte{},
fmt.Sprintf("%x", jwkHash),
contactStr,
"yes",
fc.Now(),
string(core.StatusValid),
0,
)
test.AssertNotError(t, err, "failed to insert test registrations row")
regID, err := res.LastInsertId()
@ -203,13 +203,7 @@ func TestFindUnrevokedNoRows(t *testing.T) {
)
test.AssertNotError(t, err, "failed to insert test keyHashToSerial row")
bkr := &badKeyRevoker{
dbMap: dbMap,
serialBatchSize: 1,
maxRevocations: 10,
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
}
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")
}
@ -225,13 +219,7 @@ func TestFindUnrevoked(t *testing.T) {
regID := insertRegistration(t, dbMap, fc)
bkr := &badKeyRevoker{
dbMap: dbMap,
serialBatchSize: 1,
maxRevocations: 10,
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
}
bkr := &badKeyRevoker{dbMap: dbMap, serialBatchSize: 1, maxRevocations: 10, clk: fc}
hashA := randHash(t)
// insert valid, unexpired
@ -256,6 +244,47 @@ func TestFindUnrevoked(t *testing.T) {
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
@ -274,19 +303,20 @@ func TestRevokeCerts(t *testing.T) {
defer test.ResetBoulderTestDatabase(t)()
fc := clock.NewFake()
mm := &mocks.Mailer{}
mr := &mockRevoker{}
bkr := &badKeyRevoker{
dbMap: dbMap,
raClient: mr,
clk: fc,
}
bkr := &badKeyRevoker{dbMap: dbMap, raClient: mr, mailer: mm, emailSubject: "testing", emailTemplate: testTemplate, clk: fc}
err = bkr.revokeCerts([]unrevokedCertificate{
{ID: 0, Serial: "ff"},
{ID: 1, Serial: "ee"},
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, mr.revoked, 2)
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) {
@ -297,20 +327,11 @@ func TestCertificateAbsent(t *testing.T) {
defer test.ResetBoulderTestDatabase(t)()
fc := clock.NewFake()
bkr := &badKeyRevoker{
dbMap: dbMap,
maxRevocations: 1,
serialBatchSize: 1,
raClient: &mockRevoker{},
logger: blog.NewMock(),
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
}
// populate DB with all the test data
regIDA := insertRegistration(t, dbMap, fc)
regIDA := insertRegistration(t, dbMap, fc, "example.com")
hashA := randHash(t)
insertBlockedRow(t, dbMap, fcBeforeRepLag(fc, bkr), hashA, regIDA, false)
insertBlockedRow(t, dbMap, fc, hashA, regIDA, false)
// Add an entry to keyHashToSerial but not to certificateStatus or certificate
// status, and expect an error.
@ -323,6 +344,17 @@ func TestCertificateAbsent(t *testing.T) {
)
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")
}
@ -336,24 +368,27 @@ func TestInvoke(t *testing.T) {
fc := clock.NewFake()
mm := &mocks.Mailer{}
mr := &mockRevoker{}
bkr := &badKeyRevoker{
dbMap: dbMap,
maxRevocations: 10,
serialBatchSize: 1,
raClient: mr,
logger: blog.NewMock(),
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
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)
regIDB := insertRegistration(t, dbMap, fc)
regIDC := insertRegistration(t, dbMap, fc)
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, fcBeforeRepLag(fc, bkr), hashA, regIDC, false)
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)
@ -363,6 +398,8 @@ func TestInvoke(t *testing.T) {
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 {
@ -374,7 +411,7 @@ func TestInvoke(t *testing.T) {
// add a row with no associated valid certificates
hashB := randHash(t)
insertBlockedRow(t, dbMap, fcBeforeRepLag(fc, bkr), hashB, regIDC, false)
insertBlockedRow(t, dbMap, fc, hashB, regIDC, false)
insertCert(t, dbMap, fc, hashB, "bb", regIDA, Expired, Revoked)
noWork, err = bkr.invoke(ctx)
@ -403,24 +440,27 @@ func TestInvokeRevokerHasNoExtantCerts(t *testing.T) {
fc := clock.NewFake()
mm := &mocks.Mailer{}
mr := &mockRevoker{}
bkr := &badKeyRevoker{dbMap: dbMap,
maxRevocations: 10,
serialBatchSize: 1,
raClient: mr,
logger: blog.NewMock(),
clk: fc,
maxExpectedReplicationLag: time.Second * 22,
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)
regIDB := insertRegistration(t, dbMap, fc)
regIDC := insertRegistration(t, dbMap, fc)
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, fcBeforeRepLag(fc, bkr), hashA, regIDA, false)
insertBlockedRow(t, dbMap, fc, hashA, regIDA, false)
insertGoodCert(t, dbMap, fc, hashA, "ee", regIDB)
insertGoodCert(t, dbMap, fc, hashA, "dd", regIDB)
@ -431,6 +471,8 @@ func TestInvokeRevokerHasNoExtantCerts(t *testing.T) {
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) {

View File

@ -46,7 +46,7 @@ type Config struct {
// One of the profile names must match the value of ra.defaultProfileName
// or large amounts of issuance will fail.
CertProfiles map[string]*issuance.ProfileConfig `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"`
CertProfiles map[string]*issuance.ProfileConfigNew `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:"-"`
@ -164,16 +164,15 @@ func main() {
metrics := ca.NewCAMetrics(scope)
cmd.FailOnError(c.PA.CheckChallenges(), "Invalid PA configuration")
cmd.FailOnError(c.PA.CheckIdentifiers(), "Invalid PA configuration")
pa, err := policy.New(c.PA.Identifiers, c.PA.Challenges, logger)
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.LoadIdentPolicyFile(c.CA.HostnamePolicyFile)
cmd.FailOnError(err, "Couldn't load identifier policy file")
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.

View File

@ -6,6 +6,7 @@ import (
"os"
akamaipb "github.com/letsencrypt/boulder/akamai/proto"
"github.com/letsencrypt/boulder/allowlist"
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
@ -94,13 +95,11 @@ type Config struct {
// default.
DefaultProfileName string `validate:"required"`
// MustStapleAllowList specified the path to a YAML file containing a
// MustStapleAllowList specifies the path to a YAML file containing a
// list of account IDs permitted to request certificates with the OCSP
// Must-Staple extension.
//
// Deprecated: This field no longer has any effect, all Must-Staple requests
// are rejected.
// TODO(#8177): Remove this field.
// Must-Staple extension. If no path is specified, the extension is
// permitted for all accounts. If the file exists but is empty, the
// extension is disabled for all accounts.
MustStapleAllowList string `validate:"omitempty"`
// GoodKey is an embedded config stanza for the goodkey library.
@ -123,6 +122,11 @@ type Config struct {
// 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
@ -167,16 +171,15 @@ func main() {
// Validate PA config and set defaults if needed
cmd.FailOnError(c.PA.CheckChallenges(), "Invalid PA configuration")
cmd.FailOnError(c.PA.CheckIdentifiers(), "Invalid PA configuration")
pa, err := policy.New(c.PA.Identifiers, c.PA.Challenges, logger)
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.LoadIdentPolicyFile(c.RA.HostnamePolicyFile)
cmd.FailOnError(err, "Couldn't load identifier policy file")
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")
@ -255,6 +258,14 @@ func main() {
validationProfiles, err := ra.NewValidationProfiles(c.RA.DefaultProfileName, c.RA.ValidationProfiles)
cmd.FailOnError(err, "Failed to load validation profiles")
var mustStapleAllowList *allowlist.List[int64]
if c.RA.MustStapleAllowList != "" {
data, err := os.ReadFile(c.RA.MustStapleAllowList)
cmd.FailOnError(err, "Failed to read allow list for Must-Staple extension")
mustStapleAllowList, err = allowlist.NewFromYAML[int64](data)
cmd.FailOnError(err, "Failed to parse allow list for Must-Staple extension")
}
if features.Get().AsyncFinalize && c.RA.FinalizeTimeout.Duration == 0 {
cmd.Fail("finalizeTimeout must be supplied when AsyncFinalize feature is enabled")
}
@ -287,6 +298,7 @@ func main() {
txnBuilder,
c.RA.MaxNames,
validationProfiles,
mustStapleAllowList,
pubc,
c.RA.FinalizeTimeout.Duration,
ctp,

View File

@ -10,7 +10,6 @@ import (
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/iana"
"github.com/letsencrypt/boulder/va"
vaConfig "github.com/letsencrypt/boulder/va/config"
vapb "github.com/letsencrypt/boulder/va/proto"
@ -82,12 +81,16 @@ func main() {
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, "tcp")
servers, err = bdns.StartDynamicProvider(c.VA.DNSProvider, 60*time.Second, proto)
cmd.FailOnError(err, "Couldn't start dynamic DNS server resolver")
}
defer servers.Stop()
@ -103,7 +106,6 @@ func main() {
scope,
clk,
c.VA.DNSTries,
c.VA.UserAgent,
logger,
tlsConfig)
} else {
@ -113,7 +115,6 @@ func main() {
scope,
clk,
c.VA.DNSTries,
c.VA.UserAgent,
logger,
tlsConfig)
}
@ -149,7 +150,7 @@ func main() {
c.VA.AccountURIPrefixes,
va.PrimaryPerspective,
"",
iana.IsReservedAddr)
bdns.IsReservedIP)
cmd.FailOnError(err, "Unable to create VA server")
start, err := bgrpc.NewServer(c.VA.GRPC, logger).Add(

View File

@ -127,11 +127,6 @@ type Config struct {
// Deprecated: This field no longer has any effect.
PendingAuthorizationLifetimeDays int `validate:"-"`
// MaxContactsPerRegistration limits the number of contact addresses which
// can be provided in a single NewAccount request. Requests containing more
// contacts than this are rejected. Default: 10.
MaxContactsPerRegistration int `validate:"omitempty,min=1"`
AccountCache *CacheConfig
Limiter struct {
@ -317,10 +312,6 @@ func main() {
c.WFE.StaleTimeout.Duration = time.Minute * 10
}
if c.WFE.MaxContactsPerRegistration == 0 {
c.WFE.MaxContactsPerRegistration = 10
}
var limiter *ratelimits.Limiter
var txnBuilder *ratelimits.TransactionBuilder
var limiterRedis *bredis.Ring
@ -355,7 +346,6 @@ func main() {
logger,
c.WFE.Timeout.Duration,
c.WFE.StaleTimeout.Duration,
c.WFE.MaxContactsPerRegistration,
rac,
sac,
eec,

View File

@ -15,12 +15,16 @@ import (
_ "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/email-exporter"
_ "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"

View File

@ -1,20 +1,21 @@
# `ceremony`
```sh
```
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.
- `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
* `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.
@ -28,29 +29,23 @@ This tool always generates key pairs such that the public and private key are bo
- `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`. |
| `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:
@ -81,31 +76,25 @@ certificate-profile:
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 ceremony
### Intermediate or Cross-Certificate ceremony
- `ceremony-type`: string describing the ceremony type, `intermediate`.
- `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 |
| --- | --- |
| `issuer-certificate-path` | Path to PEM issuer certificate. |
| `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:
@ -117,8 +106,8 @@ pkcs11:
signing-key-slot: 0
signing-key-label: root signing key
inputs:
issuer-certificate-path: /home/user/root-cert.pem
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:
@ -142,95 +131,26 @@ certificate-profile:
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.
### Cross-Certificate ceremony
- `ceremony-type`: string describing the ceremony type, `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 |
| --- | --- |
| `issuer-certificate-path` | Path to PEM issuer certificate. |
| `public-key-path` | Path to PEM subject public key for certificate. |
| `certificate-to-cross-sign-path` | Path to PEM self-signed certificate that this ceremony is a cross-sign of. |
- `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: cross-certificate
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
public-key-path: /home/user/root-signing-pub-2.pem
certificate-to-cross-sign-path: /home/user/root-cert-2.pem
outputs:
certificate-path: /home/user/root-cert-2-cross.pem
certificate-profile:
signature-algorithm: ECDSAWithSHA384
common-name: CA root 2
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
key-usages:
- Digital Signature
- Cert Sign
- CRL Sign
```
This config generates a cross-sign of the already-created "CA root 2", issued from the similarly-already-created "CA root". The subject key used is taken from `/home/user/root-signing-pub-2.pem`. The EKUs and Subject Key Identifier are taken from `/home/user/root-cert-2-cross.pem`. The issuer is `/home/user/root-cert.pem`, and the Issuer and Authority Key Identifier fields are taken from that cert. The resulting certificate is written to `/home/user/root-cert-2-cross.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:
@ -253,28 +173,119 @@ certificate-profile:
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`. |
| `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. |
@ -300,30 +311,23 @@ This config generates an ECDSA P-384 key in the HSM with the object label `inter
- `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. |
@ -355,28 +359,21 @@ This config generates a OCSP response signed by a key in the HSM, identified by
- `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. |

View File

@ -76,6 +76,8 @@ type certType int
const (
rootCert certType = iota
intermediateCert
ocspCert
crlCert
crossCert
requestCert
)
@ -151,12 +153,23 @@ func (profile *certProfile) verifyProfile(ct certType) error {
}
// BR 7.1.2.10.5 CA Certificate Certificate Policies
// OID 2.23.140.1.2.1 is CABF BRs Domain Validated
// 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
}
@ -181,6 +194,8 @@ var stringToKeyUsage = map[string]x509.KeyUsage{
"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
@ -237,6 +252,11 @@ func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbc
}
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")
}
@ -276,18 +296,25 @@ func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbc
// 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 is included in intermediate certificates, as required by
// Section 7.1.2.10.6 of the CA/BF Baseline Requirements.
// id-kp-clientAuth is excluded, as required by section 3.2.1 of the Chrome
// Root Program Requirements.
cert.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// 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
// The SKID needs to match the previous SKID, no matter how it was computed.
cert.SubjectKeyId = tbcs.SubjectKeyId
}
for _, policyConfig := range profile.Policies {

View File

@ -1,6 +1,7 @@
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@ -132,8 +133,9 @@ func TestMakeTemplateRoot(t *testing.T) {
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), 1)
test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageServerAuth)
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) {
@ -173,6 +175,73 @@ func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) {
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
@ -298,6 +367,114 @@ func TestVerifyProfile(t *testing.T) {
},
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",

View File

@ -7,9 +7,8 @@ import (
"fmt"
"log"
"github.com/miekg/pkcs11"
"github.com/letsencrypt/boulder/pkcs11helpers"
"github.com/miekg/pkcs11"
)
var stringToCurve = map[string]elliptic.Curve{
@ -71,7 +70,7 @@ func ecPub(
return nil, err
}
if pubKey.Curve != expectedCurve {
return nil, errors.New("returned EC parameters doesn't match expected curve")
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())

View File

@ -7,9 +7,8 @@ import (
"fmt"
"log"
"github.com/miekg/pkcs11"
"github.com/letsencrypt/boulder/pkcs11helpers"
"github.com/miekg/pkcs11"
)
type hsmRandReader struct {
@ -50,7 +49,7 @@ func generateKey(session *pkcs11helpers.Session, label string, outputPath string
{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: %w", label, err)
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
@ -59,25 +58,25 @@ func generateKey(session *pkcs11helpers.Session, label string, outputPath string
case "rsa":
pubKey, keyID, err = rsaGenerate(session, label, config.RSAModLength)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA key pair: %w", err)
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: %w", err)
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: %w", err)
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: %w", outputPath, err)
return nil, fmt.Errorf("Failed to write public key to %q: %s", outputPath, err)
}
log.Printf("Public key written to %q\n", outputPath)

View File

@ -239,7 +239,7 @@ type intermediateConfig struct {
SkipLints []string `yaml:"skip-lints"`
}
func (ic intermediateConfig) validate() error {
func (ic intermediateConfig) validate(ct certType) error {
err := ic.PKCS11.validate()
if err != nil {
return err
@ -260,7 +260,7 @@ func (ic intermediateConfig) validate() error {
}
// Certificate profile
err = ic.CertProfile.verifyProfile(intermediateCert)
err = ic.CertProfile.verifyProfile(ct)
if err != nil {
return err
}
@ -504,7 +504,7 @@ func loadCert(filename string) (*x509.Certificate, error) {
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 %q", filename)
return nil, fmt.Errorf("No data in cert PEM file %s", filename)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
@ -599,7 +599,7 @@ func loadPubKey(filename string) (crypto.PublicKey, []byte, error) {
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 %q", filename)
return nil, nil, fmt.Errorf("No data in cert PEM file %s", filename)
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
@ -658,14 +658,17 @@ func rootCeremony(configBytes []byte) error {
return nil
}
func intermediateCeremony(configBytes []byte) error {
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()
err = config.validate(ct)
if err != nil {
return fmt.Errorf("failed to validate config: %s", err)
}
@ -681,7 +684,7 @@ func intermediateCeremony(configBytes []byte) error {
if err != nil {
return err
}
template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, nil, intermediateCert)
template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, nil, ct)
if err != nil {
return fmt.Errorf("failed to create certificate profile: %s", err)
}
@ -710,7 +713,10 @@ func intermediateCeremony(configBytes []byte) error {
return nil
}
func crossCertCeremony(configBytes []byte) error {
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 {
@ -737,7 +743,7 @@ func crossCertCeremony(configBytes []byte) error {
if err != nil {
return err
}
template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, toBeCrossSigned, crossCert)
template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, toBeCrossSigned, ct)
if err != nil {
return fmt.Errorf("failed to create certificate profile: %s", err)
}
@ -767,24 +773,12 @@ func crossCertCeremony(configBytes []byte) error {
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
// We want the Extended Key Usages of our cross-signs to be identical to those
// in the cert being cross-signed, for the sake of consistency. However, our
// Root CA Certificates do not contain any EKUs, as required by BR 7.1.2.1.2.
// Therefore, cross-signs of our roots count as "unrestricted" cross-signs per
// the definition in BR 7.1.2.2.3, and are subject to the requirement that
// the cross-sign's Issuer and Subject fields must either:
// - have identical organizationNames; or
// - have orgnaizationNames which are affiliates of each other.
// Therefore, we enforce that cross-signs with empty EKUs have identical
// Subject Organization Name fields... or allow one special case where the
// issuer is "Internet Security Research Group" and the subject is "ISRG" to
// allow us to migrate from the longer string to the shorter one.
if !slices.Equal(lintCert.ExtKeyUsage, toBeCrossSigned.ExtKeyUsage) {
return fmt.Errorf("lint cert and toBeCrossSigned cert EKUs differ")
}
if len(lintCert.ExtKeyUsage) == 0 {
if !slices.Equal(lintCert.Subject.Organization, issuer.Subject.Organization) &&
!(slices.Equal(issuer.Subject.Organization, []string{"Internet Security Research Group"}) && slices.Equal(lintCert.Subject.Organization, []string{"ISRG"})) {
// "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")
}
}
@ -1050,12 +1044,12 @@ func main() {
log.Fatalf("root ceremony failed: %s", err)
}
case "cross-certificate":
err = crossCertCeremony(configBytes)
err = crossCertCeremony(configBytes, crossCert)
if err != nil {
log.Fatalf("cross-certificate ceremony failed: %s", err)
}
case "intermediate":
err = intermediateCeremony(configBytes)
err = intermediateCeremony(configBytes, intermediateCert)
if err != nil {
log.Fatalf("intermediate ceremony failed: %s", err)
}
@ -1064,6 +1058,11 @@ func main() {
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 {
@ -1079,7 +1078,12 @@ func main() {
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, key, ocsp-response, crl")
log.Fatalf("unknown ceremony-type, must be one of: root, cross-certificate, intermediate, cross-csr, ocsp-signer, key, ocsp-response, crl, crl-signer")
}
}

View File

@ -484,7 +484,7 @@ func TestIntermediateConfigValidate(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.validate()
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 != "" {

View File

@ -8,7 +8,6 @@ import (
"encoding/json"
"flag"
"fmt"
"net/netip"
"os"
"regexp"
"slices"
@ -38,8 +37,8 @@ import (
"github.com/letsencrypt/boulder/sa"
)
// For defense-in-depth in addition to using the PA & its identPolicy to check
// domain names we also perform a check against the regex's from the
// 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*$`),
@ -79,7 +78,7 @@ func (r *report) dump() error {
type reportEntry struct {
Valid bool `json:"valid"`
SANs []string `json:"sans"`
DNSNames []string `json:"dnsNames"`
Problems []string `json:"problems,omitempty"`
}
@ -101,7 +100,7 @@ type certChecker struct {
kp goodkey.KeyPolicy
dbMap certDB
getPrecert precertGetter
certs chan *corepb.Certificate
certs chan core.Certificate
clock clock.Clock
rMu *sync.Mutex
issuedReport report
@ -125,14 +124,14 @@ func newChecker(saDbMap certDB,
if err != nil {
return nil, err
}
return precertPb.Der, nil
return precertPb.DER, nil
}
return certChecker{
pa: pa,
kp: kp,
dbMap: saDbMap,
getPrecert: precertGetter,
certs: make(chan *corepb.Certificate, batchSize),
certs: make(chan core.Certificate, batchSize),
rMu: new(sync.Mutex),
clock: clk,
issuedReport: report{Entries: make(map[string]reportEntry)},
@ -215,7 +214,7 @@ func (c *certChecker) getCerts(ctx context.Context) error {
batchStartID := initialID
var retries int
for {
certs, highestID, err := sa.SelectCertificates(
certs, err := sa.SelectCertificates(
ctx,
c.dbMap,
`WHERE id > :id AND
@ -240,16 +239,16 @@ func (c *certChecker) getCerts(ctx context.Context) error {
}
retries = 0
for _, cert := range certs {
c.certs <- cert
c.certs <- cert.Certificate
}
if len(certs) == 0 {
break
}
lastCert := certs[len(certs)-1]
if lastCert.Issued.AsTime().After(c.issuedReport.end) {
batchStartID = lastCert.ID
if lastCert.Issued.After(c.issuedReport.end) {
break
}
batchStartID = highestID
}
// Close channel so range operations won't block once the channel empties out
@ -259,13 +258,13 @@ func (c *certChecker) getCerts(ctx context.Context) error {
func (c *certChecker) processCerts(ctx context.Context, wg *sync.WaitGroup, badResultsOnly bool) {
for cert := range c.certs {
sans, problems := c.checkCert(ctx, cert)
dnsNames, problems := c.checkCert(ctx, cert)
valid := len(problems) == 0
c.rMu.Lock()
if !badResultsOnly || (badResultsOnly && !valid) {
c.issuedReport.Entries[cert.Serial] = reportEntry{
Valid: valid,
SANs: sans,
DNSNames: dnsNames,
Problems: problems,
}
}
@ -303,8 +302,8 @@ var expectedExtensionContent = map[string][]byte{
// 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 *corepb.Certificate, idents identifier.ACMEIdentifiers) error {
authzs, err := sa.SelectAuthzsMatchingIssuance(ctx, c.dbMap, cert.RegistrationID, cert.Issued.AsTime(), idents)
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)
}
@ -313,18 +312,18 @@ func (c *certChecker) checkValidations(ctx context.Context, cert *corepb.Certifi
return fmt.Errorf("no relevant authzs found valid at %s", cert.Issued)
}
// We may get multiple authorizations for the same identifier, but that's
// okay. Any authorization for a given identifier is sufficient.
identToAuthz := make(map[identifier.ACMEIdentifier]*corepb.Authorization)
// 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 {
identToAuthz[identifier.FromProto(m.Identifier)] = m
nameToAuthz[m.DnsName] = m
}
var errors []error
for _, ident := range idents {
_, ok := identToAuthz[ident]
for _, name := range dnsNames {
_, ok := nameToAuthz[name]
if !ok {
errors = append(errors, fmt.Errorf("missing authz for %q", ident.Value))
errors = append(errors, fmt.Errorf("missing authz for %q", name))
continue
}
}
@ -334,196 +333,159 @@ func (c *certChecker) checkValidations(ctx context.Context, cert *corepb.Certifi
return nil
}
// checkCert returns a list of Subject Alternative Names in the certificate and a list of problems with the certificate.
func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) ([]string, []string) {
// 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) ([]string, []string) {
var dnsNames []string
var problems []string
// Check that the digests match.
if cert.Digest != core.Fingerprint256(cert.Der) {
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))
// This is a fatal error, we can't do any further processing.
return nil, problems
}
// Now that it's parsed, we can extract the SANs.
sans := slices.Clone(parsedCert.DNSNames)
for _, ip := range parsedCert.IPAddresses {
sans = append(sans, ip.String())
}
// Run zlint checks.
results := zlint.LintCertificateEx(parsedCert, c.lints)
for name, res := range results.Results {
if 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.AsTime()) {
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.AsTime().Add(-6*time.Hour)) || parsedCert.NotBefore.After(cert.Issued.AsTime().Add(6*time.Hour)) {
problems = append(problems, "Stored issuance date is outside of 6 hour window of certificate NotBefore")
}
// Check that the cert doesn't contain any SANs of unexpected types.
if len(parsedCert.EmailAddresses) != 0 || len(parsedCert.URIs) != 0 {
problems = append(problems, "Certificate contains SAN of unacceptable type (email or URI)")
}
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(sans, 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 DNS name and IP
// address in the SANs. 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(identifier.ACMEIdentifiers{identifier.NewDNS(name)})
if err != nil {
problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
continue
}
// 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))
}
}
for _, name := range parsedCert.IPAddresses {
ip, ok := netip.AddrFromSlice(name)
if !ok {
problems = append(problems, fmt.Sprintf("SANs contain malformed IP %q", name))
continue
}
err = c.pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewIP(ip)})
if err != nil {
problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
continue
}
}
// Check the cert has the correct key usage extensions
serverAndClient := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth, zX509.ExtKeyUsageClientAuth})
serverOnly := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth})
if !(serverAndClient || serverOnly) {
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)
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.LintCertificateEx(parsedCert, c.lints)
for name, res := range results.Results {
if 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.
//
// TODO(#7311): We'll need to iterate over IP address identifiers too.
for _, name := range parsedCert.DNSNames {
err = c.pa.WillingToIssue([]identifier.ACMEIdentifier{identifier.NewDNS(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
serverAndClient := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth, zX509.ExtKeyUsageClientAuth})
serverOnly := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth})
if !(serverAndClient || serverOnly) {
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)
precertDER, err := c.getPrecert(ctx, cert.Serial)
if err != nil {
problems = append(problems, fmt.Sprintf("Certificate does not correspond to precert for %s: %s", cert.Serial, err))
// 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 {
idents := identifier.FromCert(p)
err = c.checkValidations(ctx, cert, idents)
if err != nil {
if features.Get().CertCheckerRequiresValidations {
problems = append(problems, err.Error())
} else {
var identValues []string
for _, ident := range idents {
identValues = append(identValues, ident.Value)
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)
}
c.logger.Errf("Certificate %s %s: %s", cert.Serial, identValues, err)
}
}
}
return sans, problems
return dnsNames, problems
}
type Config struct {
@ -595,7 +557,6 @@ func main() {
// Validate PA config and set defaults if needed.
cmd.FailOnError(config.PA.CheckChallenges(), "Invalid PA configuration")
cmd.FailOnError(config.PA.CheckIdentifiers(), "Invalid PA configuration")
kp, err := sagoodkey.NewPolicy(&config.CertChecker.GoodKey, nil)
cmd.FailOnError(err, "Unable to create key policy")
@ -609,10 +570,10 @@ func main() {
})
prometheus.DefaultRegisterer.MustRegister(checkerLatency)
pa, err := policy.New(config.PA.Identifiers, config.PA.Challenges, logger)
pa, err := policy.New(config.PA.Challenges, logger)
cmd.FailOnError(err, "Failed to create PA")
err = pa.LoadIdentPolicyFile(config.CertChecker.HostnamePolicyFile)
err = pa.LoadHostnamePolicyFile(config.CertChecker.HostnamePolicyFile)
cmd.FailOnError(err, "Failed to load HostnamePolicyFile")
if config.CertChecker.CTLogListFile != "" {

View File

@ -27,11 +27,9 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/letsencrypt/boulder/core"
corepb "github.com/letsencrypt/boulder/core/proto"
"github.com/letsencrypt/boulder/ctpolicy/loglist"
"github.com/letsencrypt/boulder/goodkey"
"github.com/letsencrypt/boulder/goodkey/sagoodkey"
"github.com/letsencrypt/boulder/identifier"
"github.com/letsencrypt/boulder/linter"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
@ -53,14 +51,11 @@ var (
func init() {
var err error
pa, err = policy.New(
map[identifier.IdentifierType]bool{identifier.TypeDNS: true, identifier.TypeIP: true},
map[core.AcmeChallenge]bool{},
blog.NewMock())
pa, err = policy.New(map[core.AcmeChallenge]bool{}, blog.NewMock())
if err != nil {
log.Fatal(err)
}
err = pa.LoadIdentPolicyFile("../../test/ident-policy.yaml")
err = pa.LoadHostnamePolicyFile("../../test/hostname-policy.yaml")
if err != nil {
log.Fatal(err)
}
@ -84,12 +79,12 @@ func BenchmarkCheckCert(b *testing.B) {
SerialNumber: serial,
}
certDer, _ := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey)
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: core.SerialToString(serial),
Digest: core.Fingerprint256(certDer),
Der: certDer,
Issued: timestamppb.New(time.Now()),
Expires: timestamppb.New(expiry),
DER: certDer,
Issued: time.Now(),
Expires: expiry,
}
b.ResetTimer()
for range b.N {
@ -130,12 +125,12 @@ func TestCheckWildcardCert(t *testing.T) {
test.AssertNotError(t, err, "Couldn't create certificate")
parsed, err := x509.ParseCertificate(wildcardCertDer)
test.AssertNotError(t, err, "Couldn't parse created certificate")
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: core.SerialToString(serial),
Digest: core.Fingerprint256(wildcardCertDer),
Expires: timestamppb.New(parsed.NotAfter),
Issued: timestamppb.New(parsed.NotBefore),
Der: wildcardCertDer,
Expires: parsed.NotAfter,
Issued: parsed.NotBefore,
DER: wildcardCertDer,
}
_, problems := checker.checkCert(context.Background(), cert)
for _, p := range problems {
@ -143,7 +138,7 @@ func TestCheckWildcardCert(t *testing.T) {
}
}
func TestCheckCertReturnsSANs(t *testing.T) {
func TestCheckCertReturnsDNSNames(t *testing.T) {
saDbMap, err := sa.DBMapForTest(vars.DBConnSA)
test.AssertNotError(t, err, "Couldn't connect to database")
saCleanup := test.ResetBoulderTestDatabase(t)
@ -162,16 +157,16 @@ func TestCheckCertReturnsSANs(t *testing.T) {
t.Fatal("failed to parse cert PEM")
}
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: "00000000000",
Digest: core.Fingerprint256(block.Bytes),
Expires: timestamppb.New(time.Now().Add(time.Hour)),
Issued: timestamppb.New(time.Now()),
Der: block.Bytes,
Expires: time.Now().Add(time.Hour),
Issued: time.Now(),
DER: block.Bytes,
}
names, problems := checker.checkCert(context.Background(), cert)
if !slices.Equal(names, []string{"quite_invalid.com", "al--so--wr--ong.com", "127.0.0.1"}) {
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"))
}
}
@ -267,11 +262,11 @@ func TestCheckCert(t *testing.T) {
// Serial doesn't match
// Expiry doesn't match
// Issued doesn't match
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: "8485f2687eba29ad455ae4e31c8679206fec",
Der: brokenCertDer,
Issued: timestamppb.New(issued.Add(12 * time.Hour)),
Expires: timestamppb.New(goodExpiry.AddDate(0, 0, 2)), // Expiration doesn't match
DER: brokenCertDer,
Issued: issued.Add(12 * time.Hour),
Expires: goodExpiry.AddDate(0, 0, 2), // Expiration doesn't match
}
_, problems := checker.checkCert(context.Background(), cert)
@ -323,9 +318,9 @@ func TestCheckCert(t *testing.T) {
test.AssertNotError(t, err, "Couldn't parse created certificate")
cert.Serial = core.SerialToString(serial)
cert.Digest = core.Fingerprint256(goodCertDer)
cert.Der = goodCertDer
cert.Expires = timestamppb.New(parsed.NotAfter)
cert.Issued = timestamppb.New(parsed.NotBefore)
cert.DER = goodCertDer
cert.Expires = parsed.NotAfter
cert.Issued = parsed.NotBefore
_, problems = checker.checkCert(context.Background(), cert)
test.AssertEquals(t, len(problems), 0)
})
@ -401,6 +396,9 @@ func (db mismatchedCountDB) SelectNullInt(_ context.Context, _ string, _ ...inte
// `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
}
@ -537,7 +535,7 @@ 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 identifier policy file.
// against the complex hostname policy file.
testcases := []struct {
Name string
Expected bool
@ -626,12 +624,12 @@ func TestIgnoredLint(t *testing.T) {
subjectCert, err := x509.ParseCertificate(subjectCertDer)
test.AssertNotError(t, err, "failed to parse EE cert")
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: core.SerialToString(serial),
Der: subjectCertDer,
DER: subjectCertDer,
Digest: core.Fingerprint256(subjectCertDer),
Issued: timestamppb.New(subjectCert.NotBefore),
Expires: timestamppb.New(subjectCert.NotAfter),
Issued: subjectCert.NotBefore,
Expires: subjectCert.NotAfter,
}
// Without any ignored lints we expect several errors and warnings about SCTs,
@ -681,12 +679,12 @@ func TestPrecertCorrespond(t *testing.T) {
SerialNumber: serial,
}
certDer, _ := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey)
cert := &corepb.Certificate{
cert := core.Certificate{
Serial: core.SerialToString(serial),
Digest: core.Fingerprint256(certDer),
Der: certDer,
Issued: timestamppb.New(time.Now()),
Expires: timestamppb.New(expiry),
DER: certDer,
Issued: time.Now(),
Expires: expiry,
}
_, problems := checker.checkCert(context.Background(), cert)
if len(problems) == 0 {

View File

@ -1,5 +1,5 @@
-----BEGIN CERTIFICATE-----
MIIDWTCCAkGgAwIBAgIILgLqdMwyzT4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
MIIDUzCCAjugAwIBAgIILgLqdMwyzT4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgOTMzZTM5MB4XDTIxMTExMTIwMjMzMloXDTIzMTIx
MTIwMjMzMlowHDEaMBgGA1UEAwwRcXVpdGVfaW52YWxpZC5jb20wggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDi4jBbqMyvhMonDngNsvie9SHPB16mdpiy
@ -7,14 +7,14 @@ Y/agreU84xUz/roKK07TpVmeqvwWvDkvHTFov7ytKdnCY+z/NXKJ3hNqflWCwU7h
Uk9TmpBp0vg+5NvalYul/+bq/B4qDhEvTBzAX3k/UYzd0GQdMyAbwXtG41f5cSK6
cWTQYfJL3gGR5/KLoTz3/VemLgEgAP/CvgcUJPbQceQViiZ4opi9hFIfUqxX2NsD
49klw8cDFu/BG2LEC+XtbdT8XevD0aGIOuYVr+Pa2mxb2QCDXu4tXOsDXH9Y/Cmk
8103QbdB8Y+usOiHG/IXxK2q4J7QNPal4ER4/PGA06V0gwrjNH8BAgMBAAGjgZow
gZcwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
8103QbdB8Y+usOiHG/IXxK2q4J7QNPal4ER4/PGA06V0gwrjNH8BAgMBAAGjgZQw
gZEwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFNIcaCjv32YRafE065dZO57ONWuk
MDcGA1UdEQQwMC6CEXF1aXRlX2ludmFsaWQuY29tghNhbC0tc28tLXdyLS1vbmcu
Y29thwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQAjSv0o5G4VuLnnwHON4P53bLvG
nYqaqYjuTEafi3hSgHAfBuhOQUVgwujoYpPp1w1fm5spfcbSwNNRte79HgV97kAu
Z4R4RHk15Xux1ITLalaHR/ilu002N0eJ7dFYawBgV2xMudULzohwmW2RjPJ5811i
WwtiVf1bA3V5SZJWSJll1BhANBs7R0pBbyTSNHR470N8TGG0jfXqgTKd0xZaH91H
rwEMo+96llbfp90Y5OfHIfym/N1sH2hVgd+ZAkhiVEiNBWZlbSyOgbZ1cCBvBXg6
TuwpQMZK9RWjlpni8yuzLGduPl8qHG1dqsUvbVqcG+WhHLbaZMNhiMfiWInL
MDEGA1UdEQQqMCiCEXF1aXRlX2ludmFsaWQuY29tghNhbC0tc28tLXdyLS1vbmcu
Y29tMA0GCSqGSIb3DQEBCwUAA4IBAQAjSv0o5G4VuLnnwHON4P53bLvGnYqaqYju
TEafi3hSgHAfBuhOQUVgwujoYpPp1w1fm5spfcbSwNNRte79HgV97kAuZ4R4RHk1
5Xux1ITLalaHR/ilu002N0eJ7dFYawBgV2xMudULzohwmW2RjPJ5811iWwtiVf1b
A3V5SZJWSJll1BhANBs7R0pBbyTSNHR470N8TGG0jfXqgTKd0xZaH91HrwEMo+96
llbfp90Y5OfHIfym/N1sH2hVgd+ZAkhiVEiNBWZlbSyOgbZ1cCBvBXg6TuwpQMZK
9RWjlpni8yuzLGduPl8qHG1dqsUvbVqcG+WhHLbaZMNhiMfiWInL
-----END CERTIFICATE-----

View File

@ -16,7 +16,6 @@ import (
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/identifier"
)
// PasswordConfig contains a path to a file containing a password.
@ -89,13 +88,19 @@ func (d *DBConfig) URL() (string, error) {
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"`
Identifiers map[identifier.IdentifierType]bool `validate:"omitempty,dive,keys,oneof=dns ip,endkeys"`
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
@ -112,17 +117,6 @@ func (pc PAConfig) CheckChallenges() error {
return nil
}
// CheckIdentifiers checks whether the list of identifiers in the PA config
// actually contains valid identifier type names
func (pc PAConfig) CheckIdentifiers() error {
for i := range pc.Identifiers {
if !i.IsValid() {
return fmt.Errorf("invalid identifier type in PA config: %s", i)
}
}
return nil
}
// HostnamePolicyConfig specifies a file from which to load a policy regarding
// what hostnames to issue for.
type HostnamePolicyConfig struct {
@ -290,7 +284,7 @@ type GRPCClientConfig struct {
// 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.77.77.10 -t SRV _foo._tcp.service.consul +short
// $ 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"`
@ -330,7 +324,7 @@ type GRPCClientConfig struct {
// 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.77.77.10 foo.service.consul +short
// $ 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"`
@ -456,7 +450,7 @@ type GRPCServerConfig struct {
// 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"`
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
@ -467,10 +461,10 @@ type GRPCServerConfig struct {
// GRPCServiceConfig contains the information needed to configure a gRPC service.
type GRPCServiceConfig struct {
// ClientNames is the list of accepted gRPC client certificate SANs.
// Connections from clients not in this list will be rejected by the
// upstream listener, and RPCs from unlisted clients will be denied by the
// server interceptor.
// 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"`
}
@ -555,7 +549,7 @@ type DNSProvider struct {
// 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.77.77.10 -t SRV _unbound._udp.service.consul +short
// $ 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"`

View File

@ -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:
```
<id> <createdAt> <problem type> "<contact contents or entry>" "<error msg>"
```
## 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 "<contact entry>" "<error msg>"
...
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 "<contact contents>" "<error msg>"
...
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: <error msg>
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": <string>,
"maxOpenConns": <int>,
"maxIdleConns": <int>,
"connMaxLifetime": <int>,
"connMaxIdleTime": <int>
}
}
}
```

212
cmd/contact-auditor/main.go Normal file
View File

@ -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{}})
}

View File

@ -0,0 +1,212 @@
package notmain
import (
"context"
"fmt"
"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"
}`)
regA = &corepb.Registration{
Id: 1,
Contact: []string{emailA},
Key: jsonKeyA,
}
regB = &corepb.Registration{
Id: 2,
Contact: []string{emailB},
Key: jsonKeyB,
}
regC = &corepb.Registration{
Id: 3,
Contact: []string{emailC},
Key: jsonKeyC,
}
// Reg D has a `tel:` contact ACME URL
regD = &corepb.Registration{
Id: 4,
Contact: []string{tel},
Key: jsonKeyD,
}
// Add the four test registrations
ctx := context.Background()
var err error
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,
}
}

View File

@ -57,9 +57,10 @@ type Config struct {
LookbackPeriod 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:
// "MUST update and publish a new CRL within twentyfour (24) hours after
// recording a Certificate as revoked."
// 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
// UpdateTimeout controls how long a single CRL shard is allowed to attempt

View File

@ -49,12 +49,6 @@ type Config struct {
// PardotBaseURL is the base URL for the Pardot API. (e.g.,
// "https://pi.pardot.com")
PardotBaseURL string `validate:"required"`
// EmailCacheSize controls how many hashed email addresses are retained
// in memory to prevent duplicates from being sent to the Pardot API.
// Each entry consumes ~120 bytes, so 100,000 entries uses around 12MB
// of memory. If left unset, no caching is performed.
EmailCacheSize int `validate:"omitempty,min=1"`
}
Syslog cmd.SyslogConfig
OpenTelemetry cmd.OpenTelemetryConfig
@ -93,11 +87,6 @@ func main() {
clientSecret, err := c.EmailExporter.ClientSecret.Pass()
cmd.FailOnError(err, "Loading clientSecret")
var cache *email.EmailCache
if c.EmailExporter.EmailCacheSize > 0 {
cache = email.NewHashedEmailCache(c.EmailExporter.EmailCacheSize, scope)
}
pardotClient, err := email.NewPardotClientImpl(
clk,
c.EmailExporter.PardotBusinessUnit,
@ -107,7 +96,7 @@ func main() {
c.EmailExporter.PardotBaseURL,
)
cmd.FailOnError(err, "Creating Pardot API client")
exporterServer := email.NewExporterImpl(pardotClient, cache, c.EmailExporter.PerDayLimit, c.EmailExporter.MaxConcurrentRequests, scope, logger)
exporterServer := email.NewExporterImpl(pardotClient, c.EmailExporter.PerDayLimit, c.EmailExporter.MaxConcurrentRequests, scope, logger)
tlsConfig, err := c.EmailExporter.TLS.Load(scope)
cmd.FailOnError(err, "Loading email-exporter TLS config")

View File

@ -0,0 +1,964 @@
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 errors.New("daily mail limit exceeded for this email 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", err)
continue
}
if parsed.Scheme != "mailto" {
continue
}
address := parsed.Opaque
err = policy.ValidEmail(address)
if err != nil {
m.log.Debugf("skipping invalid email: %s", 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 {
DaysToExpiration int
TruncatedDNSNames []string
TruncatedSerials []string
}{
DaysToExpiration: email.DaysToExpiration,
TruncatedDNSNames: truncatedDomains,
TruncatedSerials: truncatedSerials,
}
logStr, err := json.Marshal(logItem)
if err != nil {
return fmt.Errorf("failed to serialize log line: %w", err)
}
m.log.Infof("attempting send for JSON=%s", string(logStr))
startSending := m.clk.Now()
err = conn.SendMail(emails, subjBuf.String(), msgBuf.String())
if err != nil {
return fmt.Errorf("failed send for %s: %w", string(logStr), 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 <example@test.org>"
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{}})
}

View File

@ -0,0 +1,996 @@
package notmain
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"fmt"
"math/big"
"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 for 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], `"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])
}
if strings.Contains(sendLogs[0], `"@gmail.com"`) {
t.Errorf("log line should not contain email address, 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) {
reg, err := sac.NewRegistration(context.Background(), &corepb.Registration{
Id: id,
Contact: contacts,
Key: jsonKey,
})
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")
}

View File

@ -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,
}
}

304
cmd/id-exporter/main.go Normal file
View File

@ -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{}})
}

View File

@ -0,0 +1,461 @@
package notmain
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"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"
}`)
// Regs A through C have `mailto:` contact ACME URL's
regA = &corepb.Registration{
Id: 1,
Contact: []string{emailA},
Key: jsonKeyA,
}
regB = &corepb.Registration{
Id: 2,
Contact: []string{emailB},
Key: jsonKeyB,
}
regC = &corepb.Registration{
Id: 3,
Contact: []string{emailC},
Key: jsonKeyC,
}
// Reg D has a `tel:` contact ACME URL
regD = &corepb.Registration{
Id: 4,
Contact: []string{tel},
Key: jsonKeyD,
}
// Add the four test registrations
ctx := context.Background()
var err error
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)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
test.AssertNotError(t, err, "creating test key")
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, key.Public(), key)
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, key.Public(), key)
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, key.Public(), key)
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, key.Public(), key)
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,
}
}

View File

@ -5,7 +5,6 @@ import (
"flag"
"fmt"
"net"
"net/netip"
"os"
"github.com/letsencrypt/boulder/cmd"
@ -42,8 +41,8 @@ func derivePrefix(key []byte, grpcAddr string) (string, error) {
return "", fmt.Errorf("nonce service gRPC address must include an IP address: got %q", grpcAddr)
}
if host != "" && port != "" {
hostIP, err := netip.ParseAddr(host)
if err != nil {
hostIP := net.ParseIP(host)
if hostIP == nil {
return "", fmt.Errorf("gRPC address host part was not an IP address")
}
if hostIP.IsUnspecified() {

619
cmd/notify-mailer/main.go Normal file
View File

@ -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{}})
}

View File

@ -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)
}
}
}

View File

@ -0,0 +1,3 @@
This is a test message body regarding these domains:
{{ range . }} {{ .Extra.domainName }}
{{ end }}

View File

@ -0,0 +1,4 @@
id,domainName
1,one.example.com
2,two.example.net
3,three.example.org
1 id domainName
2 1 one.example.com
3 2 two.example.net
4 3 three.example.org

View File

@ -11,7 +11,6 @@ import (
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/iana"
"github.com/letsencrypt/boulder/va"
vaConfig "github.com/letsencrypt/boulder/va/config"
vapb "github.com/letsencrypt/boulder/va/proto"
@ -87,12 +86,16 @@ func main() {
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, "tcp")
servers, err = bdns.StartDynamicProvider(c.RVA.DNSProvider, 60*time.Second, proto)
cmd.FailOnError(err, "Couldn't start dynamic DNS server resolver")
}
defer servers.Stop()
@ -112,7 +115,6 @@ func main() {
scope,
clk,
c.RVA.DNSTries,
c.RVA.UserAgent,
logger,
tlsConfig)
} else {
@ -122,7 +124,6 @@ func main() {
scope,
clk,
c.RVA.DNSTries,
c.RVA.UserAgent,
logger,
tlsConfig)
}
@ -138,7 +139,7 @@ func main() {
c.RVA.AccountURIPrefixes,
c.RVA.Perspective,
c.RVA.RIR,
iana.IsReservedAddr)
bdns.IsReservedIP)
cmd.FailOnError(err, "Unable to create Remote-VA server")
start, err := bgrpc.NewServer(c.RVA.GRPC, logger).Add(

View File

@ -1,5 +1,5 @@
// Read a list of reversed FQDNs and/or normal IP addresses, separated by
// newlines. Print only those that are rejected by the current policy.
// Read a list of reversed hostnames, separated by newlines. Print only those
// that are rejected by the current policy.
package notmain
@ -9,7 +9,6 @@ import (
"fmt"
"io"
"log"
"net/netip"
"os"
"github.com/letsencrypt/boulder/cmd"
@ -24,7 +23,7 @@ func init() {
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/ident-policy.yaml", "File containing an identifier policy in YAML.")
policyFile := flag.String("policy", "test/hostname-policy.yaml", "File containing a hostname policy in yaml.")
flag.Parse()
var input io.Reader
@ -41,25 +40,18 @@ func main() {
scanner := bufio.NewScanner(input)
logger := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7})
logger.Info(cmd.VersionString())
pa, err := policy.New(nil, nil, logger)
pa, err := policy.New(nil, logger)
if err != nil {
log.Fatal(err)
}
err = pa.LoadIdentPolicyFile(*policyFile)
err = pa.LoadHostnamePolicyFile(*policyFile)
if err != nil {
log.Fatalf("reading %s: %s", *policyFile, err)
}
var errors bool
for scanner.Scan() {
n := sa.EncodeIssuedName(scanner.Text())
var ident identifier.ACMEIdentifier
ip, err := netip.ParseAddr(n)
if err == nil {
ident = identifier.NewIP(ip)
} else {
ident = identifier.NewDNS(n)
}
err = pa.WillingToIssue(identifier.ACMEIdentifiers{ident})
n := sa.ReverseName(scanner.Text())
err := pa.WillingToIssue([]identifier.ACMEIdentifier{identifier.NewDNS(n)})
if err != nil {
errors = true
fmt.Printf("%s: %s\n", n, err)

View File

@ -15,7 +15,6 @@ import (
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/db"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/rocsp"
@ -40,8 +39,8 @@ func makeClient() (*rocsp.RWClient, clock.Clock) {
rdb := redis.NewRing(&redis.RingOptions{
Addrs: map[string]string{
"shard1": "10.77.77.2:4218",
"shard2": "10.77.77.3:4218",
"shard1": "10.33.33.2:4218",
"shard2": "10.33.33.3:4218",
},
Username: "unittest-rw",
Password: "824968fa490f4ecec1e52d5e34916bdb60d45f8d",
@ -51,34 +50,29 @@ func makeClient() (*rocsp.RWClient, clock.Clock) {
return rocsp.NewWritingClient(rdb, 500*time.Millisecond, clk, metrics.NoopRegisterer), clk
}
func insertCertificateStatus(t *testing.T, dbMap db.Executor, serial string, notAfter, ocspLastUpdated time.Time) int64 {
result, err := dbMap.ExecContext(context.Background(),
`INSERT INTO certificateStatus
(serial, notAfter, status, ocspLastUpdated, revokedDate, revokedReason, lastExpirationNagSent, issuerID)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
serial,
notAfter,
core.OCSPStatusGood,
ocspLastUpdated,
time.Time{},
0,
time.Time{},
99)
test.AssertNotError(t, err, "inserting certificate status")
id, err := result.LastInsertId()
test.AssertNotError(t, err, "getting last insert ID")
return id
}
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)()
firstID := insertCertificateStatus(t, dbMap, "1337", clk.Now().Add(12*time.Hour), time.Time{})
secondID := insertCertificateStatus(t, dbMap, "1338", clk.Now().Add(36*time.Hour), time.Time{})
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)
@ -137,7 +131,11 @@ func TestLoadFromDB(t *testing.T) {
defer test.ResetBoulderTestDatabase(t)
for i := range 100 {
insertCertificateStatus(t, dbMap, fmt.Sprintf("%036x", i), clk.Now().Add(200*time.Hour), clk.Now())
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)
}

View File

@ -31,7 +31,7 @@ import (
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
"google.golang.org/grpc/grpclog"
"github.com/letsencrypt/boulder/config"

View File

@ -23,24 +23,22 @@ var (
validPAConfig = []byte(`{
"dbConnect": "dummyDBConnect",
"enforcePolicyWhitelist": false,
"challenges": { "http-01": true },
"identifiers": { "dns": true, "ip": true }
"challenges": { "http-01": true }
}`)
invalidPAConfig = []byte(`{
"dbConnect": "dummyDBConnect",
"enforcePolicyWhitelist": false,
"challenges": { "nonsense": true },
"identifiers": { "openpgp": true }
"challenges": { "nonsense": true }
}`)
noChallengesIdentsPAConfig = []byte(`{
noChallengesPAConfig = []byte(`{
"dbConnect": "dummyDBConnect",
"enforcePolicyWhitelist": false
}`)
emptyChallengesIdentsPAConfig = []byte(`{
emptyChallengesPAConfig = []byte(`{
"dbConnect": "dummyDBConnect",
"enforcePolicyWhitelist": false,
"challenges": {},
"identifiers": {}
"challenges": {}
}`)
)
@ -49,25 +47,21 @@ func TestPAConfigUnmarshal(t *testing.T) {
err := json.Unmarshal(validPAConfig, &pc1)
test.AssertNotError(t, err, "Failed to unmarshal PAConfig")
test.AssertNotError(t, pc1.CheckChallenges(), "Flagged valid challenges as bad")
test.AssertNotError(t, pc1.CheckIdentifiers(), "Flagged valid identifiers 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")
test.AssertError(t, pc2.CheckIdentifiers(), "Considered invalid identifiers as good")
var pc3 PAConfig
err = json.Unmarshal(noChallengesIdentsPAConfig, &pc3)
err = json.Unmarshal(noChallengesPAConfig, &pc3)
test.AssertNotError(t, err, "Failed to unmarshal PAConfig")
test.AssertError(t, pc3.CheckChallenges(), "Disallow empty challenges map")
test.AssertNotError(t, pc3.CheckIdentifiers(), "Disallowed empty identifiers map")
var pc4 PAConfig
err = json.Unmarshal(emptyChallengesIdentsPAConfig, &pc4)
err = json.Unmarshal(emptyChallengesPAConfig, &pc4)
test.AssertNotError(t, err, "Failed to unmarshal PAConfig")
test.AssertError(t, pc4.CheckChallenges(), "Disallow empty challenges map")
test.AssertNotError(t, pc4.CheckIdentifiers(), "Disallowed empty identifiers map")
}
func TestMysqlLogger(t *testing.T) {
@ -133,13 +127,16 @@ func TestReadConfigFile(t *testing.T) {
test.AssertError(t, err, "ReadConfigFile('') did not error")
type config struct {
GRPC *GRPCClientConfig
TLS *TLSConfig
NotifyMailer struct {
DB DBConfig
SMTPConfig
}
Syslog SyslogConfig
}
var c config
err = ReadConfigFile("../test/config/health-checker.json", &c)
test.AssertNotError(t, err, "ReadConfigFile(../test/config/health-checker.json) errored")
test.AssertEquals(t, c.GRPC.Timeout.Duration, 1*time.Second)
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) {
@ -276,6 +273,7 @@ func TestFailExit(t *testing.T) {
return
}
//nolint: gosec // Test-only code is not concerned about untrusted values in os.Args[0]
cmd := exec.Command(os.Args[0], "-test.run=TestFailExit")
cmd.Env = append(os.Environ(), "TIME_TO_DIE=1")
output, err := cmd.CombinedOutput()
@ -302,6 +300,7 @@ func TestPanicStackTrace(t *testing.T) {
return
}
//nolint: gosec // Test-only code is not concerned about untrusted values in os.Args[0]
cmd := exec.Command(os.Args[0], "-test.run=TestPanicStackTrace")
cmd.Env = append(os.Environ(), "AT_THE_DISCO=1")
output, err := cmd.CombinedOutput()

View File

@ -67,8 +67,3 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error {
d.Duration = dur
return nil
}
// MarshalYAML returns the string form of the duration, as a string.
func (d Duration) MarshalYAML() (any, error) {
return d.Duration.String(), nil
}

View File

@ -25,11 +25,6 @@ func TLSALPNChallenge01(token string) Challenge {
return newChallenge(ChallengeTypeTLSALPN01, token)
}
// DNSAccountChallenge01 constructs a dns-account-01 challenge.
func DNSAccountChallenge01(token string) Challenge {
return newChallenge(ChallengeTypeDNSAccount01, 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) {
@ -40,8 +35,6 @@ func NewChallenge(kind AcmeChallenge, token string) (Challenge, error) {
return DNSChallenge01(token), nil
case ChallengeTypeTLSALPN01:
return TLSALPNChallenge01(token), nil
case ChallengeTypeDNSAccount01:
return DNSAccountChallenge01(token), nil
default:
return Challenge{}, fmt.Errorf("unrecognized challenge type %q", kind)
}

View File

@ -32,16 +32,12 @@ func TestChallenges(t *testing.T) {
dns01 := DNSChallenge01(token)
test.AssertNotError(t, dns01.CheckPending(), "CheckConsistencyForClientOffer returned an error")
dnsAccount01 := DNSAccountChallenge01(token)
test.AssertNotError(t, dnsAccount01.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, ChallengeTypeDNSAccount01.IsValid(), "Refused valid challenge")
test.Assert(t, !AcmeChallenge("nonsense-71").IsValid(), "Accepted invalid challenge")
}

View File

@ -7,7 +7,7 @@ import (
// PolicyAuthority defines the public interface for the Boulder PA
// TODO(#5891): Move this interface to a more appropriate location.
type PolicyAuthority interface {
WillingToIssue(identifier.ACMEIdentifiers) error
WillingToIssue([]identifier.ACMEIdentifier) error
ChallengeTypesFor(identifier.ACMEIdentifier) ([]AcmeChallenge, error)
ChallengeTypeEnabled(AcmeChallenge) bool
CheckAuthzChallenges(*Authorization) error

View File

@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"hash/fnv"
"net/netip"
"net"
"strings"
"time"
@ -53,23 +53,22 @@ type AcmeChallenge string
// These types are the available challenges
const (
ChallengeTypeHTTP01 = AcmeChallenge("http-01")
ChallengeTypeDNS01 = AcmeChallenge("dns-01")
ChallengeTypeTLSALPN01 = AcmeChallenge("tls-alpn-01")
ChallengeTypeDNSAccount01 = AcmeChallenge("dns-account-01")
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, ChallengeTypeDNSAccount01:
case ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01:
return true
default:
return false
}
}
// OCSPStatus defines the state of OCSP for a certificate
// OCSPStatus defines the state of OCSP for a domain
type OCSPStatus string
// These status are the states of OCSP
@ -99,7 +98,7 @@ type RawCertificateRequest struct {
// to account keys.
type Registration struct {
// Unique identifier
ID int64 `json:"id,omitempty"`
ID int64 `json:"id,omitempty" db:"id"`
// Account key to which the details are attached
Key *jose.JSONWebKey `json:"key"`
@ -124,11 +123,11 @@ type ValidationRecord struct {
// Shared
//
// Hostname can hold either a DNS name or an IP address.
Hostname string `json:"hostname,omitempty"`
Port string `json:"port,omitempty"`
AddressesResolved []netip.Addr `json:"addressesResolved,omitempty"`
AddressUsed netip.Addr `json:"addressUsed,omitempty"`
// TODO(#7311): Replace DnsName with Identifier.
DnsName 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
@ -144,7 +143,7 @@ type ValidationRecord struct {
// AddressesTried: [ ::1 ],
// ...
// }
AddressesTried []netip.Addr `json:"addressesTried,omitempty"`
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
@ -211,7 +210,7 @@ func (ch Challenge) RecordsSane() bool {
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 == netip.Addr{}) ||
if rec.URL == "" || rec.DnsName == "" || rec.Port == "" || rec.AddressUsed == nil ||
len(rec.AddressesResolved) == 0 {
return false
}
@ -225,17 +224,17 @@ func (ch Challenge) RecordsSane() bool {
}
// 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 == netip.Addr{}) || len(ch.ValidationRecord[0].AddressesResolved) == 0 {
if ch.ValidationRecord[0].DnsName == "" || ch.ValidationRecord[0].Port == "" ||
ch.ValidationRecord[0].AddressUsed == nil || len(ch.ValidationRecord[0].AddressesResolved) == 0 {
return false
}
case ChallengeTypeDNS01, ChallengeTypeDNSAccount01:
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 == "" {
if ch.ValidationRecord[0].DnsName == "" {
return false
}
return true
@ -272,30 +271,30 @@ func (ch Challenge) StringID() string {
return base64.RawURLEncoding.EncodeToString(h.Sum(nil)[0:4])
}
// Authorization represents the authorization of an account key holder to act on
// behalf of an identifier. 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.
// 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 string `json:"-" db:"id"`
// The identifier for which authorization is being given
Identifier identifier.ACMEIdentifier `json:"identifier,omitempty"`
Identifier identifier.ACMEIdentifier `json:"identifier,omitempty" db:"identifier"`
// The registration ID associated with the authorization
RegistrationID int64 `json:"-"`
RegistrationID int64 `json:"-" db:"registrationID"`
// The status of the validation of this authorization
Status AcmeStatus `json:"status,omitempty"`
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"`
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
@ -305,7 +304,7 @@ type Authorization struct {
//
// 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"`
Challenges []Challenge `json:"challenges,omitempty" db:"-"`
// https://datatracker.ietf.org/doc/html/rfc8555#page-29
//
@ -319,7 +318,7 @@ type Authorization struct {
// 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"`
Wildcard bool `json:"wildcard,omitempty" db:"-"`
// CertificateProfileName is the name of the profile associated with the
// order that first resulted in the creation of this authorization. Omitted
@ -416,9 +415,9 @@ type CertificateStatus struct {
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 CRL updater. See
// https://github.com/letsencrypt/boulder/issues/1864.
// 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"`
@ -430,6 +429,16 @@ type CertificateStatus struct {
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

View File

@ -4,7 +4,7 @@ import (
"crypto/rsa"
"encoding/json"
"math/big"
"net/netip"
"net"
"testing"
"time"
@ -37,10 +37,10 @@ func TestRecordSanityCheckOnUnsupportedChallengeType(t *testing.T) {
rec := []ValidationRecord{
{
URL: "http://localhost/test",
Hostname: "localhost",
DnsName: "localhost",
Port: "80",
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
AddressUsed: netip.MustParseAddr("127.0.0.1"),
AddressesResolved: []net.IP{{127, 0, 0, 1}},
AddressUsed: net.IP{127, 0, 0, 1},
ResolverAddrs: []string{"eastUnboundAndDown"},
},
}
@ -59,7 +59,7 @@ func TestChallengeSanityCheck(t *testing.T) {
}`), &accountKey)
test.AssertNotError(t, err, "Error unmarshaling JWK")
types := []AcmeChallenge{ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01}
types := []AcmeChallenge{ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01}
for _, challengeType := range types {
chall := Challenge{
Type: challengeType,
@ -152,8 +152,6 @@ func TestChallengeStringID(t *testing.T) {
test.AssertEquals(t, ch.StringID(), "iFVMwA")
ch.Type = ChallengeTypeHTTP01
test.AssertEquals(t, ch.StringID(), "0Gexug")
ch.Type = ChallengeTypeDNSAccount01
test.AssertEquals(t, ch.StringID(), "8z2wSg")
}
func TestFindChallengeByType(t *testing.T) {

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc-gen-go v1.34.1
// protoc v3.20.1
// source: core.proto
@ -12,7 +12,6 @@ import (
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
@ -23,18 +22,21 @@ const (
)
type Identifier struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_core_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Identifier) String() string {
@ -45,7 +47,7 @@ func (*Identifier) ProtoMessage() {}
func (x *Identifier) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -75,8 +77,11 @@ func (x *Identifier) GetValue() string {
}
type Challenge struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
// Fields specified by RFC 8555, Section 8.
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
Url string `protobuf:"bytes,9,opt,name=url,proto3" json:"url,omitempty"`
@ -87,15 +92,15 @@ type Challenge struct {
Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"`
// Additional fields for our own record keeping.
Validationrecords []*ValidationRecord `protobuf:"bytes,10,rep,name=validationrecords,proto3" json:"validationrecords,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Challenge) Reset() {
*x = Challenge{}
mi := &file_core_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Challenge) String() string {
@ -106,7 +111,7 @@ func (*Challenge) ProtoMessage() {}
func (x *Challenge) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -178,28 +183,31 @@ func (x *Challenge) GetValidationrecords() []*ValidationRecord {
}
type ValidationRecord struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"` // netip.Addr.MarshalText()
AddressUsed []byte `protobuf:"bytes,4,opt,name=addressUsed,proto3" json:"addressUsed,omitempty"` // netip.Addr.MarshalText()
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"` // netip.Addr.MarshalText()
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ValidationRecord) Reset() {
*x = ValidationRecord{}
mi := &file_core_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ValidationRecord) String() string {
@ -210,7 +218,7 @@ func (*ValidationRecord) ProtoMessage() {}
func (x *ValidationRecord) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[2]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -282,19 +290,22 @@ func (x *ValidationRecord) GetResolverAddrs() []string {
}
type ProblemDetails struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_core_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ProblemDetails) String() string {
@ -305,7 +316,7 @@ func (*ProblemDetails) ProtoMessage() {}
func (x *ProblemDetails) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[3]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -342,7 +353,10 @@ func (x *ProblemDetails) GetHttpStatus() int32 {
}
type Certificate struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
@ -350,15 +364,15 @@ type Certificate struct {
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Certificate) Reset() {
*x = Certificate{}
mi := &file_core_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Certificate) String() string {
@ -369,7 +383,7 @@ func (*Certificate) ProtoMessage() {}
func (x *Certificate) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[4]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -427,7 +441,10 @@ func (x *Certificate) GetExpires() *timestamppb.Timestamp {
}
type CertificateStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
@ -438,15 +455,15 @@ type CertificateStatus struct {
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CertificateStatus) Reset() {
*x = CertificateStatus{}
mi := &file_core_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CertificateStatus) String() string {
@ -457,7 +474,7 @@ func (*CertificateStatus) ProtoMessage() {}
func (x *CertificateStatus) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[5]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -536,22 +553,26 @@ func (x *CertificateStatus) GetIssuerID() int64 {
}
type Registration struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 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"`
Agreement string `protobuf:"bytes,5,opt,name=agreement,proto3" json:"agreement,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"`
unknownFields protoimpl.UnknownFields
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"`
Agreement string `protobuf:"bytes,5,opt,name=agreement,proto3" json:"agreement,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{}
mi := &file_core_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Registration) String() string {
@ -562,7 +583,7 @@ func (*Registration) ProtoMessage() {}
func (x *Registration) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[6]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -591,6 +612,13 @@ func (x *Registration) GetKey() []byte {
return nil
}
func (x *Registration) GetContact() []string {
if x != nil {
return x.Contact
}
return nil
}
func (x *Registration) GetAgreement() string {
if x != nil {
return x.Agreement
@ -613,23 +641,27 @@ func (x *Registration) GetStatus() string {
}
type Authorization struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
RegistrationID int64 `protobuf:"varint,3,opt,name=registrationID,proto3" json:"registrationID,omitempty"`
Identifier *Identifier `protobuf:"bytes,11,opt,name=identifier,proto3" json:"identifier,omitempty"`
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
RegistrationID int64 `protobuf:"varint,3,opt,name=registrationID,proto3" json:"registrationID,omitempty"`
// Fields specified by RFC 8555, Section 7.1.4
DnsName string `protobuf:"bytes,2,opt,name=dnsName,proto3" json:"dnsName,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"`
CertificateProfileName string `protobuf:"bytes,10,opt,name=certificateProfileName,proto3" json:"certificateProfileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Authorization) Reset() {
*x = Authorization{}
mi := &file_core_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Authorization) String() string {
@ -640,7 +672,7 @@ func (*Authorization) ProtoMessage() {}
func (x *Authorization) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[7]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -669,11 +701,11 @@ func (x *Authorization) GetRegistrationID() int64 {
return 0
}
func (x *Authorization) GetIdentifier() *Identifier {
func (x *Authorization) GetDnsName() string {
if x != nil {
return x.Identifier
return x.DnsName
}
return nil
return ""
}
func (x *Authorization) GetStatus() string {
@ -705,15 +737,18 @@ func (x *Authorization) GetCertificateProfileName() string {
}
type Order struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"`
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
RegistrationID int64 `protobuf:"varint,2,opt,name=registrationID,proto3" json:"registrationID,omitempty"`
// Fields specified by RFC 8555, Section 7.1.3
// Note that we do not respect notBefore and notAfter, and we infer the
// finalize and certificate URLs from the id and certificateSerial fields.
Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"`
Expires *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=expires,proto3" json:"expires,omitempty"`
Identifiers []*Identifier `protobuf:"bytes,16,rep,name=identifiers,proto3" json:"identifiers,omitempty"`
DnsNames []string `protobuf:"bytes,8,rep,name=dnsNames,proto3" json:"dnsNames,omitempty"`
Error *ProblemDetails `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
V2Authorizations []int64 `protobuf:"varint,11,rep,packed,name=v2Authorizations,proto3" json:"v2Authorizations,omitempty"`
CertificateSerial string `protobuf:"bytes,5,opt,name=certificateSerial,proto3" json:"certificateSerial,omitempty"`
@ -722,15 +757,15 @@ type Order struct {
CertificateProfileName string `protobuf:"bytes,14,opt,name=certificateProfileName,proto3" json:"certificateProfileName,omitempty"`
Replaces string `protobuf:"bytes,15,opt,name=replaces,proto3" json:"replaces,omitempty"`
BeganProcessing bool `protobuf:"varint,9,opt,name=beganProcessing,proto3" json:"beganProcessing,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Order) Reset() {
*x = Order{}
mi := &file_core_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Order) String() string {
@ -741,7 +776,7 @@ func (*Order) ProtoMessage() {}
func (x *Order) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[8]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -784,9 +819,9 @@ func (x *Order) GetExpires() *timestamppb.Timestamp {
return nil
}
func (x *Order) GetIdentifiers() []*Identifier {
func (x *Order) GetDnsNames() []string {
if x != nil {
return x.Identifiers
return x.DnsNames
}
return nil
}
@ -841,20 +876,23 @@ func (x *Order) GetBeganProcessing() bool {
}
type CRLEntry struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 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"`
unknownFields protoimpl.UnknownFields
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{}
mi := &file_core_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_core_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CRLEntry) String() string {
@ -865,7 +903,7 @@ func (*CRLEntry) ProtoMessage() {}
func (x *CRLEntry) ProtoReflect() protoreflect.Message {
mi := &file_core_proto_msgTypes[9]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -903,7 +941,7 @@ func (x *CRLEntry) GetRevokedAt() *timestamppb.Timestamp {
var File_core_proto protoreflect.FileDescriptor
var file_core_proto_rawDesc = string([]byte{
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,
@ -998,100 +1036,98 @@ var file_core_proto_rawDesc = string([]byte{
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, 0xb8, 0x01, 0x0a,
0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0xcc, 0x01, 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,
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, 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, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x06, 0x10,
0x07, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0xc8, 0x02, 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, 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, 0x30, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x49, 0x64, 0x65,
0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66,
0x69, 0x65, 0x72, 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, 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, 0x0a, 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, 0x05, 0x10, 0x06,
0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x4a, 0x04, 0x08, 0x02,
0x10, 0x03, 0x22, 0x93, 0x04, 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, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 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, 0x32, 0x0a, 0x0b, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72,
0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x49,
0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0b, 0x69, 0x64, 0x65, 0x6e, 0x74,
0x69, 0x66, 0x69, 0x65, 0x72, 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, 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, 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, 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, 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, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65,
0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65,
0x70, 0x6c, 0x61, 0x63, 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,
0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x07, 0x4a, 0x04, 0x08, 0x0a,
0x10, 0x0b, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 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,
})
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, 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, 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, 0x04, 0x10, 0x05, 0x4a,
0x04, 0x08, 0x06, 0x10, 0x07, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0xaa, 0x02, 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, 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, 0x64, 0x6e, 0x73, 0x4e, 0x61, 0x6d, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6e, 0x73, 0x4e, 0x61, 0x6d, 0x65, 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, 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, 0x0a, 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, 0x05, 0x10, 0x06, 0x4a, 0x04, 0x08, 0x07,
0x10, 0x08, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x22, 0xf5, 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, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 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, 0x1a, 0x0a, 0x08, 0x64, 0x6e, 0x73, 0x4e,
0x61, 0x6d, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x64, 0x6e, 0x73, 0x4e,
0x61, 0x6d, 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, 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, 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, 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, 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, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6c,
0x61, 0x63, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x70, 0x6c,
0x61, 0x63, 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, 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 []byte
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(unsafe.Slice(unsafe.StringData(file_core_proto_rawDesc), len(file_core_proto_rawDesc)))
file_core_proto_rawDescData = protoimpl.X.CompressGZIP(file_core_proto_rawDescData)
})
return file_core_proto_rawDescData
}
var file_core_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_core_proto_goTypes = []any{
var file_core_proto_goTypes = []interface{}{
(*Identifier)(nil), // 0: core.Identifier
(*Challenge)(nil), // 1: core.Challenge
(*ValidationRecord)(nil), // 2: core.ValidationRecord
@ -1115,19 +1151,17 @@ var file_core_proto_depIdxs = []int32{
10, // 7: core.CertificateStatus.lastExpirationNagSent:type_name -> google.protobuf.Timestamp
10, // 8: core.CertificateStatus.notAfter:type_name -> google.protobuf.Timestamp
10, // 9: core.Registration.createdAt:type_name -> google.protobuf.Timestamp
0, // 10: core.Authorization.identifier:type_name -> core.Identifier
10, // 11: core.Authorization.expires:type_name -> google.protobuf.Timestamp
1, // 12: core.Authorization.challenges:type_name -> core.Challenge
10, // 13: core.Order.expires:type_name -> google.protobuf.Timestamp
0, // 14: core.Order.identifiers:type_name -> core.Identifier
3, // 15: core.Order.error:type_name -> core.ProblemDetails
10, // 16: core.Order.created:type_name -> google.protobuf.Timestamp
10, // 17: core.CRLEntry.revokedAt:type_name -> google.protobuf.Timestamp
18, // [18:18] is the sub-list for method output_type
18, // [18:18] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
10, // 10: core.Authorization.expires:type_name -> google.protobuf.Timestamp
1, // 11: core.Authorization.challenges:type_name -> core.Challenge
10, // 12: core.Order.expires:type_name -> google.protobuf.Timestamp
3, // 13: core.Order.error:type_name -> core.ProblemDetails
10, // 14: core.Order.created:type_name -> google.protobuf.Timestamp
10, // 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() }
@ -1135,11 +1169,133 @@ 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.(*Identifier); 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.(*Challenge); 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.(*ValidationRecord); 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.(*ProblemDetails); 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.(*Certificate); 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.(*CertificateStatus); 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.(*Registration); 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.(*Authorization); 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.(*Order); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_core_proto_msgTypes[9].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: unsafe.Slice(unsafe.StringData(file_core_proto_rawDesc), len(file_core_proto_rawDesc)),
RawDescriptor: file_core_proto_rawDesc,
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
@ -1150,6 +1306,7 @@ func file_core_proto_init() {
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
}

View File

@ -30,15 +30,15 @@ message ValidationRecord {
// Next unused field number: 9
string hostname = 1;
string port = 2;
repeated bytes addressesResolved = 3; // netip.Addr.MarshalText()
bytes addressUsed = 4; // netip.Addr.MarshalText()
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; // netip.Addr.MarshalText()
repeated bytes addressesTried = 7; // net.IP.MarshalText()
repeated string resolverAddrs = 8;
}
@ -83,7 +83,7 @@ message Registration {
// Next unused field number: 10
int64 id = 1;
bytes key = 2;
reserved 3; // Previously contact
repeated string contact = 3;
reserved 4; // Previously contactsPresent
string agreement = 5;
reserved 6; // Previously initialIP
@ -93,13 +93,12 @@ message Registration {
}
message Authorization {
// Next unused field number: 12
// Next unused field number: 11
reserved 5, 7, 8;
string id = 1;
int64 registrationID = 3;
// Fields specified by RFC 8555, Section 7.1.4
reserved 2; // Previously dnsName
Identifier identifier = 11;
string dnsName = 2;
string status = 4;
google.protobuf.Timestamp expires = 9;
repeated core.Challenge challenges = 6;
@ -109,7 +108,7 @@ message Authorization {
}
message Order {
// Next unused field number: 17
// Next unused field number: 16
reserved 3, 6, 10;
int64 id = 1;
int64 registrationID = 2;
@ -118,8 +117,7 @@ message Order {
// finalize and certificate URLs from the id and certificateSerial fields.
string status = 7;
google.protobuf.Timestamp expires = 12;
reserved 8; // Previously dnsNames
repeated Identifier identifiers = 16;
repeated string dnsNames = 8;
ProblemDetails error = 4;
repeated int64 v2Authorizations = 11;
string certificateSerial = 5;

View File

@ -21,6 +21,7 @@ import (
"path"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"time"
@ -321,15 +322,26 @@ func UniqueLowerNames(names []string) (unique []string) {
return
}
// HashIdentifiers returns a hash of the identifiers requested. This is intended
// for use when interacting with the orderFqdnSets table and rate limiting.
func HashIdentifiers(idents identifier.ACMEIdentifiers) []byte {
var values []string
for _, ident := range identifier.Normalize(idents) {
values = append(values, ident.Value)
// NormalizeIdentifiers returns the set of all unique ACME identifiers in the
// input after all of them are lowercased. The returned identifier values will
// be in their lowercased form and sorted alphabetically by value.
func NormalizeIdentifiers(identifiers []identifier.ACMEIdentifier) []identifier.ACMEIdentifier {
for i := range identifiers {
identifiers[i].Value = strings.ToLower(identifiers[i].Value)
}
hash := sha256.Sum256([]byte(strings.Join(values, ",")))
sort.Slice(identifiers, func(i, j int) bool {
return fmt.Sprintf("%s:%s", identifiers[i].Type, identifiers[i].Value) < fmt.Sprintf("%s:%s", identifiers[j].Type, identifiers[j].Value)
})
return slices.Compact(identifiers)
}
// 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[:]
}

View File

@ -1,15 +1,14 @@
package core
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"net/netip"
"os"
"slices"
"sort"
"strings"
"testing"
@ -256,6 +255,26 @@ func TestUniqueLowerNames(t *testing.T) {
test.AssertDeepEquals(t, []string{"a.com", "bar.com", "baz.com", "foobar.com"}, u)
}
func TestNormalizeIdentifiers(t *testing.T) {
idents := []identifier.ACMEIdentifier{
{Type: "DNS", Value: "foobar.com"},
{Type: "DNS", Value: "fooBAR.com"},
{Type: "DNS", Value: "baz.com"},
{Type: "DNS", Value: "foobar.com"},
{Type: "DNS", Value: "bar.com"},
{Type: "DNS", Value: "bar.com"},
{Type: "DNS", Value: "a.com"},
}
expected := []identifier.ACMEIdentifier{
{Type: "DNS", Value: "a.com"},
{Type: "DNS", Value: "bar.com"},
{Type: "DNS", Value: "baz.com"},
{Type: "DNS", Value: "foobar.com"},
}
u := NormalizeIdentifiers(idents)
test.AssertDeepEquals(t, expected, u)
}
func TestValidSerial(t *testing.T) {
notLength32Or36 := "A"
length32 := strings.Repeat("A", 32)
@ -321,98 +340,31 @@ func TestRetryBackoff(t *testing.T) {
}
func TestHashIdentifiers(t *testing.T) {
dns1 := identifier.NewDNS("example.com")
dns1_caps := identifier.NewDNS("eXaMpLe.COM")
dns2 := identifier.NewDNS("high-energy-cheese-lab.nrc-cnrc.gc.ca")
dns2_caps := identifier.NewDNS("HIGH-ENERGY-CHEESE-LAB.NRC-CNRC.GC.CA")
ipv4_1 := identifier.NewIP(netip.MustParseAddr("10.10.10.10"))
ipv4_2 := identifier.NewIP(netip.MustParseAddr("172.16.16.16"))
ipv6_1 := identifier.NewIP(netip.MustParseAddr("2001:0db8:0bad:0dab:c0ff:fee0:0007:1337"))
ipv6_2 := identifier.NewIP(netip.MustParseAddr("3fff::"))
func TestHashNames(t *testing.T) {
// Test that it is deterministic
h1 := HashNames([]string{"a"})
h2 := HashNames([]string{"a"})
test.AssertByteEquals(t, h1, h2)
testCases := []struct {
Name string
Idents1 identifier.ACMEIdentifiers
Idents2 identifier.ACMEIdentifiers
ExpectedEqual bool
}{
{
Name: "Deterministic for DNS",
Idents1: identifier.ACMEIdentifiers{dns1},
Idents2: identifier.ACMEIdentifiers{dns1},
ExpectedEqual: true,
},
{
Name: "Deterministic for IPv4",
Idents1: identifier.ACMEIdentifiers{ipv4_1},
Idents2: identifier.ACMEIdentifiers{ipv4_1},
ExpectedEqual: true,
},
{
Name: "Deterministic for IPv6",
Idents1: identifier.ACMEIdentifiers{ipv6_1},
Idents2: identifier.ACMEIdentifiers{ipv6_1},
ExpectedEqual: true,
},
{
Name: "Differentiates for DNS",
Idents1: identifier.ACMEIdentifiers{dns1},
Idents2: identifier.ACMEIdentifiers{dns2},
ExpectedEqual: false,
},
{
Name: "Differentiates for IPv4",
Idents1: identifier.ACMEIdentifiers{ipv4_1},
Idents2: identifier.ACMEIdentifiers{ipv4_2},
ExpectedEqual: false,
},
{
Name: "Differentiates for IPv6",
Idents1: identifier.ACMEIdentifiers{ipv6_1},
Idents2: identifier.ACMEIdentifiers{ipv6_2},
ExpectedEqual: false,
},
{
Name: "Not subject to ordering",
Idents1: identifier.ACMEIdentifiers{
dns1, dns2, ipv4_1, ipv4_2, ipv6_1, ipv6_2,
},
Idents2: identifier.ACMEIdentifiers{
ipv6_1, dns2, ipv4_2, dns1, ipv4_1, ipv6_2,
},
ExpectedEqual: true,
},
{
Name: "Not case sensitive",
Idents1: identifier.ACMEIdentifiers{
dns1, dns2,
},
Idents2: identifier.ACMEIdentifiers{
dns1_caps, dns2_caps,
},
ExpectedEqual: true,
},
{
Name: "Not subject to duplication",
Idents1: identifier.ACMEIdentifiers{
dns1, dns1,
},
Idents2: identifier.ACMEIdentifiers{dns1},
ExpectedEqual: true,
},
}
// Test that it differentiates
h1 = HashNames([]string{"a"})
h2 = HashNames([]string{"b"})
test.Assert(t, !bytes.Equal(h1, h2), "Should have been different")
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
h1 := HashIdentifiers(tc.Idents1)
h2 := HashIdentifiers(tc.Idents2)
if slices.Equal(h1, h2) != tc.ExpectedEqual {
t.Errorf("Comparing hashes of idents %#v and %#v, expected equality to be %v", tc.Idents1, tc.Idents2, tc.ExpectedEqual)
}
})
}
// 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)
}
func TestIsCanceled(t *testing.T) {

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc-gen-go v1.34.1
// protoc v3.20.1
// source: storer.proto
@ -13,7 +13,6 @@ import (
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
@ -24,21 +23,24 @@ const (
)
type UploadCRLRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
Payload isUploadCRLRequest_Payload `protobuf_oneof:"payload"`
}
func (x *UploadCRLRequest) Reset() {
*x = UploadCRLRequest{}
mi := &file_storer_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_storer_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *UploadCRLRequest) String() string {
@ -49,7 +51,7 @@ func (*UploadCRLRequest) ProtoMessage() {}
func (x *UploadCRLRequest) ProtoReflect() protoreflect.Message {
mi := &file_storer_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -64,27 +66,23 @@ func (*UploadCRLRequest) Descriptor() ([]byte, []int) {
return file_storer_proto_rawDescGZIP(), []int{0}
}
func (x *UploadCRLRequest) GetPayload() isUploadCRLRequest_Payload {
if x != nil {
return x.Payload
func (m *UploadCRLRequest) GetPayload() isUploadCRLRequest_Payload {
if m != nil {
return m.Payload
}
return nil
}
func (x *UploadCRLRequest) GetMetadata() *CRLMetadata {
if x != nil {
if x, ok := x.Payload.(*UploadCRLRequest_Metadata); ok {
return x.Metadata
}
if x, ok := x.GetPayload().(*UploadCRLRequest_Metadata); ok {
return x.Metadata
}
return nil
}
func (x *UploadCRLRequest) GetCrlChunk() []byte {
if x != nil {
if x, ok := x.Payload.(*UploadCRLRequest_CrlChunk); ok {
return x.CrlChunk
}
if x, ok := x.GetPayload().(*UploadCRLRequest_CrlChunk); ok {
return x.CrlChunk
}
return nil
}
@ -106,21 +104,24 @@ func (*UploadCRLRequest_Metadata) isUploadCRLRequest_Payload() {}
func (*UploadCRLRequest_CrlChunk) isUploadCRLRequest_Payload() {}
type CRLMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
Expires *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires,proto3" json:"expires,omitempty"`
CacheControl string `protobuf:"bytes,5,opt,name=cacheControl,proto3" json:"cacheControl,omitempty"`
unknownFields protoimpl.UnknownFields
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"`
Expires *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires,proto3" json:"expires,omitempty"`
CacheControl string `protobuf:"bytes,5,opt,name=cacheControl,proto3" json:"cacheControl,omitempty"`
}
func (x *CRLMetadata) Reset() {
*x = CRLMetadata{}
mi := &file_storer_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_storer_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CRLMetadata) String() string {
@ -131,7 +132,7 @@ func (*CRLMetadata) ProtoMessage() {}
func (x *CRLMetadata) ProtoReflect() protoreflect.Message {
mi := &file_storer_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -183,7 +184,7 @@ func (x *CRLMetadata) GetCacheControl() string {
var File_storer_proto protoreflect.FileDescriptor
var file_storer_proto_rawDesc = string([]byte{
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,
@ -218,22 +219,22 @@ var file_storer_proto_rawDesc = string([]byte{
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 []byte
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(unsafe.Slice(unsafe.StringData(file_storer_proto_rawDesc), len(file_storer_proto_rawDesc)))
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 = []any{
var file_storer_proto_goTypes = []interface{}{
(*UploadCRLRequest)(nil), // 0: storer.UploadCRLRequest
(*CRLMetadata)(nil), // 1: storer.CRLMetadata
(*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp
@ -256,7 +257,33 @@ func file_storer_proto_init() {
if File_storer_proto != nil {
return
}
file_storer_proto_msgTypes[0].OneofWrappers = []any{
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),
}
@ -264,7 +291,7 @@ func file_storer_proto_init() {
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_storer_proto_rawDesc), len(file_storer_proto_rawDesc)),
RawDescriptor: file_storer_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
@ -275,6 +302,7 @@ func file_storer_proto_init() {
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
}

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.3.0
// - protoc v3.20.1
// source: storer.proto
@ -53,24 +53,20 @@ type CRLStorer_UploadCRLClient = grpc.ClientStreamingClient[UploadCRLRequest, em
// CRLStorerServer is the server API for CRLStorer service.
// All implementations must embed UnimplementedCRLStorerServer
// for forward compatibility.
// for forward compatibility
type CRLStorerServer interface {
UploadCRL(grpc.ClientStreamingServer[UploadCRLRequest, emptypb.Empty]) error
mustEmbedUnimplementedCRLStorerServer()
}
// UnimplementedCRLStorerServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCRLStorerServer struct{}
// 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() {}
func (UnimplementedCRLStorerServer) testEmbeddedByValue() {}
// 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
@ -80,13 +76,6 @@ type UnsafeCRLStorerServer interface {
}
func RegisterCRLStorerServer(s grpc.ServiceRegistrar, srv CRLStorerServer) {
// If the following call pancis, it indicates UnimplementedCRLStorerServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CRLStorer_ServiceDesc, srv)
}

View File

@ -80,8 +80,8 @@ func NewUpdater(
return nil, fmt.Errorf("must have positive number of shards, got: %d", numShards)
}
if updatePeriod >= 24*time.Hour {
return nil, fmt.Errorf("must update CRLs at least every 24 hours, got: %s", updatePeriod)
if updatePeriod >= 7*24*time.Hour {
return nil, fmt.Errorf("must update CRLs at least every 7 days, got: %s", updatePeriod)
}
if updateTimeout >= updatePeriod {

View File

@ -5,7 +5,6 @@ import (
"crypto"
"crypto/x509"
"errors"
"net/netip"
"strings"
"github.com/letsencrypt/boulder/core"
@ -35,14 +34,13 @@ var (
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")
invalidURIPresent = berrors.BadCSRError("CSR contains one or more URI fields")
invalidIPPresent = berrors.BadCSRError("CSR contains one or more IP address fields")
invalidNoIdent = berrors.BadCSRError("at least one identifier is required")
invalidIPCN = berrors.BadCSRError("CSR contains IP address in Common Name")
)
// VerifyCSR checks the validity of a x509.CertificateRequest. It uses
// identifier.FromCSR to normalize the DNS names before checking whether we'll
// issue for them.
// NamesFromCSR to normalize the DNS names before checking whether we'll issue
// for them.
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 {
@ -66,64 +64,67 @@ func VerifyCSR(ctx context.Context, csr *x509.CertificateRequest, maxNames int,
if len(csr.EmailAddresses) > 0 {
return invalidEmailPresent
}
if len(csr.URIs) > 0 {
return invalidURIPresent
if len(csr.IPAddresses) > 0 {
return invalidIPPresent
}
// Reject all CSRs which have an IP address in the CN. We want to get rid of
// CNs entirely anyway, and IP addresses are a new feature, so don't let
// clients get in the habit of including them in the CN. We don't use
// CNFromCSR here because that also filters out IP address CNs, for defense
// in depth.
_, err = netip.ParseAddr(csr.Subject.CommonName)
if err == nil { // Inverted! Successful parsing is a bad thing in this case.
return invalidIPCN
}
// NamesFromCSR also performs normalization, returning values that may not
// match the literal CSR contents.
names := NamesFromCSR(csr)
// FromCSR also performs normalization, returning values that may not match
// the literal CSR contents.
idents := identifier.FromCSR(csr)
if len(idents) == 0 {
if len(names.SANs) == 0 && names.CN == "" {
return invalidNoIdent
}
if len(idents) > maxNames {
return berrors.BadCSRError("CSR contains more than %d identifiers", maxNames)
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(idents)
err = pa.WillingToIssue(identifier.FromDNSNames(names.SANs))
if err != nil {
return err
}
return nil
}
// CNFromCSR returns the lower-cased Subject Common Name from the CSR, if a
// short enough CN was provided. If it was too long or appears to be an IP,
// there will be no CN. If none was provided, the CN will be the first SAN that
// is short enough, which is done only for backwards compatibility with prior
// Let's Encrypt behaviour.
func CNFromCSR(csr *x509.CertificateRequest) string {
type names struct {
SANs []string
CN string
}
// NamesFromCSR deduplicates and lower-cases the Subject Common Name and Subject
// Alternative Names from the CSR. If a CN was provided, it will be used if it
// is short enough, otherwise there will be no CN. If no CN was provided, the CN
// will be the first SAN that is short enough, which is done only for backwards
// compatibility with prior Let's Encrypt behaviour. The resulting SANs will
// always include the original CN, if any.
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 len(csr.Subject.CommonName) > maxCNLength {
return ""
return names{SANs: core.UniqueLowerNames(sans)}
}
if csr.Subject.CommonName != "" {
_, err := netip.ParseAddr(csr.Subject.CommonName)
if err == nil { // Inverted! Successful parsing is a bad thing in this case.
return ""
}
return strings.ToLower(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 dnsName
// SAN which is shorter than the maximum acceptable CN length (if any). We
// will never promote an ipAddress SAN to the CN.
for _, name := range csr.DNSNames {
// 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 strings.ToLower(name)
return names{SANs: core.UniqueLowerNames(sans), CN: strings.ToLower(name)}
}
}
return ""
return names{SANs: core.UniqueLowerNames(sans)}
}

View File

@ -9,8 +9,6 @@ import (
"encoding/asn1"
"errors"
"net"
"net/netip"
"net/url"
"strings"
"testing"
@ -28,7 +26,7 @@ func (pa *mockPA) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]core.Acm
return []core.AcmeChallenge{}, nil
}
func (pa *mockPA) WillingToIssue(idents identifier.ACMEIdentifiers) error {
func (pa *mockPA) WillingToIssue(idents []identifier.ACMEIdentifier) error {
for _, ident := range idents {
if ident.Value == "bad-name.com" || ident.Value == "other-bad-name.com" {
return errors.New("policy forbids issuing for identifier")
@ -70,13 +68,6 @@ func TestVerifyCSR(t *testing.T) {
signedReqWithIPAddress := new(x509.CertificateRequest)
*signedReqWithIPAddress = *signedReq
signedReqWithIPAddress.IPAddresses = []net.IP{net.IPv4(1, 2, 3, 4)}
signedReqWithIPCN := new(x509.CertificateRequest)
*signedReqWithIPCN = *signedReq
signedReqWithIPCN.Subject.CommonName = "1.2.3.4"
signedReqWithURI := new(x509.CertificateRequest)
*signedReqWithURI = *signedReq
testURI, _ := url.ParseRequestURI("https://example.com/")
signedReqWithURI.URIs = []*url.URL{testURI}
signedReqWithAllLongSANs := new(x509.CertificateRequest)
*signedReqWithAllLongSANs = *signedReq
signedReqWithAllLongSANs.DNSNames = []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com"}
@ -124,7 +115,7 @@ func TestVerifyCSR(t *testing.T) {
signedReqWithHosts,
1,
&mockPA{},
berrors.BadCSRError("CSR contains more than 1 identifiers"),
berrors.BadCSRError("CSR contains more than 1 DNS names"),
},
{
signedReqWithBadNames,
@ -142,19 +133,7 @@ func TestVerifyCSR(t *testing.T) {
signedReqWithIPAddress,
100,
&mockPA{},
nil,
},
{
signedReqWithIPCN,
100,
&mockPA{},
invalidIPCN,
},
{
signedReqWithURI,
100,
&mockPA{},
invalidURIPresent,
invalidIPPresent,
},
{
signedReqWithAllLongSANs,
@ -170,38 +149,44 @@ func TestVerifyCSR(t *testing.T) {
}
}
func TestCNFromCSR(t *testing.T) {
func TestNamesFromCSR(t *testing.T) {
tooLongString := strings.Repeat("a", maxCNLength+1)
cases := []struct {
name string
csr *x509.CertificateRequest
expectedCN string
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, all SANs too long to be the CN",
@ -210,6 +195,7 @@ func TestCNFromCSR(t *testing.T) {
tooLongString + ".b.com",
}},
"",
[]string{tooLongString + ".a.com", tooLongString + ".b.com"},
},
{
"no explicit CN, leading SANs too long to be the CN",
@ -220,6 +206,7 @@ func TestCNFromCSR(t *testing.T) {
"b.com",
}},
"a.com",
[]string{"a.com", tooLongString + ".a.com", tooLongString + ".b.com", "b.com"},
},
{
"explicit CN, leading SANs too long to be the CN",
@ -232,6 +219,7 @@ func TestCNFromCSR(t *testing.T) {
"b.com",
}},
"a.com",
[]string{"a.com", tooLongString + ".a.com", tooLongString + ".b.com", "b.com"},
},
{
"explicit CN that's too long to be the CN",
@ -239,6 +227,7 @@ func TestCNFromCSR(t *testing.T) {
Subject: pkix.Name{CommonName: tooLongString + ".a.com"},
},
"",
[]string{tooLongString + ".a.com"},
},
{
"explicit CN that's too long to be the CN, with a SAN",
@ -248,27 +237,14 @@ func TestCNFromCSR(t *testing.T) {
"b.com",
}},
"",
},
{
"explicit CN that's an IP",
&x509.CertificateRequest{
Subject: pkix.Name{CommonName: "127.0.0.1"},
},
"",
},
{
"no CN, only IP SANs",
&x509.CertificateRequest{
IPAddresses: []net.IP{
netip.MustParseAddr("127.0.0.1").AsSlice(),
},
},
"",
[]string{tooLongString + ".a.com", "b.com"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
test.AssertEquals(t, CNFromCSR(tc.csr), tc.expectedCN)
names := NamesFromCSR(tc.csr)
test.AssertEquals(t, names.CN, tc.expectedCN)
test.AssertDeepEquals(t, names.SANs, tc.expectedNames)
})
}
}

View File

@ -1,9 +1,93 @@
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 {
@ -25,3 +109,13 @@ type CTConfig struct {
// 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
}

View File

@ -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")
}

View File

@ -2,7 +2,6 @@ package ctpolicy
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
@ -24,14 +23,15 @@ const (
// 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
shardExpiryGauge *prometheus.GaugeVec
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
@ -45,6 +45,15 @@ func New(pub pubpb.PublisherClient, sctLogs loglist.List, infoLogs loglist.List,
)
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",
@ -54,30 +63,43 @@ func New(pub pubpb.PublisherClient, sctLogs loglist.List, infoLogs loglist.List,
)
stats.MustRegister(shardExpiryGauge)
for _, log := range sctLogs {
if log.EndExclusive.IsZero() {
// Handles the case for non-temporally sharded logs too.
shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(0))
} else {
shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(log.EndExclusive.Unix()))
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,
shardExpiryGauge: shardExpiryGauge,
pub: pub,
sctLogs: sctLogs,
infoLogs: infoLogs,
finalLogs: finalLogs,
stagger: stagger,
log: log,
winnerCounter: winnerCounter,
operatorGroupsGauge: operatorGroupsGauge,
shardExpiryGauge: shardExpiryGauge,
}
}
type result struct {
log loglist.Log
sct []byte
url string
err error
}
@ -93,68 +115,73 @@ func (ctp *CTPolicy) GetSCTs(ctx context.Context, cert core.CertDER, expiration
subCtx, cancel := context.WithCancel(ctx)
defer cancel()
// This closure will be called in parallel once for each log.
getOne := func(i int, l loglist.Log) ([]byte, error) {
// Sleep a little bit to stagger our requests to the later logs. Use `i-1`
// to compute the stagger duration so that the first two logs (indices 0
// 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 we got enough SCTs from other
// logs already) before the sleep is complete, quit instead.
// 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()
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: l.Url,
LogPublicKey: base64.StdEncoding.EncodeToString(l.Key),
LogURL: url,
LogPublicKey: key,
Der: cert,
Kind: pubpb.SubmissionType_sct,
})
if err != nil {
return nil, fmt.Errorf("ct submission to %q (%q) failed: %w", l.Name, l.Url, err)
return nil, url, fmt.Errorf("ct submission to %q (%q) failed: %w", g, url, err)
}
return sct.Sct, nil
return sct.Sct, url, nil
}
// Identify the set of candidate logs whose temporal interval includes this
// cert's expiry. Randomize the order of the logs so that we're not always
// trying to submit to the same two.
logs := ctp.sctLogs.ForTime(expiration).Permute()
// 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. Ensure that the results 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.
resChan := make(chan result, len(logs))
for i, log := range logs {
go func(i int, l loglist.Log) {
sctDER, err := getOne(i, l)
resChan <- result{log: l, sct: sctDER, err: err}
}(i, log)
// 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 can collect len(logs) results from the channel because every goroutine
// is guaranteed to write one result (either sct or error) to the channel.
results := make([]result, 0)
// 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(logs) {
res := <-resChan
for range len(ctp.sctLogs) {
res := <-results
if res.err != nil {
errs = append(errs, res.err.Error())
ctp.winnerCounter.WithLabelValues(res.log.Url, failed).Inc()
if res.url != "" {
ctp.winnerCounter.WithLabelValues(res.url, failed).Inc()
}
continue
}
results = append(results, res)
ctp.winnerCounter.WithLabelValues(res.log.Url, succeeded).Inc()
scts := compliantSet(results)
if scts != nil {
scts = append(scts, res.sct)
ctp.winnerCounter.WithLabelValues(res.url, succeeded).Inc()
if len(scts) >= 2 {
return scts, nil
}
}
@ -169,36 +196,6 @@ func (ctp *CTPolicy) GetSCTs(ctx context.Context, cert core.CertDER, expiration
return nil, berrors.MissingSCTsError("failed to get 2 SCTs, got %d error(s): %s", len(errs), strings.Join(errs, "; "))
}
// compliantSet returns a slice of SCTs which complies with all relevant CT Log
// Policy requirements, namely that the set of SCTs:
// - contain at least two SCTs, which
// - come from logs run by at least two different operators, and
// - contain at least one RFC6962-compliant (i.e. non-static/tiled) log.
//
// If no such set of SCTs exists, returns nil.
func compliantSet(results []result) core.SCTDERs {
for _, first := range results {
if first.err != nil {
continue
}
for _, second := range results {
if second.err != nil {
continue
}
if first.log.Operator == second.log.Operator {
// The two SCTs must come from different operators.
continue
}
if first.log.Tiled && second.log.Tiled {
// At least one must come from a non-tiled log.
continue
}
return core.SCTDERs{first.sct, second.sct}
}
}
return nil
}
// 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.
@ -208,26 +205,29 @@ func (ctp *CTPolicy) submitAllBestEffort(blob core.CertDER, kind pubpb.Submissio
logs = ctp.infoLogs
}
for _, log := range logs {
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: base64.StdEncoding.EncodeToString(log.Key),
Der: blob,
Kind: kind,
},
)
if err != nil {
ctp.log.Warningf("ct submission of cert to log %q failed: %s", log.Url, err)
for _, group := range logs {
for _, log := range group {
if log.StartInclusive.After(expiry) || log.EndExclusive.Equal(expiry) || log.EndExclusive.Before(expiry) {
continue
}
}(log)
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

View File

@ -1,7 +1,6 @@
package ctpolicy
import (
"bytes"
"context"
"errors"
"strings"
@ -9,9 +8,6 @@ import (
"time"
"github.com/jmhodges/clock"
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/ctpolicy/loglist"
berrors "github.com/letsencrypt/boulder/errors"
@ -19,6 +15,8 @@ import (
"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{}
@ -47,7 +45,7 @@ func TestGetSCTs(t *testing.T) {
testCases := []struct {
name string
mock pubpb.PublisherClient
logs loglist.List
groups loglist.List
ctx context.Context
result core.SCTDERs
expectErr string
@ -56,11 +54,17 @@ func TestGetSCTs(t *testing.T) {
{
name: "basic success case",
mock: &mockPub{},
logs: loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
{Name: "LogA2", Operator: "OperA", Url: "UrlA2", Key: []byte("KeyA2")},
{Name: "LogB1", Operator: "OperB", Url: "UrlB1", Key: []byte("KeyB1")},
{Name: "LogC1", Operator: "OperC", Url: "UrlC1", Key: []byte("KeyC1")},
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}},
@ -68,24 +72,36 @@ func TestGetSCTs(t *testing.T) {
{
name: "basic failure case",
mock: &mockFailPub{},
logs: loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
{Name: "LogA2", Operator: "OperA", Url: "UrlA2", Key: []byte("KeyA2")},
{Name: "LogB1", Operator: "OperB", Url: "UrlB1", Key: []byte("KeyB1")},
{Name: "LogC1", Operator: "OperC", Url: "UrlC1", Key: []byte("KeyC1")},
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 4 error(s)",
expectErr: "failed to get 2 SCTs, got 3 error(s)",
berrorType: &missingSCTErr,
},
{
name: "parent context timeout failure case",
mock: &mockSlowPub{},
logs: loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
{Name: "LogA2", Operator: "OperA", Url: "UrlA2", Key: []byte("KeyA2")},
{Name: "LogB1", Operator: "OperB", Url: "UrlB1", Key: []byte("KeyB1")},
{Name: "LogC1", Operator: "OperC", Url: "UrlC1", Key: []byte("KeyC1")},
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",
@ -95,7 +111,7 @@ func TestGetSCTs(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctp := New(tc.mock, tc.logs, nil, nil, 0, blog.NewMock(), metrics.NoopRegisterer)
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)
@ -124,9 +140,15 @@ func (mp *mockFailOnePub) SubmitToSingleCTWithResult(_ context.Context, req *pub
func TestGetSCTsMetrics(t *testing.T) {
ctp := New(&mockFailOnePub{badURL: "UrlA1"}, loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
{Name: "LogB1", Operator: "OperB", Url: "UrlB1", Key: []byte("KeyB1")},
{Name: "LogC1", Operator: "OperC", Url: "UrlC1", Key: []byte("KeyC1")},
"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")
@ -137,7 +159,9 @@ func TestGetSCTsMetrics(t *testing.T) {
func TestGetSCTsFailMetrics(t *testing.T) {
// Ensure the proper metrics are incremented when GetSCTs fails.
ctp := New(&mockFailOnePub{badURL: "UrlA1"}, loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
"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")
@ -149,7 +173,9 @@ func TestGetSCTsFailMetrics(t *testing.T) {
defer cancel()
ctp = New(&mockSlowPub{}, loglist.List{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1")},
"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")
@ -159,96 +185,78 @@ func TestGetSCTsFailMetrics(t *testing.T) {
}
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{
{Name: "LogA1", Operator: "OperA", Url: "UrlA1", Key: []byte("KeyA1"), EndExclusive: Tomorrow},
{Name: "LogA2", Operator: "OperA", Url: "UrlA2", Key: []byte("KeyA2"), EndExclusive: NextWeek},
{Name: "LogB1", Operator: "OperB", Url: "UrlB1", Key: []byte("KeyB1"), EndExclusive: Tomorrow},
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)
}
func TestCompliantSet(t *testing.T) {
for _, tc := range []struct {
name string
results []result
want core.SCTDERs
}{
{
name: "nil input",
results: nil,
want: nil,
},
{
name: "zero length input",
results: []result{},
want: nil,
},
{
name: "only one result",
results: []result{
{log: loglist.Log{Operator: "A", Tiled: false}, sct: []byte("sct1")},
},
want: nil,
},
{
name: "only one good result",
results: []result{
{log: loglist.Log{Operator: "A", Tiled: false}, sct: []byte("sct1")},
{log: loglist.Log{Operator: "B", Tiled: false}, err: errors.New("oops")},
},
want: nil,
},
{
name: "only one operator",
results: []result{
{log: loglist.Log{Operator: "A", Tiled: false}, sct: []byte("sct1")},
{log: loglist.Log{Operator: "A", Tiled: false}, sct: []byte("sct2")},
},
want: nil,
},
{
name: "all tiled",
results: []result{
{log: loglist.Log{Operator: "A", Tiled: true}, sct: []byte("sct1")},
{log: loglist.Log{Operator: "B", Tiled: true}, sct: []byte("sct2")},
},
want: nil,
},
{
name: "happy path",
results: []result{
{log: loglist.Log{Operator: "A", Tiled: false}, err: errors.New("oops")},
{log: loglist.Log{Operator: "A", Tiled: true}, sct: []byte("sct2")},
{log: loglist.Log{Operator: "A", Tiled: false}, sct: []byte("sct3")},
{log: loglist.Log{Operator: "B", Tiled: false}, err: errors.New("oops")},
{log: loglist.Log{Operator: "B", Tiled: true}, sct: []byte("sct4")},
{log: loglist.Log{Operator: "B", Tiled: false}, sct: []byte("sct6")},
{log: loglist.Log{Operator: "C", Tiled: false}, err: errors.New("oops")},
{log: loglist.Log{Operator: "C", Tiled: true}, sct: []byte("sct8")},
{log: loglist.Log{Operator: "C", Tiled: false}, sct: []byte("sct9")},
},
// The second and sixth results should be picked, because first and fourth
// are skipped for being errors, and fifth is skipped for also being tiled.
want: core.SCTDERs{[]byte("sct2"), []byte("sct6")},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := compliantSet(tc.results)
if len(got) != len(tc.want) {
t.Fatalf("compliantSet(%#v) returned %d SCTs, but want %d", tc.results, len(got), len(tc.want))
}
for i, sct := range tc.want {
if !bytes.Equal(got[i], sct) {
t.Errorf("compliantSet(%#v) returned unexpected SCT at index %d", tc.results, i)
}
}
})
}
}

View File

@ -7,7 +7,7 @@ import (
"fmt"
"math/rand/v2"
"os"
"slices"
"strings"
"time"
"github.com/google/certificate-transparency-go/loglist3"
@ -31,23 +31,28 @@ const Informational purpose = "info"
// necessarily still issuing SCTs today.
const Validation purpose = "lint"
// List represents a list of logs arranged by the "v3" schema as published by
// Chrome: https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
type List []Log
// 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 determine whether we want to submit to that log, and how to
// do so.
// 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 {
Operator string
Name string
Id string
Key []byte
Url string
Key string
StartInclusive time.Time
EndExclusive time.Time
State loglist3.LogStatus
Tiled bool
}
// usableForPurpose returns true if the log state is acceptable for the given
@ -84,17 +89,15 @@ func newHelper(file []byte) (List, error) {
return nil, fmt.Errorf("failed to parse CT Log List: %w", err)
}
result := make(List, 0)
result := make(List)
for _, op := range parsed.Operators {
group := make(OperatorGroup)
for _, log := range op.Logs {
info := Log{
Operator: op.Name,
Name: log.Description,
Id: base64.StdEncoding.EncodeToString(log.LogID),
Key: log.Key,
Url: log.URL,
State: log.State.LogStatus(),
Tiled: false,
Name: log.Description,
Url: log.URL,
Key: base64.StdEncoding.EncodeToString(log.Key),
State: log.State.LogStatus(),
}
if log.TemporalInterval != nil {
@ -102,27 +105,9 @@ func newHelper(file []byte) (List, error) {
info.EndExclusive = log.TemporalInterval.EndExclusive
}
result = append(result, info)
}
for _, log := range op.TiledLogs {
info := Log{
Operator: op.Name,
Name: log.Description,
Id: base64.StdEncoding.EncodeToString(log.LogID),
Key: log.Key,
Url: log.SubmissionURL,
State: log.State.LogStatus(),
Tiled: true,
}
if log.TemporalInterval != nil {
info.StartInclusive = log.TemporalInterval.StartInclusive
info.EndExclusive = log.TemporalInterval.EndExclusive
}
result = append(result, info)
group[base64.StdEncoding.EncodeToString(log.LogID)] = info
}
result[op.Name] = group
}
return result, nil
@ -151,23 +136,45 @@ func (ll List) SubsetForPurpose(names []string, p purpose) (List, error) {
// 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) {
res := make(List, 0)
remaining := make(map[string]struct{}, len(names))
for _, name := range names {
found := false
for _, log := range ll {
if log.Name == name {
if found {
return nil, fmt.Errorf("found multiple logs matching name %q", name)
}
found = true
res = append(res, log)
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 !found {
return nil, fmt.Errorf("no log found matching name %q", name)
if len(newGroup) > 0 {
newList[operator] = newGroup
}
}
return res, nil
if len(remaining) > 0 {
missed := make([]string, 0, 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
@ -175,55 +182,88 @@ func (ll List) subset(names []string) (List, error) {
// 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) {
res := make(List, 0)
operators := make(map[string]struct{})
for _, log := range ll {
if !usableForPurpose(log.State, p) {
continue
}
newList := make(List)
for operator, group := range ll {
newGroup := make(OperatorGroup)
for id, log := range group {
if !usableForPurpose(log.State, p) {
continue
}
res = append(res, log)
operators[log.Operator] = struct{}{}
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(operators) < 2 && p != Informational {
if len(newList) < 2 && p != Informational {
return nil, errors.New("log list does not have enough groups to satisfy Chrome policy")
}
return res, nil
return newList, nil
}
// ForTime returns a new log list containing only those logs whose temporal
// intervals include the given certificate expiration timestamp.
func (ll List) ForTime(expiry time.Time) List {
res := slices.Clone(ll)
res = slices.DeleteFunc(res, func(l Log) bool {
if (l.StartInclusive.IsZero() || l.StartInclusive.Equal(expiry) || l.StartInclusive.Before(expiry)) &&
(l.EndExclusive.IsZero() || l.EndExclusive.After(expiry)) {
return false
}
return true
})
return res
}
// Permute returns a new log list containing the exact same logs, but in a
// randomly-shuffled order.
func (ll List) Permute() List {
res := slices.Clone(ll)
rand.Shuffle(len(res), func(i int, j int) {
res[i], res[j] = res[j], res[i]
})
return res
}
// GetByID returns the Log matching the given ID, or an error if no such
// log can be found.
func (ll List) GetByID(logID string) (Log, error) {
for _, log := range ll {
if log.Id == logID {
return log, 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 Log{}, fmt.Errorf("no log with ID %q found", logID)
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
}

View File

@ -5,7 +5,6 @@ import (
"time"
"github.com/google/certificate-transparency-go/loglist3"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/test"
)
@ -16,12 +15,18 @@ func TestNew(t *testing.T) {
func TestSubset(t *testing.T) {
input := List{
Log{Name: "Log A1"},
Log{Name: "Log A2"},
Log{Name: "Log B1"},
Log{Name: "Log B2"},
Log{Name: "Log C1"},
Log{Name: "Log C2"},
"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)
@ -37,9 +42,13 @@ func TestSubset(t *testing.T) {
test.AssertEquals(t, len(actual), 0)
expected := List{
Log{Name: "Log B1"},
Log{Name: "Log A1"},
Log{Name: "Log A2"},
"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")
@ -48,136 +57,154 @@ func TestSubset(t *testing.T) {
func TestForPurpose(t *testing.T) {
input := List{
Log{Name: "Log A1", Operator: "A", State: loglist3.UsableLogStatus},
Log{Name: "Log A2", Operator: "A", State: loglist3.RejectedLogStatus},
Log{Name: "Log B1", Operator: "B", State: loglist3.UsableLogStatus},
Log{Name: "Log B2", Operator: "B", State: loglist3.RetiredLogStatus},
Log{Name: "Log C1", Operator: "C", State: loglist3.PendingLogStatus},
Log{Name: "Log C2", Operator: "C", State: loglist3.ReadOnlyLogStatus},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
"ID A2": Log{Name: "Log A2", State: loglist3.RejectedLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.UsableLogStatus},
"ID B2": Log{Name: "Log B2", State: loglist3.RetiredLogStatus},
},
"Operator C": {
"ID C1": Log{Name: "Log C1", State: loglist3.PendingLogStatus},
"ID C2": Log{Name: "Log C2", State: loglist3.ReadOnlyLogStatus},
},
}
expected := List{
Log{Name: "Log A1", Operator: "A", State: loglist3.UsableLogStatus},
Log{Name: "Log B1", Operator: "B", State: loglist3.UsableLogStatus},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.UsableLogStatus},
},
}
actual, err := input.forPurpose(Issuance)
test.AssertNotError(t, err, "should have two acceptable logs")
test.AssertDeepEquals(t, actual, expected)
input = List{
Log{Name: "Log A1", Operator: "A", State: loglist3.UsableLogStatus},
Log{Name: "Log A2", Operator: "A", State: loglist3.RejectedLogStatus},
Log{Name: "Log B1", Operator: "B", State: loglist3.QualifiedLogStatus},
Log{Name: "Log B2", Operator: "B", State: loglist3.RetiredLogStatus},
Log{Name: "Log C1", Operator: "C", State: loglist3.PendingLogStatus},
Log{Name: "Log C2", Operator: "C", State: loglist3.ReadOnlyLogStatus},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
"ID A2": Log{Name: "Log A2", State: loglist3.RejectedLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.QualifiedLogStatus},
"ID B2": Log{Name: "Log B2", State: loglist3.RetiredLogStatus},
},
"Operator C": {
"ID C1": Log{Name: "Log C1", State: loglist3.PendingLogStatus},
"ID C2": Log{Name: "Log C2", State: loglist3.ReadOnlyLogStatus},
},
}
_, err = input.forPurpose(Issuance)
test.AssertError(t, err, "should only have one acceptable log")
expected = List{
Log{Name: "Log A1", Operator: "A", State: loglist3.UsableLogStatus},
Log{Name: "Log C2", Operator: "C", State: loglist3.ReadOnlyLogStatus},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
},
"Operator C": {
"ID C2": Log{Name: "Log C2", State: loglist3.ReadOnlyLogStatus},
},
}
actual, err = input.forPurpose(Validation)
test.AssertNotError(t, err, "should have two acceptable logs")
test.AssertDeepEquals(t, actual, expected)
expected = List{
Log{Name: "Log A1", Operator: "A", State: loglist3.UsableLogStatus},
Log{Name: "Log B1", Operator: "B", State: loglist3.QualifiedLogStatus},
Log{Name: "Log C1", Operator: "C", State: loglist3.PendingLogStatus},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.QualifiedLogStatus},
},
"Operator C": {
"ID C1": Log{Name: "Log C1", State: loglist3.PendingLogStatus},
},
}
actual, err = input.forPurpose(Informational)
test.AssertNotError(t, err, "should have three acceptable logs")
test.AssertDeepEquals(t, actual, expected)
}
func TestForTime(t *testing.T) {
fc := clock.NewFake()
fc.Set(time.Now())
func TestOperatorForLogID(t *testing.T) {
input := List{
Log{Name: "Fully Bound", StartInclusive: fc.Now().Add(-time.Hour), EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Open End", StartInclusive: fc.Now().Add(-time.Hour)},
Log{Name: "Open Start", EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Fully Open"},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.QualifiedLogStatus},
},
}
expected := List{
Log{Name: "Fully Bound", StartInclusive: fc.Now().Add(-time.Hour), EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Open End", StartInclusive: fc.Now().Add(-time.Hour)},
Log{Name: "Open Start", EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Fully Open"},
}
actual := input.ForTime(fc.Now())
test.AssertDeepEquals(t, actual, expected)
actual, err := input.OperatorForLogID("ID B1")
test.AssertNotError(t, err, "should have found log")
test.AssertEquals(t, actual, "Operator B")
expected = List{
Log{Name: "Fully Bound", StartInclusive: fc.Now().Add(-time.Hour), EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Open End", StartInclusive: fc.Now().Add(-time.Hour)},
Log{Name: "Open Start", EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Fully Open"},
}
actual = input.ForTime(fc.Now().Add(-time.Hour))
test.AssertDeepEquals(t, actual, expected)
expected = List{
Log{Name: "Open Start", EndExclusive: fc.Now().Add(time.Hour)},
Log{Name: "Fully Open"},
}
actual = input.ForTime(fc.Now().Add(-2 * time.Hour))
test.AssertDeepEquals(t, actual, expected)
expected = List{
Log{Name: "Open End", StartInclusive: fc.Now().Add(-time.Hour)},
Log{Name: "Fully Open"},
}
actual = input.ForTime(fc.Now().Add(time.Hour))
test.AssertDeepEquals(t, actual, expected)
_, err = input.OperatorForLogID("Other ID")
test.AssertError(t, err, "should not have found log")
}
func TestPermute(t *testing.T) {
input := List{
Log{Name: "Log A1"},
Log{Name: "Log A2"},
Log{Name: "Log B1"},
Log{Name: "Log B2"},
Log{Name: "Log C1"},
Log{Name: "Log C2"},
"Operator A": {
"ID A1": Log{Name: "Log A1", State: loglist3.UsableLogStatus},
"ID A2": Log{Name: "Log A2", State: loglist3.RejectedLogStatus},
},
"Operator B": {
"ID B1": Log{Name: "Log B1", State: loglist3.QualifiedLogStatus},
"ID B2": Log{Name: "Log B2", State: loglist3.RetiredLogStatus},
},
"Operator C": {
"ID C1": Log{Name: "Log C1", State: loglist3.PendingLogStatus},
"ID C2": Log{Name: "Log C2", State: loglist3.ReadOnlyLogStatus},
},
}
foundIndices := make(map[string]map[int]int)
for _, log := range input {
foundIndices[log.Name] = make(map[int]int)
}
for range 100 {
actual := input.Permute()
for index, log := range actual {
foundIndices[log.Name][index]++
}
}
for name, counts := range foundIndices {
for index, count := range counts {
if count == 0 {
t.Errorf("Log %s appeared at index %d too few times", name, index)
}
}
}
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 TestGetByID(t *testing.T) {
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{
Log{Name: "Log A1", Id: "ID A1"},
Log{Name: "Log B1", Id: "ID B1"},
"Operator A": {
"ID A1": Log{Name: "Log A1"},
},
}
_, _, err := input.PickOne("Operator B", date0)
test.AssertError(t, err, "should have failed to find operator")
expected := Log{Name: "Log A1", Id: "ID A1"}
actual, err := input.GetByID("ID A1")
test.AssertNotError(t, err, "should have found log")
test.AssertDeepEquals(t, actual, expected)
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")
_, err = input.GetByID("Other ID")
test.AssertError(t, err, "should not have found 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)
}

View File

@ -129,18 +129,6 @@ func (m *WrappedMap) BeginTx(ctx context.Context) (Transaction, error) {
}, err
}
func (m *WrappedMap) ColumnsForModel(model interface{}) ([]string, error) {
tbl, err := m.dbMap.TableFor(reflect.TypeOf(model), true)
if err != nil {
return nil, err
}
var columns []string
for _, col := range tbl.Columns {
columns = append(columns, col.ColumnName)
}
return columns, nil
}
// WrappedTransaction wraps a *borp.Transaction such that its major functions
// wrap error results in ErrDatabaseOp instances before returning them to the
// caller.

View File

@ -123,7 +123,7 @@ func TestTableFromQuery(t *testing.T) {
expectedTable string
}{
{
query: "SELECT id, jwk, jwk_sha256, contact, agreement, createdAt, status FROM registrations WHERE jwk_sha256 = ?",
query: "SELECT id, jwk, jwk_sha256, contact, agreement, createdAt, LockCol, status FROM registrations WHERE jwk_sha256 = ?",
expectedTable: "registrations",
},
{
@ -135,11 +135,11 @@ func TestTableFromQuery(t *testing.T) {
expectedTable: "authz2",
},
{
query: "insert into `registrations` (`id`,`jwk`,`jwk_sha256`,`contact`,`agreement`,`createdAt`,`status`) values (null,?,?,?,?,?,?,?);",
query: "insert into `registrations` (`id`,`jwk`,`jw k_sha256`,`contact`,`agreement`,`createdAt`,`LockCol`,`status`) values (null,?,?,?,?,?,?,?,?);",
expectedTable: "`registrations`",
},
{
query: "update `registrations` set `jwk`=?, `jwk_sha256`=?, `contact`=?, `agreement`=?, `createdAt`=?, `status`=? where `id`=?;",
query: "update `registrations` set `jwk`=?, `jwk_sh a256`=?, `contact`=?, `agreement`=?, `createdAt`=?, `LockCol` =?, `status`=? where `id`=? and `LockCol`=?;",
expectedTable: "`registrations`",
},
{

View File

@ -85,10 +85,6 @@ func (mi *MultiInserter) query() (string, []interface{}) {
// Insert inserts all the collected rows into the database represented by
// `queryer`.
func (mi *MultiInserter) Insert(ctx context.Context, db Execer) error {
if len(mi.values) == 0 {
return nil
}
query, queryArgs := mi.query()
res, err := db.ExecContext(ctx, query, queryArgs...)
if err != nil {

View File

@ -1,7 +1,7 @@
services:
boulder:
environment:
FAKE_DNS: 64.112.117.122
FAKE_DNS: 10.77.77.77
BOULDER_CONFIG_DIR: test/config-next
GOFLAGS: -mod=vendor
GOCACHE: /boulder/.gocache/go-build-next

View File

@ -11,9 +11,9 @@ services:
GO_VERSION: 1.24.1
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. This is
# pointing at the boulder service's "public" IP, where challtestsrv is.
FAKE_DNS: 64.112.117.122
# 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
@ -24,10 +24,12 @@ services:
networks:
bouldernet:
ipv4_address: 10.77.77.77
publicnet:
ipv4_address: 64.112.117.122
publicnet2:
ipv4_address: 64.112.117.134
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).
@ -36,17 +38,12 @@ services:
# 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.77.77.10
dns: 10.55.55.10
extra_hosts:
# Allow the boulder container to be reached as "ca.example.org", so we
# can put that name inside our integration test certs (e.g. as a crl
# 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.
# TODO(#8215): Move s3-test-srv to a separate service.
- "ca.example.org:64.112.117.122"
# Allow the boulder container to be reached as "integration.trust", for
# similar reasons, but intended for use as a SAN rather than a CRLDP.
# TODO(#8215): Move observer's probe target to a separate service.
- "integration.trust:64.112.117.122"
- "ca.example.org:10.77.77.77"
ports:
- 4001:4001 # ACMEv2
- 4002:4002 # OCSP
@ -79,7 +76,7 @@ services:
- setup
bmysql:
image: mariadb:10.11.13
image: mariadb:10.5
networks:
bouldernet:
aliases:
@ -94,7 +91,6 @@ services:
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
@ -117,12 +113,8 @@ services:
- ./test/:/test/:cached
command: redis-server /test/redis-ocsp.config
networks:
bouldernet:
# TODO(#8215): Remove this static IP allocation (and similar below) when
# we tear down ocsp-responder. We only have it because ocsp-responder
# requires IPs in its "ShardAddrs" config, while ratelimit redis
# supports looking up shards via hostname and SRV record.
ipv4_address: 10.77.77.2
redisnet:
ipv4_address: 10.33.33.2
bredis_2:
image: redis:6.2.7
@ -130,8 +122,8 @@ services:
- ./test/:/test/:cached
command: redis-server /test/redis-ocsp.config
networks:
bouldernet:
ipv4_address: 10.77.77.3
redisnet:
ipv4_address: 10.33.33.3
bredis_3:
image: redis:6.2.7
@ -139,8 +131,8 @@ services:
- ./test/:/test/:cached
command: redis-server /test/redis-ratelimits.config
networks:
bouldernet:
ipv4_address: 10.77.77.4
redisnet:
ipv4_address: 10.33.33.4
bredis_4:
image: redis:6.2.7
@ -148,14 +140,16 @@ services:
- ./test/:/test/:cached
command: redis-server /test/redis-ratelimits.config
networks:
bouldernet:
ipv4_address: 10.77.77.5
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"
@ -163,42 +157,27 @@ services:
bjaeger:
image: jaegertracing/all-in-one:1.50
networks:
- bouldernet
bouldernet:
ipv4_address: 10.77.77.17
bpkimetal:
image: ghcr.io/pkimetal/pkimetal:v1.20.0
image: ghcr.io/pkimetal/pkimetal:v1.19.0
networks:
- bouldernet
bouldernet:
ipv4_address: 10.77.77.9
networks:
# This network represents the data-center internal network. It is used for
# boulder services and their infrastructure, such as consul, mariadb, and
# redis.
# 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
# Only issue DHCP addresses in the top half of the range, to avoid
# conflict with static addresses.
ip_range: 10.77.77.128/25
# This network represents the public internet. It uses a real public IP space
# (that Let's Encrypt controls) so that our integration tests are happy to
# validate and issue for it. It is used by challtestsrv, which binds to
# 64.112.117.122:80 and :443 for its HTTP-01 challenge responder.
#
# TODO(#8215): Put akamai-test-srv and s3-test-srv on this network.
publicnet:
driver: bridge
ipam:
driver: default
config:
- subnet: 64.112.117.0/25
# This network is used for two things in the integration tests:
# - challtestsrv binds to 64.112.117.134:443 for its tls-alpn-01 challenge
# - 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
@ -206,13 +185,24 @@ networks:
# - 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 64.112.117.134:80.
#
# TODO(#8215): Deprecate this network, replacing it with individual IPs within
# the existing publicnet.
publicnet2:
# HTTP server and binds it to 10.88.88.88:80.
integrationtestnet:
driver: bridge
ipam:
driver: default
config:
- subnet: 64.112.117.128/25
- 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

View File

@ -236,7 +236,7 @@ order finalization and does not offer the new-cert endpoint.
* 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 identifiers
* 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
@ -303,7 +303,7 @@ ACME v2:
* 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 identifiers
* 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

View File

@ -23,13 +23,13 @@ docker compose up boulder
Then, in a different window, run the following to connect to `bredis_1`:
```shell
./test/redis-cli.sh -h 10.77.77.2
./test/redis-cli.sh -h 10.33.33.2
```
Similarly, to connect to `bredis_2`:
```shell
./test/redis-cli.sh -h 10.77.77.3
./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
@ -40,7 +40,7 @@ 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.77.77.2:4218 \
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

View File

@ -80,35 +80,43 @@ release is being tagged (not the date that the release is expected to be
deployed):
```sh
go run github.com/letsencrypt/boulder/tools/release/tag@main
git tag -s -m "Boulder release $(date +%F)" -s "release-$(date +%F)"
git push origin "release-$(date +%F)"
```
This will print the newly-created tag and instructions on how to push it after
you are satisfied that it is correct. Alternately you can run the command with
the `-push` flag to push the resulting tag automatically.
### Clean Hotfix Releases
### 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.
Sometimes it is necessary to create a new release which looks like a prior
release but with one or more additional commits added. This is usually the case
when we discover a critical bug in the currently-deployed version that needs to
be fixed, but we don't want to include other changes that have already been
merged to `main` since the currently-deployed release was tagged.
In this situation, we create a new hotfix release branch starting at the point
of the previous release tag. We then use the normal GitHub PR and code-review
process to merge the necessary fix(es) to the branch. Finally we create a new release tag at the tip of the release branch instead of the tip of main.
To create the new release branch, substitute the name of the release tag which you want to use as the starting point into this command:
If it is still the same day as an already-tagged release, increment the letter suffix of the tag:
```sh
go run github.com/letsencrypt/boulder/tools/release/branch@main v0.YYYYMMDD.0
git tag -s -m "Boulder hotfix release $(date +%F)a" -s "release-$(date +%F)a"
git push origin "release-$(date +%F)a"
```
This will create a release branch named `release-branch-v0.YYYYMMDD`. When all necessary PRs have been merged into that branch, create the new tag by substituting the branch name into this command:
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
go run github.com/letsencrypt/boulder/tools/release/tag@main release-branch-v0.YYYYMMDD
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

View File

@ -1,92 +0,0 @@
package email
import (
"crypto/sha256"
"encoding/hex"
"sync"
"github.com/golang/groupcache/lru"
"github.com/prometheus/client_golang/prometheus"
)
type EmailCache struct {
sync.Mutex
cache *lru.Cache
requests *prometheus.CounterVec
}
func NewHashedEmailCache(maxEntries int, stats prometheus.Registerer) *EmailCache {
requests := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "email_cache_requests",
}, []string{"status"})
stats.MustRegister(requests)
return &EmailCache{
cache: lru.New(maxEntries),
requests: requests,
}
}
func hashEmail(email string) string {
sum := sha256.Sum256([]byte(email))
return hex.EncodeToString(sum[:])
}
func (c *EmailCache) Seen(email string) bool {
if c == nil {
// If the cache is nil we assume it was not configured.
return false
}
hash := hashEmail(email)
c.Lock()
defer c.Unlock()
_, ok := c.cache.Get(hash)
if !ok {
c.requests.WithLabelValues("miss").Inc()
return false
}
c.requests.WithLabelValues("hit").Inc()
return true
}
func (c *EmailCache) Remove(email string) {
if c == nil {
// If the cache is nil we assume it was not configured.
return
}
hash := hashEmail(email)
c.Lock()
defer c.Unlock()
c.cache.Remove(hash)
}
// StoreIfAbsent stores the email in the cache if it is not already present, as
// a single atomic operation. It returns true if the email was stored and false
// if it was already in the cache. If the cache is nil, true is always returned.
func (c *EmailCache) StoreIfAbsent(email string) bool {
if c == nil {
// If the cache is nil we assume it was not configured.
return true
}
hash := hashEmail(email)
c.Lock()
defer c.Unlock()
_, ok := c.cache.Get(hash)
if ok {
c.requests.WithLabelValues("hit").Inc()
return false
}
c.cache.Add(hash, nil)
c.requests.WithLabelValues("miss").Inc()
return true
}

View File

@ -17,8 +17,8 @@ import (
// contactsQueueCap limits the queue size to prevent unbounded growth. This
// value is adjustable as needed. Each RFC 5321 email address, encoded in UTF-8,
// is at most 320 bytes. Storing 100,000 emails requires ~34.4 MB of memory.
const contactsQueueCap = 100000
// is at most 320 bytes. Storing 10,000 emails requires ~3.44 MB of memory.
const contactsQueueCap = 10000
var ErrQueueFull = errors.New("email-exporter queue is full")
@ -40,9 +40,7 @@ type ExporterImpl struct {
maxConcurrentRequests int
limiter *rate.Limiter
client PardotClient
emailCache *EmailCache
emailsHandledCounter prometheus.Counter
pardotErrorCounter prometheus.Counter
log blog.Logger
}
@ -55,7 +53,7 @@ var _ emailpb.ExporterServer = (*ExporterImpl)(nil)
// is assigned 40% (20,000 requests), it should also receive 40% of the max
// concurrent requests (e.g., 2 out of 5). For more details, see:
// https://developer.salesforce.com/docs/marketing/pardot/guide/overview.html?q=rate%20limits
func NewExporterImpl(client PardotClient, cache *EmailCache, perDayLimit float64, maxConcurrentRequests int, scope prometheus.Registerer, logger blog.Logger) *ExporterImpl {
func NewExporterImpl(client PardotClient, perDayLimit float64, maxConcurrentRequests int, scope prometheus.Registerer, logger blog.Logger) *ExporterImpl {
limiter := rate.NewLimiter(rate.Limit(perDayLimit/86400.0), maxConcurrentRequests)
emailsHandledCounter := prometheus.NewCounter(prometheus.CounterOpts{
@ -64,20 +62,12 @@ func NewExporterImpl(client PardotClient, cache *EmailCache, perDayLimit float64
})
scope.MustRegister(emailsHandledCounter)
pardotErrorCounter := prometheus.NewCounter(prometheus.CounterOpts{
Name: "email_exporter_errors",
Help: "Total number of Pardot API errors encountered by the email exporter",
})
scope.MustRegister(pardotErrorCounter)
impl := &ExporterImpl{
maxConcurrentRequests: maxConcurrentRequests,
limiter: limiter,
toSend: make([]string, 0, contactsQueueCap),
client: client,
emailCache: cache,
emailsHandledCounter: emailsHandledCounter,
pardotErrorCounter: pardotErrorCounter,
log: logger,
}
impl.wake = sync.NewCond(&impl.Mutex)
@ -147,11 +137,6 @@ func (impl *ExporterImpl) Start(daemonCtx context.Context) {
impl.toSend = impl.toSend[:last]
impl.Unlock()
if !impl.emailCache.StoreIfAbsent(email) {
// Another worker has already processed this email.
continue
}
err := impl.limiter.Wait(daemonCtx)
if err != nil && !errors.Is(err, context.Canceled) {
impl.log.Errf("Unexpected limiter.Wait() error: %s", err)
@ -160,12 +145,9 @@ func (impl *ExporterImpl) Start(daemonCtx context.Context) {
err = impl.client.SendContact(email)
if err != nil {
impl.emailCache.Remove(email)
impl.pardotErrorCounter.Inc()
impl.log.Errf("Sending Contact to Pardot: %s", err)
} else {
impl.emailsHandledCounter.Inc()
}
impl.emailsHandledCounter.Inc()
}
}

View File

@ -12,8 +12,6 @@ import (
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/test"
"github.com/prometheus/client_golang/prometheus"
)
var ctx = context.Background()
@ -37,8 +35,9 @@ func newMockPardotClientImpl() (PardotClient, *mockPardotClientImpl) {
// SendContact adds an email to CreatedContacts.
func (m *mockPardotClientImpl) SendContact(email string) error {
m.Lock()
defer m.Unlock()
m.CreatedContacts = append(m.CreatedContacts, email)
m.Unlock()
return nil
}
@ -56,7 +55,7 @@ func (m *mockPardotClientImpl) getCreatedContacts() []string {
// cleanup() must be called.
func setup() (*ExporterImpl, *mockPardotClientImpl, func(), func()) {
mockClient, clientImpl := newMockPardotClientImpl()
exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
exporter := NewExporterImpl(mockClient, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
daemonCtx, cancel := context.WithCancel(context.Background())
return exporter, clientImpl,
func() { exporter.Start(daemonCtx) },
@ -89,9 +88,6 @@ func TestSendContacts(t *testing.T) {
}
test.AssertSliceContains(t, gotContacts, wantContacts[0])
test.AssertSliceContains(t, gotContacts, wantContacts[1])
// Check that the error counter was not incremented.
test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 0)
}
func TestSendContactsQueueFull(t *testing.T) {
@ -134,92 +130,3 @@ func TestSendContactsQueueDrains(t *testing.T) {
test.AssertEquals(t, 100, len(clientImpl.getCreatedContacts()))
}
type mockAlwaysFailClient struct{}
func (m *mockAlwaysFailClient) SendContact(email string) error {
return fmt.Errorf("simulated failure")
}
func TestSendContactsErrorMetrics(t *testing.T) {
t.Parallel()
mockClient := &mockAlwaysFailClient{}
exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
daemonCtx, cancel := context.WithCancel(context.Background())
exporter.Start(daemonCtx)
_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
Emails: []string{"test@example.com"},
})
test.AssertNotError(t, err, "Error creating contacts")
// Drain the queue.
cancel()
exporter.Drain()
// Check that the error counter was incremented.
test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 1)
}
func TestSendContactDeduplication(t *testing.T) {
t.Parallel()
cache := NewHashedEmailCache(1000, metrics.NoopRegisterer)
mockClient, clientImpl := newMockPardotClientImpl()
exporter := NewExporterImpl(mockClient, cache, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
daemonCtx, cancel := context.WithCancel(context.Background())
exporter.Start(daemonCtx)
_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
Emails: []string{"duplicate@example.com", "duplicate@example.com"},
})
test.AssertNotError(t, err, "Error enqueuing contacts")
// Drain the queue.
cancel()
exporter.Drain()
contacts := clientImpl.getCreatedContacts()
test.AssertEquals(t, 1, len(contacts))
test.AssertEquals(t, "duplicate@example.com", contacts[0])
// Only one successful send should be recorded.
test.AssertMetricWithLabelsEquals(t, exporter.emailsHandledCounter, prometheus.Labels{}, 1)
if !cache.Seen("duplicate@example.com") {
t.Errorf("duplicate@example.com should have been cached after send")
}
}
func TestSendContactErrorRemovesFromCache(t *testing.T) {
t.Parallel()
cache := NewHashedEmailCache(1000, metrics.NoopRegisterer)
fc := &mockAlwaysFailClient{}
exporter := NewExporterImpl(fc, cache, 1000000, 1, metrics.NoopRegisterer, blog.NewMock())
daemonCtx, cancel := context.WithCancel(context.Background())
exporter.Start(daemonCtx)
_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
Emails: []string{"error@example.com"},
})
test.AssertNotError(t, err, "enqueue failed")
// Drain the queue.
cancel()
exporter.Drain()
// The email should have been evicted from the cache after send encountered
// an error.
if cache.Seen("error@example.com") {
t.Errorf("error@example.com should have been evicted from cache after send errors")
}
// Check that the error counter was incremented.
test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 1)
}

View File

@ -18,12 +18,10 @@ const (
// tokenPath is the path to the Salesforce OAuth2 token endpoint.
tokenPath = "/services/oauth2/token"
// contactsPath is the path to the Pardot v5 Prospect upsert-by-email
// endpoint. This endpoint will create a new Prospect if one does not
// already exist with the same email address.
//
// https://developer.salesforce.com/docs/marketing/pardot/guide/prospect-v5.html#prospect-upsert-by-email
contactsPath = "/api/v5/objects/prospects/do/upsertLatestByEmail"
// contactsPath is the path to the Pardot v5 Prospects endpoint. This
// endpoint will create a new Prospect if one does not already exist with
// the same email address.
contactsPath = "/api/v5/objects/prospects"
// maxAttempts is the maximum number of attempts to retry a request.
maxAttempts = 3
@ -62,7 +60,7 @@ type PardotClientImpl struct {
businessUnit string
clientId string
clientSecret string
endpointURL string
contactsURL string
tokenURL string
token *oAuthToken
clk clock.Clock
@ -72,7 +70,7 @@ var _ PardotClient = &PardotClientImpl{}
// NewPardotClientImpl creates a new PardotClientImpl.
func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret, oauthbaseURL, pardotBaseURL string) (*PardotClientImpl, error) {
endpointURL, err := url.JoinPath(pardotBaseURL, contactsPath)
contactsURL, err := url.JoinPath(pardotBaseURL, contactsPath)
if err != nil {
return nil, fmt.Errorf("failed to join contacts path: %w", err)
}
@ -85,10 +83,11 @@ func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret,
businessUnit: businessUnit,
clientId: clientId,
clientSecret: clientSecret,
endpointURL: endpointURL,
contactsURL: contactsURL,
tokenURL: tokenURL,
token: &oAuthToken{},
clk: clk,
token: &oAuthToken{},
clk: clk,
}, nil
}
@ -142,19 +141,6 @@ func redactEmail(body []byte, email string) string {
return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
}
type prospect struct {
// Email is the email address of the prospect.
Email string `json:"email"`
}
type upsertPayload struct {
// MatchEmail is the email address to match against existing prospects to
// avoid adding duplicates.
MatchEmail string `json:"matchEmail"`
// Prospect is the prospect data to be upserted.
Prospect prospect `json:"prospect"`
}
// SendContact submits an email to the Pardot Contacts endpoint, retrying up
// to 3 times with exponential backoff.
func (pc *PardotClientImpl) SendContact(email string) error {
@ -171,10 +157,7 @@ func (pc *PardotClientImpl) SendContact(email string) error {
return fmt.Errorf("failed to update token: %w", err)
}
payload, err := json.Marshal(upsertPayload{
MatchEmail: email,
Prospect: prospect{Email: email},
})
payload, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
@ -183,7 +166,7 @@ func (pc *PardotClientImpl) SendContact(email string) error {
for attempt := range maxAttempts {
time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
req, err := http.NewRequest("POST", pc.endpointURL, bytes.NewReader(payload))
req, err := http.NewRequest("POST", pc.contactsURL, bytes.NewReader(payload))
if err != nil {
finalErr = fmt.Errorf("failed to create new contact request: %w", err)
continue

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc-gen-go v1.34.1
// protoc v3.20.1
// source: exporter.proto
@ -12,7 +12,6 @@ import (
emptypb "google.golang.org/protobuf/types/known/emptypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
@ -23,17 +22,20 @@ const (
)
type SendContactsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Emails []string `protobuf:"bytes,1,rep,name=emails,proto3" json:"emails,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Emails []string `protobuf:"bytes,1,rep,name=emails,proto3" json:"emails,omitempty"`
}
func (x *SendContactsRequest) Reset() {
*x = SendContactsRequest{}
mi := &file_exporter_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_exporter_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SendContactsRequest) String() string {
@ -44,7 +46,7 @@ func (*SendContactsRequest) ProtoMessage() {}
func (x *SendContactsRequest) ProtoReflect() protoreflect.Message {
mi := &file_exporter_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -68,7 +70,7 @@ func (x *SendContactsRequest) GetEmails() []string {
var File_exporter_proto protoreflect.FileDescriptor
var file_exporter_proto_rawDesc = string([]byte{
var file_exporter_proto_rawDesc = []byte{
0x0a, 0x0e, 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x12, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 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,
@ -84,22 +86,22 @@ var file_exporter_proto_rawDesc = string([]byte{
0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f,
0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
}
var (
file_exporter_proto_rawDescOnce sync.Once
file_exporter_proto_rawDescData []byte
file_exporter_proto_rawDescData = file_exporter_proto_rawDesc
)
func file_exporter_proto_rawDescGZIP() []byte {
file_exporter_proto_rawDescOnce.Do(func() {
file_exporter_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_exporter_proto_rawDesc), len(file_exporter_proto_rawDesc)))
file_exporter_proto_rawDescData = protoimpl.X.CompressGZIP(file_exporter_proto_rawDescData)
})
return file_exporter_proto_rawDescData
}
var file_exporter_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_exporter_proto_goTypes = []any{
var file_exporter_proto_goTypes = []interface{}{
(*SendContactsRequest)(nil), // 0: email.SendContactsRequest
(*emptypb.Empty)(nil), // 1: google.protobuf.Empty
}
@ -118,11 +120,25 @@ func file_exporter_proto_init() {
if File_exporter_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_exporter_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SendContactsRequest); 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: unsafe.Slice(unsafe.StringData(file_exporter_proto_rawDesc), len(file_exporter_proto_rawDesc)),
RawDescriptor: file_exporter_proto_rawDesc,
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
@ -133,6 +149,7 @@ func file_exporter_proto_init() {
MessageInfos: file_exporter_proto_msgTypes,
}.Build()
File_exporter_proto = out.File
file_exporter_proto_rawDesc = nil
file_exporter_proto_goTypes = nil
file_exporter_proto_depIdxs = nil
}

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.3.0
// - protoc v3.20.1
// source: exporter.proto
@ -50,24 +50,20 @@ func (c *exporterClient) SendContacts(ctx context.Context, in *SendContactsReque
// ExporterServer is the server API for Exporter service.
// All implementations must embed UnimplementedExporterServer
// for forward compatibility.
// for forward compatibility
type ExporterServer interface {
SendContacts(context.Context, *SendContactsRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedExporterServer()
}
// UnimplementedExporterServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedExporterServer struct{}
// UnimplementedExporterServer must be embedded to have forward compatible implementations.
type UnimplementedExporterServer struct {
}
func (UnimplementedExporterServer) SendContacts(context.Context, *SendContactsRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendContacts not implemented")
}
func (UnimplementedExporterServer) mustEmbedUnimplementedExporterServer() {}
func (UnimplementedExporterServer) testEmbeddedByValue() {}
// UnsafeExporterServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ExporterServer will
@ -77,13 +73,6 @@ type UnsafeExporterServer interface {
}
func RegisterExporterServer(s grpc.ServiceRegistrar, srv ExporterServer) {
// If the following call pancis, it indicates UnimplementedExporterServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&Exporter_ServiceDesc, srv)
}

View File

@ -1,23 +1,13 @@
// Package errors provide a special error type for use in Boulder. This error
// type carries additional type information with it, and has two special powers:
// 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.
//
// 1. It is recognized by our gRPC code, and the type metadata and detail string
// will cross gRPC boundaries intact.
//
// 2. It is recognized by our frontend API "rendering" code, and will be
// automatically converted to the corresponding urn:ietf:params:acme:error:...
// ACME Problem Document.
//
// This means that a deeply-nested service (such as the SA) that wants to ensure
// that the ACME client sees a particular problem document (such as NotFound)
// can return a BoulderError and be sure that it will be propagated all the way
// to the client.
//
// Note, however, that any additional context wrapped *around* the BoulderError
// (such as by fmt.Errorf("oops: %w")) will be lost when the error is converted
// into a problem document. Similarly, any type information wrapped *by* a
// BoulderError (such as a sql.ErrNoRows) is lost at the gRPC serialization
// boundary.
// 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 (
@ -41,7 +31,7 @@ const (
// InternalServer is deprecated. Instead, pass a plain Go error. That will get
// turned into a probs.InternalServerError by the WFE.
InternalServer ErrorType = iota
_ // Reserved, previously NotSupported
_
Malformed
Unauthorized
NotFound
@ -68,9 +58,6 @@ const (
// The certificate being indicated for replacement already has a replacement
// order.
AlreadyReplaced
BadSignatureAlgorithm
AccountDoesNotExist
BadNonce
)
func (ErrorType) Error() string {
@ -95,15 +82,10 @@ type SubBoulderError struct {
Identifier identifier.ACMEIdentifier
}
// Error implements the error interface, returning a string representation of
// this error.
func (be *BoulderError) Error() string {
return be.Detail
}
// Unwrap implements the optional error-unwrapping interface. It returns the
// underlying type, all of when themselves implement the error interface, so
// that `if errors.Is(someError, berrors.Malformed)` works.
func (be *BoulderError) Unwrap() error {
return be.Type
}
@ -179,30 +161,30 @@ func New(errType ErrorType, msg string) error {
// newf is a convenience function for creating a new BoulderError with a
// formatted message.
func newf(errType ErrorType, msg string, args ...any) error {
func newf(errType ErrorType, msg string, args ...interface{}) error {
return &BoulderError{
Type: errType,
Detail: fmt.Sprintf(msg, args...),
}
}
func InternalServerError(msg string, args ...any) error {
func InternalServerError(msg string, args ...interface{}) error {
return newf(InternalServer, msg, args...)
}
func MalformedError(msg string, args ...any) error {
func MalformedError(msg string, args ...interface{}) error {
return newf(Malformed, msg, args...)
}
func UnauthorizedError(msg string, args ...any) error {
func UnauthorizedError(msg string, args ...interface{}) error {
return newf(Unauthorized, msg, args...)
}
func NotFoundError(msg string, args ...any) error {
func NotFoundError(msg string, args ...interface{}) error {
return newf(NotFound, msg, args...)
}
func RateLimitError(retryAfter time.Duration, msg string, args ...any) error {
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...),
@ -210,7 +192,7 @@ func RateLimitError(retryAfter time.Duration, msg string, args ...any) error {
}
}
func RegistrationsPerIPAddressError(retryAfter time.Duration, msg string, args ...any) error {
func RegistrationsPerIPAddressError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-registrations-per-ip-address", args...),
@ -218,7 +200,7 @@ func RegistrationsPerIPAddressError(retryAfter time.Duration, msg string, args .
}
}
func RegistrationsPerIPv6RangeError(retryAfter time.Duration, msg string, args ...any) error {
func RegistrationsPerIPv6RangeError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-registrations-per-ipv6-range", args...),
@ -226,7 +208,7 @@ func RegistrationsPerIPv6RangeError(retryAfter time.Duration, msg string, args .
}
}
func NewOrdersPerAccountError(retryAfter time.Duration, msg string, args ...any) error {
func NewOrdersPerAccountError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-orders-per-account", args...),
@ -234,7 +216,7 @@ func NewOrdersPerAccountError(retryAfter time.Duration, msg string, args ...any)
}
}
func CertificatesPerDomainError(retryAfter time.Duration, msg string, args ...any) error {
func CertificatesPerDomainError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-certificates-per-registered-domain", args...),
@ -242,15 +224,15 @@ func CertificatesPerDomainError(retryAfter time.Duration, msg string, args ...an
}
}
func CertificatesPerFQDNSetError(retryAfter time.Duration, msg string, args ...any) error {
func CertificatesPerFQDNSetError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-certificates-per-exact-set-of-identifiers", args...),
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#new-certificates-per-exact-set-of-hostnames", args...),
RetryAfter: retryAfter,
}
}
func FailedAuthorizationsPerDomainPerAccountError(retryAfter time.Duration, msg string, args ...any) error {
func FailedAuthorizationsPerDomainPerAccountError(retryAfter time.Duration, msg string, args ...interface{}) error {
return &BoulderError{
Type: RateLimit,
Detail: fmt.Sprintf(msg+": see https://letsencrypt.org/docs/rate-limits/#authorization-failures-per-hostname-per-account", args...),
@ -258,55 +240,55 @@ func FailedAuthorizationsPerDomainPerAccountError(retryAfter time.Duration, msg
}
}
func RejectedIdentifierError(msg string, args ...any) error {
func RejectedIdentifierError(msg string, args ...interface{}) error {
return newf(RejectedIdentifier, msg, args...)
}
func InvalidEmailError(msg string, args ...any) error {
func InvalidEmailError(msg string, args ...interface{}) error {
return newf(InvalidEmail, msg, args...)
}
func UnsupportedContactError(msg string, args ...any) error {
func UnsupportedContactError(msg string, args ...interface{}) error {
return newf(UnsupportedContact, msg, args...)
}
func ConnectionFailureError(msg string, args ...any) error {
func ConnectionFailureError(msg string, args ...interface{}) error {
return newf(ConnectionFailure, msg, args...)
}
func CAAError(msg string, args ...any) error {
func CAAError(msg string, args ...interface{}) error {
return newf(CAA, msg, args...)
}
func MissingSCTsError(msg string, args ...any) error {
func MissingSCTsError(msg string, args ...interface{}) error {
return newf(MissingSCTs, msg, args...)
}
func DuplicateError(msg string, args ...any) error {
func DuplicateError(msg string, args ...interface{}) error {
return newf(Duplicate, msg, args...)
}
func OrderNotReadyError(msg string, args ...any) error {
func OrderNotReadyError(msg string, args ...interface{}) error {
return newf(OrderNotReady, msg, args...)
}
func DNSError(msg string, args ...any) error {
func DNSError(msg string, args ...interface{}) error {
return newf(DNS, msg, args...)
}
func BadPublicKeyError(msg string, args ...any) error {
func BadPublicKeyError(msg string, args ...interface{}) error {
return newf(BadPublicKey, msg, args...)
}
func BadCSRError(msg string, args ...any) error {
func BadCSRError(msg string, args ...interface{}) error {
return newf(BadCSR, msg, args...)
}
func AlreadyReplacedError(msg string, args ...any) error {
func AlreadyReplacedError(msg string, args ...interface{}) error {
return newf(AlreadyReplaced, msg, args...)
}
func AlreadyRevokedError(msg string, args ...any) error {
func AlreadyRevokedError(msg string, args ...interface{}) error {
return newf(AlreadyRevoked, msg, args...)
}
@ -318,18 +300,6 @@ func UnknownSerialError() error {
return newf(UnknownSerial, "unknown serial")
}
func InvalidProfileError(msg string, args ...any) error {
func InvalidProfileError(msg string, args ...interface{}) error {
return newf(InvalidProfile, msg, args...)
}
func BadSignatureAlgorithmError(msg string, args ...any) error {
return newf(BadSignatureAlgorithm, msg, args...)
}
func AccountDoesNotExistError(msg string, args ...any) error {
return newf(AccountDoesNotExist, msg, args...)
}
func BadNonceError(msg string, args ...any) error {
return newf(BadNonce, msg, args...)
}

Some files were not shown because too many files have changed in this diff Show More