package sa import ( "context" "crypto/sha256" "crypto/x509" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "math" "net/netip" "net/url" "slices" "strconv" "strings" "time" "github.com/go-jose/go-jose/v4" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/letsencrypt/boulder/core" corepb "github.com/letsencrypt/boulder/core/proto" "github.com/letsencrypt/boulder/db" berrors "github.com/letsencrypt/boulder/errors" "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/identifier" "github.com/letsencrypt/boulder/probs" "github.com/letsencrypt/boulder/revocation" sapb "github.com/letsencrypt/boulder/sa/proto" ) // errBadJSON is an error type returned when a json.Unmarshal performed by the // SA fails. It includes both the Unmarshal error and the original JSON data in // its error message to make it easier to track down the bad JSON data. type errBadJSON struct { msg string json []byte err error } // Error returns an error message that includes the json.Unmarshal error as well // as the bad JSON data. func (e errBadJSON) Error() string { return fmt.Sprintf( "%s: error unmarshaling JSON %q: %s", e.msg, string(e.json), e.err) } // badJSONError is a convenience function for constructing a errBadJSON instance // with the provided args. func badJSONError(msg string, jsonData []byte, err error) error { return errBadJSON{ msg: msg, json: jsonData, err: err, } } const regFields = "id, jwk, jwk_sha256, agreement, createdAt, LockCol, status" // ClearEmail removes the provided email address from one specified registration. If // there are multiple email addresses present, it does not modify other ones. If the email // address is not present, it does not modify the registration and will return a nil error. func ClearEmail(ctx context.Context, dbMap db.DatabaseMap, regID int64, email string) error { _, overallError := db.WithTransaction(ctx, dbMap, func(tx db.Executor) (interface{}, error) { curr, err := selectRegistration(ctx, tx, "id", regID) if err != nil { return nil, err } currPb, err := registrationModelToPb(curr) if err != nil { return nil, err } // newContacts will be a copy of all emails in currPb.Contact _except_ the one to be removed var newContacts []string for _, contact := range currPb.Contact { if contact != "mailto:"+email { newContacts = append(newContacts, contact) } } if slices.Equal(currPb.Contact, newContacts) { return nil, nil } // We don't want to write literal JSON "null" strings into the database if the // list of contact addresses is empty. Replace any possibly-`nil` slice with // an empty JSON array. We don't need to check reg.ContactPresent, because // we're going to write the whole object to the database anyway. jsonContact := []byte("[]") if len(newContacts) != 0 { jsonContact, err = json.Marshal(newContacts) if err != nil { return nil, err } } // UPDATE the row with a direct database query, in order to avoid LockCol issues. result, err := tx.ExecContext(ctx, "UPDATE registrations SET contact = ? WHERE id = ? LIMIT 1", jsonContact, regID, ) if err != nil { return nil, err } rowsAffected, err := result.RowsAffected() if err != nil || rowsAffected != 1 { return nil, berrors.InternalServerError("no registration updated with new contact field") } return nil, nil }) if overallError != nil { return overallError } return nil } // selectRegistration selects all fields of one registration model func selectRegistration(ctx context.Context, s db.OneSelector, whereCol string, args ...interface{}) (*regModel, error) { if whereCol != "id" && whereCol != "jwk_sha256" { return nil, fmt.Errorf("column name %q invalid for registrations table WHERE clause", whereCol) } var model regModel err := s.SelectOne( ctx, &model, "SELECT "+regFields+" FROM registrations WHERE "+whereCol+" = ? LIMIT 1", args..., ) return &model, err } const certFields = "id, registrationID, serial, digest, der, issued, expires" // SelectCertificate selects all fields of one certificate object identified by // a serial. If more than one row contains the same serial only the first is // returned. func SelectCertificate(ctx context.Context, s db.OneSelector, serial string) (*corepb.Certificate, error) { var model certificateModel err := s.SelectOne( ctx, &model, "SELECT "+certFields+" FROM certificates WHERE serial = ? LIMIT 1", serial, ) return model.toPb(), err } const precertFields = "registrationID, serial, der, issued, expires" // SelectPrecertificate selects all fields of one precertificate object // identified by serial. func SelectPrecertificate(ctx context.Context, s db.OneSelector, serial string) (*corepb.Certificate, error) { var model lintingCertModel err := s.SelectOne( ctx, &model, "SELECT "+precertFields+" FROM precertificates WHERE serial = ? LIMIT 1", serial) if err != nil { return nil, err } return model.toPb(), nil } // SelectCertificates selects all fields of multiple certificate objects // // Returns a slice of *corepb.Certificate along with the highest ID field seen // (which can be used as input to a subsequent query when iterating in primary // key order). func SelectCertificates(ctx context.Context, s db.Selector, q string, args map[string]interface{}) ([]*corepb.Certificate, int64, error) { var models []certificateModel _, err := s.Select( ctx, &models, "SELECT "+certFields+" FROM certificates "+q, args) var pbs []*corepb.Certificate var highestID int64 for _, m := range models { pbs = append(pbs, m.toPb()) if m.ID > highestID { highestID = m.ID } } return pbs, highestID, err } type CertStatusMetadata struct { ID int64 `db:"id"` Serial string `db:"serial"` Status core.OCSPStatus `db:"status"` OCSPLastUpdated time.Time `db:"ocspLastUpdated"` RevokedDate time.Time `db:"revokedDate"` RevokedReason revocation.Reason `db:"revokedReason"` LastExpirationNagSent time.Time `db:"lastExpirationNagSent"` NotAfter time.Time `db:"notAfter"` IsExpired bool `db:"isExpired"` IssuerID int64 `db:"issuerID"` } const certStatusFields = "id, serial, status, ocspLastUpdated, revokedDate, revokedReason, lastExpirationNagSent, notAfter, isExpired, issuerID" // SelectCertificateStatus selects all fields of one certificate status model // identified by serial func SelectCertificateStatus(ctx context.Context, s db.OneSelector, serial string) (*corepb.CertificateStatus, error) { var model certificateStatusModel err := s.SelectOne( ctx, &model, "SELECT "+certStatusFields+" FROM certificateStatus WHERE serial = ? LIMIT 1", serial, ) return model.toPb(), err } // RevocationStatusModel represents a small subset of the columns in the // certificateStatus table, used to determine the authoritative revocation // status of a certificate. type RevocationStatusModel struct { Status core.OCSPStatus `db:"status"` RevokedDate time.Time `db:"revokedDate"` RevokedReason revocation.Reason `db:"revokedReason"` } // SelectRevocationStatus returns the authoritative revocation information for // the certificate with the given serial. func SelectRevocationStatus(ctx context.Context, s db.OneSelector, serial string) (*sapb.RevocationStatus, error) { var model RevocationStatusModel err := s.SelectOne( ctx, &model, "SELECT status, revokedDate, revokedReason FROM certificateStatus WHERE serial = ? LIMIT 1", serial, ) if err != nil { return nil, err } statusInt, ok := core.OCSPStatusToInt[model.Status] if !ok { return nil, fmt.Errorf("got unrecognized status %q", model.Status) } return &sapb.RevocationStatus{ Status: int64(statusInt), RevokedDate: timestamppb.New(model.RevokedDate), RevokedReason: int64(model.RevokedReason), }, nil } var mediumBlobSize = int(math.Pow(2, 24)) type issuedNameModel struct { ID int64 `db:"id"` ReversedName string `db:"reversedName"` NotBefore time.Time `db:"notBefore"` Serial string `db:"serial"` } // regModel is the description of a core.Registration in the database before type regModel struct { ID int64 `db:"id"` Key []byte `db:"jwk"` KeySHA256 string `db:"jwk_sha256"` Agreement string `db:"agreement"` CreatedAt time.Time `db:"createdAt"` LockCol int64 Status string `db:"status"` } func registrationPbToModel(reg *corepb.Registration) (*regModel, error) { // Even though we don't need to convert from JSON to an in-memory JSONWebKey // for the sake of the `Key` field, we do need to do the conversion in order // to compute the SHA256 key digest. var jwk jose.JSONWebKey err := jwk.UnmarshalJSON(reg.Key) if err != nil { return nil, err } sha, err := core.KeyDigestB64(jwk.Key) if err != nil { return nil, err } var createdAt time.Time if !core.IsAnyNilOrZero(reg.CreatedAt) { createdAt = reg.CreatedAt.AsTime() } return ®Model{ ID: reg.Id, Key: reg.Key, KeySHA256: sha, Agreement: reg.Agreement, CreatedAt: createdAt, Status: reg.Status, }, nil } func registrationModelToPb(reg *regModel) (*corepb.Registration, error) { if reg.ID == 0 || len(reg.Key) == 0 { return nil, errors.New("incomplete Registration retrieved from DB") } return &corepb.Registration{ Id: reg.ID, Key: reg.Key, Agreement: reg.Agreement, CreatedAt: timestamppb.New(reg.CreatedAt.UTC()), Status: reg.Status, }, nil } type recordedSerialModel struct { ID int64 Serial string RegistrationID int64 Created time.Time Expires time.Time } type lintingCertModel struct { ID int64 Serial string RegistrationID int64 DER []byte Issued time.Time Expires time.Time } func (model lintingCertModel) toPb() *corepb.Certificate { return &corepb.Certificate{ RegistrationID: model.RegistrationID, Serial: model.Serial, Digest: "", Der: model.DER, Issued: timestamppb.New(model.Issued), Expires: timestamppb.New(model.Expires), } } type certificateModel struct { ID int64 `db:"id"` RegistrationID int64 `db:"registrationID"` Serial string `db:"serial"` Digest string `db:"digest"` DER []byte `db:"der"` Issued time.Time `db:"issued"` Expires time.Time `db:"expires"` } func (model certificateModel) toPb() *corepb.Certificate { return &corepb.Certificate{ RegistrationID: model.RegistrationID, Serial: model.Serial, Digest: model.Digest, Der: model.DER, Issued: timestamppb.New(model.Issued), Expires: timestamppb.New(model.Expires), } } type certificateStatusModel struct { ID int64 `db:"id"` Serial string `db:"serial"` Status core.OCSPStatus `db:"status"` OCSPLastUpdated time.Time `db:"ocspLastUpdated"` RevokedDate time.Time `db:"revokedDate"` RevokedReason revocation.Reason `db:"revokedReason"` LastExpirationNagSent time.Time `db:"lastExpirationNagSent"` NotAfter time.Time `db:"notAfter"` IsExpired bool `db:"isExpired"` IssuerID int64 `db:"issuerID"` } func (model certificateStatusModel) toPb() *corepb.CertificateStatus { return &corepb.CertificateStatus{ Serial: model.Serial, Status: string(model.Status), OcspLastUpdated: timestamppb.New(model.OCSPLastUpdated), RevokedDate: timestamppb.New(model.RevokedDate), RevokedReason: int64(model.RevokedReason), LastExpirationNagSent: timestamppb.New(model.LastExpirationNagSent), NotAfter: timestamppb.New(model.NotAfter), IsExpired: model.IsExpired, IssuerID: model.IssuerID, } } // orderModel represents one row in the orders table. The CertificateProfileName // column is a pointer because the column is NULL-able. type orderModel struct { ID int64 RegistrationID int64 Expires time.Time Created time.Time Error []byte CertificateSerial string BeganProcessing bool CertificateProfileName *string Replaces *string } type orderToAuthzModel struct { OrderID int64 AuthzID int64 } func orderToModel(order *corepb.Order) (*orderModel, error) { // Make a local copy so we can take a reference to it below. profile := order.CertificateProfileName replaces := order.Replaces om := &orderModel{ ID: order.Id, RegistrationID: order.RegistrationID, Expires: order.Expires.AsTime(), Created: order.Created.AsTime(), BeganProcessing: order.BeganProcessing, CertificateSerial: order.CertificateSerial, CertificateProfileName: &profile, Replaces: &replaces, } if order.Error != nil { errJSON, err := json.Marshal(order.Error) if err != nil { return nil, err } if len(errJSON) > mediumBlobSize { return nil, fmt.Errorf("Error object is too large to store in the database") } om.Error = errJSON } return om, nil } func modelToOrder(om *orderModel) (*corepb.Order, error) { profile := "" if om.CertificateProfileName != nil { profile = *om.CertificateProfileName } replaces := "" if om.Replaces != nil { replaces = *om.Replaces } order := &corepb.Order{ Id: om.ID, RegistrationID: om.RegistrationID, Expires: timestamppb.New(om.Expires), Created: timestamppb.New(om.Created), CertificateSerial: om.CertificateSerial, BeganProcessing: om.BeganProcessing, CertificateProfileName: profile, Replaces: replaces, } if len(om.Error) > 0 { var problem corepb.ProblemDetails err := json.Unmarshal(om.Error, &problem) if err != nil { return &corepb.Order{}, badJSONError( "failed to unmarshal order model's error", om.Error, err) } order.Error = &problem } return order, nil } var challTypeToUint = map[string]uint8{ "http-01": 0, "dns-01": 1, "tls-alpn-01": 2, } var uintToChallType = map[uint8]string{ 0: "http-01", 1: "dns-01", 2: "tls-alpn-01", } var identifierTypeToUint = map[string]uint8{ "dns": 0, "ip": 1, } var uintToIdentifierType = map[uint8]identifier.IdentifierType{ 0: "dns", 1: "ip", } var statusToUint = map[core.AcmeStatus]uint8{ core.StatusPending: 0, core.StatusValid: 1, core.StatusInvalid: 2, core.StatusDeactivated: 3, core.StatusRevoked: 4, } var uintToStatus = map[uint8]core.AcmeStatus{ 0: core.StatusPending, 1: core.StatusValid, 2: core.StatusInvalid, 3: core.StatusDeactivated, 4: core.StatusRevoked, } func statusUint(status core.AcmeStatus) uint8 { return statusToUint[status] } // authzFields is used in a variety of places in sa.go, and modifications to // it must be carried through to every use in sa.go const authzFields = "id, identifierType, identifierValue, registrationID, certificateProfileName, status, expires, challenges, attempted, attemptedAt, token, validationError, validationRecord" // authzModel represents one row in the authz2 table. The CertificateProfileName // column is a pointer because the column is NULL-able. type authzModel struct { ID int64 `db:"id"` IdentifierType uint8 `db:"identifierType"` IdentifierValue string `db:"identifierValue"` RegistrationID int64 `db:"registrationID"` CertificateProfileName *string `db:"certificateProfileName"` Status uint8 `db:"status"` Expires time.Time `db:"expires"` Challenges uint8 `db:"challenges"` Attempted *uint8 `db:"attempted"` AttemptedAt *time.Time `db:"attemptedAt"` Token []byte `db:"token"` ValidationError []byte `db:"validationError"` ValidationRecord []byte `db:"validationRecord"` } // rehydrateHostPort mutates a validation record. If the URL in the validation // record cannot be parsed, an error will be returned. If the Hostname and Port // fields already exist in the validation record, they will be retained. // Otherwise, the Hostname and Port will be derived and set from the URL field // of the validation record. func rehydrateHostPort(vr *core.ValidationRecord) error { if vr.URL == "" { return fmt.Errorf("rehydrating validation record, URL field cannot be empty") } parsedUrl, err := url.Parse(vr.URL) if err != nil { return fmt.Errorf("parsing validation record URL %q: %w", vr.URL, err) } if vr.Hostname == "" { hostname := parsedUrl.Hostname() if hostname == "" { return fmt.Errorf("hostname missing in URL %q", vr.URL) } vr.Hostname = hostname } if vr.Port == "" { // CABF BRs section 1.6.1: Authorized Ports: One of the following ports: 80 // (http), 443 (https) if parsedUrl.Port() == "" { // If there is only a scheme, then we'll determine the appropriate port. switch parsedUrl.Scheme { case "https": vr.Port = "443" case "http": vr.Port = "80" default: // This should never happen since the VA should have already // checked the scheme. return fmt.Errorf("unknown scheme %q in URL %q", parsedUrl.Scheme, vr.URL) } } else if parsedUrl.Port() == "80" || parsedUrl.Port() == "443" { // If :80 or :443 were embedded in the URL field // e.g. '"url":"https://example.com:443"' vr.Port = parsedUrl.Port() } else { return fmt.Errorf("only ports 80/tcp and 443/tcp are allowed in URL %q", vr.URL) } } return nil } // SelectAuthzsMatchingIssuance looks for a set of authzs that would have // authorized a given issuance that is known to have occurred. The returned // authzs will all belong to the given regID, will have potentially been valid // at the time of issuance, and will have the appropriate identifier type and // value. This may return multiple authzs for the same identifier type and value. // // This returns "potentially" valid authzs because a client may have set an // authzs status to deactivated after issuance, so we return both valid and // deactivated authzs. It also uses a small amount of leeway (1s) to account // for possible clock skew. // // This function doesn't do anything special for authzs with an expiration in // the past. If the stored authz has a valid status, it is returned with a // valid status regardless of whether it is also expired. func SelectAuthzsMatchingIssuance( ctx context.Context, s db.Selector, regID int64, issued time.Time, idents identifier.ACMEIdentifiers, ) ([]*corepb.Authorization, error) { // The WHERE clause returned by this function does not contain any // user-controlled strings; all user-controlled input ends up in the // returned placeholder args. identConditions, identArgs := buildIdentifierQueryConditions(idents) query := fmt.Sprintf(`SELECT %s FROM authz2 WHERE registrationID = ? AND status IN (?, ?) AND expires >= ? AND attemptedAt <= ? AND (%s)`, authzFields, identConditions) var args []any args = append(args, regID, statusToUint[core.StatusValid], statusToUint[core.StatusDeactivated], issued.Add(-1*time.Second), // leeway for clock skew issued.Add(1*time.Second), // leeway for clock skew ) args = append(args, identArgs...) var authzModels []authzModel _, err := s.Select(ctx, &authzModels, query, args...) if err != nil { return nil, err } var authzs []*corepb.Authorization for _, model := range authzModels { authz, err := modelToAuthzPB(model) if err != nil { return nil, err } authzs = append(authzs, authz) } return authzs, err } // hasMultipleNonPendingChallenges checks if a slice of challenges contains // more than one non-pending challenge func hasMultipleNonPendingChallenges(challenges []*corepb.Challenge) bool { nonPending := false for _, c := range challenges { if c.Status == string(core.StatusValid) || c.Status == string(core.StatusInvalid) { if !nonPending { nonPending = true } else { return true } } } return false } // newAuthzReqToModel converts an sapb.NewAuthzRequest to the authzModel storage // representation. It hardcodes the status to "pending" because it should be // impossible to create an authz in any other state. func newAuthzReqToModel(authz *sapb.NewAuthzRequest, profile string) (*authzModel, error) { am := &authzModel{ IdentifierType: identifierTypeToUint[authz.Identifier.Type], IdentifierValue: authz.Identifier.Value, RegistrationID: authz.RegistrationID, Status: statusToUint[core.StatusPending], Expires: authz.Expires.AsTime(), } if profile != "" { am.CertificateProfileName = &profile } for _, challType := range authz.ChallengeTypes { // Set the challenge type bit in the bitmap am.Challenges |= 1 << challTypeToUint[challType] } token, err := base64.RawURLEncoding.DecodeString(authz.Token) if err != nil { return nil, err } am.Token = token return am, nil } // authzPBToModel converts a protobuf authorization representation to the // authzModel storage representation. // Deprecated: this function is only used as part of test setup, do not // introduce any new uses in production code. func authzPBToModel(authz *corepb.Authorization) (*authzModel, error) { ident := identifier.FromProto(authz.Identifier) am := &authzModel{ IdentifierType: identifierTypeToUint[ident.ToProto().Type], IdentifierValue: ident.Value, RegistrationID: authz.RegistrationID, Status: statusToUint[core.AcmeStatus(authz.Status)], Expires: authz.Expires.AsTime(), } if authz.CertificateProfileName != "" { profile := authz.CertificateProfileName am.CertificateProfileName = &profile } if authz.Id != "" { // The v1 internal authorization objects use a string for the ID, the v2 // storage format uses a integer ID. In order to maintain compatibility we // convert the integer ID to a string. id, err := strconv.Atoi(authz.Id) if err != nil { return nil, err } am.ID = int64(id) } if hasMultipleNonPendingChallenges(authz.Challenges) { return nil, errors.New("multiple challenges are non-pending") } // In the v2 authorization style we don't store individual challenges with their own // token, validation errors/records, etc. Instead we store a single token/error/record // set, a bitmap of available challenge types, and a row indicating which challenge type // was 'attempted'. // // Since we don't currently have the singular token/error/record set abstracted out to // the core authorization type yet we need to extract these from the challenges array. // We assume that the token in each challenge is the same and that if any of the challenges // has a non-pending status that it should be considered the 'attempted' challenge and // we extract the error/record set from that particular challenge. var tokenStr string for _, chall := range authz.Challenges { // Set the challenge type bit in the bitmap am.Challenges |= 1 << challTypeToUint[chall.Type] tokenStr = chall.Token // If the challenge status is not core.StatusPending we assume it was the 'attempted' // challenge and extract the relevant fields we need. if chall.Status == string(core.StatusValid) || chall.Status == string(core.StatusInvalid) { attemptedType := challTypeToUint[chall.Type] am.Attempted = &attemptedType // If validated Unix timestamp is zero then keep the core.Challenge Validated object nil. var validated *time.Time if !core.IsAnyNilOrZero(chall.Validated) { val := chall.Validated.AsTime() validated = &val } am.AttemptedAt = validated // Marshal corepb.ValidationRecords to core.ValidationRecords so that we // can marshal them to JSON. records := make([]core.ValidationRecord, len(chall.Validationrecords)) for i, recordPB := range chall.Validationrecords { if chall.Type == string(core.ChallengeTypeHTTP01) { // Remove these fields because they can be rehydrated later // on from the URL field. recordPB.Hostname = "" recordPB.Port = "" } var err error records[i], err = grpc.PBToValidationRecord(recordPB) if err != nil { return nil, err } } var err error am.ValidationRecord, err = json.Marshal(records) if err != nil { return nil, err } // If there is a error associated with the challenge marshal it to JSON // so that we can store it in the database. if chall.Error != nil { prob, err := grpc.PBToProblemDetails(chall.Error) if err != nil { return nil, err } am.ValidationError, err = json.Marshal(prob) if err != nil { return nil, err } } } token, err := base64.RawURLEncoding.DecodeString(tokenStr) if err != nil { return nil, err } am.Token = token } return am, nil } // populateAttemptedFields takes a challenge and populates it with the validation fields status, // validation records, and error (the latter only if the validation failed) from an authzModel. func populateAttemptedFields(am authzModel, challenge *corepb.Challenge) error { if len(am.ValidationError) != 0 { // If the error is non-empty the challenge must be invalid. challenge.Status = string(core.StatusInvalid) var prob probs.ProblemDetails err := json.Unmarshal(am.ValidationError, &prob) if err != nil { return badJSONError( "failed to unmarshal authz2 model's validation error", am.ValidationError, err) } challenge.Error, err = grpc.ProblemDetailsToPB(&prob) if err != nil { return err } } else { // If the error is empty the challenge must be valid. challenge.Status = string(core.StatusValid) } var records []core.ValidationRecord err := json.Unmarshal(am.ValidationRecord, &records) if err != nil { return badJSONError( "failed to unmarshal authz2 model's validation record", am.ValidationRecord, err) } challenge.Validationrecords = make([]*corepb.ValidationRecord, len(records)) for i, r := range records { // Fixes implicit memory aliasing in for loop so we can deference r // later on for rehydrateHostPort. r := r if challenge.Type == string(core.ChallengeTypeHTTP01) { err := rehydrateHostPort(&r) if err != nil { return err } } challenge.Validationrecords[i], err = grpc.ValidationRecordToPB(r) if err != nil { return err } } return nil } func modelToAuthzPB(am authzModel) (*corepb.Authorization, error) { identType, ok := uintToIdentifierType[am.IdentifierType] if !ok { return nil, fmt.Errorf("unrecognized identifier type encoding %d", am.IdentifierType) } profile := "" if am.CertificateProfileName != nil { profile = *am.CertificateProfileName } pb := &corepb.Authorization{ Id: fmt.Sprintf("%d", am.ID), Status: string(uintToStatus[am.Status]), Identifier: identifier.ACMEIdentifier{Type: identType, Value: am.IdentifierValue}.ToProto(), RegistrationID: am.RegistrationID, Expires: timestamppb.New(am.Expires), CertificateProfileName: profile, } // Populate authorization challenge array. We do this by iterating through // the challenge type bitmap and creating a challenge of each type if its // bit is set. Each of these challenges has the token from the authorization // model and has its status set to core.StatusPending by default. If the // challenge type is equal to that in the 'attempted' row we set the status // to core.StatusValid or core.StatusInvalid depending on if there is anything // in ValidationError and populate the ValidationRecord and ValidationError // fields. for pos := uint8(0); pos < 8; pos++ { if (am.Challenges>>pos)&1 == 1 { challType := uintToChallType[pos] challenge := &corepb.Challenge{ Type: challType, Status: string(core.StatusPending), Token: base64.RawURLEncoding.EncodeToString(am.Token), } // If the challenge type matches the attempted type it must be either // valid or invalid and we need to populate extra fields. // Also, once any challenge has been attempted, we consider the other // challenges "gone" per https://tools.ietf.org/html/rfc8555#section-7.1.4 if am.Attempted != nil { if uintToChallType[*am.Attempted] == challType { err := populateAttemptedFields(am, challenge) if err != nil { return nil, err } // Get the attemptedAt time and assign to the challenge validated time. var validated *timestamppb.Timestamp if am.AttemptedAt != nil { validated = timestamppb.New(*am.AttemptedAt) } challenge.Validated = validated pb.Challenges = append(pb.Challenges, challenge) } } else { // When no challenge has been attempted yet, all challenges are still // present. pb.Challenges = append(pb.Challenges, challenge) } } } return pb, nil } type keyHashModel struct { ID int64 KeyHash []byte CertNotAfter time.Time CertSerial string } var stringToSourceInt = map[string]int{ "API": 1, "admin-revoker": 2, } // incidentModel represents a row in the 'incidents' table. type incidentModel struct { ID int64 `db:"id"` SerialTable string `db:"serialTable"` URL string `db:"url"` RenewBy time.Time `db:"renewBy"` Enabled bool `db:"enabled"` } func incidentModelToPB(i incidentModel) sapb.Incident { return sapb.Incident{ Id: i.ID, SerialTable: i.SerialTable, Url: i.URL, RenewBy: timestamppb.New(i.RenewBy), Enabled: i.Enabled, } } // incidentSerialModel represents a row in an 'incident_*' table. type incidentSerialModel struct { Serial string `db:"serial"` RegistrationID *int64 `db:"registrationID"` OrderID *int64 `db:"orderID"` LastNoticeSent *time.Time `db:"lastNoticeSent"` } // crlEntryModel has just the certificate status fields necessary to construct // an entry in a CRL. type crlEntryModel struct { Serial string `db:"serial"` Status core.OCSPStatus `db:"status"` RevokedReason revocation.Reason `db:"revokedReason"` RevokedDate time.Time `db:"revokedDate"` } // orderFQDNSet contains the SHA256 hash of the lowercased, comma joined names // from a new-order request, along with the corresponding orderID, the // registration ID, and the order expiry. This is used to find // existing orders for reuse. type orderFQDNSet struct { ID int64 SetHash []byte OrderID int64 RegistrationID int64 Expires time.Time } func addFQDNSet(ctx context.Context, db db.Inserter, idents identifier.ACMEIdentifiers, serial string, issued time.Time, expires time.Time) error { return db.Insert(ctx, &core.FQDNSet{ SetHash: core.HashIdentifiers(idents), Serial: serial, Issued: issued, Expires: expires, }) } // addOrderFQDNSet creates a new OrderFQDNSet row using the provided // information. This function accepts a transaction so that the orderFqdnSet // addition can take place within the order addition transaction. The caller is // required to rollback the transaction if an error is returned. func addOrderFQDNSet( ctx context.Context, db db.Inserter, idents identifier.ACMEIdentifiers, orderID int64, regID int64, expires time.Time) error { return db.Insert(ctx, &orderFQDNSet{ SetHash: core.HashIdentifiers(idents), OrderID: orderID, RegistrationID: regID, Expires: expires, }) } // deleteOrderFQDNSet deletes a OrderFQDNSet row that matches the provided // orderID. This function accepts a transaction so that the deletion can // take place within the finalization transaction. The caller is required to // rollback the transaction if an error is returned. func deleteOrderFQDNSet( ctx context.Context, db db.Execer, orderID int64) error { result, err := db.ExecContext(ctx, ` DELETE FROM orderFqdnSets WHERE orderID = ?`, orderID) if err != nil { return err } rowsDeleted, err := result.RowsAffected() if err != nil { return err } // We always expect there to be an order FQDN set row for each // pending/processing order that is being finalized. If there isn't one then // something is amiss and should be raised as an internal server error if rowsDeleted == 0 { return berrors.InternalServerError("No orderFQDNSet exists to delete") } return nil } func addIssuedNames(ctx context.Context, queryer db.Execer, cert *x509.Certificate, isRenewal bool) error { if len(cert.DNSNames) == 0 && len(cert.IPAddresses) == 0 { return berrors.InternalServerError("certificate has no DNSNames or IPAddresses") } multiInserter, err := db.NewMultiInserter("issuedNames", []string{"reversedName", "serial", "notBefore", "renewal"}) if err != nil { return err } for _, name := range cert.DNSNames { err = multiInserter.Add([]interface{}{ reverseFQDN(name), core.SerialToString(cert.SerialNumber), cert.NotBefore.Truncate(24 * time.Hour), isRenewal, }) if err != nil { return err } } for _, ip := range cert.IPAddresses { err = multiInserter.Add([]interface{}{ ip.String(), core.SerialToString(cert.SerialNumber), cert.NotBefore.Truncate(24 * time.Hour), isRenewal, }) if err != nil { return err } } return multiInserter.Insert(ctx, queryer) } // EncodeIssuedName translates a FQDN to/from the issuedNames table by reversing // its dot-separated elements, and translates an IP address by returning its // normal string form. // // This is for strings of ambiguous identifier values. If you know your string // is a FQDN, use reverseFQDN(). If you have an IP address, use // netip.Addr.String() or net.IP.String(). func EncodeIssuedName(name string) string { netIP, err := netip.ParseAddr(name) if err == nil { return netIP.String() } return reverseFQDN(name) } // reverseFQDN reverses the elements of a dot-separated FQDN. // // If your string might be an IP address, use EncodeIssuedName() instead. func reverseFQDN(fqdn string) string { labels := strings.Split(fqdn, ".") for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 { labels[i], labels[j] = labels[j], labels[i] } return strings.Join(labels, ".") } func addKeyHash(ctx context.Context, db db.Inserter, cert *x509.Certificate) error { if cert.RawSubjectPublicKeyInfo == nil { return errors.New("certificate has a nil RawSubjectPublicKeyInfo") } h := sha256.Sum256(cert.RawSubjectPublicKeyInfo) khm := &keyHashModel{ KeyHash: h[:], CertNotAfter: cert.NotAfter, CertSerial: core.SerialToString(cert.SerialNumber), } return db.Insert(ctx, khm) } var blockedKeysColumns = "keyHash, added, source, comment" // statusForOrder examines the status of a provided order's authorizations to // determine what the overall status of the order should be. In summary: // - If the order has an error, the order is invalid // - If any of the order's authorizations are in any state other than // valid or pending, the order is invalid. // - If any of the order's authorizations are pending, the order is pending. // - If all of the order's authorizations are valid, and there is // a certificate serial, the order is valid. // - If all of the order's authorizations are valid, and we have began // processing, but there is no certificate serial, the order is processing. // - If all of the order's authorizations are valid, and we haven't begun // processing, then the order is status ready. // // An error is returned for any other case. func statusForOrder(order *corepb.Order, authzValidityInfo []authzValidity, now time.Time) (string, error) { // Without any further work we know an order with an error is invalid if order.Error != nil { return string(core.StatusInvalid), nil } // If the order is expired the status is invalid and we don't need to get // order authorizations. Its important to exit early in this case because an // order that references an expired authorization will be itself have been // expired (because we match the order expiry to the associated authz expiries // in ra.NewOrder), and expired authorizations may be purged from the DB. // Because of this purging fetching the authz's for an expired order may // return fewer authz objects than expected, triggering a 500 error response. if order.Expires.AsTime().Before(now) { return string(core.StatusInvalid), nil } // If getAuthorizationStatuses returned a different number of authorization // objects than the order's slice of authorization IDs something has gone // wrong worth raising an internal error about. if len(authzValidityInfo) != len(order.V2Authorizations) { return "", berrors.InternalServerError( "getAuthorizationStatuses returned the wrong number of authorization statuses "+ "(%d vs expected %d) for order %d", len(authzValidityInfo), len(order.V2Authorizations), order.Id) } // Keep a count of the authorizations seen pendingAuthzs := 0 validAuthzs := 0 otherAuthzs := 0 expiredAuthzs := 0 // Loop over each of the order's authorization objects to examine the authz status for _, info := range authzValidityInfo { switch uintToStatus[info.Status] { case core.StatusPending: pendingAuthzs++ case core.StatusValid: validAuthzs++ case core.StatusInvalid: otherAuthzs++ case core.StatusDeactivated: otherAuthzs++ case core.StatusRevoked: otherAuthzs++ default: return "", berrors.InternalServerError( "Order is in an invalid state. Authz has invalid status %d", info.Status) } if info.Expires.Before(now) { expiredAuthzs++ } } // An order is invalid if **any** of its authzs are invalid, deactivated, // revoked, or expired, see https://tools.ietf.org/html/rfc8555#section-7.1.6 if otherAuthzs > 0 || expiredAuthzs > 0 { return string(core.StatusInvalid), nil } // An order is pending if **any** of its authzs are pending if pendingAuthzs > 0 { return string(core.StatusPending), nil } // An order is fully authorized if it has valid authzs for each of the order // identifiers fullyAuthorized := len(order.Identifiers) == validAuthzs // If the order isn't fully authorized we've encountered an internal error: // Above we checked for any invalid or pending authzs and should have returned // early. Somehow we made it this far but also don't have the correct number // of valid authzs. if !fullyAuthorized { return "", berrors.InternalServerError( "Order has the incorrect number of valid authorizations & no pending, " + "deactivated or invalid authorizations") } // If the order is fully authorized and the certificate serial is set then the // order is valid if fullyAuthorized && order.CertificateSerial != "" { return string(core.StatusValid), nil } // If the order is fully authorized, and we have began processing it, then the // order is processing. if fullyAuthorized && order.BeganProcessing { return string(core.StatusProcessing), nil } if fullyAuthorized && !order.BeganProcessing { return string(core.StatusReady), nil } return "", berrors.InternalServerError( "Order %d is in an invalid state. No state known for this order's "+ "authorizations", order.Id) } // authzValidity is a subset of authzModel type authzValidity struct { IdentifierType uint8 `db:"identifierType"` IdentifierValue string `db:"identifierValue"` Status uint8 `db:"status"` Expires time.Time `db:"expires"` } // getAuthorizationStatuses takes a sequence of authz IDs, and returns the // status and expiration date of each of them. func getAuthorizationStatuses(ctx context.Context, s db.Selector, ids []int64) ([]authzValidity, error) { var params []interface{} for _, id := range ids { params = append(params, id) } var validities []authzValidity _, err := s.Select( ctx, &validities, fmt.Sprintf("SELECT identifierType, identifierValue, status, expires FROM authz2 WHERE id IN (%s)", db.QuestionMarks(len(ids))), params..., ) if err != nil { return nil, err } return validities, nil } // authzForOrder retrieves the authorization IDs for an order. func authzForOrder(ctx context.Context, s db.Selector, orderID int64) ([]int64, error) { var v2IDs []int64 _, err := s.Select( ctx, &v2IDs, "SELECT authzID FROM orderToAuthz2 WHERE orderID = ?", orderID, ) return v2IDs, err } // crlShardModel represents one row in the crlShards table. The ThisUpdate and // NextUpdate fields are pointers because they are NULL-able columns. type crlShardModel struct { ID int64 `db:"id"` IssuerID int64 `db:"issuerID"` Idx int `db:"idx"` ThisUpdate *time.Time `db:"thisUpdate"` NextUpdate *time.Time `db:"nextUpdate"` LeasedUntil time.Time `db:"leasedUntil"` } // revokedCertModel represents one row in the revokedCertificates table. It // contains all of the information necessary to populate a CRL entry or OCSP // response for the indicated certificate. type revokedCertModel struct { ID int64 `db:"id"` IssuerID int64 `db:"issuerID"` Serial string `db:"serial"` NotAfterHour time.Time `db:"notAfterHour"` ShardIdx int64 `db:"shardIdx"` RevokedDate time.Time `db:"revokedDate"` RevokedReason revocation.Reason `db:"revokedReason"` } // replacementOrderModel represents one row in the replacementOrders table. It // contains all of the information necessary to link a renewal order to the // certificate it replaces. type replacementOrderModel struct { // ID is an auto-incrementing row ID. ID int64 `db:"id"` // Serial is the serial number of the replaced certificate. Serial string `db:"serial"` // OrderId is the ID of the replacement order OrderID int64 `db:"orderID"` // OrderExpiry is the expiry time of the new order. This is used to // determine if we can accept a new replacement order for the same Serial. OrderExpires time.Time `db:"orderExpires"` // Replaced is a boolean indicating whether the certificate has been // replaced, i.e. whether the new order has been finalized. Once this is // true, no new replacement orders can be accepted for the same Serial. Replaced bool `db:"replaced"` } // addReplacementOrder inserts or updates the replacementOrders row matching the // provided serial with the details provided. This function accepts a // transaction so that the insert or update takes place within the new order // transaction. func addReplacementOrder(ctx context.Context, db db.SelectExecer, serial string, orderID int64, orderExpires time.Time) error { var existingID []int64 _, err := db.Select(ctx, &existingID, ` SELECT id FROM replacementOrders WHERE serial = ? LIMIT 1`, serial, ) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("checking for existing replacement order: %w", err) } if len(existingID) > 0 { // Update existing replacementOrder row. _, err = db.ExecContext(ctx, ` UPDATE replacementOrders SET orderID = ?, orderExpires = ? WHERE id = ?`, orderID, orderExpires, existingID[0], ) if err != nil { return fmt.Errorf("updating replacement order: %w", err) } } else { // Insert new replacementOrder row. _, err = db.ExecContext(ctx, ` INSERT INTO replacementOrders (serial, orderID, orderExpires) VALUES (?, ?, ?)`, serial, orderID, orderExpires, ) if err != nil { return fmt.Errorf("creating replacement order: %w", err) } } return nil } // setReplacementOrderFinalized sets the replaced flag for the replacementOrder // row matching the provided orderID to true. This function accepts a // transaction so that the update can take place within the finalization // transaction. func setReplacementOrderFinalized(ctx context.Context, db db.Execer, orderID int64) error { _, err := db.ExecContext(ctx, ` UPDATE replacementOrders SET replaced = true WHERE orderID = ? LIMIT 1`, orderID, ) if err != nil { return err } return nil } type identifierModel struct { Type uint8 `db:"identifierType"` Value string `db:"identifierValue"` } func newIdentifierModelFromPB(pb *corepb.Identifier) (identifierModel, error) { idType, ok := identifierTypeToUint[pb.Type] if !ok { return identifierModel{}, fmt.Errorf("unsupported identifier type %q", pb.Type) } return identifierModel{ Type: idType, Value: pb.Value, }, nil } func newPBFromIdentifierModel(id identifierModel) (*corepb.Identifier, error) { idType, ok := uintToIdentifierType[id.Type] if !ok { return nil, fmt.Errorf("unsupported identifier type %d", id.Type) } return &corepb.Identifier{ Type: string(idType), Value: id.Value, }, nil } func newIdentifierModelsFromPB(pbs []*corepb.Identifier) ([]identifierModel, error) { ids := make([]identifierModel, 0, len(pbs)) for _, pb := range pbs { id, err := newIdentifierModelFromPB(pb) if err != nil { return nil, err } ids = append(ids, id) } return ids, nil } func newPBFromIdentifierModels(ids []identifierModel) (*sapb.Identifiers, error) { pbs := make([]*corepb.Identifier, 0, len(ids)) for _, id := range ids { pb, err := newPBFromIdentifierModel(id) if err != nil { return nil, err } pbs = append(pbs, pb) } return &sapb.Identifiers{Identifiers: pbs}, nil } // buildIdentifierQueryConditions takes a slice of identifiers and returns a // string (conditions to use within the prepared statement) and a slice of anys // (arguments for the prepared statement), both to use within a WHERE clause for // queries against the authz2 table. // // Although this function takes user-controlled input, it does not include any // of that input directly in the returned SQL string. The resulting string // contains only column names, boolean operators, and questionmark placeholders. func buildIdentifierQueryConditions(idents identifier.ACMEIdentifiers) (string, []any) { if len(idents) == 0 { // No identifier values to check. return "FALSE", []any{} } identsByType := map[identifier.IdentifierType][]string{} for _, id := range idents { identsByType[id.Type] = append(identsByType[id.Type], id.Value) } var conditions []string var args []any for idType, idValues := range identsByType { conditions = append(conditions, fmt.Sprintf("identifierType = ? AND identifierValue IN (%s)", db.QuestionMarks(len(idValues)), ), ) args = append(args, identifierTypeToUint[string(idType)]) for _, idValue := range idValues { args = append(args, idValue) } } return strings.Join(conditions, " OR "), args } // pausedModel represents a row in the paused table. It contains the // registrationID of the paused account, the time the (account, identifier) pair // was paused, and the time the pair was unpaused. The UnpausedAt field is // nullable because the pair may not have been unpaused yet. A pair is // considered paused if there is a matching row in the paused table with a NULL // UnpausedAt time. type pausedModel struct { identifierModel RegistrationID int64 `db:"registrationID"` PausedAt time.Time `db:"pausedAt"` UnpausedAt *time.Time `db:"unpausedAt"` } type overrideModel struct { LimitEnum int64 `db:"limitEnum"` BucketKey string `db:"bucketKey"` Comment string `db:"comment"` PeriodNS int64 `db:"periodNS"` Count int64 `db:"count"` Burst int64 `db:"burst"` UpdatedAt time.Time `db:"updatedAt"` Enabled bool `db:"enabled"` } func overrideModelForPB(pb *sapb.RateLimitOverride, updatedAt time.Time, enabled bool) overrideModel { return overrideModel{ LimitEnum: pb.LimitEnum, BucketKey: pb.BucketKey, Comment: pb.Comment, PeriodNS: pb.Period.AsDuration().Nanoseconds(), Count: pb.Count, Burst: pb.Burst, UpdatedAt: updatedAt, Enabled: enabled, } } func newPBFromOverrideModel(m *overrideModel) *sapb.RateLimitOverride { return &sapb.RateLimitOverride{ LimitEnum: m.LimitEnum, BucketKey: m.BucketKey, Comment: m.Comment, Period: durationpb.New(time.Duration(m.PeriodNS)), Count: m.Count, Burst: m.Burst, } }