diff --git a/appvalidate/assertion.go b/appvalidate/assertion.go index 0407651d01..b51ba402e2 100644 --- a/appvalidate/assertion.go +++ b/appvalidate/assertion.go @@ -29,6 +29,10 @@ type AssertionUpdate struct { AssertionCounter uint32 `bson:"assertionCounter,omitempty"` } +type AssertionResponse struct { + Data any `json:"data"` +} + type AssertionClientData struct { Challenge string `json:"challenge"` Partner string `json:"partner"` // Which partner are we requesting a secret from - currently only one supported @@ -44,6 +48,7 @@ func NewAssertionVerify(userID string) *AssertionVerify { func (av *AssertionVerify) Validate(v structure.Validator) { v.String("assertion", &av.Assertion).NotEmpty().Matches(base64Chars) v.String("clientData.challenge", &av.ClientData.Challenge).NotEmpty() + v.String("clientData.partner", &av.ClientData.Partner).OneOf(PartnerCoastal, PartnerPalmTree) v.String("userId", &av.UserID).NotEmpty() v.String("keyId", &av.KeyID).NotEmpty() diff --git a/appvalidate/coastal_secrets.go b/appvalidate/coastal_secrets.go index 7de6a0423d..2dc4b5b383 100644 --- a/appvalidate/coastal_secrets.go +++ b/appvalidate/coastal_secrets.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/url" "path" @@ -19,12 +20,14 @@ const ( ) type CoastalSecretsConfig struct { - BaseURL string `env:"COASTAL_BASE_URL"` - APICertificatePath string `env:"COASTAL_API_CERTIFICATE_PATH"` - APIKey string `env:"COASTAL_API_KEY"` - ClientID string `env:"COASTAL_CLIENT_ID"` - ClientSecret string `env:"COSTAL_CLIENT_SECRET"` + APICertificatePath string `envconfig:"COASTAL_API_CERTIFICATE_PATH"` + APIKey string `envconfig:"COASTAL_API_KEY"` + BaseURL string `envconfig:"COASTAL_BASE_URL"` + ClientID string `envconfig:"COASTAL_CLIENT_ID"` + ClientSecret string `envconfig:"COSTAL_CLIENT_SECRET"` + RCTypeID string `envconfig:"COASTAL_RC_TYPE_ID"` } + type CoastalSecrets struct { Config CoastalSecretsConfig } @@ -57,13 +60,14 @@ type CoastalResponse struct { } func (c *CoastalSecrets) GetSecret(ctx context.Context, partnerDataRaw []byte) (*CoastalResponse, error) { - var payload CoastalPayload - if err := json.Unmarshal(partnerDataRaw, &payload); err != nil { - return nil, err + payload := newCoastalPayload(c.Config.RCTypeID) + if err := json.Unmarshal(partnerDataRaw, payload); err != nil { + return nil, fmt.Errorf("unable to unmarshal Coastal payload: %w", err) } + // Todo: calculate rcbMac / rcbSignature when partner API is updated. - if err := structValidator.New().Validate(&payload); err != nil { - return nil, err + if err := structValidator.New().Validate(payload); err != nil { + return nil, fmt.Errorf("unable to validate Coastal payload: %w", err) } var buf bytes.Buffer @@ -73,7 +77,7 @@ func (c *CoastalSecrets) GetSecret(ctx context.Context, partnerDataRaw []byte) ( u, err := url.Parse(c.Config.BaseURL) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to prase Coastal API baseURL: %w", err) } u.Path = path.Join(u.Path, c.Config.APICertificatePath) @@ -89,12 +93,17 @@ func (c *CoastalSecrets) GetSecret(ctx context.Context, partnerDataRaw []byte) ( res, err := http.DefaultClient.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to issue Coastal API request: %w", err) } defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("unsuccessful Coastal API response: %v: %v", res.StatusCode, res.Status) + } + var response CoastalResponse if err := json.NewDecoder(res.Body).Decode(&response); err != nil { - return nil, err + return nil, fmt.Errorf("unable to read Coastal API response: %w", err) } return &response, nil } @@ -109,3 +118,9 @@ func (p *CoastalPayload) Validate(v structure.Validator) { v.String("csr", &p.CSR).NotEmpty() v.String("rcbMac", &p.RCBMac).NotEmpty() } + +func newCoastalPayload(rcTypeID string) *CoastalPayload { + return &CoastalPayload{ + RCTypeID: rcTypeID, + } +} diff --git a/appvalidate/palmtree_secrets.go b/appvalidate/palmtree_secrets.go new file mode 100644 index 0000000000..024249876f --- /dev/null +++ b/appvalidate/palmtree_secrets.go @@ -0,0 +1,162 @@ +package appvalidate + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + + "github.com/kelseyhightower/envconfig" + + "github.com/tidepool-org/platform/structure" + structValidator "github.com/tidepool-org/platform/structure/validator" +) + +const ( + PartnerPalmTree = "PalmTree" +) + +type PalmTreeSecretsConfig struct { + BaseURL string `envconfig:"PALMTREE_BASE_URL"` + CalID string `envconfig:"PALMTREE_CAL_ID"` + ProfileID string `envconfig:"PALMTREE_PROFILE_ID"` + CertFile string `envconfig:"PALMTREE_TLS_CERT_FILE"` + KeyFile string `envconfig:"PALMTREE_TLS_KEY_FILE"` +} + +type PalmTreeSecrets struct { + Config PalmTreeSecretsConfig + client *http.Client +} + +func NewPalmTreeSecretsConfig() (*PalmTreeSecretsConfig, error) { + cfg := &PalmTreeSecretsConfig{} + if err := envconfig.Process("", cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func NewPalmTreeSecrets(c *PalmTreeSecretsConfig) (*PalmTreeSecrets, error) { + if c == nil { + return nil, errors.New("empty PalmTree config") + } + cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile) + if err != nil { + return nil, fmt.Errorf("unable to load PalmTree X.509 key pair: %w", err) + } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + }, + } + + return &PalmTreeSecrets{ + Config: *c, + client: &http.Client{Transport: tr}, + }, nil +} + +type PalmTreePayload struct { + CSR string `json:"csr"` + ProfileID string `json:"profileId"` + RequiredFormat palmTreeRequiredFormat `json:"requiredFormat"` + CertificateRequestDetails palmTreeCertificateRequestDetails `json:"optionalCertificateRequestDetails"` +} + +type palmTreeCertificateRequestDetails struct { + SubjectDN string `json:"subjectDn"` +} + +type palmTreeRequiredFormat struct { + Format string `json:"format"` +} + +type PalmTreeResponse struct { + Type string `json:"type"` + + Message struct { + Message string `json:"message"` + Details []any `json:"details"` + } `json:"message"` + + Enrollment struct { + ID string `json:"id"` + SerialNumber string `json:"serialNumber"` + SubjectName string `json:"subjectName"` + IssuerName string `json:"issuerName"` + ValidityPeroid string `json:"validityPeriod"` + Status string `json:"status"` + Body string `json:"body"` + } `json:"enrollment"` +} + +func (pt *PalmTreeSecrets) GetSecret(ctx context.Context, partnerDataRaw []byte) (*PalmTreeResponse, error) { + payload := newPalmtreePayload(pt.Config.ProfileID) + + if err := json.Unmarshal(partnerDataRaw, payload); err != nil { + return nil, fmt.Errorf("unable to unmarshal PalmTree payload: %w", err) + } + + if err := structValidator.New().Validate(payload); err != nil { + return nil, fmt.Errorf("unable to validate PalmTree payload: %w", err) + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + return nil, err + } + + u, err := url.Parse(pt.Config.BaseURL) + if err != nil { + return nil, fmt.Errorf("unable to prase PalmTree API baseURL: %w", err) + } + u.Path = path.Join(u.Path, fmt.Sprintf("v1/certificate-authorities/%s/enrollments", url.PathEscape(pt.Config.CalID))) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), &buf) + if err != nil { + return nil, err + } + req.Header.Add("content-type", "application/json") + req.Header.Add("accept", "application/json") + + res, err := pt.client.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to issue PalmTree API request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("unsuccessful PalmTree API response: %v: %v", res.StatusCode, res.Status) + } + + var response PalmTreeResponse + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("unable to read PalmTree API response: %w", err) + } + return &response, nil +} + +func (p *PalmTreePayload) Validate(v structure.Validator) { + v.String("csr", &p.CSR).NotEmpty() + v.String("profileId", &p.ProfileID).NotEmpty() + v.String("requiredFormat.format", &p.RequiredFormat.Format).NotEmpty() + v.String("optionalCertificateRequestDetails.subjectDn", &p.CertificateRequestDetails.SubjectDN).NotEmpty() +} + +func newPalmtreePayload(profileID string) *PalmTreePayload { + return &PalmTreePayload{ + ProfileID: profileID, + RequiredFormat: palmTreeRequiredFormat{ + Format: "PEM", + }, + CertificateRequestDetails: palmTreeCertificateRequestDetails{ + SubjectDN: "C=US", + }, + } +} diff --git a/auth/service/api/v1/appvalidate.go b/auth/service/api/v1/appvalidate.go index 16cfc81d0e..7174400d99 100644 --- a/auth/service/api/v1/appvalidate.go +++ b/auth/service/api/v1/appvalidate.go @@ -20,7 +20,6 @@ func (r *Router) AppValidateRoutes() []*rest.Route { rest.Post("/v1/attestations/challenges", api.RequireUser(r.CreateAttestationChallenge)), rest.Post("/v1/attestations/verifications", api.RequireUser(r.VerifyAttestation)), rest.Post("/v1/assertions/challenges", api.RequireUser(r.CreateAssertionChallenge)), - // Rename this route to show actual intent of retrieving secret when secret retrieval is implemented. rest.Post("/v1/assertions/verifications", api.RequireUser(r.VerifyAssertion)), } } @@ -143,11 +142,23 @@ func (r *Router) VerifyAssertion(res rest.ResponseWriter, req *rest.Request) { case appvalidate.PartnerCoastal: secret, err := r.CoastalSecrets().GetSecret(ctx, []byte(assertVerify.ClientData.PartnerData)) if err != nil { - log.LoggerFromContext(ctx).WithFields(logFields).WithError(err).Error("unable to create fetch coastal secrets") + log.LoggerFromContext(ctx).WithFields(logFields).WithError(err).Error("unable to create fetch Coastal secrets") responder.InternalServerError(err) return } - responder.Data(http.StatusOK, secret) + responder.Data(http.StatusOK, appvalidate.AssertionResponse{ + Data: secret, + }) + case appvalidate.PartnerPalmTree: + secret, err := r.PalmTreeSecrets().GetSecret(ctx, []byte(assertVerify.ClientData.PartnerData)) + if err != nil { + log.LoggerFromContext(ctx).WithFields(logFields).WithError(err).Error("unable to create fetch PalmTree secrets") + responder.InternalServerError(err) + return + } + responder.Data(http.StatusOK, appvalidate.AssertionResponse{ + Data: secret, + }) default: responder.Error(http.StatusBadRequest, fmt.Errorf("unknown partner, %s", assertVerify.ClientData.Partner)) } diff --git a/auth/service/service.go b/auth/service/service.go index ebc2f9b921..ff7f2d9caa 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -28,7 +28,11 @@ type Service interface { Status(context.Context) *Status AppValidator() *appvalidate.Validator + + // As there are only 2 secrets for now, I will keep them separated as + // opposed to having a more "factory" of secrets. CoastalSecrets() *appvalidate.CoastalSecrets + PalmTreeSecrets() *appvalidate.PalmTreeSecrets } type Status struct { diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 51e189aee8..0fc19de0b7 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -56,6 +56,7 @@ type Service struct { deviceCheck apple.DeviceCheck appValidator *appvalidate.Validator coastalSecrets *appvalidate.CoastalSecrets + palmTreeSecrets *appvalidate.PalmTreeSecrets } func New() *Service { @@ -114,6 +115,9 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeCoastalSecrets(); err != nil { return err } + if err := s.initializePalmTreeSecrets(); err != nil { + return err + } return s.initializeUserEventsHandler() } @@ -169,6 +173,10 @@ func (s *Service) CoastalSecrets() *appvalidate.CoastalSecrets { return s.coastalSecrets } +func (s *Service) PalmTreeSecrets() *appvalidate.PalmTreeSecrets { + return s.palmTreeSecrets +} + func (s *Service) Status(ctx context.Context) *service.Status { return &service.Status{ Version: s.VersionReporter().Long(), @@ -472,6 +480,15 @@ func (s *Service) initializeCoastalSecrets() error { return nil } +func (s *Service) initializePalmTreeSecrets() error { + cfg, err := appvalidate.NewPalmTreeSecretsConfig() + if err != nil { + return err + } + s.palmTreeSecrets, err = appvalidate.NewPalmTreeSecrets(cfg) + return err +} + func (s *Service) terminateUserEventsHandler() { if s.userEventsHandler != nil { s.Logger().Info("Terminating the userEventsHandler")