From a6317c82c58f88884540447e0c1920244ef3b61d Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 12 Jan 2024 19:07:24 +0100 Subject: [PATCH] Add legacydb package to support existing MySQL DB - add new legacydb package - fix warnings --- cmd/signerclient/main.go | 24 +- internal/client/client.go | 167 ++++- internal/config/config.go | 70 +- internal/handler/handler.go | 30 +- internal/legacydb/legacydb.go | 1014 ++++++++++++++++++++++++++++ internal/legacydb/legacydb_test.go | 186 +++++ templates/mail/en/gpg_body.txt | 12 + templates/mail/en/gpg_subject.txt | 1 + 8 files changed, 1437 insertions(+), 67 deletions(-) create mode 100644 internal/legacydb/legacydb.go create mode 100644 internal/legacydb/legacydb_test.go create mode 100644 templates/mail/en/gpg_body.txt create mode 100644 templates/mail/en/gpg_subject.txt diff --git a/cmd/signerclient/main.go b/cmd/signerclient/main.go index 651ae53..38e91c5 100644 --- a/cmd/signerclient/main.go +++ b/cmd/signerclient/main.go @@ -1,5 +1,5 @@ /* -Copyright 2022 CAcert Inc. +Copyright CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -31,6 +31,10 @@ import ( "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" + "git.cacert.org/cacert-gosigner/pkg/protocol" + + "git.cacert.org/cacert-gosignerclient/internal/legacydb" + "git.cacert.org/cacert-gosignerclient/internal/client" "git.cacert.org/cacert-gosignerclient/internal/config" "git.cacert.org/cacert-gosignerclient/internal/handler" @@ -103,6 +107,8 @@ func generateDefaultConfig() error { serial: device: /dev/ttyUSB0 baud: 112500 +database: + dsn: "user:password@/dbname" ` cfg, err := config.LoadConfiguration(strings.NewReader(defaultBaseConfiguration)) @@ -141,6 +147,18 @@ func startClient(configFile string, logger *logrus.Logger) error { defer func() { _ = signerClient.Close() }() + commands := make(chan *protocol.Command, clientConfig.CommandChannelCapacity) + + legacyDB, err := legacydb.New(logger, &clientConfig.Database, commands) + if err != nil { + return fmt.Errorf("could not initialize legacy database: %w", err) + } + + defer func() { _ = legacyDB.Close() }() + + signerClient.RegisterCommandSource(legacyDB) + signerClient.RegisterResponseSink(legacyDB) + logger.Info("setup complete, starting client operation") ctx, cancel := context.WithCancel(context.Background()) @@ -157,14 +175,14 @@ func startClient(configFile string, logger *logrus.Logger) error { cancel() }() - callbacks := make(chan interface{}, client.CallBackBufferSize) + callbacks := make(chan any, client.CallBackBufferSize) clientHandler, err := handler.New(clientConfig, logger, callbacks) if err != nil { return fmt.Errorf("could not setup client handler: %w", err) } - if err = signerClient.Run(ctx, callbacks, clientHandler); err != nil { + if err = signerClient.Run(ctx, callbacks, clientHandler, commands); err != nil { return fmt.Errorf("error in client: %w", err) } diff --git a/internal/client/client.go b/internal/client/client.go index 4720529..91c32eb 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,5 +1,5 @@ /* -Copyright 2022 CAcert Inc. +Copyright CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -82,21 +82,24 @@ type Client struct { config *config.ClientConfig signerInfo *SignerInfo knownCACertificates map[string]*CACertificateInfo + commandSources []CommandSource + responseSinks map[messages.ResponseCode]ResponseSink sync.Mutex } func (c *Client) Run( - ctx context.Context, callback <-chan interface{}, handler protocol.ClientHandler, + ctx context.Context, callback <-chan any, handler protocol.ClientHandler, + commands chan *protocol.Command, ) error { const componentCount = 4 - protocolErrors, framerErrors := make(chan error), make(chan error) + protocolErrors, framerErrors, sourceErrors := make(chan error), make(chan error), make(chan error) subCtx, cancel := context.WithCancel(ctx) wg := sync.WaitGroup{} wg.Add(componentCount) + wg.Add(len(c.commandSources)) - commands := make(chan *protocol.Command, c.config.CommandChannelCapacity) fromSigner := make(chan []byte) toSigner := make(chan []byte) @@ -107,6 +110,8 @@ func (c *Client) Run( c.logger.Info("shutdown complete") }() + c.RunSources(subCtx, &wg, sourceErrors) + go func(f protocol.Framer) { defer wg.Done() @@ -171,6 +176,12 @@ func (c *Client) Run( return fmt.Errorf("error from protocol: %w", err) } + return nil + case err := <-sourceErrors: + if err != nil { + return fmt.Errorf("error from command source: %w", err) + } + return nil } } @@ -205,10 +216,15 @@ func (c *Client) Close() error { type commandGenerator func(context.Context, chan<- *protocol.Command) error -func (c *Client) commandLoop(ctx context.Context, commands chan *protocol.Command, callback <-chan interface{}) { +func (c *Client) commandLoop(ctx context.Context, commands chan *protocol.Command, callback <-chan any) { healthTimer := time.NewTimer(c.config.HealthStart) fetchCRLTimer := time.NewTimer(c.config.FetchCRLStart) - nextCommands := make(chan *protocol.Command) + + defer func() { + close(commands) + + c.logger.Info("command loop stopped") + }() for { select { @@ -216,57 +232,54 @@ func (c *Client) commandLoop(ctx context.Context, commands chan *protocol.Comman return case callbackData := <-callback: go func() { - err := c.handleCallback(ctx, nextCommands, callbackData) + err := c.handleCallback(ctx, commands, callbackData) if err != nil { c.logger.WithError(err).Error("callback handling failed") } }() case <-fetchCRLTimer.C: - go c.scheduleRequiredCRLFetches(ctx, nextCommands) + go c.scheduleRequiredCRLFetches(ctx, commands) fetchCRLTimer.Reset(c.config.FetchCRLInterval) case <-healthTimer.C: - go c.scheduleHealthCheck(ctx, nextCommands) + go c.scheduleHealthCheck(ctx, commands) healthTimer.Reset(c.config.HealthInterval) - case nextCommand, ok := <-nextCommands: - if !ok { - return - } - - commands <- nextCommand - - c.logger.WithFields(map[string]interface{}{ - "command": nextCommand.Announce, - "buffer length": len(commands), - }).Trace("sent command") } } } +type ErrNoResponseSink struct { + msg string +} + +func (e ErrNoResponseSink) Error() string { + return fmt.Sprintf("no response sink for %s response found", e.msg) +} + func (c *Client) handleCallback( ctx context.Context, newCommands chan<- *protocol.Command, - data interface{}, + data any, ) error { - var handler commandGenerator + var ( + handler commandGenerator + err error + ) switch d := data.(type) { case SignerInfo: handler = c.updateSignerInfo(d) - case *messages.CAInfoResponse: - handler = c.updateCAInformation(d) - case *messages.FetchCRLResponse: - handler = c.updateCRL(d) + case *protocol.Response: + handler, err = c.handleResponse(d) + if err != nil { + return err + } default: - return fmt.Errorf("unknown callback data of type %T", data) + return fmt.Errorf("unknown callback data of type %T", d) } - if err := handler(ctx, newCommands); err != nil { - return err - } - - return nil + return handler(ctx, newCommands) } func (c *Client) updateSignerInfo( @@ -609,6 +622,94 @@ func (c *Client) setLastKnownCRL(caName string, number *big.Int) { caInfo.LastKnownCRL = number } +type CommandSource interface { + Run(context.Context) error +} + +type ResponseSink interface { + SupportedResponses() []messages.ResponseCode + HandleResponse(context.Context, *messages.ResponseAnnounce, any) error + NotifyError(ctx context.Context, requestID, message string) error +} + +func (c *Client) RegisterCommandSource(source CommandSource) { + c.commandSources = append(c.commandSources, source) +} + +func (c *Client) RegisterResponseSink(sink ResponseSink) { + for _, code := range sink.SupportedResponses() { + c.responseSinks[code] = sink + } +} + +func (c *Client) handleResponse(r *protocol.Response) (commandGenerator, error) { + var handler commandGenerator + + switch payload := r.Response.(type) { + case *messages.CAInfoResponse: + handler = c.updateCAInformation(payload) + case *messages.FetchCRLResponse: + handler = c.updateCRL(payload) + case *messages.ErrorResponse: + handler = func(ctx context.Context, _ chan<- *protocol.Command) error { + for _, sink := range c.responseSinks { + if err := sink.NotifyError(ctx, r.Announce.ID, payload.Message); err != nil { + return fmt.Errorf("error from response sink: %w", err) + } + } + + return nil + } + case *messages.SignCertificateResponse: + sink, ok := c.responseSinks[messages.RespSignCertificate] + if !ok { + return nil, ErrNoResponseSink{"sign certificate"} + } + + handler = func(ctx context.Context, _ chan<- *protocol.Command) error { + if err := sink.HandleResponse(ctx, r.Announce, payload); err != nil { + return fmt.Errorf("error from response sink: %w", err) + } + + return nil + } + case *messages.SignOpenPGPResponse: + sink, ok := c.responseSinks[messages.RespSignOpenPGP] + if !ok { + return nil, ErrNoResponseSink{"sign openpgp"} + } + + handler = func(ctx context.Context, _ chan<- *protocol.Command) error { + if err := sink.HandleResponse(ctx, r.Announce, payload); err != nil { + return fmt.Errorf("error from response sink: %w", err) + } + + return nil + } + default: + return nil, fmt.Errorf("unhandled response %s", payload) + } + + return handler, nil +} + +func (c *Client) RunSources(ctx context.Context, wg *sync.WaitGroup, errorChan chan error) { + for _, source := range c.commandSources { + go func(s CommandSource) { + defer wg.Done() + + err := s.Run(ctx) + if err != nil { + c.logger.WithError(err).Error("command source failed") + + errorChan <- err + } + + c.logger.Info("command source stopped") + }(source) + } +} + func New( cfg *config.ClientConfig, logger *logrus.Logger, @@ -623,6 +724,8 @@ func New( framer: cobsFramer, config: cfg, knownCACertificates: make(map[string]*CACertificateInfo), + responseSinks: make(map[messages.ResponseCode]ResponseSink), + commandSources: make([]CommandSource, 0), } err = client.setupConnection(&serial.Config{ diff --git a/internal/config/config.go b/internal/config/config.go index fb8f631..61af1e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,5 +1,5 @@ /* -Copyright 2022 CAcert Inc. +Copyright 2022-2023 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -36,6 +36,9 @@ const ( defaultResponseDataTimeout = 2 * time.Second defaultFilesDirectory = "public" defaultCommandChannelCapacity = 100 + defaultDatabaseConnMaxLiveTime = 3 * time.Minute + defaultDatabaseMaxOpenConns = 10 + defaultDatabaseMaxIdleConns = 10 ) type SettingsError struct { @@ -52,6 +55,13 @@ type Serial struct { Timeout time.Duration `yaml:"timeout"` } +type Database struct { + DSN string `yaml:"dsn"` + ConnMaxLiveTime time.Duration `yaml:"conn-max-live-time"` + MaxOpenConns int `yaml:"max-open-conns"` + MaxIdleConns int `yaml:"max-idle-conns"` +} + type ClientConfig struct { Serial Serial `yaml:"serial"` HealthInterval time.Duration `yaml:"health-interval"` @@ -63,6 +73,7 @@ type ClientConfig struct { PublicCRLDirectory string `yaml:"public-crl-directory"` PublicCertificateDirectory string `yaml:"public-certificate-directory"` CommandChannelCapacity int `yaml:"command-channel-capacity"` + Database Database `yaml:"database"` } func (c *ClientConfig) UnmarshalYAML(n *yaml.Node) error { @@ -77,6 +88,7 @@ func (c *ClientConfig) UnmarshalYAML(n *yaml.Node) error { PublicCRLDirectory string `yaml:"public-crl-directory"` PublicCertificateDirectory string `yaml:"public-certificate-directory"` CommandChannelCapacity int `yaml:"command-channel-capacity"` + Database Database `yaml:"database"` }{} err := n.Decode(&data) @@ -84,16 +96,8 @@ func (c *ClientConfig) UnmarshalYAML(n *yaml.Node) error { return fmt.Errorf("could not decode YAML: %w", err) } - if data.Serial.Device == "" { - return SettingsError{"you must specify a serial 'device'"} - } - - if data.Serial.Baud == 0 { - data.Serial.Baud = 115200 - } - - if data.Serial.Timeout == 0 { - data.Serial.Timeout = defaultSerialTimeout + if err := checkSerialConfig(&data.Serial); err != nil { + return err } c.Serial = data.Serial @@ -152,6 +156,50 @@ func (c *ClientConfig) UnmarshalYAML(n *yaml.Node) error { c.PublicCertificateDirectory = data.PublicCRLDirectory + if err := checkDatabaseConfig(&data.Database); err != nil { + return err + } + + c.Database = data.Database + + c.Database = data.Database + + return nil +} + +func checkDatabaseConfig(d *Database) error { + if d.DSN == "" { + return SettingsError{"you must specify a database 'dsn'"} + } + + if d.ConnMaxLiveTime == 0 { + d.ConnMaxLiveTime = defaultDatabaseConnMaxLiveTime + } + + if d.MaxOpenConns == 0 { + d.MaxOpenConns = defaultDatabaseMaxOpenConns + } + + if d.MaxIdleConns == 0 { + d.MaxIdleConns = defaultDatabaseMaxIdleConns + } + + return nil +} + +func checkSerialConfig(s *Serial) error { + if s.Device == "" { + return SettingsError{"you must specify a serial 'device'"} + } + + if s.Baud == 0 { + s.Baud = 115200 + } + + if s.Timeout == 0 { + s.Timeout = defaultSerialTimeout + } + return nil } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5b9e8fe..1d7aaf7 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,5 +1,5 @@ /* -Copyright 2022 CAcert Inc. +Copyright 2022-2023 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -37,7 +37,7 @@ import ( type SignerClientHandler struct { logger *logrus.Logger config *config.ClientConfig - clientCallback chan<- interface{} + clientCallback chan<- any } var errInputClosed = errors.New("input channel has been closed") @@ -155,16 +155,13 @@ func (s *SignerClientHandler) HandleResponse(ctx context.Context, response *prot s.logger.WithField("response", response).Debug("full response") switch r := response.Response.(type) { - case *messages.ErrorResponse: - s.logger.WithField("message", r.Message).Error("error from signer") case *messages.HealthResponse: s.handleHealthResponse(ctx, r) - case *messages.CAInfoResponse: - s.handleCAInfoResponse(ctx, r) - case *messages.FetchCRLResponse: - s.handleFetchCRLResponse(ctx, r) default: - s.logger.WithField("response", response).Warnf("unhandled response of type %T", response.Response) + s.logger.WithField("response", response).Tracef( + "delegate response handling of type %T", response.Response, + ) + s.handleGenericResponse(ctx, response) } return nil @@ -215,20 +212,11 @@ func (s *SignerClientHandler) handleHealthResponse(ctx context.Context, r *messa } } -func (s *SignerClientHandler) handleCAInfoResponse(ctx context.Context, r *messages.CAInfoResponse) { +func (s *SignerClientHandler) handleGenericResponse(ctx context.Context, response *protocol.Response) { select { case <-ctx.Done(): return - case s.clientCallback <- r: - break - } -} - -func (s *SignerClientHandler) handleFetchCRLResponse(ctx context.Context, r *messages.FetchCRLResponse) { - select { - case <-ctx.Done(): - return - case s.clientCallback <- r: + case s.clientCallback <- response: break } } @@ -236,7 +224,7 @@ func (s *SignerClientHandler) handleFetchCRLResponse(ctx context.Context, r *mes func New( config *config.ClientConfig, logger *logrus.Logger, - clientCallback chan interface{}, + clientCallback chan any, ) (protocol.ClientHandler, error) { return &SignerClientHandler{ logger: logger, diff --git a/internal/legacydb/legacydb.go b/internal/legacydb/legacydb.go new file mode 100644 index 0000000..6a95fa4 --- /dev/null +++ b/internal/legacydb/legacydb.go @@ -0,0 +1,1014 @@ +/* +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 is 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" + "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 = ?` +) + +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", + } + + 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, + 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 int + md string + subject string + ) + + if err := rows.Scan(&csrID, &csrFileName, &certType, &md, &subject); err != nil { + return fmt.Errorf("could not scan row: %w", err) + } + + csrBytes, 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 + } + + 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 + } + + command := &protocol.Command{ + Announce: messages.BuildCommandAnnounce(messages.CmdSignCertificate), + Command: messages.SignCertificateCommand{ + IssuerID: issuerID, + ProfileName: profileID, + CSRData: csrBytes, + CommonName: subjParts.Subject.CommonName, + Organization: subjParts.Subject.Organization[0], + OrganizationalUnit: subjParts.Subject.OrganizationalUnit[0], + Locality: subjParts.Subject.Locality[0], + Province: subjParts.Subject.Province[0], + Country: subjParts.Subject.Country[0], + Hostnames: subjParts.DNSNames, + EmailAddresses: subjParts.EmailAddresses, + PreferredHash: hashAlg, + }, + } + + 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 +} + +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 { + panic("not implemented") +} + +func (d *LegacyDB) revokePersonalServerCerts(_ context.Context) error { + panic("not implemented") +} + +func (d *LegacyDB) revokeOrganizationClientCerts(_ context.Context) error { + panic("not implemented") +} + +func (d *LegacyDB) revokeOrganizationServerCerts(_ context.Context) error { + panic("not implemented") +} + +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, query string, rowID int, certBytes []byte) error { + panic(fmt.Sprintf( + "not implemented: record certificate with query %s for rowID %d (%d bytes)", + 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("gpg", 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, sqlRecordPersonalClientCert, pending.rowID, r.CertificateData) + case respPersonalServerCertificate: + err = d.recordCertificate(ctx, sqlRecordPersonalServerCert, pending.rowID, r.CertificateData) + case respOrganizationalClientCertificate, respOrganizationalCodeSigningCertificate: + err = d.recordCertificate(ctx, sqlRecordOrganizationalClientCert, pending.rowID, r.CertificateData) + case respOrganizationalServerCertificate: + err = d.recordCertificate(ctx, 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) + } + + c, err := mail.NewClient("localhost") + 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") + } +} diff --git a/internal/legacydb/legacydb_test.go b/internal/legacydb/legacydb_test.go new file mode 100644 index 0000000..34ffb77 --- /dev/null +++ b/internal/legacydb/legacydb_test.go @@ -0,0 +1,186 @@ +/* +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 + +import ( + "crypto/x509" + "crypto/x509/pkix" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_extractSubjectParts(t *testing.T) { + tests := []struct { + name string + subject string + want *x509.Certificate + wantErr bool + }{ + { + "personal user subject", + "/CN=John Doe/emailAddress=john.doe@example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "John Doe"}, + EmailAddresses: []string{"john.doe@example.org"}, + }, + false, + }, + { + "subject with supported and unsupported alt names", + "/CN=a.example.com/subjectAltName=DNS:a.example.com/" + + "subjectAltName=otherName:1.3.6.1.5.5.7.8.5;UTF8:a.example.com", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "a.example.com"}, + DNSNames: []string{"a.example.com"}, + }, + false, + }, + { + "subject with ISO-8859-1 special characters", + "/CN=D\xf6ner Kebap/emailAddress=doener@example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "Döner Kebap"}, + EmailAddresses: []string{"doener@example.org"}, + }, + false, + }, + { + "subject with Windows1252 special characters", + "/CN=J\xe1no\x9a Test\x9c/emailAddress=janos.testoe@example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "Jánoš Testœ"}, + EmailAddresses: []string{"janos.testoe@example.org"}, + }, + false, + }, + { + "WoT User subject", + "/CN=CAcert WoT User/emailAddress=test@example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "CAcert WoT User"}, + EmailAddresses: []string{"test@example.org"}, + }, + false, + }, + { + "Keep address order", + "/CN=CAcert WoT User/emailAddress=wot.user@example.com/emailAddress=wu@example.com", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "CAcert WoT User"}, + EmailAddresses: []string{"wot.user@example.com", "wu@example.com"}, + }, + false, + }, + { + "Keep DNS name order", + "/CN=Test User/subjectAltName=DNS:www.example.com/subjectAltName=DNS:example.com", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "Test User"}, + DNSNames: []string{"www.example.com", "example.com"}, + }, + false, + }, + { + "Organization user without OU", + "/CN=Test User/emailAddress=test@example.org/organizationName=Acme Inc./" + + "localityName=Example town/stateOrProvinceName=BW/countryName=DE", + &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test User", + Organization: []string{"Acme Inc."}, + Locality: []string{"Example town"}, + Province: []string{"BW"}, + Country: []string{"DE"}, + }, + EmailAddresses: []string{"test@example.org"}, + }, + false, + }, + { + "Organization user with OU", + "/CN=Test User/emailAddress=test@example.org/organizationalUnitName=IT/" + + "organizationName=Acme Inc./localityName=Example town/countryName=DE", + &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Test User", + Organization: []string{"Acme Inc."}, + OrganizationalUnit: []string{"IT"}, + Locality: []string{"Example town"}, + Country: []string{"DE"}, + }, + EmailAddresses: []string{"test@example.org"}, + }, + false, + }, + { + "Organization domain without OU", + "/organizationName=Acme Inc./localityName=Example Town/stateOrProvinceName=BW/countryName=DE/" + + "commonName=www.example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "www.example.org", + Organization: []string{"Acme Inc."}, + Locality: []string{"Example Town"}, + Province: []string{"BW"}, + Country: []string{"DE"}, + }, + DNSNames: []string{"www.example.org"}, + }, + false, + }, + { + "Organization domain with OU", + "/organizationalUnitName=IT/organizationName=Acme Inc./localityName=Example Town/" + + "stateOrProvinceName=BW/countryName=DE/commonName=example.org", + &x509.Certificate{ + Subject: pkix.Name{CommonName: "example.org", + Organization: []string{"Acme Inc."}, + OrganizationalUnit: []string{"IT"}, + Locality: []string{"Example Town"}, + Province: []string{"BW"}, + Country: []string{"DE"}, + }, + DNSNames: []string{"example.org"}, + }, + false, + }, + { + "Empty subject", + "", + nil, + true, + }, + { + "No = in part", + "/CNexample", + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractSubjectParts(tt.subject) + if (err != nil) != tt.wantErr { + t.Errorf("extractSubjectParts() error = %v, wantErr %v", err, tt.wantErr) + + return + } + assert.Equal(t, tt.want, got, "extractSubjectParts() got = %v, want %v", got, tt.want) + }) + } +} diff --git a/templates/mail/en/gpg_body.txt b/templates/mail/en/gpg_body.txt new file mode 100644 index 0000000..a3dd87e --- /dev/null +++ b/templates/mail/en/gpg_body.txt @@ -0,0 +1,12 @@ +Your CAcert signed key for {{ .Email }} is available online at: + +https://www.cacert.org/gpg.php?id=3&cert={{ .RowID }} + +To help improve the trust of CAcert in general, it's appreciated if you could also sign our key and upload it to a key +server. Below is a copy of our primary key details: + +pub 1024D/65D0FD58 2003-07-11 CA Cert Signing Authority (Root CA) +Key fingerprint = A31D 4F81 EF4E BD07 B456 FA04 D2BB 0D01 65D0 FD58 + +Best regards +CAcert.org Support! \ No newline at end of file diff --git a/templates/mail/en/gpg_subject.txt b/templates/mail/en/gpg_subject.txt new file mode 100644 index 0000000..ec64bba --- /dev/null +++ b/templates/mail/en/gpg_subject.txt @@ -0,0 +1 @@ +[CAcert.org] Your GPG/PGP Key \ No newline at end of file