diff --git a/services/skus/controllers.go b/services/skus/controllers.go index 8dc8750ca..93acffd41 100644 --- a/services/skus/controllers.go +++ b/services/skus/controllers.go @@ -841,48 +841,54 @@ func MerchantTransactions(service *Service) handlers.AppHandler { // VerifyCredentialV2 - version 2 of verify credential func VerifyCredentialV2(service *Service) handlers.AppHandler { - return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + ctx := r.Context() - logger := logging.Logger(ctx, "VerifyCredentialV2") - logger.Debug().Msg("starting VerifyCredentialV2 controller") + l := logging.Logger(ctx, "VerifyCredentialV2") var req = new(VerifyCredentialRequestV2) if err := inputs.DecodeAndValidateReader(ctx, req, r.Body); err != nil { - logger.Error().Err(err).Msg("failed to read request") + l.Error().Err(err).Msg("failed to read request") return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) } - return service.verifyCredential(ctx, req, w) - }) + appErr := service.verifyCredential(ctx, req, w) + if appErr != nil { + l.Error().Err(appErr).Msg("failed to verify credential") + } + + return appErr + } } // VerifyCredentialV1 is the handler for verifying subscription credentials func VerifyCredentialV1(service *Service) handlers.AppHandler { - return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { ctx := r.Context() - - logger := logging.Logger(r.Context(), "VerifyCredentialV1") - logger.Debug().Msg("starting VerifyCredentialV1 controller") + l := logging.Logger(r.Context(), "VerifyCredentialV1") var req = new(VerifyCredentialRequestV1) err := requestutils.ReadJSON(r.Context(), r.Body, &req) if err != nil { - logger.Error().Err(err).Msg("failed to read request") + l.Error().Err(err).Msg("failed to read request") return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) } - logger.Debug().Msg("read verify credential post body") + l.Debug().Msg("read verify credential post body") _, err = govalidator.ValidateStruct(req) if err != nil { - logger.Error().Err(err).Msg("failed to validate request") + l.Error().Err(err).Msg("failed to validate request") return handlers.WrapError(err, "Error in request validation", http.StatusBadRequest) } - logger.Debug().Msg("validated verify credential post body") + appErr := service.verifyCredential(ctx, req, w) + if appErr != nil { + l.Error().Err(appErr).Msg("failed to verify credential") + } - return service.verifyCredential(ctx, req, w) - }) + return appErr + } } // WebhookRouter - handles calls from various payment method webhooks informing payments of completion diff --git a/services/skus/service.go b/services/skus/service.go index d2f54b112..0504b49b0 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -1297,9 +1297,8 @@ type credential interface { GetPresentation(context.Context) string } -// TODO refactor this see issue #1502 // verifyCredential - given a credential, verify it. -func (s *Service) verifyCredential(ctx context.Context, req credential, w http.ResponseWriter) *handlers.AppError { +func (s *Service) verifyCredential(ctx context.Context, cred credential, w http.ResponseWriter) *handlers.AppError { logger := logging.Logger(ctx, "verifyCredential") merchant, err := GetMerchant(ctx) @@ -1312,9 +1311,9 @@ func (s *Service) verifyCredential(ctx context.Context, req credential, w http.R caveats := GetCaveats(ctx) - if req.GetMerchantID(ctx) != merchant { + if cred.GetMerchantID(ctx) != merchant { logger.Warn(). - Str("req.MerchantID", req.GetMerchantID(ctx)). + Str("req.MerchantID", cred.GetMerchantID(ctx)). Str("merchant", merchant). Msg("merchant does not match the key's merchant") return handlers.WrapError(nil, "Verify request merchant does not match authentication", http.StatusForbidden) @@ -1324,9 +1323,9 @@ func (s *Service) verifyCredential(ctx context.Context, req credential, w http.R if caveats != nil { if sku, ok := caveats["sku"]; ok { - if req.GetSku(ctx) != sku { + if cred.GetSku(ctx) != sku { logger.Warn(). - Str("req.SKU", req.GetSku(ctx)). + Str("req.SKU", cred.GetSku(ctx)). Str("sku", sku). Msg("sku caveat does not match") return handlers.WrapError(nil, "Verify request sku does not match authentication", http.StatusForbidden) @@ -1335,130 +1334,97 @@ func (s *Service) verifyCredential(ctx context.Context, req credential, w http.R } logger.Debug().Msg("caveats validated") - if req.GetType(ctx) == singleUse || req.GetType(ctx) == timeLimitedV2 { - var bytes []byte - bytes, err = base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) - if err != nil { - return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) - } + kind := cred.GetType(ctx) + switch kind { + case singleUse, timeLimitedV2: + return s.verifyBlindedTokenCredential(ctx, cred, w) + case timeLimited: + return s.verifyTimeLimitedV1Credential(ctx, cred, w) + default: + return handlers.WrapError(nil, "Unknown credential type", http.StatusBadRequest) + } +} - var decodedCredential cbr.CredentialRedemption - err = json.Unmarshal(bytes, &decodedCredential) - if err != nil { - return handlers.WrapError(err, "Error in presentation formatting", http.StatusBadRequest) - } +// verifyBlindedTokenCredential verifies a single use or time limited v2 credential. +func (s *Service) verifyBlindedTokenCredential(ctx context.Context, req credential, w http.ResponseWriter) *handlers.AppError { + bytes, err := base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) + if err != nil { + return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) + } - // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details - issuerID, err := encodeIssuerID(req.GetMerchantID(ctx), req.GetSku(ctx)) - if err != nil { - return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) - } - if issuerID != decodedCredential.Issuer { - return handlers.WrapError(nil, "Error, outer merchant and sku don't match issuer", http.StatusBadRequest) - } + decodedCred := &cbr.CredentialRedemption{} + if err := json.Unmarshal(bytes, decodedCred); err != nil { + return handlers.WrapError(err, "Error in presentation formatting", http.StatusBadRequest) + } - switch req.GetType(ctx) { - case singleUse: - err = s.cbClient.RedeemCredential(ctx, decodedCredential.Issuer, decodedCredential.TokenPreimage, - decodedCredential.Signature, decodedCredential.Issuer) - case timeLimitedV2: - err = s.cbClient.RedeemCredentialV3(ctx, decodedCredential.Issuer, decodedCredential.TokenPreimage, - decodedCredential.Signature, decodedCredential.Issuer) - default: - return handlers.WrapError(fmt.Errorf("credential type %s not suppoted", req.GetType(ctx)), - "unknown credential type %s", http.StatusBadRequest) - } + // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details. + issuerID, err := encodeIssuerID(req.GetMerchantID(ctx), req.GetSku(ctx)) + if err != nil { + return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) + } - if err != nil { - // if this is a duplicate redemption these are not verified - if err.Error() == cbr.ErrDupRedeem.Error() || err.Error() == cbr.ErrBadRequest.Error() { - return handlers.WrapError(err, "invalid credentials", http.StatusForbidden) - } - return handlers.WrapError(err, "Error verifying credentials", http.StatusInternalServerError) - } + if issuerID != decodedCred.Issuer { + return handlers.WrapError(nil, "Error, outer merchant and sku don't match issuer", http.StatusBadRequest) + } + + return s.redeemBlindedCred(ctx, w, req.GetType(ctx), decodedCred) +} + +// verifyTimeLimitedV1Credential verifies a time limited v1 credential. +func (s *Service) verifyTimeLimitedV1Credential(ctx context.Context, req credential, w http.ResponseWriter) *handlers.AppError { + data, err := base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) + if err != nil { + return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) + } - return handlers.RenderContent(ctx, "Credentials successfully verified", w, http.StatusOK) + present := &tlv1CredPresentation{} + if err := json.Unmarshal(data, present); err != nil { + return handlers.WrapError(err, "Error in presentation formatting", http.StatusBadRequest) } - if req.GetType(ctx) == "time-limited" { - // Presentation includes a token and token metadata test test - type Presentation struct { - IssuedAt string `json:"issuedAt"` - ExpiresAt string `json:"expiresAt"` - Token string `json:"token"` - } + merchID := req.GetMerchantID(ctx) - var bytes []byte - bytes, err = base64.StdEncoding.DecodeString(req.GetPresentation(ctx)) - if err != nil { - logger.Error().Err(err). - Msg("failed to decode the request token presentation") - return handlers.WrapError(err, "Error in decoding presentation", http.StatusBadRequest) - } - logger.Debug().Str("presentation", string(bytes)).Msg("presentation decoded") + // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details. + issuerID, err := encodeIssuerID(merchID, req.GetSku(ctx)) + if err != nil { + return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) + } - var presentation Presentation - err = json.Unmarshal(bytes, &presentation) - if err != nil { - logger.Error().Err(err). - Msg("failed to unmarshal the request token presentation") - return handlers.WrapError(err, "Error in presentation formatting", http.StatusBadRequest) - } + keys, err := s.GetCredentialSigningKeys(ctx, merchID) + if err != nil { + return handlers.WrapError(err, "failed to get merchant signing key", http.StatusInternalServerError) + } - logger.Debug().Str("presentation", string(bytes)).Msg("presentation unmarshalled") + issuedAt, err := time.Parse("2006-01-02", present.IssuedAt) + if err != nil { + return handlers.WrapError(err, "Error parsing issuedAt", http.StatusBadRequest) + } - // Ensure that the credential being redeemed (opaque to merchant) matches the outer credential details - issuerID, err := encodeIssuerID(req.GetMerchantID(ctx), req.GetSku(ctx)) - if err != nil { - logger.Error().Err(err). - Msg("failed to encode the issuer id") - return handlers.WrapError(err, "Error in outer merchantId or sku", http.StatusBadRequest) - } - logger.Debug().Str("issuer", issuerID).Msg("issuer encoded") + expiresAt, err := time.Parse("2006-01-02", present.ExpiresAt) + if err != nil { + return handlers.WrapError(err, "Error parsing expiresAt", http.StatusBadRequest) + } - keys, err := s.GetCredentialSigningKeys(ctx, req.GetMerchantID(ctx)) - if err != nil { - return handlers.WrapError(err, "failed to get merchant signing key", http.StatusInternalServerError) - } + for _, key := range keys { + timeLimitedSecret := cryptography.NewTimeLimitedSecret(key) - issuedAt, err := time.Parse("2006-01-02", presentation.IssuedAt) - if err != nil { - logger.Error().Err(err). - Msg("failed to parse issued at time of credential") - return handlers.WrapError(err, "Error parsing issuedAt", http.StatusBadRequest) - } - expiresAt, err := time.Parse("2006-01-02", presentation.ExpiresAt) + verified, err := timeLimitedSecret.Verify([]byte(issuerID), issuedAt, expiresAt, present.Token) if err != nil { - logger.Error().Err(err). - Msg("failed to parse expires at time of credential") - return handlers.WrapError(err, "Error parsing expiresAt", http.StatusBadRequest) + return handlers.WrapError(err, "Error in token verification", http.StatusBadRequest) } - for _, key := range keys { - timeLimitedSecret := cryptography.NewTimeLimitedSecret(key) - verified, err := timeLimitedSecret.Verify([]byte(issuerID), issuedAt, expiresAt, presentation.Token) - if err != nil { - logger.Error().Err(err). - Msg("failed to verify time limited credential") - return handlers.WrapError(err, "Error in token verification", http.StatusBadRequest) + if verified { + // Check against expiration time, issued time. + now := time.Now() + if now.After(expiresAt) || now.Before(issuedAt) { + return handlers.WrapError(nil, "Credentials are not valid", http.StatusForbidden) } - if verified { - // check against expiration time, issued time - if time.Now().After(expiresAt) || time.Now().Before(issuedAt) { - logger.Error(). - Msg("credentials are not valid") - return handlers.RenderContent(ctx, "Credentials are not valid", w, http.StatusForbidden) - } - logger.Debug().Msg("credentials verified") - return handlers.RenderContent(ctx, "Credentials successfully verified", w, http.StatusOK) - } + return handlers.RenderContent(ctx, "Credentials successfully verified", w, http.StatusOK) } - logger.Error(). - Msg("credentials could not be verified") - return handlers.RenderContent(ctx, "Credentials could not be verified", w, http.StatusForbidden) } - return handlers.WrapError(nil, "Unknown credential type", http.StatusBadRequest) + + return handlers.WrapError(nil, "Credentials could not be verified", http.StatusForbidden) } // RunSendSigningRequestJob - send the order credentials signing requests @@ -1806,6 +1772,41 @@ func (s *Service) createStripeSessID(ctx context.Context, req *model.CreateOrder return nil } +func (s *Service) redeemBlindedCred(ctx context.Context, w http.ResponseWriter, kind string, cred *cbr.CredentialRedemption) *handlers.AppError { + var redeemFn func(ctx context.Context, issuer, preimage, signature, payload string) error + + switch kind { + case singleUse: + redeemFn = s.cbClient.RedeemCredential + case timeLimitedV2: + redeemFn = s.cbClient.RedeemCredentialV3 + default: + return handlers.WrapError(fmt.Errorf("credential type %s not suppoted", kind), "unknown credential type %s", http.StatusBadRequest) + } + + // FIXME: we shouldn't be using the issuer as the payload, it ideally would be a unique request identifier + // to allow for more flexible idempotent behavior. + if err := redeemFn(ctx, cred.Issuer, cred.TokenPreimage, cred.Signature, cred.Issuer); err != nil { + msg := err.Error() + + // Time limited v2: Expose a credential id so the caller can decide whether to allow multiple redemptions. + if kind == timeLimitedV2 && msg == cbr.ErrDupRedeem.Error() { + data := &blindedCredVrfResult{ID: cred.TokenPreimage, Duplicate: true} + + return handlers.RenderContent(ctx, data, w, http.StatusOK) + } + + // Duplicate redemptions are not verified. + if msg == cbr.ErrDupRedeem.Error() || msg == cbr.ErrBadRequest.Error() { + return handlers.WrapError(err, "invalid credentials", http.StatusForbidden) + } + + return handlers.WrapError(err, "Error verifying credentials", http.StatusInternalServerError) + } + + return handlers.RenderContent(ctx, &blindedCredVrfResult{ID: cred.TokenPreimage}, w, http.StatusOK) +} + func createOrderItems(req *model.CreateOrderRequestNew) ([]model.OrderItem, error) { result := make([]model.OrderItem, 0) @@ -1882,3 +1883,14 @@ func durationFromISO(v string) (time.Duration, error) { return time.Until(*durt), nil } + +type blindedCredVrfResult struct { + ID string `json:"id"` + Duplicate bool `json:"duplicate"` +} + +type tlv1CredPresentation struct { + Token string `json:"token"` + IssuedAt string `json:"issuedAt"` + ExpiresAt string `json:"expiresAt"` +}