sfe: Implement self-service frontend for account pausing/unpausing (#7500)

Adds a new boulder component named `sfe` aka the Self-service FrontEnd
which is dedicated to non-ACME related Subscriber functions. This change
implements one such function which is a web interface and handlers for
account unpausing.

When paused, an ACME client receives a log line URL with a JWT parameter
from the WFE. For the observant Subscriber, manually clicking the link
opens their web browser and displays a page with a pre-filled HTML form.
Upon clicking the form button, the SFE sends an HTTP POST back to itself
and either validates the JWT and issues an RA gRPC request to unpause
the account, or returns an HTML error page.

The SFE and WFE should share a 32 byte seed value e.g. the output of
`openssl rand -hex 16` which will be used as a go-jose symmetric signer
using the HS256 algorithm. The SFE will check various [RFC
7519](https://datatracker.ietf.org/doc/html/rfc7519) claims on the JWT
such as the `iss`, `aud`, `nbf`, `exp`, `iat`, and a custom `apiVersion`
claim.

The SFE should not yet be relied upon or deployed to staging/production
environments. It is very much a work in progress, but this change is big
enough as-is.

Related to https://github.com/letsencrypt/boulder/issues/7406
Part of https://github.com/letsencrypt/boulder/issues/7499
This commit is contained in:
Phil Porada 2024-07-10 10:52:33 -04:00 committed by GitHub
parent 63452d5afe
commit 30c6e592f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2140 additions and 39 deletions

View File

@ -6,7 +6,6 @@ import (
"encoding/pem"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
@ -19,12 +18,12 @@ import (
bgrpc "github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/grpc/noncebalancer"
"github.com/letsencrypt/boulder/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/nonce"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/ratelimits"
bredis "github.com/letsencrypt/boulder/redis"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/web"
"github.com/letsencrypt/boulder/wfe2"
)
@ -42,7 +41,7 @@ type Config struct {
TLSListenAddress string `validate:"omitempty,hostname_port"`
// Timeout is the per-request overall timeout. This should be slightly
// lower than the upstream's timeout when making request to the WFE.
// lower than the upstream's timeout when making requests to the WFE.
Timeout config.Duration `validate:"-"`
ServerCertificatePath string `validate:"required_with=TLSListenAddress"`
@ -196,22 +195,6 @@ func loadChain(certFiles []string) (*issuance.Certificate, []byte, error) {
return certs[0], buf.Bytes(), nil
}
type errorWriter struct {
blog.Logger
}
func (ew errorWriter) Write(p []byte) (n int, err error) {
// log.Logger will append a newline to all messages before calling
// Write. Our log checksum checker doesn't like newlines, because
// syslog will strip them out so the calculated checksums will
// differ. So that we don't hit this corner case for every line
// logged from inside net/http.Server we strip the newline before
// we get to the checksum generator.
p = bytes.TrimRight(p, "\n")
ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p)))
return
}
func main() {
listenAddr := flag.String("addr", "", "HTTP listen address override")
tlsAddr := flag.String("tls-addr", "", "HTTPS listen address override")
@ -391,15 +374,7 @@ func main() {
logger.Infof("Server running, listening on %s....", c.WFE.ListenAddress)
handler := wfe.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)
srv := http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: c.WFE.ListenAddress,
ErrorLog: log.New(errorWriter{logger}, "", 0),
Handler: handler,
}
srv := web.NewServer(c.WFE.ListenAddress, handler, logger)
go func() {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
@ -407,14 +382,7 @@ func main() {
}
}()
tlsSrv := http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: c.WFE.TLSListenAddress,
ErrorLog: log.New(errorWriter{logger}, "", 0),
Handler: handler,
}
tlsSrv := web.NewServer(c.WFE.TLSListenAddress, handler, logger)
if tlsSrv.Addr != "" {
go func() {
logger.Infof("TLS server listening on %s", tlsSrv.Addr)

View File

@ -29,6 +29,7 @@ import (
_ "github.com/letsencrypt/boulder/cmd/remoteva"
_ "github.com/letsencrypt/boulder/cmd/reversed-hostname-checker"
_ "github.com/letsencrypt/boulder/cmd/rocsp-tool"
_ "github.com/letsencrypt/boulder/cmd/sfe"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/cmd"

View File

@ -52,6 +52,8 @@ func TestConfigValidation(t *testing.T) {
}
case "boulder-wfe2":
fileNames = []string{"wfe2.json"}
case "sfe":
fileNames = []string{"sfe.json"}
case "nonce-service":
fileNames = []string{
"nonce-a.json",

View File

@ -553,3 +553,12 @@ type DNSProvider struct {
// 1 1 8153 0a4d4d4d.addr.dc1.consul.
SRVLookup ServiceDomain `validate:"required"`
}
type UnpauseConfig struct {
// HMACKey is a shared symmetric secret used to sign/validate unpause JWTs.
// It should be 32 alphanumeric characters, e.g. the output of `openssl rand
// -hex 16` to satisfy the go-jose HS256 algorithm implementation. In a
// multi-DC deployment this value should be the same across all boulder-wfe
// and sfe instances.
HMACKey PasswordConfig `validate:"-"`
}

143
cmd/sfe/main.go Normal file
View File

@ -0,0 +1,143 @@
package notmain
import (
"context"
"flag"
"net/http"
"os"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
rapb "github.com/letsencrypt/boulder/ra/proto"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/sfe"
"github.com/letsencrypt/boulder/web"
)
type Config struct {
SFE struct {
DebugAddr string `validate:"omitempty,hostname_port"`
// ListenAddress is the address:port on which to listen for incoming
// HTTP requests. Defaults to ":80".
ListenAddress string `validate:"omitempty,hostname_port"`
// Timeout is the per-request overall timeout. This should be slightly
// lower than the upstream's timeout when making requests to the SFE.
Timeout config.Duration `validate:"-"`
// ShutdownStopTimeout is the duration that the SFE will wait before
// shutting down any listening servers.
ShutdownStopTimeout config.Duration
TLS cmd.TLSConfig
RAService *cmd.GRPCClientConfig
SAService *cmd.GRPCClientConfig
Unpause cmd.UnpauseConfig
Features features.Config
}
Syslog cmd.SyslogConfig
OpenTelemetry cmd.OpenTelemetryConfig
// OpenTelemetryHTTPConfig configures tracing on incoming HTTP requests
OpenTelemetryHTTPConfig cmd.OpenTelemetryHTTPConfig
}
func main() {
listenAddr := flag.String("addr", "", "HTTP listen address override")
debugAddr := flag.String("debug-addr", "", "Debug server address override")
configFile := flag.String("config", "", "File path to the configuration file for this service")
flag.Parse()
if *configFile == "" {
flag.Usage()
os.Exit(1)
}
var c Config
err := cmd.ReadConfigFile(*configFile, &c)
cmd.FailOnError(err, "Reading JSON config file into config structure")
features.Set(c.SFE.Features)
if *listenAddr != "" {
c.SFE.ListenAddress = *listenAddr
}
if c.SFE.ListenAddress == "" {
cmd.Fail("HTTP listen address is not configured")
}
if *debugAddr != "" {
c.SFE.DebugAddr = *debugAddr
}
stats, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.SFE.DebugAddr)
logger.Info(cmd.VersionString())
clk := cmd.Clock()
unpauseHMACKey, err := c.SFE.Unpause.HMACKey.Pass()
cmd.FailOnError(err, "Failed to load unpauseHMACKey")
if len(unpauseHMACKey) != 32 {
cmd.Fail("Invalid unpauseHMACKey length, should be 32 alphanumeric characters")
}
// The jose.SigningKey key interface where this is used can be satisfied by
// a byte slice, not a string.
unpauseHMACKeyBytes := []byte(unpauseHMACKey)
tlsConfig, err := c.SFE.TLS.Load(stats)
cmd.FailOnError(err, "TLS config")
raConn, err := bgrpc.ClientSetup(c.SFE.RAService, tlsConfig, stats, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA")
rac := rapb.NewRegistrationAuthorityClient(raConn)
saConn, err := bgrpc.ClientSetup(c.SFE.SAService, tlsConfig, stats, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA")
sac := sapb.NewStorageAuthorityReadOnlyClient(saConn)
sfei, err := sfe.NewSelfServiceFrontEndImpl(
stats,
clk,
logger,
c.SFE.Timeout.Duration,
rac,
sac,
unpauseHMACKeyBytes,
)
cmd.FailOnError(err, "Unable to create SFE")
logger.Infof("Server running, listening on %s....", c.SFE.ListenAddress)
handler := sfei.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)
srv := web.NewServer(c.SFE.ListenAddress, handler, logger)
go func() {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
cmd.FailOnError(err, "Running HTTP server")
}
}()
// When main is ready to exit (because it has received a shutdown signal),
// gracefully shutdown the servers. Calling these shutdown functions causes
// ListenAndServe() and ListenAndServeTLS() to immediately return, then waits
// for any lingering connection-handling goroutines to finish their work.
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), c.SFE.ShutdownStopTimeout.Duration)
defer cancel()
_ = srv.Shutdown(ctx)
oTelShutdown(ctx)
}()
cmd.WaitForSignal()
}
func init() {
cmd.RegisterCommand("sfe", main, &cmd.ConfigValidator{Config: &Config{}})
}

22
sfe/pages/index.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<h1>No Action Required</h1>
<div>
<p>
There is no action for you to take. This page is intended for
Subscribers whose accounts have been temporarily restricted from
requesting new certificates for certain hostnames, following a
significant number of failed validation attempts without any recent
successes. If your account was paused, your <a
href="https://letsencrypt.org/docs/client-options/">ACME client</a>
would provide you with a URL to visit to unpause your account.
</p>
</div>
</body>
{{template "footer"}}

View File

@ -0,0 +1,64 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Unpause - Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<div>
<h1>Action Required to Unpause Your ACME Account</h1>
<p>
You have been directed to this page because your Account ID {{ .AccountID }}
is temporarily restricted from requesting new certificates for certain
hostnames including, but potentially not limited to, the following:
<ul>
{{ range $domain := .PausedDomains }}<li>{{ $domain }}</li>{{ end }}
</ul>
</p>
<h2>Why Did This Happen?</h2>
<p>
This often happens when domain names expire, point to new hosts, or if
there are issues with the DNS configuration or web server settings.
These problems prevent your ACME client from successfully <a
href="https://letsencrypt.org/how-it-works/">validating control over the
domain</a>, which is necessary for issuing TLS certificates.
</p>
<h2>What Can You Do?</h2>
<p>
Please check the DNS configuration and web server settings for the
affected hostnames. Ensure they are properly set up to respond to ACME
challenges. This might involve updating DNS records, renewing domain
registrations, or adjusting web server configurations. If you use a
hosting provider or third-party service for domain management, you may
need to coordinate with them. If you believe you've fixed the underlying
issue, consider attempting issuance against our <a
href="https://letsencrypt.org/docs/staging-environment/">staging
environment</a> to verify your fix.
</p>
<h2>Ready to Unpause?</h2>
<p>
Once you have addressed these issues, click the button below to remove
the pause on your account. This action will allow you to resume
requesting certificates for all affected hostnames associated with your
account.
</p>
<p>
<strong>Note:</strong> If you face difficulties unpausing your account or
need more guidance, our <a
href="https://community.letsencrypt.org">community support forum</a> is
a great resource for troubleshooting and advice.
</p>
<div>
<form action="{{ .UnpauseFormRedirectionPath }}?jwt={{ .JWT }}" method="POST">
<button class="primary" id="submit">Please Unpause My Account</button>
</form>
</div>
</div>
</body>
{{template "footer"}}

View File

@ -0,0 +1,20 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Unpause - Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<div>
<h1>Invalid Request To Unpause Account</h1>
<p>
Your unpause request was invalid meaning that we could not find all of
the data required in the URL. Please verify you copied the log line from
your client correctly. You may visit our <a
href="https://community.letsencrypt.org">community forum</a> and request
assistance if the problem persists.
</p>
</div>
</body>
{{template "footer"}}

View File

@ -0,0 +1,32 @@
<!doctype html>
<html dir="ltr" lang="en-US">
<header>
<title>Unpause - Self-Service Frontend</title>
{{ template "meta" }}
</header>
<body>
<div>
{{ if eq .UnpauseSuccessful true }}
<h1>Your ACME Account Has Been Unpaused</h1>
<p>
Your ACME account has been unpaused. To obtain a new certificate,
re-attempt issuance with your ACME client. Future repeated validation
failures with no successes will result in your account being paused
again.
</p>
{{ else }}
<h1>Error Occurred While Unpausing Account</h1>
<p>
An error was encountered when attempting to unpause your account. Please
try again later. You may visit our <a
href="https://community.letsencrypt.org">community forum</a> and request
assistance if the problem persists.
</p>
{{ end }}
</div>
</body>
{{template "footer"}}

320
sfe/sfe.go Normal file
View File

@ -0,0 +1,320 @@
package sfe
import (
"embed"
"errors"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
"text/template"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/jmhodges/clock"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics/measured_http"
rapb "github.com/letsencrypt/boulder/ra/proto"
sapb "github.com/letsencrypt/boulder/sa/proto"
)
const (
// The API version should be checked when parsing parameters to quickly deny
// a client request. Can be used to mass-invalidate URLs. Must be
// concatenated with other path slugs.
unpauseAPIPrefix = "/sfe/v1"
unpauseGetForm = unpauseAPIPrefix + "/unpause"
unpausePostForm = unpauseAPIPrefix + "/do-unpause"
unpauseStatus = unpauseAPIPrefix + "/unpause-status"
)
var (
//go:embed all:static
staticFS embed.FS
//go:embed all:templates all:pages
dynamicFS embed.FS
)
// SelfServiceFrontEndImpl provides all the logic for Boulder's selfservice
// frontend web-facing interface, i.e., a portal where a subscriber can unpause
// their account. Its methods are primarily handlers for HTTPS requests for the
// various non-ACME functions.
type SelfServiceFrontEndImpl struct {
ra rapb.RegistrationAuthorityClient
sa sapb.StorageAuthorityReadOnlyClient
log blog.Logger
clk clock.Clock
// requestTimeout is the per-request overall timeout.
requestTimeout time.Duration
// unpauseHMACKey is used to validate incoming JWT signatures on the unpause
// endpoint and should be shared by the SFE and WFE.
unpauseHMACKey []byte
// HTML pages served by the SFE
templatePages *template.Template
}
// NewSelfServiceFrontEndImpl constructs a web service for Boulder
func NewSelfServiceFrontEndImpl(
stats prometheus.Registerer,
clk clock.Clock,
logger blog.Logger,
requestTimeout time.Duration,
rac rapb.RegistrationAuthorityClient,
sac sapb.StorageAuthorityReadOnlyClient,
unpauseHMACKey []byte,
) (SelfServiceFrontEndImpl, error) {
// Parse the files once at startup to avoid each request causing the server
// to JIT parse. The pages are stored in an in-memory embed.FS to prevent
// unnecessary filesystem I/O on a physical HDD.
tmplPages := template.Must(template.New("pages").ParseFS(dynamicFS, "templates/layout.html", "pages/*"))
sfe := SelfServiceFrontEndImpl{
log: logger,
clk: clk,
requestTimeout: requestTimeout,
ra: rac,
sa: sac,
unpauseHMACKey: unpauseHMACKey,
templatePages: tmplPages,
}
return sfe, nil
}
// Handler returns an http.Handler that uses various functions for various
// non-ACME-specified paths. Each endpoint should have a corresponding HTML
// page that shares the same name as the endpoint.
func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler {
m := http.NewServeMux()
sfs, _ := fs.Sub(staticFS, "static")
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
m.Handle("GET /static/", staticAssetsHandler)
m.HandleFunc("/", sfe.Index)
m.HandleFunc("GET /build", sfe.BuildID)
m.HandleFunc(unpauseGetForm, sfe.UnpauseForm)
m.HandleFunc(unpausePostForm, sfe.UnpauseSubmit)
m.HandleFunc(unpauseStatus, sfe.UnpauseStatus)
return measured_http.New(m, sfe.clk, stats, oTelHTTPOptions...)
}
// renderTemplate takes the name of an HTML template and optional dynamicData
// which are rendered and served back to the client via the response writer.
func (sfe *SelfServiceFrontEndImpl) renderTemplate(w http.ResponseWriter, filename string, dynamicData any) {
if len(filename) == 0 {
http.Error(w, "Template page does not exist", http.StatusInternalServerError)
return
}
err := sfe.templatePages.ExecuteTemplate(w, filename, dynamicData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Index is the homepage of the SFE
func (sfe *SelfServiceFrontEndImpl) Index(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet && request.Method != http.MethodHead {
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
sfe.renderTemplate(response, "index.html", nil)
}
// BuildID tells the requester what boulder build version is running.
func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "text/plain")
response.WriteHeader(http.StatusOK)
detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime())
if _, err := fmt.Fprintln(response, detailsString); err != nil {
sfe.log.Warningf("Could not write response: %s", err)
}
}
// unpauseJWT is generated by a WFE and is used to round-trip back through the
// SFE to unpause the requester's account.
type unpauseJWT string
// UnpauseForm allows a requester to unpause their account via a form present on
// the page. The Subscriber's client will receive a log line emitted by the WFE
// which contains a URL pre-filled with a JWT that will populate a hidden field
// in this form.
func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodHead && request.Method != http.MethodGet {
response.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
incomingJWT := request.URL.Query().Get("jwt")
if incomingJWT == "" {
sfe.unpauseInvalidRequest(response)
return
}
regID, domains, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
if err != nil {
sfe.unpauseStatusHelper(response, false)
return
}
type tmplData struct {
UnpauseFormRedirectionPath string
JWT string
AccountID string
PausedDomains []string
}
// Serve the actual unpause page given to a Subscriber. Populates the
// unpause form with the JWT from the URL.
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, regID, domains})
}
// UnpauseSubmit serves a page indicating if the unpause form submission
// succeeded or failed upon clicking the unpause button. We are explicitly
// choosing to not address CSRF at this time because we control creation and
// redemption of the JWT.
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
response.Header().Set("Access-Control-Allow-Methods", "POST")
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
incomingJWT := request.URL.Query().Get("jwt")
if incomingJWT == "" {
sfe.unpauseInvalidRequest(response)
return
}
regID, _, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
if err != nil {
sfe.unpauseStatusHelper(response, false)
return
}
// TODO(#7356) Declare a registration ID variable to populate an
// rapb unpause account request message.
_, innerErr := strconv.ParseInt(regID, 10, 64)
if innerErr != nil {
sfe.unpauseStatusHelper(response, false)
return
}
// TODO(#7536) Send gRPC nrequest to the RA informing it to unpause
// the account specified in the claim. At this point we should wait
// for the RA to process the request before returning to the client,
// just in case the request fails.
// Success, the account has been unpaused.
http.Redirect(response, request, unpauseStatus, http.StatusFound)
}
// unpauseInvalidRequest is a helper that displays a page indicating the
// Subscriber perform basic troubleshooting due to lack of JWT in the data
// object.
func (sfe *SelfServiceFrontEndImpl) unpauseInvalidRequest(response http.ResponseWriter) {
sfe.renderTemplate(response, "unpause-invalid-request.html", nil)
}
type unpauseStatusTemplateData struct {
UnpauseSuccessful bool
}
// unpauseStatus is a helper that, by default, displays a failure message to the
// Subscriber indicating that their account has failed to unpause. For failure
// scenarios, only when the JWT validation should call this. Other types of
// failures should use unpauseInvalidRequest. For successes, call UnpauseStatus
// instead.
func (sfe *SelfServiceFrontEndImpl) unpauseStatusHelper(response http.ResponseWriter, status bool) {
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplateData{status})
}
// UnpauseStatus displays a success message to the Subscriber indicating that
// their account has been unpaused.
func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodHead && request.Method != http.MethodGet {
response.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
// TODO(#7580) This should only be reachable after a client has clicked the
// "Please unblock my account" button and that request succeeding. No one
// should be able to access this page otherwise.
sfe.unpauseStatusHelper(response, true)
}
// validateUnpauseJWTforAccount validates the signature and contents of an
// unpauseJWT and verify that the its claims match a set of expected claims.
// After JWT validation, return the registration ID from claim's subject and
// paused domains if the validation was successful or an error.
func (sfe *SelfServiceFrontEndImpl) validateUnpauseJWTforAccount(incomingJWT unpauseJWT) (string, []string, error) {
slug := strings.Split(unpauseAPIPrefix, "/")
if len(slug) != 3 {
return "", nil, errors.New("Could not parse API version")
}
token, err := jwt.ParseSigned(string(incomingJWT), []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return "", nil, fmt.Errorf("parsing JWT: %s", err)
}
type sfeJWTClaims struct {
jwt.Claims
// Version is a custom claim used to mass invalidate existing JWTs by
// changing the API version via unpausePath.
Version string `json:"apiVersion,omitempty"`
// Domains is set of comma separated paused domains.
Domains string `json:"pausedDomains,omitempty"`
}
incomingClaims := sfeJWTClaims{}
err = token.Claims(sfe.unpauseHMACKey[:], &incomingClaims)
if err != nil {
return "", nil, err
}
expectedClaims := jwt.Expected{
Issuer: "WFE",
AnyAudience: jwt.Audience{"SFE Unpause"},
// Time is passed into the jwt package for tests to manipulate time.
Time: sfe.clk.Now(),
}
err = incomingClaims.Validate(expectedClaims)
if err != nil {
return "", nil, err
}
if len(incomingClaims.Subject) == 0 {
return "", nil, errors.New("Account ID required for account unpausing")
}
if incomingClaims.Version == "" {
return "", nil, errors.New("Incoming JWT was created with no API version")
}
if incomingClaims.Version != slug[2] {
return "", nil, fmt.Errorf("JWT created for unpause API version %s was provided to the incompatible API version %s", incomingClaims.Version, slug[2])
}
return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil
}

431
sfe/sfe_test.go Normal file
View File

@ -0,0 +1,431 @@
package sfe
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/jmhodges/clock"
"golang.org/x/crypto/ocsp"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/features"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/mocks"
"github.com/letsencrypt/boulder/must"
"github.com/letsencrypt/boulder/revocation"
"github.com/letsencrypt/boulder/test"
capb "github.com/letsencrypt/boulder/ca/proto"
corepb "github.com/letsencrypt/boulder/core/proto"
rapb "github.com/letsencrypt/boulder/ra/proto"
)
type MockRegistrationAuthority struct {
lastRevocationReason revocation.Reason
}
func (ra *MockRegistrationAuthority) NewRegistration(ctx context.Context, in *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) {
in.Id = 1
created := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
in.CreatedAt = timestamppb.New(created)
return in, nil
}
func (ra *MockRegistrationAuthority) UpdateRegistration(ctx context.Context, in *rapb.UpdateRegistrationRequest, _ ...grpc.CallOption) (*corepb.Registration, error) {
if !bytes.Equal(in.Base.Key, in.Update.Key) {
in.Base.Key = in.Update.Key
}
return in.Base, nil
}
func (ra *MockRegistrationAuthority) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) {
return &corepb.Authorization{}, nil
}
func (ra *MockRegistrationAuthority) RevokeCertByApplicant(ctx context.Context, in *rapb.RevokeCertByApplicantRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) {
ra.lastRevocationReason = revocation.Reason(in.Code)
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) RevokeCertByKey(ctx context.Context, in *rapb.RevokeCertByKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) {
ra.lastRevocationReason = revocation.Reason(ocsp.KeyCompromise)
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) GenerateOCSP(ctx context.Context, req *rapb.GenerateOCSPRequest, _ ...grpc.CallOption) (*capb.OCSPResponse, error) {
return nil, nil
}
func (ra *MockRegistrationAuthority) AdministrativelyRevokeCertificate(context.Context, *rapb.AdministrativelyRevokeCertificateRequest, ...grpc.CallOption) (*emptypb.Empty, error) {
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) OnValidationUpdate(context.Context, core.Authorization, ...grpc.CallOption) error {
return nil
}
func (ra *MockRegistrationAuthority) DeactivateAuthorization(context.Context, *corepb.Authorization, ...grpc.CallOption) (*emptypb.Empty, error) {
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) DeactivateRegistration(context.Context, *corepb.Registration, ...grpc.CallOption) (*emptypb.Empty, error) {
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) UnpauseAccount(context.Context, *rapb.UnpauseAccountRequest, ...grpc.CallOption) (*emptypb.Empty, error) {
return &emptypb.Empty{}, nil
}
func (ra *MockRegistrationAuthority) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) {
created := time.Date(2021, 1, 1, 1, 1, 1, 0, time.UTC)
expires := time.Date(2021, 2, 1, 1, 1, 1, 0, time.UTC)
return &corepb.Order{
Id: 1,
RegistrationID: in.RegistrationID,
Created: timestamppb.New(created),
Expires: timestamppb.New(expires),
Names: in.Names,
Status: string(core.StatusPending),
V2Authorizations: []int64{1},
}, nil
}
func (ra *MockRegistrationAuthority) FinalizeOrder(ctx context.Context, in *rapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) {
in.Order.Status = string(core.StatusProcessing)
return in.Order, nil
}
func mustParseURL(s string) *url.URL {
return must.Do(url.Parse(s))
}
const hmacKey = "pcl04dl3tt3rb1gb4dd4db0d34ts000p"
func setupSFE(t *testing.T) (SelfServiceFrontEndImpl, clock.FakeClock) {
features.Reset()
fc := clock.NewFake()
// Set to some non-zero time.
fc.Set(time.Date(2020, 10, 10, 0, 0, 0, 0, time.UTC))
stats := metrics.NoopRegisterer
mockSA := mocks.NewStorageAuthorityReadOnly(fc)
sfe, err := NewSelfServiceFrontEndImpl(
stats,
fc,
blog.NewMock(),
10*time.Second,
&MockRegistrationAuthority{},
mockSA,
[]byte(hmacKey),
)
test.AssertNotError(t, err, "Unable to create SFE")
return sfe, fc
}
func TestIndexPath(t *testing.T) {
t.Parallel()
sfe, _ := setupSFE(t)
responseWriter := httptest.NewRecorder()
sfe.Index(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL("/"),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "<title>Self-Service Frontend</title>")
}
func TestBuildIDPath(t *testing.T) {
t.Parallel()
sfe, _ := setupSFE(t)
responseWriter := httptest.NewRecorder()
sfe.BuildID(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL("/build"),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "Boulder=(")
}
func TestUnpausePaths(t *testing.T) {
t.Parallel()
sfe, fc := setupSFE(t)
now := fc.Now()
// GET with no JWT
responseWriter := httptest.NewRecorder()
sfe.UnpauseForm(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(unpauseGetForm),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "request was invalid meaning that we could not")
// GET with an invalid JWT
responseWriter = httptest.NewRecorder()
sfe.UnpauseForm(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=x")),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "error was encountered when attempting to unpause your account")
// GET with a valid JWT
validJWT, err := makeJWTForAccount(now, now, now.Add(24*time.Hour), []byte(hmacKey), 1, "v1", "example.com")
test.AssertNotError(t, err, "Should have been able to create JWT, but could not")
responseWriter = httptest.NewRecorder()
sfe.UnpauseForm(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=" + string(validJWT))),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "This action will allow you to resume")
// POST with no JWT
responseWriter = httptest.NewRecorder()
sfe.UnpauseSubmit(responseWriter, &http.Request{
Method: "POST",
URL: mustParseURL(unpausePostForm),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "request was invalid meaning that we could not")
// POST with an invalid JWT
responseWriter = httptest.NewRecorder()
sfe.UnpauseSubmit(responseWriter, &http.Request{
Method: "POST",
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=x")),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "An error was encountered when attempting to unpause")
// POST with a valid JWT redirects to a success page
responseWriter = httptest.NewRecorder()
sfe.UnpauseSubmit(responseWriter, &http.Request{
Method: "POST",
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + string(validJWT))),
})
test.AssertEquals(t, responseWriter.Code, http.StatusFound)
test.AssertEquals(t, unpauseStatus, responseWriter.Result().Header.Get("Location"))
// Redirecting after a successful unpause POST displays the success page.
responseWriter = httptest.NewRecorder()
sfe.UnpauseStatus(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(unpauseStatus),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "Your ACME account has been unpaused.")
}
// makeJWTForAccount is a standin for a WFE method that returns an unpauseJWT or
// an error. The JWT contains a set of claims which should be validated by the
// caller.
func makeJWTForAccount(notBefore time.Time, issuedAt time.Time, expiresAt time.Time, hmacKey []byte, regID int64, apiVersion string, pausedDomains string) (unpauseJWT, error) {
if len(hmacKey) != 32 {
return "", fmt.Errorf("invalid seed length")
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: hmacKey}, (&jose.SignerOptions{}).WithType("JWT"))
if err != nil {
return "", fmt.Errorf("making signer: %s", err)
}
// Ensure that we test an empty subject
var subject string
if regID == 0 {
subject = ""
} else {
subject = fmt.Sprint(regID)
}
// Ensure that we test receiving an empty API version string while
// defaulting the rest to match SFE unpausePath.
if apiVersion == "magicEmptyString" {
apiVersion = ""
} else if apiVersion == "" {
apiVersion = "v1"
}
// Ensure that we always send at least one domain in the JWT.
if pausedDomains == "" {
pausedDomains = "example.com"
}
// The SA returns a maximum of 15 domains and the SFE displays some text
// about "potentially more domains" being paused.
domains := strings.Split(pausedDomains, ",")
if len(domains) > 15 {
domains = domains[:15]
}
// Join slice back into a comma separated string with the maximum of 15
// domains.
pausedDomains = strings.Join(domains, ",")
customClaims := struct {
Version string `json:"apiVersion,omitempty"`
Domains string `json:"pausedDomains,omitempty"`
}{
apiVersion,
pausedDomains,
}
wfeClaims := jwt.Claims{
Issuer: "WFE",
Subject: subject,
Audience: jwt.Audience{"SFE Unpause"},
NotBefore: jwt.NewNumericDate(notBefore),
IssuedAt: jwt.NewNumericDate(issuedAt),
Expiry: jwt.NewNumericDate(expiresAt),
}
signedJWT, err := jwt.Signed(signer).Claims(&wfeClaims).Claims(&customClaims).Serialize()
if err != nil {
return "", fmt.Errorf("signing JWT: %s", err)
}
return unpauseJWT(signedJWT), nil
}
func TestValidateJWT(t *testing.T) {
t.Parallel()
sfe, fc := setupSFE(t)
now := fc.Now()
originalClock := fc
testCases := []struct {
Name string
IssuedAt time.Time
NotBefore time.Time
ExpiresAt time.Time
HMACKey string
RegID int64 // Default value set in makeJWTForAccount
Version string // Default value set in makeJWTForAccount
PausedDomains string // Default value set in makeJWTForAccount
ExpectedPausedDomains []string
ExpectedMakeJWTSubstr string
ExpectedValidationErrSubstr string
}{
{
Name: "valid",
IssuedAt: now,
NotBefore: now,
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: hmacKey,
RegID: 1,
ExpectedPausedDomains: []string{"example.com"},
},
{
Name: "valid, but more than 15 domains sent",
IssuedAt: now,
NotBefore: now,
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: hmacKey,
RegID: 1,
PausedDomains: "1.example.com,2.example.com,3.example.com,4.example.com,5.example.com,6.example.com,7.example.com,8.example.com,9.example.com,10.example.com,11.example.com,12.example.com,13.example.com,14.example.com,15.example.com,16.example.com",
ExpectedPausedDomains: []string{"1.example.com", "2.example.com", "3.example.com", "4.example.com", "5.example.com", "6.example.com", "7.example.com", "8.example.com", "9.example.com", "10.example.com", "11.example.com", "12.example.com", "13.example.com", "14.example.com", "15.example.com"},
},
{
Name: "apiVersion mismatch",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: hmacKey,
RegID: 1,
Version: "v2",
ExpectedValidationErrSubstr: "incompatible API version",
},
{
Name: "no API specified in claim",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: hmacKey,
RegID: 1,
Version: "magicEmptyString",
ExpectedValidationErrSubstr: "no API version",
},
{
Name: "creating JWT with empty seed fails",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: "",
RegID: 1,
ExpectedMakeJWTSubstr: "invalid seed length",
ExpectedValidationErrSubstr: "JWS format must have",
},
{
Name: "registration ID is required to pass validation",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(24 * time.Hour),
HMACKey: hmacKey,
RegID: 0, // This is a magic case where 0 is turned into an empty string in the Subject field of a jwt.Claims
ExpectedValidationErrSubstr: "required for account unpausing",
},
{
Name: "validating expired JWT fails",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(-24 * time.Hour),
HMACKey: hmacKey,
RegID: 1,
ExpectedValidationErrSubstr: "token is expired (exp)",
},
{
Name: "validating JWT with hash derived from different seed fails",
IssuedAt: now,
NotBefore: now.Add(5 * time.Minute),
ExpiresAt: now.Add(1 * time.Hour),
HMACKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
RegID: 1,
ExpectedValidationErrSubstr: "cryptographic primitive",
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
fc = originalClock
newJWT, err := makeJWTForAccount(tc.NotBefore, tc.IssuedAt, tc.ExpiresAt, []byte(tc.HMACKey), tc.RegID, tc.Version, tc.PausedDomains)
if tc.ExpectedMakeJWTSubstr != "" || string(newJWT) == "" {
test.AssertError(t, err, "JWT was created but should not have been")
test.AssertContains(t, err.Error(), tc.ExpectedMakeJWTSubstr)
} else {
test.AssertNotError(t, err, "Should have been able to create a JWT")
}
// Advance the clock an arbitrary amount. The WFE sets a notBefore
// claim in the JWT as a first pass annoyance for clients attempting
// to automate unpausing.
fc.Add(10 * time.Minute)
_, domains, err := sfe.validateUnpauseJWTforAccount(newJWT)
if tc.ExpectedValidationErrSubstr != "" || err != nil {
test.AssertError(t, err, "Error expected, but received none")
test.AssertContains(t, err.Error(), tc.ExpectedValidationErrSubstr)
} else {
test.AssertNotError(t, err, "Unable to validate JWT")
test.AssertDeepEquals(t, domains, tc.ExpectedPausedDomains)
}
})
}
}

45
sfe/static/main.css Normal file
View File

@ -0,0 +1,45 @@
/* Universal styles */
* {
font-family: system-ui, sans-serif;
}
/* Basic layout */
body {
max-width: 40em;
margin: 2em auto 0;
padding: 0 1rem;
}
/* Link styling */
a {
color: #3771c8;
}
a:hover {
color: #274f8c;
}
/* Content styling */
.content {
display: none;
border: 1px solid #c2e9e9;
background-color: #c2e9e9;
padding: 1.5rem;
border-radius: 0 0.25rem 0.25rem 0.25rem;
}
.content.active {
display: block;
}
/* Button styling */
button {
font-size: 1rem;
}
button.primary {
color: #fff;
background-color: #3771c8;
padding: 0.625rem 1.6rem;
font-weight: 600;
border-radius: 0.25rem;
border: none;
}
button.primary:hover {
background-color: #285999;
cursor: pointer;
}
/* jep 2024 */

16
sfe/templates/layout.html Normal file
View File

@ -0,0 +1,16 @@
{{define "meta"}}
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="stylesheet" href="/static/main.css">
{{ end }}
{{ define "footer" }}
<footer>
<div>
<p><a href="https://letsencrypt.org">Let's Encrypt</a> is a free, automated, and open certificate authority brought to you by the nonprofit <a href="https://www.isrg.org/">Internet Security Research Group (ISRG)</a>.</p>
</div>
</footer>
{{ end }}

View File

@ -42,7 +42,7 @@ ipki() (
# Used by Boulder gRPC services as both server and client mTLS certificates.
for SERVICE in admin-revoker expiration-mailer ocsp-responder consul \
wfe akamai-purger bad-key-revoker crl-updater crl-storer \
health-checker rocsp-tool; do
health-checker rocsp-tool sfe; do
minica -domains "${SERVICE}.boulder" &
done

View File

@ -94,7 +94,8 @@
"admin-revoker.boulder",
"bad-key-revoker.boulder",
"ocsp-responder.boulder",
"wfe.boulder"
"wfe.boulder",
"sfe.boulder"
]
},
"grpc.health.v1.Health": {

View File

@ -35,7 +35,8 @@
"clientNames": [
"admin-revoker.boulder",
"ocsp-responder.boulder",
"wfe.boulder"
"wfe.boulder",
"sfe.boulder"
]
},
"grpc.health.v1.Health": {

50
test/config-next/sfe.json Normal file
View File

@ -0,0 +1,50 @@
{
"sfe": {
"listenAddress": "0.0.0.0:4003",
"debugAddr": ":8015",
"timeout": "30s",
"shutdownStopTimeout": "10s",
"tls": {
"caCertFile": "test/certs/ipki/minica.pem",
"certFile": "test/certs/ipki/sfe.boulder/cert.pem",
"keyFile": "test/certs/ipki/sfe.boulder/key.pem"
},
"raService": {
"dnsAuthority": "consul.service.consul",
"srvLookup": {
"service": "ra",
"domain": "service.consul"
},
"timeout": "15s",
"noWaitForReady": true,
"hostOverride": "ra.boulder"
},
"saService": {
"dnsAuthority": "consul.service.consul",
"srvLookup": {
"service": "sa",
"domain": "service.consul"
},
"timeout": "15s",
"noWaitForReady": true,
"hostOverride": "sa.boulder"
},
"unpause": {
"hmacKey": {
"passwordFile": "test/secrets/sfe_unpause_key"
}
},
"features": {}
},
"syslog": {
"stdoutlevel": 4,
"sysloglevel": -1
},
"openTelemetry": {
"endpoint": "bjaeger:4317",
"sampleratio": 1
},
"openTelemetryHttpConfig": {
"trustIncomingSpans": true
}
}

View File

@ -14,6 +14,7 @@
"/var/log/boulder-sa.log",
"/var/log/boulder-va.log",
"/var/log/boulder-wfe2.log",
"/var/log/sfe.log",
"/var/log/crl-storer.log",
"/var/log/crl-updater.log",
"/var/log/nonce-service.log",

50
test/config/sfe.json Normal file
View File

@ -0,0 +1,50 @@
{
"sfe": {
"listenAddress": "0.0.0.0:4003",
"debugAddr": ":8015",
"timeout": "30s",
"shutdownStopTimeout": "10s",
"tls": {
"caCertFile": "test/certs/ipki/minica.pem",
"certFile": "test/certs/ipki/sfe.boulder/cert.pem",
"keyFile": "test/certs/ipki/sfe.boulder/key.pem"
},
"raService": {
"dnsAuthority": "consul.service.consul",
"srvLookup": {
"service": "ra",
"domain": "service.consul"
},
"timeout": "15s",
"noWaitForReady": true,
"hostOverride": "ra.boulder"
},
"saService": {
"dnsAuthority": "consul.service.consul",
"srvLookup": {
"service": "sa",
"domain": "service.consul"
},
"timeout": "15s",
"noWaitForReady": true,
"hostOverride": "sa.boulder"
},
"unpause": {
"hmacKey": {
"passwordFile": "test/secrets/sfe_unpause_key"
}
},
"features": {}
},
"syslog": {
"stdoutlevel": 6,
"sysloglevel": -1
},
"openTelemetry": {
"endpoint": "bjaeger:4317",
"sampleratio": 1
},
"openTelemetryHttpConfig": {
"trustIncomingSpans": true
}
}

View File

@ -0,0 +1 @@
0074cfe534d69a17c916df92e8982972

View File

@ -133,6 +133,10 @@ SERVICES = (
4001, None, None,
('./bin/boulder', 'boulder-wfe2', '--config', os.path.join(config_dir, 'wfe2.json'), '--addr', ':4001', '--tls-addr', ':4431', '--debug-addr', ':8013'),
('boulder-ra-1', 'boulder-ra-2', 'boulder-sa-1', 'boulder-sa-2', 'nonce-service-taro-1', 'nonce-service-taro-2', 'nonce-service-zinc-1')),
Service('sfe',
4003, None, None,
('./bin/boulder', 'sfe', '--config', os.path.join(config_dir, 'sfe.json'), '--addr', ':4003', '--debug-addr', ':8015'),
('boulder-ra-1', 'boulder-ra-2', 'boulder-sa-1', 'boulder-sa-2',)),
Service('log-validator',
8016, None, None,
('./bin/boulder', 'log-validator', '--config', os.path.join(config_dir, 'log-validator.json'), '--debug-addr', ':8016'),

315
vendor/github.com/go-jose/go-jose/v4/jwt/builder.go generated vendored Normal file
View File

@ -0,0 +1,315 @@
/*-
* Copyright 2016 Zbigniew Mandziejewicz
* Copyright 2016 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jwt
import (
"bytes"
"reflect"
"github.com/go-jose/go-jose/v4/json"
"github.com/go-jose/go-jose/v4"
)
// Builder is a utility for making JSON Web Tokens. Calls can be chained, and
// errors are accumulated until the final call to Serialize.
type Builder interface {
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
// into single JSON object. If you are passing private claims, make sure to set
// struct field tags to specify the name for the JSON key to be used when
// serializing.
Claims(i interface{}) Builder
// Token builds a JSONWebToken from provided data.
Token() (*JSONWebToken, error)
// Serialize serializes a token.
Serialize() (string, error)
}
// NestedBuilder is a utility for making Signed-Then-Encrypted JSON Web Tokens.
// Calls can be chained, and errors are accumulated until final call to
// Serialize.
type NestedBuilder interface {
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
// into single JSON object. If you are passing private claims, make sure to set
// struct field tags to specify the name for the JSON key to be used when
// serializing.
Claims(i interface{}) NestedBuilder
// Token builds a NestedJSONWebToken from provided data.
Token() (*NestedJSONWebToken, error)
// Serialize serializes a token.
Serialize() (string, error)
}
type builder struct {
payload map[string]interface{}
err error
}
type signedBuilder struct {
builder
sig jose.Signer
}
type encryptedBuilder struct {
builder
enc jose.Encrypter
}
type nestedBuilder struct {
builder
sig jose.Signer
enc jose.Encrypter
}
// Signed creates builder for signed tokens.
func Signed(sig jose.Signer) Builder {
return &signedBuilder{
sig: sig,
}
}
// Encrypted creates builder for encrypted tokens.
func Encrypted(enc jose.Encrypter) Builder {
return &encryptedBuilder{
enc: enc,
}
}
// SignedAndEncrypted creates builder for signed-then-encrypted tokens.
// ErrInvalidContentType will be returned if encrypter doesn't have JWT content type.
func SignedAndEncrypted(sig jose.Signer, enc jose.Encrypter) NestedBuilder {
if contentType, _ := enc.Options().ExtraHeaders[jose.HeaderContentType].(jose.ContentType); contentType != "JWT" {
return &nestedBuilder{
builder: builder{
err: ErrInvalidContentType,
},
}
}
return &nestedBuilder{
sig: sig,
enc: enc,
}
}
func (b builder) claims(i interface{}) builder {
if b.err != nil {
return b
}
m, ok := i.(map[string]interface{})
switch {
case ok:
return b.merge(m)
case reflect.Indirect(reflect.ValueOf(i)).Kind() == reflect.Struct:
m, err := normalize(i)
if err != nil {
return builder{
err: err,
}
}
return b.merge(m)
default:
return builder{
err: ErrInvalidClaims,
}
}
}
func normalize(i interface{}) (map[string]interface{}, error) {
m := make(map[string]interface{})
raw, err := json.Marshal(i)
if err != nil {
return nil, err
}
d := json.NewDecoder(bytes.NewReader(raw))
d.SetNumberType(json.UnmarshalJSONNumber)
if err := d.Decode(&m); err != nil {
return nil, err
}
return m, nil
}
func (b *builder) merge(m map[string]interface{}) builder {
p := make(map[string]interface{})
for k, v := range b.payload {
p[k] = v
}
for k, v := range m {
p[k] = v
}
return builder{
payload: p,
}
}
func (b *builder) token(p func(interface{}) ([]byte, error), h []jose.Header) (*JSONWebToken, error) {
return &JSONWebToken{
payload: p,
Headers: h,
}, nil
}
func (b *signedBuilder) Claims(i interface{}) Builder {
return &signedBuilder{
builder: b.builder.claims(i),
sig: b.sig,
}
}
func (b *signedBuilder) Token() (*JSONWebToken, error) {
sig, err := b.sign()
if err != nil {
return nil, err
}
h := make([]jose.Header, len(sig.Signatures))
for i, v := range sig.Signatures {
h[i] = v.Header
}
return b.builder.token(sig.Verify, h)
}
func (b *signedBuilder) Serialize() (string, error) {
sig, err := b.sign()
if err != nil {
return "", err
}
return sig.CompactSerialize()
}
func (b *signedBuilder) sign() (*jose.JSONWebSignature, error) {
if b.err != nil {
return nil, b.err
}
p, err := json.Marshal(b.payload)
if err != nil {
return nil, err
}
return b.sig.Sign(p)
}
func (b *encryptedBuilder) Claims(i interface{}) Builder {
return &encryptedBuilder{
builder: b.builder.claims(i),
enc: b.enc,
}
}
func (b *encryptedBuilder) Serialize() (string, error) {
enc, err := b.encrypt()
if err != nil {
return "", err
}
return enc.CompactSerialize()
}
func (b *encryptedBuilder) Token() (*JSONWebToken, error) {
enc, err := b.encrypt()
if err != nil {
return nil, err
}
return b.builder.token(enc.Decrypt, []jose.Header{enc.Header})
}
func (b *encryptedBuilder) encrypt() (*jose.JSONWebEncryption, error) {
if b.err != nil {
return nil, b.err
}
p, err := json.Marshal(b.payload)
if err != nil {
return nil, err
}
return b.enc.Encrypt(p)
}
func (b *nestedBuilder) Claims(i interface{}) NestedBuilder {
return &nestedBuilder{
builder: b.builder.claims(i),
sig: b.sig,
enc: b.enc,
}
}
// Token produced a token suitable for serialization. It cannot be decrypted
// without serializing and then deserializing.
func (b *nestedBuilder) Token() (*NestedJSONWebToken, error) {
enc, err := b.signAndEncrypt()
if err != nil {
return nil, err
}
return &NestedJSONWebToken{
allowedSignatureAlgorithms: nil,
enc: enc,
Headers: []jose.Header{enc.Header},
}, nil
}
func (b *nestedBuilder) Serialize() (string, error) {
enc, err := b.signAndEncrypt()
if err != nil {
return "", err
}
return enc.CompactSerialize()
}
func (b *nestedBuilder) FullSerialize() (string, error) {
enc, err := b.signAndEncrypt()
if err != nil {
return "", err
}
return enc.FullSerialize(), nil
}
func (b *nestedBuilder) signAndEncrypt() (*jose.JSONWebEncryption, error) {
if b.err != nil {
return nil, b.err
}
p, err := json.Marshal(b.payload)
if err != nil {
return nil, err
}
sig, err := b.sig.Sign(p)
if err != nil {
return nil, err
}
p2, err := sig.CompactSerialize()
if err != nil {
return nil, err
}
return b.enc.Encrypt([]byte(p2))
}

130
vendor/github.com/go-jose/go-jose/v4/jwt/claims.go generated vendored Normal file
View File

@ -0,0 +1,130 @@
/*-
* Copyright 2016 Zbigniew Mandziejewicz
* Copyright 2016 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jwt
import (
"strconv"
"time"
"github.com/go-jose/go-jose/v4/json"
)
// Claims represents public claim values (as specified in RFC 7519).
type Claims struct {
Issuer string `json:"iss,omitempty"`
Subject string `json:"sub,omitempty"`
Audience Audience `json:"aud,omitempty"`
Expiry *NumericDate `json:"exp,omitempty"`
NotBefore *NumericDate `json:"nbf,omitempty"`
IssuedAt *NumericDate `json:"iat,omitempty"`
ID string `json:"jti,omitempty"`
}
// NumericDate represents date and time as the number of seconds since the
// epoch, ignoring leap seconds. Non-integer values can be represented
// in the serialized format, but we round to the nearest second.
// See RFC7519 Section 2: https://tools.ietf.org/html/rfc7519#section-2
type NumericDate int64
// NewNumericDate constructs NumericDate from time.Time value.
func NewNumericDate(t time.Time) *NumericDate {
if t.IsZero() {
return nil
}
// While RFC 7519 technically states that NumericDate values may be
// non-integer values, we don't bother serializing timestamps in
// claims with sub-second accurancy and just round to the nearest
// second instead. Not convined sub-second accuracy is useful here.
out := NumericDate(t.Unix())
return &out
}
// MarshalJSON serializes the given NumericDate into its JSON representation.
func (n NumericDate) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatInt(int64(n), 10)), nil
}
// UnmarshalJSON reads a date from its JSON representation.
func (n *NumericDate) UnmarshalJSON(b []byte) error {
s := string(b)
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return ErrUnmarshalNumericDate
}
*n = NumericDate(f)
return nil
}
// Time returns time.Time representation of NumericDate.
func (n *NumericDate) Time() time.Time {
if n == nil {
return time.Time{}
}
return time.Unix(int64(*n), 0)
}
// Audience represents the recipients that the token is intended for.
type Audience []string
// UnmarshalJSON reads an audience from its JSON representation.
func (s *Audience) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch v := v.(type) {
case string:
*s = []string{v}
case []interface{}:
a := make([]string, len(v))
for i, e := range v {
s, ok := e.(string)
if !ok {
return ErrUnmarshalAudience
}
a[i] = s
}
*s = a
default:
return ErrUnmarshalAudience
}
return nil
}
// MarshalJSON converts audience to json representation.
func (s Audience) MarshalJSON() ([]byte, error) {
if len(s) == 1 {
return json.Marshal(s[0])
}
return json.Marshal([]string(s))
}
// Contains checks whether a given string is included in the Audience
func (s Audience) Contains(v string) bool {
for _, a := range s {
if a == v {
return true
}
}
return false
}

20
vendor/github.com/go-jose/go-jose/v4/jwt/doc.go generated vendored Normal file
View File

@ -0,0 +1,20 @@
/*-
* Copyright 2017 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
Package jwt provides an implementation of the JSON Web Token standard.
*/
package jwt

53
vendor/github.com/go-jose/go-jose/v4/jwt/errors.go generated vendored Normal file
View File

@ -0,0 +1,53 @@
/*-
* Copyright 2016 Zbigniew Mandziejewicz
* Copyright 2016 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jwt
import "errors"
// ErrUnmarshalAudience indicates that aud claim could not be unmarshalled.
var ErrUnmarshalAudience = errors.New("go-jose/go-jose/jwt: expected string or array value to unmarshal to Audience")
// ErrUnmarshalNumericDate indicates that JWT NumericDate could not be unmarshalled.
var ErrUnmarshalNumericDate = errors.New("go-jose/go-jose/jwt: expected number value to unmarshal NumericDate")
// ErrInvalidClaims indicates that given claims have invalid type.
var ErrInvalidClaims = errors.New("go-jose/go-jose/jwt: expected claims to be value convertible into JSON object")
// ErrInvalidIssuer indicates invalid iss claim.
var ErrInvalidIssuer = errors.New("go-jose/go-jose/jwt: validation failed, invalid issuer claim (iss)")
// ErrInvalidSubject indicates invalid sub claim.
var ErrInvalidSubject = errors.New("go-jose/go-jose/jwt: validation failed, invalid subject claim (sub)")
// ErrInvalidAudience indicated invalid aud claim.
var ErrInvalidAudience = errors.New("go-jose/go-jose/jwt: validation failed, invalid audience claim (aud)")
// ErrInvalidID indicates invalid jti claim.
var ErrInvalidID = errors.New("go-jose/go-jose/jwt: validation failed, invalid ID claim (jti)")
// ErrNotValidYet indicates that token is used before time indicated in nbf claim.
var ErrNotValidYet = errors.New("go-jose/go-jose/jwt: validation failed, token not valid yet (nbf)")
// ErrExpired indicates that token is used after expiry time indicated in exp claim.
var ErrExpired = errors.New("go-jose/go-jose/jwt: validation failed, token is expired (exp)")
// ErrIssuedInTheFuture indicates that the iat field is in the future.
var ErrIssuedInTheFuture = errors.New("go-jose/go-jose/jwt: validation field, token issued in the future (iat)")
// ErrInvalidContentType indicates that token requires JWT cty header.
var ErrInvalidContentType = errors.New("go-jose/go-jose/jwt: expected content type to be JWT (cty header)")

198
vendor/github.com/go-jose/go-jose/v4/jwt/jwt.go generated vendored Normal file
View File

@ -0,0 +1,198 @@
/*-
* Copyright 2016 Zbigniew Mandziejewicz
* Copyright 2016 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jwt
import (
"fmt"
"strings"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/json"
)
// JSONWebToken represents a JSON Web Token (as specified in RFC7519).
type JSONWebToken struct {
payload func(k interface{}) ([]byte, error)
unverifiedPayload func() []byte
Headers []jose.Header
}
type NestedJSONWebToken struct {
enc *jose.JSONWebEncryption
Headers []jose.Header
// Used when parsing and decrypting an input
allowedSignatureAlgorithms []jose.SignatureAlgorithm
}
// Claims deserializes a JSONWebToken into dest using the provided key.
func (t *JSONWebToken) Claims(key interface{}, dest ...interface{}) error {
b, err := t.payload(key)
if err != nil {
return err
}
for _, d := range dest {
if err := json.Unmarshal(b, d); err != nil {
return err
}
}
return nil
}
// UnsafeClaimsWithoutVerification deserializes the claims of a
// JSONWebToken into the dests. For signed JWTs, the claims are not
// verified. This function won't work for encrypted JWTs.
func (t *JSONWebToken) UnsafeClaimsWithoutVerification(dest ...interface{}) error {
if t.unverifiedPayload == nil {
return fmt.Errorf("go-jose/go-jose: Cannot get unverified claims")
}
claims := t.unverifiedPayload()
for _, d := range dest {
if err := json.Unmarshal(claims, d); err != nil {
return err
}
}
return nil
}
func (t *NestedJSONWebToken) Decrypt(decryptionKey interface{}) (*JSONWebToken, error) {
b, err := t.enc.Decrypt(decryptionKey)
if err != nil {
return nil, err
}
sig, err := ParseSigned(string(b), t.allowedSignatureAlgorithms)
if err != nil {
return nil, err
}
return sig, nil
}
// ParseSigned parses token from JWS form.
func ParseSigned(s string, signatureAlgorithms []jose.SignatureAlgorithm) (*JSONWebToken, error) {
sig, err := jose.ParseSignedCompact(s, signatureAlgorithms)
if err != nil {
return nil, err
}
headers := make([]jose.Header, len(sig.Signatures))
for i, signature := range sig.Signatures {
headers[i] = signature.Header
}
return &JSONWebToken{
payload: sig.Verify,
unverifiedPayload: sig.UnsafePayloadWithoutVerification,
Headers: headers,
}, nil
}
func validateKeyEncryptionAlgorithm(algs []jose.KeyAlgorithm) error {
for _, alg := range algs {
switch alg {
case jose.ED25519,
jose.RSA1_5,
jose.RSA_OAEP,
jose.RSA_OAEP_256,
jose.ECDH_ES,
jose.ECDH_ES_A128KW,
jose.ECDH_ES_A192KW,
jose.ECDH_ES_A256KW:
return fmt.Errorf("asymmetric encryption algorithms not supported for JWT: "+
"invalid key encryption algorithm: %s", alg)
case jose.PBES2_HS256_A128KW,
jose.PBES2_HS384_A192KW,
jose.PBES2_HS512_A256KW:
return fmt.Errorf("password-based encryption not supported for JWT: "+
"invalid key encryption algorithm: %s", alg)
}
}
return nil
}
func parseEncryptedCompact(
s string,
keyAlgorithms []jose.KeyAlgorithm,
contentEncryption []jose.ContentEncryption,
) (*jose.JSONWebEncryption, error) {
err := validateKeyEncryptionAlgorithm(keyAlgorithms)
if err != nil {
return nil, err
}
enc, err := jose.ParseEncryptedCompact(s, keyAlgorithms, contentEncryption)
if err != nil {
return nil, err
}
return enc, nil
}
// ParseEncrypted parses token from JWE form.
//
// The keyAlgorithms and contentEncryption parameters are used to validate the "alg" and "enc"
// header parameters respectively. They must be nonempty, and each "alg" or "enc" header in
// parsed data must contain a value that is present in the corresponding parameter. That
// includes the protected and unprotected headers as well as all recipients. To accept
// multiple algorithms, pass a slice of all the algorithms you want to accept.
func ParseEncrypted(s string,
keyAlgorithms []jose.KeyAlgorithm,
contentEncryption []jose.ContentEncryption,
) (*JSONWebToken, error) {
enc, err := parseEncryptedCompact(s, keyAlgorithms, contentEncryption)
if err != nil {
return nil, err
}
return &JSONWebToken{
payload: enc.Decrypt,
Headers: []jose.Header{enc.Header},
}, nil
}
// ParseSignedAndEncrypted parses signed-then-encrypted token from JWE form.
//
// The encryptionKeyAlgorithms and contentEncryption parameters are used to validate the "alg" and "enc"
// header parameters, respectively, of the outer JWE. They must be nonempty, and each "alg" or "enc"
// header in parsed data must contain a value that is present in the corresponding parameter. That
// includes the protected and unprotected headers as well as all recipients. To accept
// multiple algorithms, pass a slice of all the algorithms you want to accept.
//
// The signatureAlgorithms parameter is used to validate the "alg" header parameter of the
// inner JWS. It must be nonempty, and the "alg" header in the inner JWS must contain a value
// that is present in the parameter.
func ParseSignedAndEncrypted(s string,
encryptionKeyAlgorithms []jose.KeyAlgorithm,
contentEncryption []jose.ContentEncryption,
signatureAlgorithms []jose.SignatureAlgorithm,
) (*NestedJSONWebToken, error) {
enc, err := parseEncryptedCompact(s, encryptionKeyAlgorithms, contentEncryption)
if err != nil {
return nil, err
}
contentType, _ := enc.Header.ExtraHeaders[jose.HeaderContentType].(string)
if strings.ToUpper(contentType) != "JWT" {
return nil, ErrInvalidContentType
}
return &NestedJSONWebToken{
allowedSignatureAlgorithms: signatureAlgorithms,
enc: enc,
Headers: []jose.Header{enc.Header},
}, nil
}

127
vendor/github.com/go-jose/go-jose/v4/jwt/validation.go generated vendored Normal file
View File

@ -0,0 +1,127 @@
/*-
* Copyright 2016 Zbigniew Mandziejewicz
* Copyright 2016 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jwt
import "time"
const (
// DefaultLeeway defines the default leeway for matching NotBefore/Expiry claims.
DefaultLeeway = 1.0 * time.Minute
)
// Expected defines values used for protected claims validation.
// If field has zero value then validation is skipped, with the exception of
// Time, where the zero value means "now." To skip validating them, set the
// corresponding field in the Claims struct to nil.
type Expected struct {
// Issuer matches the "iss" claim exactly.
Issuer string
// Subject matches the "sub" claim exactly.
Subject string
// AnyAudience matches if there is a non-empty intersection between
// its values and the values in the "aud" claim.
AnyAudience Audience
// ID matches the "jti" claim exactly.
ID string
// Time matches the "exp", "nbf" and "iat" claims with leeway.
Time time.Time
}
// WithTime copies expectations with new time.
func (e Expected) WithTime(t time.Time) Expected {
e.Time = t
return e
}
// Validate checks claims in a token against expected values.
// A default leeway value of one minute is used to compare time values.
//
// The default leeway will cause the token to be deemed valid until one
// minute after the expiration time. If you're a server application that
// wants to give an extra minute to client tokens, use this
// function. If you're a client application wondering if the server
// will accept your token, use ValidateWithLeeway with a leeway <=0,
// otherwise this function might make you think a token is valid when
// it is not.
func (c Claims) Validate(e Expected) error {
return c.ValidateWithLeeway(e, DefaultLeeway)
}
// ValidateWithLeeway checks claims in a token against expected values. A
// custom leeway may be specified for comparing time values. You may pass a
// zero value to check time values with no leeway, but you should note that
// numeric date values are rounded to the nearest second and sub-second
// precision is not supported.
//
// The leeway gives some extra time to the token from the server's
// point of view. That is, if the token is expired, ValidateWithLeeway
// will still accept the token for 'leeway' amount of time. This fails
// if you're using this function to check if a server will accept your
// token, because it will think the token is valid even after it
// expires. So if you're a client validating if the token is valid to
// be submitted to a server, use leeway <=0, if you're a server
// validation a token, use leeway >=0.
func (c Claims) ValidateWithLeeway(e Expected, leeway time.Duration) error {
if e.Issuer != "" && e.Issuer != c.Issuer {
return ErrInvalidIssuer
}
if e.Subject != "" && e.Subject != c.Subject {
return ErrInvalidSubject
}
if e.ID != "" && e.ID != c.ID {
return ErrInvalidID
}
if len(e.AnyAudience) != 0 {
var intersection bool
for _, v := range e.AnyAudience {
if c.Audience.Contains(v) {
intersection = true
break
}
}
if !intersection {
return ErrInvalidAudience
}
}
// validate using the e.Time, or time.Now if not provided
validationTime := e.Time
if validationTime.IsZero() {
validationTime = time.Now()
}
if c.NotBefore != nil && validationTime.Add(leeway).Before(c.NotBefore.Time()) {
return ErrNotValidYet
}
if c.Expiry != nil && validationTime.Add(-leeway).After(c.Expiry.Time()) {
return ErrExpired
}
// IssuedAt is optional but cannot be in the future. This is not required by the RFC, but
// something is misconfigured if this happens and we should not trust it.
if c.IssuedAt != nil && validationTime.Add(leeway).Before(c.IssuedAt.Time()) {
return ErrIssuedInTheFuture
}
return nil
}

1
vendor/modules.txt vendored
View File

@ -149,6 +149,7 @@ github.com/fsnotify/fsnotify
github.com/go-jose/go-jose/v4
github.com/go-jose/go-jose/v4/cipher
github.com/go-jose/go-jose/v4/json
github.com/go-jose/go-jose/v4/jwt
# github.com/go-logr/logr v1.4.1
## explicit; go 1.18
github.com/go-logr/logr

40
web/server.go Normal file
View File

@ -0,0 +1,40 @@
package web
import (
"bytes"
"fmt"
"log"
"net/http"
"time"
blog "github.com/letsencrypt/boulder/log"
)
type errorWriter struct {
blog.Logger
}
func (ew errorWriter) Write(p []byte) (n int, err error) {
// log.Logger will append a newline to all messages before calling
// Write. Our log checksum checker doesn't like newlines, because
// syslog will strip them out so the calculated checksums will
// differ. So that we don't hit this corner case for every line
// logged from inside net/http.Server we strip the newline before
// we get to the checksum generator.
p = bytes.TrimRight(p, "\n")
ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p)))
return
}
// NewServer returns an http.Server which will listen on the given address, when
// started, for each path in the handler. Errors are sent to the given logger.
func NewServer(listenAddr string, handler http.Handler, logger blog.Logger) http.Server {
return http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: listenAddr,
ErrorLog: log.New(errorWriter{logger}, "", 0),
Handler: handler,
}
}

36
web/server_test.go Normal file
View File

@ -0,0 +1,36 @@
package web
import (
"context"
"errors"
"net/http"
"sync"
"testing"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/test"
)
func TestNewServer(t *testing.T) {
srv := NewServer(":0", nil, blog.NewMock())
var wg sync.WaitGroup
wg.Add(1)
go func() {
err := srv.ListenAndServe()
test.Assert(t, errors.Is(err, http.ErrServerClosed), "Could not start server")
wg.Done()
}()
err := srv.Shutdown(context.TODO())
test.AssertNotError(t, err, "Could not shut down server")
wg.Wait()
}
func TestUnorderedShutdownIsFine(t *testing.T) {
srv := NewServer(":0", nil, blog.NewMock())
err := srv.Shutdown(context.TODO())
test.AssertNotError(t, err, "Could not shut down server")
err = srv.ListenAndServe()
test.Assert(t, errors.Is(err, http.ErrServerClosed), "Could not start server")
}