/* Copyright CAcert Inc. SPDX-License-Identifier: Apache-2.0 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 legacydb emulates the behavior of the old signer client by polling from a MySQL database using the old // schema, reading CSR files from the filesystem and storing certificates in the file system. package legacydb import ( "bytes" "context" "crypto" "crypto/rand" "crypto/x509" "database/sql" "encoding/pem" "errors" "fmt" "math/big" "os" "path" "strconv" "strings" "sync" "text/template" "time" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/packet" _ "github.com/go-sql-driver/mysql" // imported for mysql database support "github.com/sirupsen/logrus" "github.com/wneessen/go-mail" "golang.org/x/text/encoding/charmap" "git.cacert.org/cacert-gosigner/pkg/messages" "git.cacert.org/cacert-gosigner/pkg/protocol" "git.cacert.org/cacert-gosignerclient/internal/config" ) type responseType int func (t responseType) String() string { switch t { case respGPG: return "gpg" case respPersonalClientCertificate: return "person_client" case respPersonalCodeSigningCertificate: return "person_code" case respPersonalServerCertificate: return "person_server" case respOrganizationalClientCertificate: return "org_client" case respOrganizationalCodeSigningCertificate: return "org_code" case respOrganizationalServerCertificate: return "org_server" default: return "generic" } } const ( respGPG responseType = iota respPersonalClientCertificate respPersonalCodeSigningCertificate respPersonalServerCertificate respOrganizationalClientCertificate respOrganizationalCodeSigningCertificate respOrganizationalServerCertificate respPersonalClientRevoke respPersonalServerRevoke respOrganizationalClientRevoke respOrganizationalServerRevoke ) const maxRetries = 3 const ( sqlFindOpenPGPKeys = `SELECT id, email, csr FROM gpg WHERE crt = '' AND csr != '' AND warning < ?` sqlFindPersonalClientCertRequests = `SELECT id, csr_name, type, md, subject FROM emailcerts WHERE crt_name = '' AND csr_name != '' AND codesign = 0 AND warning < ?` sqlFindPersonalCodeSigningCertRequests = `SELECT id, csr_name, type, md, subject FROM emailcerts WHERE crt_name = '' AND csr_name != '' AND codesign = 1 AND warning < ?` sqlFindPersonalServerCertRequests = `SELECT id, csr_name, type, md, subject FROM domaincerts WHERE crt_name = '' AND csr_name != '' AND warning < ?` sqlFindOrganizationalClientCertRequests = `SELECT id, csr_name, type, md, subject FROM orgemailcerts WHERE crt_name = '' AND csr_name != '' AND codesign = 0 AND warning < ?` sqlFindOrganizationalCodeSigningCertRequests = `SELECT id, csr_name, type, md, subject FROM orgemailcerts WHERE crt_name = '' AND csr_name != '' AND codesign = 1 AND warning < ?` sqlFindOrganizationalServerCertRequests = `SELECT id, csr_name, type, md, subject FROM orgdomaincerts WHERE crt_name = '' AND csr_name != '' AND warning < ?` sqlRecordPersonalClientCert = `UPDATE emailcerts SET crt_name=?, modified=CURRENT_TIMESTAMP, serial=?, expire=? WHERE id = ?` sqlRecordPersonalServerCert = `UPDATE domaincerts SET crt_name=?, modified=CURRENT_TIMESTAMP, serial=?, expire=? WHERE id = ?` sqlRecordOrganizationalClientCert = `UPDATE orgemailcerts SET crt_name=?, modified=CURRENT_TIMESTAMP, serial=?, expire=? WHERE id = ?` sqlRecordOrganizationalServerCert = `UPDATE orgdomaincerts SET crt_name=?, modified=CURRENT_TIMESTAMP, serial=?, expire=? WHERE id = ?` sqlRecordFailedOpenPGP = `UPDATE gpg SET warning = warning + 1 WHERE id = ?` sqlRecordFailedPersonalClientCertificate = `UPDATE emailcerts SET warning = warning + 1 WHERE id = ?` sqlRecordFailedPersonalServerCertificate = `UPDATE domaincerts SET warning = warning + 1 WHERE id = ?` sqlRecordFailedOrganizationalClientCertificate = `UPDATE orgemailcerts SET warning = warning + 1 WHERE id = ?` sqlRecordFailedOrganizationalServerCertificate = `UPDATE orgdomaincerts SET warning = warning + 1 WHERE id = ?` ) const ( prefixOpenPGP = "gpg" prefixPersonalClient = "client" prefixPersonalServer = "server" prefixOrganizationalClient = "orgclient" prefixOrganizationalServer = "orgserver" ) type pendingResponse struct { responseType responseType rowID int } type emailData struct { responseType responseType rowID int } type LegacyDB struct { db *sql.DB logger *logrus.Logger commands chan *protocol.Command emails chan emailData issuerIDs map[responseType]string profiles map[responseType]string pending map[string]pendingResponse algorithms map[string]crypto.Hash failureQuery map[responseType]string sync.Mutex } func (d *LegacyDB) NotifyError(ctx context.Context, requestID, message string) error { pr, ok := d.pending[requestID] if !ok { d.logger.Debugf("ignoring unknown request id %s", requestID) return nil } switch pr.responseType { case respGPG, respOrganizationalClientCertificate, respOrganizationalCodeSigningCertificate, respOrganizationalServerCertificate, respPersonalClientCertificate, respPersonalCodeSigningCertificate, respPersonalServerCertificate: break case respPersonalClientRevoke, respPersonalServerRevoke, respOrganizationalClientRevoke, respOrganizationalServerRevoke: d.logger.WithFields(logrus.Fields{ "type": pr.responseType, "row": pr.rowID, "request_id": requestID, "message": message, }).Warn("revocation failed") return nil default: return fmt.Errorf("unhandled pending response of type %s with row ID %d", pr.responseType, pr.rowID) } d.logger.WithFields(logrus.Fields{ "type": pr.responseType, "row": pr.rowID, "request_id": requestID, "message": message, }).Warn("request failed") query, ok := d.failureQuery[pr.responseType] if !ok { return fmt.Errorf("no failure query for %s defined", pr.responseType) } d.recordFailure(ctx, query, pr.rowID) delete(d.pending, requestID) return nil } func (d *LegacyDB) SupportedResponses() []messages.ResponseCode { return []messages.ResponseCode{ messages.RespSignCertificate, messages.RespRevokeCertificate, messages.RespSignOpenPGP, } } type ErrUnsupportedResponseType struct { response any } func (e ErrUnsupportedResponseType) Error() string { return fmt.Sprintf("unsupported response type %T", e.response) } func (d *LegacyDB) HandleResponse(ctx context.Context, announce *messages.ResponseAnnounce, r any) error { switch v := r.(type) { case *messages.RevokeCertificateResponse: if err := d.handleRevokedCertificate(ctx, announce, v); err != nil { return err } case *messages.SignCertificateResponse: if err := d.handleSignedCertificate(ctx, announce, v); err != nil { return err } case *messages.SignOpenPGPResponse: if err := d.handleSignedOpenPGPKey(ctx, announce, v); err != nil { return err } default: return ErrUnsupportedResponseType{r} } return nil } func New(logger *logrus.Logger, config *config.Database, commands chan *protocol.Command) (*LegacyDB, error) { db, err := sql.Open("mysql", config.DSN) if err != nil { return nil, fmt.Errorf("could not open database: %w", err) } db.SetConnMaxLifetime(config.ConnMaxLiveTime) db.SetMaxOpenConns(config.MaxOpenConns) db.SetMaxIdleConns(config.MaxIdleConns) const emailChanSize = 100 emails := make(chan emailData, emailChanSize) // TODO: replace hardcoded issuerIDs with configuration or auto-detection issuerIDs := map[responseType]string{ respGPG: "gpg", respPersonalClientCertificate: "rsa_person_2022", respPersonalCodeSigningCertificate: "rsa_person_2022", respPersonalServerCertificate: "rsa_server_2022", respOrganizationalClientCertificate: "rsa_person_2022", respOrganizationalCodeSigningCertificate: "rsa_person_2022", respOrganizationalServerCertificate: "rsa_server_2022", } // TODO: replace hardcoded profiles with configuration or auto-detection profiles := map[responseType]string{ respGPG: "gpg", respPersonalClientCertificate: "person", respPersonalCodeSigningCertificate: "code", respPersonalServerCertificate: "server", respOrganizationalClientCertificate: "org_person", respOrganizationalCodeSigningCertificate: "org_code", respOrganizationalServerCertificate: "org_server", } supportedHashAlgorithms := map[string]crypto.Hash{ "sha256": crypto.SHA256, "sha384": crypto.SHA384, "sha512": crypto.SHA512, } failureQuery := map[responseType]string{ respGPG: sqlRecordFailedOpenPGP, respPersonalClientCertificate: sqlRecordFailedPersonalClientCertificate, respPersonalCodeSigningCertificate: sqlRecordFailedPersonalClientCertificate, respPersonalServerCertificate: sqlRecordFailedPersonalServerCertificate, respOrganizationalClientCertificate: sqlRecordFailedOrganizationalClientCertificate, respOrganizationalCodeSigningCertificate: sqlRecordFailedOrganizationalClientCertificate, respOrganizationalServerCertificate: sqlRecordFailedOrganizationalServerCertificate, } pending := make(map[string]pendingResponse) return &LegacyDB{ db: db, logger: logger, commands: commands, emails: emails, algorithms: supportedHashAlgorithms, issuerIDs: issuerIDs, profiles: profiles, pending: pending, failureQuery: failureQuery, }, nil } func (d *LegacyDB) Run(ctx context.Context) error { //nolint:gocognit,cyclop const tickDuration = 2700 * time.Millisecond subCtx, cancel := context.WithCancel(ctx) defer func() { d.logger.Trace("run cancel in LegacyDB source") cancel() }() go func() { for { select { case <-subCtx.Done(): return case e := <-d.emails: err := d.sendNotificationEmail(subCtx, e) if err != nil { d.logger.WithError(err).Warn("could not send email notification") } } } }() nextTick := time.NewTimer(tickDuration) for { select { case <-ctx.Done(): nextTick.Stop() return nil case <-nextTick.C: if err := d.requestSignedOpenPGPKeys(ctx); err != nil { return err } if err := d.requestCerts( ctx, sqlFindPersonalClientCertRequests, respPersonalClientCertificate, ); err != nil { return err } if err := d.requestCerts( ctx, sqlFindPersonalCodeSigningCertRequests, respPersonalCodeSigningCertificate, ); err != nil { return err } if err := d.requestCerts( ctx, sqlFindPersonalServerCertRequests, respPersonalServerCertificate, ); err != nil { return err } if err := d.requestCerts( ctx, sqlFindOrganizationalClientCertRequests, respOrganizationalClientCertificate, ); err != nil { return err } if err := d.requestCerts( ctx, sqlFindOrganizationalCodeSigningCertRequests, respOrganizationalCodeSigningCertificate, ); err != nil { return err } if err := d.requestCerts( ctx, sqlFindOrganizationalServerCertRequests, respOrganizationalServerCertificate, ); err != nil { return err } if err := d.revokePersonalClientCerts(ctx); err != nil { return err } if err := d.revokePersonalServerCerts(ctx); err != nil { return err } if err := d.revokeOrganizationClientCerts(ctx); err != nil { return err } if err := d.revokeOrganizationServerCerts(ctx); err != nil { return err } nextTick.Reset(tickDuration) } } } func (d *LegacyDB) Close() error { err := d.db.Close() if err != nil { return fmt.Errorf("could not close database connection pool: %w", err) } return nil } func (d *LegacyDB) requestSignedOpenPGPKeys(ctx context.Context) error { rows, err := d.db.QueryContext(ctx, sqlFindOpenPGPKeys, maxRetries) if err != nil { return fmt.Errorf("could not execute query: %w", err) } defer func() { _ = rows.Close() }() idsWithIssues := make([]int, 0) for rows.Next() { if err := rows.Err(); err != nil { return fmt.Errorf("could not fetch row: %w", err) } var ( csrID int email string pubKeyFileName string ) if err := rows.Scan(&csrID, &email, &pubKeyFileName); err != nil { return fmt.Errorf("could not scan row: %w", err) } keyBytes, err := os.ReadFile(pubKeyFileName) if err != nil { d.logger.WithFields(logrus.Fields{ "id": csrID, "file_name": pubKeyFileName, }).WithError(err).Warn("could not read public key data") idsWithIssues = append(idsWithIssues, csrID) continue } command := &protocol.Command{ Announce: messages.BuildCommandAnnounce(messages.CmdSignOpenPGP), Command: messages.SignOpenPGPCommand{ IssuerID: d.issuerIDs[respGPG], ProfileName: d.profiles[respGPG], PublicKey: keyBytes, CommonName: "", EmailAddresses: []string{email}, }, } d.Lock() d.pending[command.Announce.ID] = pendingResponse{respGPG, csrID} d.Unlock() d.commands <- command } if len(idsWithIssues) > 0 { d.logger.WithFields(logrus.Fields{ "ids_with_issues": idsWithIssues, "rt": respGPG, }).Warn("some certificates failed") for _, id := range idsWithIssues { d.recordFailure(ctx, sqlRecordFailedOpenPGP, id) } } return nil } func (d *LegacyDB) requestCerts(ctx context.Context, query string, rt responseType) error { issuerID, ok := d.issuerIDs[rt] if !ok { return fmt.Errorf("no known issuer id for type %s", rt) } profileID, ok := d.profiles[rt] if !ok { return fmt.Errorf("no known profile id for type %s", rt) } rows, err := d.db.QueryContext(ctx, query, maxRetries) if err != nil { return fmt.Errorf("could not execute query: %w", err) } defer func() { _ = rows.Close() }() idsWithIssues := make([]int, 0) for rows.Next() { if err := rows.Err(); err != nil { return fmt.Errorf("could not fetch row: %w", err) } var ( csrID int csrFileName string certType sql.NullInt16 md string subject string ) if err := rows.Scan(&csrID, &csrFileName, &certType, &md, &subject); err != nil { return fmt.Errorf("could not scan row: %w", err) } pemBytes, err := os.ReadFile(csrFileName) if err != nil { d.logger.WithFields(logrus.Fields{ "id": csrID, "file_name": csrFileName, }).WithError(err).Warn("could not read CSR data") idsWithIssues = append(idsWithIssues, csrID) continue } csrBlock, remaining := pem.Decode(pemBytes) if len(remaining) > 0 { d.logger.WithFields(logrus.Fields{"id": csrID, "file_name": csrFileName}).Warn("unhandled CSR bytes") idsWithIssues = append(idsWithIssues, csrID) continue } if csrBlock.Type != "CERTIFICATE REQUEST" { d.logger.WithFields(logrus.Fields{"id": csrID, "file_name": csrFileName, "pem_block_type": csrBlock.Type}).Warn("unhandled PEM block type") idsWithIssues = append(idsWithIssues, csrID) continue } hashAlg, ok := d.algorithms[md] if !ok { d.logger.WithFields(logrus.Fields{}).Warn("unsupported hash algorithm") idsWithIssues = append(idsWithIssues, csrID) continue } subjParts, err := extractSubjectParts(subject) if err != nil { d.logger.WithFields(logrus.Fields{ "id": csrID, "subject": subject, }).WithError(err).Warn("could not parse subject") idsWithIssues = append(idsWithIssues, csrID) continue } signCertCommand := buildSignCertificateCommand(issuerID, profileID, csrBlock.Bytes, subjParts, hashAlg) command := &protocol.Command{ Announce: messages.BuildCommandAnnounce(messages.CmdSignCertificate), Command: signCertCommand, } d.Lock() d.pending[command.Announce.ID] = pendingResponse{rt, csrID} d.Unlock() d.commands <- command } if len(idsWithIssues) > 0 { d.logger.WithFields(logrus.Fields{ "ids_with_issues": idsWithIssues, "rt": rt, }).Warn("some certificates failed") } return nil } func buildSignCertificateCommand( issuerID string, profileID string, csrBytes []byte, subjParts *x509.Certificate, hashAlg crypto.Hash, ) messages.SignCertificateCommand { signCertCommand := messages.SignCertificateCommand{ IssuerID: issuerID, ProfileName: profileID, CSRData: csrBytes, CommonName: subjParts.Subject.CommonName, Hostnames: subjParts.DNSNames, EmailAddresses: subjParts.EmailAddresses, PreferredHash: hashAlg, } if len(subjParts.Subject.Organization) > 0 { signCertCommand.Organization = subjParts.Subject.Organization[0] } if len(subjParts.Subject.OrganizationalUnit) > 0 { signCertCommand.OrganizationalUnit = subjParts.Subject.OrganizationalUnit[0] } if len(subjParts.Subject.Locality) > 0 { signCertCommand.Locality = subjParts.Subject.Locality[0] } if len(subjParts.Subject.Province) > 0 { signCertCommand.Province = subjParts.Subject.Province[0] } if len(subjParts.Subject.Country) > 0 { signCertCommand.Country = subjParts.Subject.Country[0] } return signCertCommand } var ( errInvalidSANPart = errors.New("invalid SAN part, missing colon") errUnsupportedSubjectPart = errors.New("unsupported subject part") ) // extractSubjectParts splits an openssl style subject string into subject DN parts and subject alternative parts of // different types func extractSubjectParts(subject string) (*x509.Certificate, error) { if len(subject) == 0 { return nil, errors.New("empty subject") } legacyCharset := charmap.Windows1252 latin1Decoder := legacyCharset.NewDecoder() utf8Subject, err := latin1Decoder.String(subject) if err != nil { return nil, fmt.Errorf("subject could not be decoded from %s", legacyCharset) } parts := strings.Split(utf8Subject, "/") if len(parts) == 0 { return nil, errors.New("no subject parts in subject") } res := &x509.Certificate{} for _, p := range parts { if p == "" { continue } pieces := strings.SplitN(p, "=", 2) if len(pieces) != 2 { return nil, fmt.Errorf("missing '=' in %s", p) } err := parseSubjectStringComponent(pieces[0], pieces[1], res) if err != nil { return nil, fmt.Errorf("could not parse subject part %s: %w", p, err) } } return res, nil } func parseSubjectStringComponent(identifier, value string, res *x509.Certificate) error { switch identifier { case "CN": res.Subject.CommonName = value case "commonName": res.Subject.CommonName = value res.DNSNames = append(res.DNSNames, value) case "organizationName": res.Subject.Organization = append(res.Subject.Organization, value) case "organizationalUnitName": res.Subject.OrganizationalUnit = append(res.Subject.OrganizationalUnit, value) case "localityName": res.Subject.Locality = append(res.Subject.Locality, value) case "stateOrProvinceName": res.Subject.Province = append(res.Subject.Province, value) case "countryName": res.Subject.Country = append(res.Subject.Country, value) case "emailAddress": res.EmailAddresses = append(res.EmailAddresses, value) case "subjectAltName": sanParts := strings.SplitN(value, ":", 2) if len(sanParts) != 2 { return errInvalidSANPart } if sanParts[0] == "DNS" { res.DNSNames = append(res.DNSNames, sanParts[1]) } default: return errUnsupportedSubjectPart } return nil } func (d *LegacyDB) revokePersonalClientCerts(_ context.Context) error { logrus.Debug("not implemented") return nil } func (d *LegacyDB) revokePersonalServerCerts(_ context.Context) error { logrus.Debug("not implemented") return nil } func (d *LegacyDB) revokeOrganizationClientCerts(_ context.Context) error { logrus.Debug("not implemented") return nil } func (d *LegacyDB) revokeOrganizationServerCerts(_ context.Context) error { logrus.Debug("not implemented") return nil } func (d *LegacyDB) writeCertificate(prefix string, rowID int, signatureData []byte) (string, error) { crtFileName := path.Join("..", "crt", prefix, strconv.Itoa(rowID/1000), fmt.Sprintf("%s-%d.crt", prefix, rowID)) err := os.WriteFile(crtFileName, signatureData, 0o644) //nolint:gosec,gomnd if err != nil { return "", fmt.Errorf("could not write to file: %w", err) } return crtFileName, nil } func (d *LegacyDB) recordCertificate(_ context.Context, prefix, query string, rowID int, certBytes []byte) error { panic(fmt.Sprintf( "not implemented: record certificate to prefix %s with query %s for rowID %d (%d bytes)", prefix, query, rowID, len(certBytes), )) } func (d *LegacyDB) recordPersonalClientRevoke(_ context.Context, rowID int, revokedAt time.Time) error { panic(fmt.Sprintf( "not implemented: record personal client certificate revocation for id %d at %s", rowID, revokedAt, )) } func (d *LegacyDB) recordPersonalServerRevoke(_ context.Context, rowID int, revokedAt time.Time) error { panic(fmt.Sprintf( "not implemented: record personal server certificate revocation for id %d at %s", rowID, revokedAt, )) } func (d *LegacyDB) recordOrganizationalClientRevoke(_ context.Context, rowID int, revokedAt time.Time) error { panic(fmt.Sprintf( "not implemented record organizational client certificate revocation for id %d at %s", rowID, revokedAt, )) } func (d *LegacyDB) recordOrganizationalServerRevoke(_ context.Context, rowID int, revokedAt time.Time) error { panic(fmt.Sprintf( "not implemented record organizational server certificate revocation for id %d at %s", rowID, revokedAt, )) } func (d *LegacyDB) recordSignedOpenPGPKey(ctx context.Context, rowID int, signatureData []byte) error { packetReader := packet.NewReader(bytes.NewReader(signatureData)) entity, err := openpgp.ReadEntity(packetReader) if err != nil { return fmt.Errorf("could not read OpenPGP packets: %w", err) } lifeTimeSecs := entity.Subkeys[0].Sig.SigLifetimeSecs if lifeTimeSecs == nil { return errors.New("signature does not expire") } gpgExpiry := entity.Subkeys[0].Sig.CreationTime.Add(time.Second * time.Duration(*lifeTimeSecs)) crtFileName, err := d.writeCertificate(prefixOpenPGP, rowID, signatureData) if err != nil { return fmt.Errorf("could not write OpenPGP result: %w", err) } _, err = d.db.ExecContext( ctx, `UPDATE gpg SET crt=?, issued=NOW(), expire=? WHERE id=?`, crtFileName, gpgExpiry, rowID, ) if err != nil { return fmt.Errorf("could not execute query: %w", err) } d.emails <- emailData{respGPG, rowID} return nil } func (d *LegacyDB) handleRevokedCertificate( ctx context.Context, announce *messages.ResponseAnnounce, v *messages.RevokeCertificateResponse, ) error { d.Lock() defer d.Unlock() pending, ok := d.pending[announce.ID] if !ok { return fmt.Errorf("could not find pending request for id %s", announce.ID) } var err error switch pending.responseType { case respPersonalClientRevoke: err = d.recordPersonalClientRevoke(ctx, pending.rowID, v.RevokedAt) case respPersonalServerRevoke: err = d.recordPersonalServerRevoke(ctx, pending.rowID, v.RevokedAt) case respOrganizationalClientRevoke: err = d.recordOrganizationalClientRevoke(ctx, pending.rowID, v.RevokedAt) case respOrganizationalServerRevoke: err = d.recordOrganizationalServerRevoke(ctx, pending.rowID, v.RevokedAt) default: return fmt.Errorf("unexpected response type for pending request %s", announce.ID) } delete(d.pending, announce.ID) return err } func (d *LegacyDB) handleSignedCertificate( ctx context.Context, announce *messages.ResponseAnnounce, r *messages.SignCertificateResponse, ) error { d.Lock() defer d.Unlock() pending, ok := d.pending[announce.ID] if !ok { return fmt.Errorf("could not find pending request for id %s", announce.ID) } var err error switch pending.responseType { case respPersonalClientCertificate, respPersonalCodeSigningCertificate: err = d.recordCertificate(ctx, prefixPersonalClient, sqlRecordPersonalClientCert, pending.rowID, r.CertificateData) case respPersonalServerCertificate: err = d.recordCertificate(ctx, prefixPersonalServer, sqlRecordPersonalServerCert, pending.rowID, r.CertificateData) case respOrganizationalClientCertificate, respOrganizationalCodeSigningCertificate: err = d.recordCertificate(ctx, prefixOrganizationalClient, sqlRecordOrganizationalClientCert, pending.rowID, r.CertificateData) case respOrganizationalServerCertificate: err = d.recordCertificate(ctx, prefixOrganizationalServer, sqlRecordOrganizationalServerCert, pending.rowID, r.CertificateData) default: return fmt.Errorf("unexpected response type for pending request %s", announce.ID) } delete(d.pending, announce.ID) return err } func (d *LegacyDB) handleSignedOpenPGPKey( ctx context.Context, announce *messages.ResponseAnnounce, v *messages.SignOpenPGPResponse, ) error { d.Lock() defer d.Unlock() pending, ok := d.pending[announce.ID] if !ok { return fmt.Errorf("could not find pending request for id %s", announce.ID) } if pending.responseType != respGPG { return fmt.Errorf("unexpected response type for pending request %s", announce.ID) } err := d.recordSignedOpenPGPKey(ctx, pending.rowID, v.SignatureData) delete(d.pending, announce.ID) return err } type templateData struct { RowID int Email string } func (d *LegacyDB) sendNotificationEmail(ctx context.Context, e emailData) error { var ( subjectTemplate, mailTemplate *template.Template err error ) language, data, err := d.getTemplateData(ctx, e) if err != nil { return err } if subjectTemplate, err = d.getSubjectTemplate(e.responseType, language); err != nil { return err } if mailTemplate, err = d.getEmailTemplate(e.responseType, language); err != nil { return err } subject := new(bytes.Buffer) if err = subjectTemplate.Execute(subject, data); err != nil { return fmt.Errorf("could not build email subject: %w", err) } rn, err := rand.Int(rand.Reader, big.NewInt(int64(1<<31))) //nolint:gomnd if err != nil { return fmt.Errorf("could not get random number for unique sender addresse: %w", err) } // TODO: make sender address configurable m := mail.NewMsg() if err = m.EnvelopeFrom(fmt.Sprintf("noreply+%d@cacert.org", rn)); err != nil { return fmt.Errorf("could not set ENVELOPE FROM address: %w", err) } m.SetMessageID() m.SetDate() m.Subject(subject.String()) if err = m.SetBodyTextTemplate(mailTemplate, data); err != nil { return fmt.Errorf("could not set text template for email body: %w", err) } const smtpPort = 1025 c, err := mail.NewClient("localhost", mail.WithPort(smtpPort)) if err != nil { return fmt.Errorf("could not create mail client: %w", err) } if err = c.DialAndSendWithContext(ctx, m); err != nil { return fmt.Errorf("failed to deliver mail: %w", err) } return nil } func (d *LegacyDB) getTemplateData(ctx context.Context, e emailData) (string, *templateData, error) { var ( userID int userLanguage string ) data := &templateData{RowID: e.rowID} if e.responseType == respGPG { row := d.db.QueryRowContext(ctx, `SELECT memid, email FROM gpg WHERE id=?`, e.rowID) if err := row.Err(); err != nil { return "", nil, fmt.Errorf("could not fetch row: %w", err) } if err := row.Scan(&userID, &data.Email); err != nil { return "", nil, fmt.Errorf("could not scan row: %w", err) } } row := d.db.QueryRowContext(ctx, `SELECT language FROM users WHERE id=?`, userID) if err := row.Err(); err != nil { return "", nil, fmt.Errorf("could not fetch row: %w", err) } if err := row.Scan(&userLanguage); err != nil { return "", nil, fmt.Errorf("could not scan row: %w", err) } return userLanguage, data, nil } func (d *LegacyDB) getLocalizedTemplate(language string, fileName string) (*template.Template, error) { templateFile := path.Join("templates", "mail", language, fileName) _, err := os.Stat(templateFile) if errors.Is(err, os.ErrNotExist) { templateFile = path.Join("templates", "mail", "en", fileName) } t, err := template.ParseFiles(templateFile) if err != nil { return nil, fmt.Errorf("could not parse template: %w", err) } return t, nil } func (d *LegacyDB) getSubjectTemplate(rt responseType, language string) (*template.Template, error) { fileName := fmt.Sprintf("%s_subject.txt", rt.String()) return d.getLocalizedTemplate(language, fileName) } func (d *LegacyDB) getEmailTemplate(rt responseType, language string) (*template.Template, error) { fileName := fmt.Sprintf("%s_body.txt", rt.String()) return d.getLocalizedTemplate(language, fileName) } func (d *LegacyDB) recordFailure(ctx context.Context, query string, rowID int) { if _, err := d.db.ExecContext(ctx, query, rowID); err != nil { d.logger.WithError(err).Error("database update failed") } }