package identity import ( "context" "crypto/sha256" "encoding/hex" "errors" "strings" "time" customer "git.sendico.io/tech/sxchnge/product/customers/model" merrors "git.sendico.io/tech/sxchnge/shared/errors" "go.uber.org/zap" ) // Profile holds unverified customer assertions. Email and customer reference are // always supplied by the authenticated backend, never accepted from the browser. type Profile struct { FullName string DateOfBirth string ResidenceCountry string } func (p Profile) Validate() error { birth, err := time.Parse("2006-01-02", p.DateOfBirth) if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' { return merrors.InvalidArgument("complete identity profile required") } return nil } type Verification struct { Status string UpgradePending bool Reason string SubmissionID string Profile Profile } // VerificationProvider consumes reviewed state through the platform public API. // It cannot accept a browser-selected subject, policy profile or approval. type VerificationProvider interface { Verification(context.Context, string) (Verification, error) SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error) } // Decimal amounts are transported losslessly in their currency's major unit. // No fiat scale or illustrative client caps are applied to provider policy. type Limit struct { Operation string Period string Currency string Cap string Remaining string Used string Reserved string } type Limits struct { Tier string ProfileRef string Entries []Limit } type LimitsProvider interface { Limits(context.Context, string) (Limits, error) } func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) { b, err := s.binding(ctx, a) if err != nil { return Limits{}, err } p, ok := s.Provider.(LimitsProvider) if !ok { return Limits{}, unavailable() } return p.Limits(ctx, b.SubjectRef) } func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) { if s == nil || s.Provider == nil || s.Store == nil { return nil, merrors.ServiceUnavailable("identity verification unavailable") } if a == nil || a.ID.IsZero() { return nil, merrors.InvalidArgument("customer required") } b, err := s.Store.Get(ctx, a.ID) if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil { if err = s.Ensure(ctx, a); err != nil { return nil, err } b, err = s.Store.Get(ctx, a.ID) } if err != nil { return nil, err } if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef { return nil, merrors.FailedPrecondition("identity binding requires reconciliation") } return b, nil } func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) { b, err := s.binding(ctx, a) if err != nil { return Verification{}, err } provider, ok := s.Provider.(VerificationProvider) if !ok { return Verification{}, unavailable() } v, err := provider.Verification(ctx, b.SubjectRef) if err == nil && v.Profile.FullName == "" { v.Profile.FullName = a.Name } return v, err } func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) { if len(key) < 16 || len(key) > 128 { return Verification{}, merrors.InvalidArgument("valid idempotency key required") } if err := p.Validate(); err != nil { return Verification{}, err } for _, photo := range [][]byte{document, selfie} { if err := ValidatePhoto(photo); err != nil { return Verification{}, err } } b, err := s.binding(ctx, a) if err != nil { return Verification{}, err } provider, ok := s.Provider.(VerificationProvider) if !ok { return Verification{}, unavailable() } digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key)) facts := b.Facts facts.Email = a.Login facts.CustomerRef = a.ID.Hex() v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie) if err != nil { s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex())) return Verification{}, err } s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID)) return v, nil }