diff --git a/.gitignore b/.gitignore index a809f9962..842d1127f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,4 @@ report.json # idea files .idea -# Generated binaries -/_examples/docscan/docscan diff --git a/README.md b/README.md index b30085144..da12e43fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=getyoti%3Ago&metric=code_smells)](https://sonarcloud.io/dashboard?id=getyoti%3Ago) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=getyoti%3Ago&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=getyoti%3Ago) -Welcome to the Yoti Go SDK. This repo contains the tools and step by step instructions you need to quickly integrate your Go back-end with Yoti so that your users can share their identity details with your application in a secure and trusted way. +Welcome to the Yoti Go SDK. This repo contains the tools and step by step instructions you need to quickly integrate your Go back-end with Yoti so that your users can receipt their identity details with your application in a secure and trusted way. ## Table of Contents diff --git a/_examples/.gitignore b/_examples/.gitignore index 4c49bd78f..041d99030 100644 --- a/_examples/.gitignore +++ b/_examples/.gitignore @@ -1 +1,9 @@ .env +# Generated binaries +docscan/docscan +idv/idv +aml/aml +docscansandbox/docscansandbox +profile/profile +profilesandbox/profilesandbox +digitalidentity/digitalidentity \ No newline at end of file diff --git a/_examples/digitalidentity/.env.example b/_examples/digitalidentity/.env.example new file mode 100644 index 000000000..dc09949e2 --- /dev/null +++ b/_examples/digitalidentity/.env.example @@ -0,0 +1,2 @@ +YOTI_CLIENT_SDK_ID= +YOTI_KEY_FILE_PATH= \ No newline at end of file diff --git a/_examples/digitalidentity/.gitignore b/_examples/digitalidentity/.gitignore new file mode 100644 index 000000000..1418f3360 --- /dev/null +++ b/_examples/digitalidentity/.gitignore @@ -0,0 +1,8 @@ +/images/YotiSelfie.jpeg + +# Example project generated self-signed certificate +/yotiSelfSignedCert.pem +/yotiSelfSignedKey.pem + +# Compiled binary +/digitalidentity diff --git a/_examples/digitalidentity/README.md b/_examples/digitalidentity/README.md new file mode 100644 index 000000000..703e89efc --- /dev/null +++ b/_examples/digitalidentity/README.md @@ -0,0 +1,46 @@ +## Table of Contents + +1) [Setup](#setup) - +How to initialise the Yoti client + +1) [Running the digitalidentity examples](#running-the-profile-example) - +Running the digitalidentity example + +## Setup + +The YotiClient is the SDK entry point. To initialise it you need include the following snippet inside your endpoint initialisation section: + +```Go +clientSdkID := "your-client-sdk-id" +key, err := os.ReadFile("path/to/your-application-pem-file.pem") +if err != nil { + // handle key load error +} + +client, err := yoti.NewClient( + clientSdkID, + key) +``` + +Where: + +* `"your-client-sdk-id"` is the SDK Client Identifier generated by Yoti Hub in the Key tab when you create your application. + +* `path/to/your-application-pem-file.pem` is the path to the application pem file. It can be downloaded from the Keys tab in the [Yoti Hub](https://hub.yoti.com/). + +Please do not open the pem file as this might corrupt the key, and you will need regenerate your key. + +Keeping your settings and access keys outside your repository is highly recommended. You can use a package like [godotenv](https://github.com/joho/godotenv) to manage environment variables more easily. + + +## Running the DigitalIdentity Example + +1. Change directory to the profile example folder: `cd _examples/digitalidentity` +2. On the [Yoti Hub](https://hub.yoti.com/): + 1. Set the application domain of your app to `localhost:8080` + 2. Set the scenario callback URL to `/digitalidentity` +3. Rename the [.env.example](_examples/digitalidentity/.env.example) file to `.env` and fill in the required configuration values (mentioned in the [Configuration](#configuration) section) +4. Build with `go build` +5. Start the compiled program by running `./digitalidentity` + +Visiting `https://localhost:8080/` should show a webpage with a Yoti button rendered on it diff --git a/_examples/digitalidentity/certificatehelper.go b/_examples/digitalidentity/certificatehelper.go new file mode 100644 index 000000000..cdcb4f23a --- /dev/null +++ b/_examples/digitalidentity/certificatehelper.go @@ -0,0 +1,175 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "os" + "strings" + "time" +) + +var ( + validFrom = "" + validFor = 2 * 365 * 24 * time.Hour + isCA = true + rsaBits = 2048 +) + +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func pemBlockForKey(priv interface{}) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) + os.Exit(2) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return nil + } +} + +func certificatePresenceCheck(certPath string, keyPath string) (present bool) { + if _, err := os.Stat(certPath); os.IsNotExist(err) { + return false + } + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + return false + } + return true +} + +func generateSelfSignedCertificate(certPath, keyPath, host string) error { + priv, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + log.Printf("failed to generate private key: %s", err) + return err + } + + notBefore, err := parseNotBefore(validFrom) + if err != nil { + log.Printf("failed to parse 'Not Before' value of cert using validFrom %q, error was: %s", validFrom, err) + return err + } + + notAfter := notBefore.Add(validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Printf("failed to generate serial number: %s", err) + return err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Yoti"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.Split(host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + log.Printf("Failed to create certificate: %s", err) + return err + } + + err = createPemFile(certPath, derBytes) + if err != nil { + log.Printf("failed to create pem file at %q: %s", certPath, err) + return err + } + log.Printf("written %s\n", certPath) + + err = createKeyFile(keyPath, priv) + if err != nil { + log.Printf("failed to create key file at %q: %s", keyPath, err) + return err + } + log.Printf("written %s\n", keyPath) + + return nil +} + +func createPemFile(certPath string, derBytes []byte) error { + certOut, err := os.Create(certPath) + + if err != nil { + log.Printf("failed to open "+certPath+" for writing: %s", err) + return err + } + + defer certOut.Close() + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + return err +} + +func createKeyFile(keyPath string, privateKey interface{}) error { + keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + log.Print("failed to open "+keyPath+" for writing:", err) + return err + } + + defer keyOut.Close() + err = pem.Encode(keyOut, pemBlockForKey(privateKey)) + + return err +} + +func parseNotBefore(validFrom string) (notBefore time.Time, err error) { + if len(validFrom) == 0 { + notBefore = time.Now() + } else { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err) + return time.Time{}, err + } + } + + return notBefore, nil +} diff --git a/_examples/digitalidentity/error.go b/_examples/digitalidentity/error.go new file mode 100644 index 000000000..71739df2d --- /dev/null +++ b/_examples/digitalidentity/error.go @@ -0,0 +1,24 @@ +package main + +import ( + "html/template" + "log" + "net/http" +) + +func errorPage(w http.ResponseWriter, r *http.Request) { + templateVars := map[string]interface{}{ + "yotiError": r.Context().Value(contextKey("yotiError")).(string), + } + log.Printf("%s", templateVars["yotiError"]) + t, err := template.ParseFiles("error.html") + if err != nil { + panic(errParsingTheTemplate + err.Error()) + } + + err = t.Execute(w, templateVars) + if err != nil { + panic(errApplyingTheParsedTemplate + err.Error()) + } + +} diff --git a/_examples/digitalidentity/error.html b/_examples/digitalidentity/error.html new file mode 100644 index 000000000..73d7e730b --- /dev/null +++ b/_examples/digitalidentity/error.html @@ -0,0 +1,11 @@ + + + + + Yoti Example Project - Error + + +

An Error Occurred

+

Error: {{.yotiError}}

+ + \ No newline at end of file diff --git a/_examples/digitalidentity/go.mod b/_examples/digitalidentity/go.mod new file mode 100644 index 000000000..7425a0d47 --- /dev/null +++ b/_examples/digitalidentity/go.mod @@ -0,0 +1,12 @@ +module digitalidentity + +go 1.19 + +require ( + github.com/getyoti/yoti-go-sdk/v3 v3.0.0 + github.com/joho/godotenv v1.3.0 +) + +require google.golang.org/protobuf v1.30.0 // indirect + +replace github.com/getyoti/yoti-go-sdk/v3 => ../../ diff --git a/_examples/digitalidentity/go.sum b/_examples/digitalidentity/go.sum new file mode 100644 index 000000000..25b7d7dcf --- /dev/null +++ b/_examples/digitalidentity/go.sum @@ -0,0 +1,11 @@ +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= diff --git a/_examples/digitalidentity/login.html b/_examples/digitalidentity/login.html new file mode 100644 index 000000000..b3d2729dc --- /dev/null +++ b/_examples/digitalidentity/login.html @@ -0,0 +1,85 @@ + + + + + + Yoti client example + + + + +
+
+
+ Yoti +
+ +

Digital Identity Share Example page

+ +

SdkId: {{.yotiClientSdkID}}

+ +
+
+
+ +
+ +
+

The Yoti app is free to download and use:

+ +
+ + Download on the App Store + + + + get it on Google Play + +
+
+
+ + + + diff --git a/_examples/digitalidentity/main.go b/_examples/digitalidentity/main.go new file mode 100644 index 000000000..b1cc553e7 --- /dev/null +++ b/_examples/digitalidentity/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path" + + "github.com/getyoti/yoti-go-sdk/v3" + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity" + _ "github.com/joho/godotenv/autoload" +) + +type contextKey string + +var ( + errApplyingTheParsedTemplate = "Error applying the parsed template: " + errParsingTheTemplate = "Error parsing the template: " +) + +func home(w http.ResponseWriter, req *http.Request) { + templateVars := map[string]interface{}{ + "yotiScenarioID": os.Getenv("YOTI_SCENARIO_ID"), + "yotiClientSdkID": os.Getenv("YOTI_CLIENT_SDK_ID")} + t, err := template.ParseFiles("login.html") + + if err != nil { + errorPage(w, req.WithContext(context.WithValue( + req.Context(), + contextKey("yotiError"), + fmt.Sprintf(errParsingTheTemplate+err.Error()), + ))) + return + } + + err = t.Execute(w, templateVars) + if err != nil { + errorPage(w, req.WithContext(context.WithValue( + req.Context(), + contextKey("yotiError"), + fmt.Sprintf(errApplyingTheParsedTemplate+err.Error()), + ))) + return + } +} +func buildDigitalIdentitySessionReq() (sessionSpec *digitalidentity.ShareSessionRequest, err error) { + policy, err := (&digitalidentity.PolicyBuilder{}).WithFullName().WithEmail().WithPhoneNumber().WithSelfie().WithAgeOver(18).WithNationality().WithGender().WithDocumentDetails().WithDocumentImages().WithWantedRememberMe().Build() + if err != nil { + return nil, fmt.Errorf("failed to build policy: %v", err) + } + + subject := []byte(`{ + "subject_id": "unique-user-id-for-examples" + }`) + + sessionReq, err := (&digitalidentity.ShareSessionRequestBuilder{}).WithPolicy(policy).WithRedirectUri("https:/www.yoti.com").WithSubject(subject).Build() + if err != nil { + return nil, fmt.Errorf("failed to build create session request: %v", err) + } + return &sessionReq, nil +} + +func generateSession(w http.ResponseWriter, r *http.Request) { + didClient, err := initialiseDigitalIdentityClient() + if err != nil { + fmt.Fprintf(w, string("Client could't be generated")) + return + } + + sessionReq, err := buildDigitalIdentitySessionReq() + if err != nil { + fmt.Fprintf(w, "failed to build session request: %v", err) + return + } + + shareSession, err := didClient.CreateShareSession(sessionReq) + if err != nil { + fmt.Fprintf(w, "failed to create share session: %v", err) + return + } + + output, err := json.Marshal(shareSession) + if err != nil { + fmt.Fprintf(w, "failed to marshall share session: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(output)) + +} + +func getReceipt(w http.ResponseWriter, r *http.Request) { + didClient, err := initialiseDigitalIdentityClient() + if err != nil { + fmt.Fprintf(w, "Client could't be generated") + return + } + receiptID := r.URL.Query().Get("ReceiptID") + + receiptValue, err := didClient.GetShareReceipt(receiptID) + if err != nil { + fmt.Fprintf(w, "failed to get share receipt: %v", err) + return + } + output, err := json.Marshal(receiptValue) + if err != nil { + fmt.Fprintf(w, "failed to marshal receipt: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(output)) +} + +func initialiseDigitalIdentityClient() (*yoti.DigitalIdentityClient, error) { + var err error + sdkID := os.Getenv("YOTI_CLIENT_SDK_ID") + keyFilePath := os.Getenv("YOTI_KEY_FILE_PATH") + key, err := os.ReadFile(keyFilePath) + if err != nil { + return nil, fmt.Errorf("failed to get key from YOTI_KEY_FILE_PATH :: %w", err) + } + + didClient, err := yoti.NewDigitalIdentityClient(sdkID, key) + if err != nil { + return nil, fmt.Errorf("failed to initialise Share client :: %w", err) + } + + return didClient, nil +} +func main() { + // Check if the cert files are available. + selfSignedCertName := "yotiSelfSignedCert.pem" + selfSignedKeyName := "yotiSelfSignedKey.pem" + certificatePresent := certificatePresenceCheck(selfSignedCertName, selfSignedKeyName) + portNumber := "8080" + // If they are not available, generate new ones. + if !certificatePresent { + err := generateSelfSignedCertificate(selfSignedCertName, selfSignedKeyName, "127.0.0.1:"+portNumber) + if err != nil { + panic("Error when creating https certs: " + err.Error()) + } + } + + http.HandleFunc("/", home) + http.HandleFunc("/v2/generate-share", generateSession) + http.HandleFunc("/v2/receipt-info", getReceipt) + + rootdir, err := os.Getwd() + if err != nil { + log.Fatal("Error: Couldn't get current working directory") + } + http.Handle("/images/", http.StripPrefix("/images", + http.FileServer(http.Dir(path.Join(rootdir, "images/"))))) + http.Handle("/static/", http.StripPrefix("/static", + http.FileServer(http.Dir(path.Join(rootdir, "static/"))))) + + log.Printf("About to listen and serve on %[1]s. Go to https://localhost:%[1]s/", portNumber) + err = http.ListenAndServeTLS(":"+portNumber, selfSignedCertName, selfSignedKeyName, nil) + + if err != nil { + panic("Error when calling `ListenAndServeTLS`: " + err.Error()) + } +} diff --git a/_examples/digitalidentity/static/assets/app-store-badge.png b/_examples/digitalidentity/static/assets/app-store-badge.png new file mode 100755 index 000000000..3ec996cc6 Binary files /dev/null and b/_examples/digitalidentity/static/assets/app-store-badge.png differ diff --git a/_examples/digitalidentity/static/assets/app-store-badge@2x.png b/_examples/digitalidentity/static/assets/app-store-badge@2x.png new file mode 100755 index 000000000..84b34068f Binary files /dev/null and b/_examples/digitalidentity/static/assets/app-store-badge@2x.png differ diff --git a/_examples/digitalidentity/static/assets/company-logo.jpg b/_examples/digitalidentity/static/assets/company-logo.jpg new file mode 100644 index 000000000..551474bfe Binary files /dev/null and b/_examples/digitalidentity/static/assets/company-logo.jpg differ diff --git a/_examples/digitalidentity/static/assets/google-play-badge.png b/_examples/digitalidentity/static/assets/google-play-badge.png new file mode 100755 index 000000000..761f237b1 Binary files /dev/null and b/_examples/digitalidentity/static/assets/google-play-badge.png differ diff --git a/_examples/digitalidentity/static/assets/google-play-badge@2x.png b/_examples/digitalidentity/static/assets/google-play-badge@2x.png new file mode 100755 index 000000000..46707cea8 Binary files /dev/null and b/_examples/digitalidentity/static/assets/google-play-badge@2x.png differ diff --git a/_examples/digitalidentity/static/assets/icons/address.svg b/_examples/digitalidentity/static/assets/icons/address.svg new file mode 100755 index 000000000..533152b76 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/address.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/calendar.svg b/_examples/digitalidentity/static/assets/icons/calendar.svg new file mode 100755 index 000000000..71ce63714 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/calendar.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg b/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg new file mode 100644 index 000000000..89f55a6fb --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/document.svg b/_examples/digitalidentity/static/assets/icons/document.svg new file mode 100755 index 000000000..10fc1de31 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/document.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/email.svg b/_examples/digitalidentity/static/assets/icons/email.svg new file mode 100755 index 000000000..67880ef32 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/email.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/gender.svg b/_examples/digitalidentity/static/assets/icons/gender.svg new file mode 100755 index 000000000..94a0ed909 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/gender.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/nationality.svg b/_examples/digitalidentity/static/assets/icons/nationality.svg new file mode 100755 index 000000000..40cbf76d0 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/nationality.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/phone.svg b/_examples/digitalidentity/static/assets/icons/phone.svg new file mode 100755 index 000000000..adbaad999 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/phone.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/profile.svg b/_examples/digitalidentity/static/assets/icons/profile.svg new file mode 100755 index 000000000..62278ecee --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/profile.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/verified.svg b/_examples/digitalidentity/static/assets/icons/verified.svg new file mode 100755 index 000000000..f6e1d94c8 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/verified.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/_examples/digitalidentity/static/assets/logo.png b/_examples/digitalidentity/static/assets/logo.png new file mode 100755 index 000000000..c60227fab Binary files /dev/null and b/_examples/digitalidentity/static/assets/logo.png differ diff --git a/_examples/digitalidentity/static/assets/logo@2x.png b/_examples/digitalidentity/static/assets/logo@2x.png new file mode 100755 index 000000000..9f29784d1 Binary files /dev/null and b/_examples/digitalidentity/static/assets/logo@2x.png differ diff --git a/_examples/digitalidentity/static/index.css b/_examples/digitalidentity/static/index.css new file mode 100644 index 000000000..14a2bc8ca --- /dev/null +++ b/_examples/digitalidentity/static/index.css @@ -0,0 +1,173 @@ +.yoti-body { + margin: 0; +} + +.yoti-top-section { + display: flex; + flex-direction: column; + + padding: 38px 0; + + background-color: #f7f8f9; + + align-items: center; +} + +.yoti-logo-section { + margin-bottom: 25px; +} + +.yoti-logo-image { + display: block; +} + +.yoti-top-header { + font-family: Roboto, sans-serif; + font-size: 40px; + font-weight: 700; + line-height: 1.2; + margin-top: 0; + margin-bottom: 80px; + text-align: center; + + color: #000; +} + +@media (min-width: 600px) { + .yoti-top-header { + line-height: 1.4; + } +} + +.yoti-sdk-integration-section { + margin: 30px 0; +} + +#yoti-share-button { + width: 250px; + height: 45px; +} + +.yoti-login-or-separator { + text-transform: uppercase; + font-family: Roboto; + font-size: 16px; + font-weight: bold; + line-height: 1.5; + text-align: center; + margin-top: 30px; +} + +.yoti-login-dialog { + display: grid; + + box-sizing: border-box; + width: 100%; + padding: 35px 38px; + + border-radius: 5px; + background: #fff; + + grid-gap: 25px; +} + +@media (min-width: 600px) { + .yoti-login-dialog { + width: 560px; + padding: 35px 88px; + } +} + +.yoti-login-dialog-header { + font-family: Roboto, sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 1.1; + + margin: 0; + + color: #000; +} + +.yoti-input { + font-family: Roboto, sans-serif; + font-size: 16px; + line-height: 1.5; + + box-sizing: border-box; + padding: 12px 15px; + + color: #000; + border: solid 2px #000; + border-radius: 4px; + background-color: #fff; +} + +.yoti-login-actions { + display: flex; + + justify-content: space-between; + align-items: center; +} + +.yoti-login-forgot-button { + font-family: Roboto, sans-serif; + font-size: 16px; + + text-transform: capitalize; +} + +.yoti-login-button { + font-family: Roboto, sans-serif; + font-size: 16px; + + box-sizing: border-box; + width: 145px; + height: 50px; + + text-transform: uppercase; + + color: #fff; + border: 0; + background-color: #000; +} + +.yoti-sponsor-app-section { + display: flex; + flex-direction: column; + + padding: 70px 0; + + align-items: center; +} + +.yoti-sponsor-app-header { + font-family: Roboto, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 1.2; + + margin: 0; + + text-align: center; + + color: #000; +} + +.yoti-store-buttons-section { + margin-top: 40px; + display: grid; + grid-gap: 10px; + grid-template-columns: 1fr; +} + +@media (min-width: 600px) { + .yoti-store-buttons-section { + grid-template-columns: 1fr 1fr; + grid-gap: 25px; + } +} + +.yoti-app-button-link { + text-decoration: none; +} \ No newline at end of file diff --git a/_examples/digitalidentity/static/profile.css b/_examples/digitalidentity/static/profile.css new file mode 100644 index 000000000..ff5579cdb --- /dev/null +++ b/_examples/digitalidentity/static/profile.css @@ -0,0 +1,431 @@ +.yoti-html { + height: 100%; +} + +.yoti-body { + margin: 0; + height: 100%; +} + +.yoti-icon-profile, +.yoti-icon-phone, +.yoti-icon-email, +.yoti-icon-calendar, +.yoti-icon-verified, +.yoti-icon-address, +.yoti-icon-gender, +.yoti-icon-nationality { + display: inline-block; + height: 28px; + width: 28px; + flex-shrink: 0; +} + +.yoti-icon-profile { + background: no-repeat url('/static/assets/icons/profile.svg'); +} + +.yoti-icon-phone { + background: no-repeat url('/static/assets/icons/phone.svg'); +} + +.yoti-icon-email { + background: no-repeat url('/static/assets/icons/email.svg'); +} + +.yoti-icon-calendar { + background: no-repeat url('/static/assets/icons/calendar.svg'); +} + +.yoti-icon-verified { + background: no-repeat url('/static/assets/icons/verified.svg'); +} + +.yoti-icon-address { + background: no-repeat url('/static/assets/icons/address.svg'); +} + +.yoti-icon-gender { + background: no-repeat url('/static/assets/icons/gender.svg'); +} + +.yoti-icon-nationality { + background: no-repeat url('/static/assets/icons/nationality.svg'); +} + +.yoti-profile-layout { + display: grid; + grid-template-columns: 1fr; +} + +@media (min-width: 1100px) { + .yoti-profile-layout { + grid-template-columns: 360px 1fr; + height: 100%; + } +} + +.yoti-profile-user-section { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: column; + padding: 40px 0; + background-color: #f7f8f9; +} + +@media (min-width: 1100px) { + .yoti-profile-user-section { + display: grid; + grid-template-rows: repeat(3, min-content); + align-items: center; + justify-content: center; + position: relative; + } +} + +.yoti-profile-picture-image { + width: 220px; + height: 220px; + border-radius: 50%; + margin-left: auto; + margin-right: auto; + display: block; +} + +.yoti-profile-picture-powered, +.yoti-profile-picture-account-creation { + font-family: Roboto; + font-size: 14px; + color: #b6bfcb; +} + +.yoti-profile-picture-powered-section { + display: flex; + flex-direction: column; + text-align: center; + align-items: center; +} + +@media (min-width: 1100px) { + .yoti-profile-picture-powered-section { + align-self: start; + } +} + +.yoti-profile-picture-powered { + margin-bottom: 20px; +} + +.yoti-profile-picture-section { + display: flex; + flex-direction: column; + align-items: center; +} + +@media (min-width: 1100px) { + .yoti-profile-picture-section { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 100%; + } +} + +.yoti-logo-image { + margin-bottom: 25px; +} + +.yoti-profile-picture-area { + position: relative; + display: inline-block; +} + +.yoti-profile-picture-verified-icon { + display: block; + background: no-repeat url("/static/assets/icons/verified.svg"); + background-size: cover; + height: 40px; + width: 40px; + position: absolute; + top: 10px; + right: 10px; +} + +.yoti-profile-name { + margin-top: 20px; + font-family: Roboto, sans-serif; + font-size: 24px; + text-align: center; + color: #333b40; +} + +.yoti-attributes-section { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + + width: 100%; + padding: 40px 0; +} + +.yoti-attributes-section.-condensed { + padding: 0; +} + +@media (min-width: 1100px) { + .yoti-attributes-section { + padding: 60px 0; + align-items: start; + overflow-y: scroll; + } + + .yoti-attributes-section.-condensed { + padding: 0; + } +} + +.yoti-company-logo { + margin-bottom: 40px; +} + +@media (min-width: 1100px) { + .yoti-company-logo { + margin-left: 130px; + } +} + +/* extended layout list */ +.yoti-attribute-list-header, +.yoti-attribute-list-subheader { + display: none; +} + +@media (min-width: 1100px) { + .yoti-attribute-list-header, + .yoti-attribute-list-subheader { + width: 100%; + + display: grid; + grid-template-columns: 200px 1fr 1fr; + grid-template-rows: 40px; + + align-items: center; + text-align: center; + + font-family: Roboto; + font-size: 14px; + color: #b6bfcb; + } +} + +.yoti-attribute-list-header-attribute, +.yoti-attribute-list-header-value { + justify-self: start; + padding: 0 20px; +} + +.yoti-attribute-list-subheader { + grid-template-rows: 30px; +} + +.yoti-attribute-list-subhead-layout { + grid-column: 3; + display: grid; + grid-template-columns: 1fr 1fr 1fr; +} + +.yoti-attribute-list { + display: grid; + width: 100%; +} + +.yoti-attribute-list-item:first-child { + border-top: 2px solid #f7f8f9; +} + +.yoti-attribute-list-item { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: minmax(60px, auto); + border-bottom: 2px solid #f7f8f9; + border-right: none; + border-left: none; +} + +.yoti-attribute-list-item.-condensed { + grid-template-columns: 50% 50%; + padding: 5px 35px; +} + +@media (min-width: 1100px) { + .yoti-attribute-list-item { + display: grid; + grid-template-columns: 200px 1fr 1fr; + grid-template-rows: minmax(80px, auto); + } + + .yoti-attribute-list-item.-condensed { + grid-template-columns: 200px 1fr; + padding: 0 75px; + } +} + +.yoti-attribute-cell { + display: flex; + align-items: center; +} + +.yoti-attribute-name { + grid-column: 1 / 2; + + display: flex; + align-items: center; + justify-content: center; + + border-right: 2px solid #f7f8f9; + + padding: 20px; +} + +@media (min-width: 1100px) { + .yoti-attribute-name { + justify-content: start; + } +} + +.yoti-attribute-name.-condensed { + justify-content: start; +} + +.yoti-attribute-name-cell { + display: flex; + align-items: center; +} + +.yoti-attribute-name-cell-text { + font-family: Roboto, sans-serif; + font-size: 16px; + color: #b6bfcb; + margin-left: 12px; +} + +.yoti-attribute-value { + grid-column: 2 / 3; + + display: flex; + align-items: center; + justify-content: center; + + padding: 20px; +} + +@media (min-width: 1100px) { + .yoti-attribute-value { + justify-content: start; + } +} + +.yoti-attribute-value.-condensed { + justify-content: start; +} + +.yoti-attribute-value-text { + font-family: Roboto, sans-serif; + font-size: 18px; + color: #333b40; + word-break: break-word; +} + +.yoti-attribute-value-text table { + font-size: 14px; + border-spacing: 0; +} + +.yoti-attribute-value-text table td:first-child { + font-weight: bold; +} + +.yoti-attribute-value-text table td { + border-bottom: 1px solid #f7f8f9; + padding: 5px; +} + +.yoti-attribute-value-text img { + width: 100%; +} + +.yoti-attribute-anchors-layout { + grid-column: 1 / 3; + grid-row: 2 / 2; + + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: minmax(40px, auto); + font-family: Roboto, sans-serif; + font-size: 14px; + + background-color: #f7f8f9; + border: 5px solid white; +} + +@media (min-width: 1100px) { + .yoti-attribute-anchors-layout { + grid-column: 3 / 4; + grid-row: 1 / 2; + } +} + +.yoti-attribute-anchors-head { + border-bottom: 1px solid #dde2e5; + display: flex; + align-items: center; + justify-content: center; +} + +@media (min-width: 1100px) { + .yoti-attribute-anchors-head { + display: none; + } +} + +.yoti-attribute-anchors { + display: flex; + align-items: center; + justify-content: center; +} + +.yoti-attribute-anchors-head.-s-v { + grid-column-start: span 1 s-v; +} + +.yoti-attribute-anchors-head.-value { + grid-column-start: span 1 value; +} + +.yoti-attribute-anchors-head.-subtype { + grid-column-start: span 1 subtype; +} + +.yoti-attribute-anchors.-s-v { + grid-column-start: span 1 s-v; +} + +.yoti-attribute-anchors.-value { + grid-column-start: span 1 value; +} + +.yoti-attribute-anchors.-subtype { + grid-column-start: span 1 subtype; +} + +.yoti-edit-section { + padding: 50px 20px; +} + +@media (min-width: 1100px) { + .yoti-edit-section { + padding: 75px 110px; + } +} diff --git a/_examples/docscansandbox/go.mod b/_examples/docscansandbox/go.mod index 5ca0bd14c..4a0f7ccde 100644 --- a/_examples/docscansandbox/go.mod +++ b/_examples/docscansandbox/go.mod @@ -21,6 +21,7 @@ require ( github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.6.1 // indirect + google.golang.org/protobuf v1.28.0 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/_examples/docscansandbox/go.sum b/_examples/docscansandbox/go.sum index e9da1058e..8385c5b09 100644 --- a/_examples/docscansandbox/go.sum +++ b/_examples/docscansandbox/go.sum @@ -35,11 +35,13 @@ github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-github/v27 v27.0.4/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -136,6 +138,8 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -151,7 +155,9 @@ google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dT google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= diff --git a/_examples/profile/dynamic-share.html b/_examples/profile/dynamic-share.html index ca3dad90a..cf05ca393 100644 --- a/_examples/profile/dynamic-share.html +++ b/_examples/profile/dynamic-share.html @@ -70,7 +70,7 @@

The Yoti app is free to download and use: window.Yoti.Share.init({ "elements": [{ - "domId": "yoti-share-button", + "domId": "yoti-receipt-button", "shareUrl": "{{.yotiShareURL}}", "clientSdkId": "{{.yotiClientSdkID}}", "button": { diff --git a/cryptoutil/crypto_utils.go b/cryptoutil/crypto_utils.go index fbfacf268..e87bdc5b4 100644 --- a/cryptoutil/crypto_utils.go +++ b/cryptoutil/crypto_utils.go @@ -1,6 +1,7 @@ package cryptoutil import ( + "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -11,6 +12,8 @@ import ( "fmt" "github.com/getyoti/yoti-go-sdk/v3/util" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" + "google.golang.org/protobuf/proto" ) // ParseRSAKey parses a PKCS1 private key from bytes @@ -114,3 +117,70 @@ func UnwrapKey(wrappedKey string, key *rsa.PrivateKey) (result []byte, err error } return decryptRsa(cipherBytes, key) } + +func decryptAESGCM(cipherText, tag, iv, secret []byte) ([]byte, error) { + block, err := aes.NewCipher(secret) + if err != nil { + return nil, fmt.Errorf("failed to create new aes cipher: %v", err) + } + + if len(tag) != 16 { + return nil, errors.New("Invalid tag length") + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create new gcm cipher: %v", err) + } + + plainText, err := gcm.Open(nil, iv, cipherText, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt receipt key: %v", err) + } + + if !bytes.Equal(tag, plainText[len(plainText)-16:]) { + return nil, errors.New("Tag doesn't match") + } + + return plainText[:len(plainText)-16], nil +} + +func decomposeAESGCMCipherText(secret []byte, tagSize int) (cipherText, tag []byte) { + if tagSize <= 0 || tagSize > len(secret) { + return nil, nil + } + + cipherText = secret[:len(secret)-tagSize] + tag = secret[len(secret)-tagSize:] + + return cipherText, tag +} + +func UnwrapReceiptKey(wrappedReceiptKey []byte, encryptedItemKey []byte, itemKeyIv []byte, key *rsa.PrivateKey) ([]byte, error) { + decryptedItemKey, err := decryptRsa(encryptedItemKey, key) + if err != nil { + return nil, fmt.Errorf("failed to decrypt item key: %v", err) + } + + cipherText, tag := decomposeAESGCMCipherText(wrappedReceiptKey, 16) + + plainText, err := decryptAESGCM(cipherText, tag, itemKeyIv, decryptedItemKey) + if err != nil { + return nil, fmt.Errorf("failed to decrypt receipt key: %v", err) + } + return plainText, nil +} + +func DecryptReceiptContent(content, receiptContentKey []byte) ([]byte, error) { + if content == nil { + return nil, fmt.Errorf("failed to decrypt receipt content is nil") + } + + decodedData := &yotiprotocom.EncryptedData{} + err := proto.Unmarshal(content, decodedData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall content: %v", content) + } + + return DecipherAes(decodedData.CipherText, decodedData.Iv, receiptContentKey) +} diff --git a/digital_identity_client.go b/digital_identity_client.go index a56110e91..b074712eb 100644 --- a/digital_identity_client.go +++ b/digital_identity_client.go @@ -71,3 +71,18 @@ func (client *DigitalIdentityClient) CreateShareSession(shareSessionRequest *dig func (client *DigitalIdentityClient) GetShareSession(sessionID string) (*digitalidentity.ShareSession, error) { return digitalidentity.GetShareSession(client.HTTPClient, sessionID, client.GetSdkID(), client.getAPIURL(), client.Key) } + +// CreateShareQrCode generates a sharing session QR code to initiate a sharing process based on session ID +func (client *DigitalIdentityClient) CreateShareQrCode(sessionID string) (share *digitalidentity.QrCode, err error) { + return digitalidentity.CreateShareQrCode(client.HTTPClient, sessionID, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// Get session QR code based on generated Qr ID +func (client *DigitalIdentityClient) GetQrCode(qrCodeId string) (share digitalidentity.ShareSessionQrCode, err error) { + return digitalidentity.GetShareSessionQrCode(client.HTTPClient, qrCodeId, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// GetShareReceipt fetches the receipt of the share given a receipt id. +func (client *DigitalIdentityClient) GetShareReceipt(receiptId string) (share digitalidentity.SharedReceiptResponse, err error) { + return digitalidentity.GetShareReceipt(client.HTTPClient, receiptId, client.GetSdkID(), client.getAPIURL(), client.Key) +} diff --git a/digitalidentity/address.go b/digitalidentity/address.go new file mode 100644 index 000000000..17bfb51e5 --- /dev/null +++ b/digitalidentity/address.go @@ -0,0 +1,52 @@ +package digitalidentity + +import ( + "reflect" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +func getFormattedAddress(profile *UserProfile, formattedAddress string) *yotiprotoattr.Attribute { + proto := getProtobufAttribute(*profile, consts.AttrStructuredPostalAddress) + + return &yotiprotoattr.Attribute{ + Name: consts.AttrAddress, + Value: []byte(formattedAddress), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: proto.Anchors, + } +} + +func ensureAddressProfile(p *UserProfile) *attribute.StringAttribute { + if structuredPostalAddress, err := p.StructuredPostalAddress(); err == nil { + if (structuredPostalAddress != nil && !reflect.DeepEqual(structuredPostalAddress, attribute.JSONAttribute{})) { + var formattedAddress string + formattedAddress, err = retrieveFormattedAddressFromStructuredPostalAddress(structuredPostalAddress.Value()) + if err == nil && formattedAddress != "" { + return attribute.NewString(getFormattedAddress(p, formattedAddress)) + } + } + } + + return nil +} + +func retrieveFormattedAddressFromStructuredPostalAddress(structuredPostalAddress interface{}) (address string, err error) { + parsedStructuredAddressMap := structuredPostalAddress.(map[string]interface{}) + if formattedAddress, ok := parsedStructuredAddressMap["formatted_address"]; ok { + return formattedAddress.(string), nil + } + return +} + +func getProtobufAttribute(profile UserProfile, key string) *yotiprotoattr.Attribute { + for _, v := range profile.attributeSlice { + if v.Name == key { + return v + } + } + + return nil +} diff --git a/digitalidentity/application_profile.go b/digitalidentity/application_profile.go new file mode 100644 index 000000000..8fae7bdaa --- /dev/null +++ b/digitalidentity/application_profile.go @@ -0,0 +1,50 @@ +package digitalidentity + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// Attribute names for application attributes +const ( + AttrConstApplicationName = "application_name" + AttrConstApplicationURL = "application_url" + AttrConstApplicationLogo = "application_logo" + AttrConstApplicationReceiptBGColor = "application_receipt_bgcolor" +) + +// ApplicationProfile is the profile of an application with convenience methods +// to access well-known attributes. +type ApplicationProfile struct { + baseProfile +} + +func newApplicationProfile(attributes *yotiprotoattr.AttributeList) ApplicationProfile { + return ApplicationProfile{ + baseProfile{ + attributeSlice: createAttributeSlice(attributes), + }, + } +} + +// ApplicationName is the name of the application +func (p ApplicationProfile) ApplicationName() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationName) +} + +// ApplicationURL is the URL where the application is available at +func (p ApplicationProfile) ApplicationURL() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationURL) +} + +// ApplicationReceiptBgColor is the background colour that will be displayed on +// each receipt the user gets as a result of a share with the application. +func (p ApplicationProfile) ApplicationReceiptBgColor() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationReceiptBGColor) +} + +// ApplicationLogo is the logo of the application that will be displayed to +// those users that perform a share with it. +func (p ApplicationProfile) ApplicationLogo() *attribute.ImageAttribute { + return p.GetImageAttribute(AttrConstApplicationLogo) +} diff --git a/digitalidentity/attribute/age_verifications.go b/digitalidentity/attribute/age_verifications.go new file mode 100644 index 000000000..a7655d06a --- /dev/null +++ b/digitalidentity/attribute/age_verifications.go @@ -0,0 +1,34 @@ +package attribute + +import ( + "strconv" + "strings" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// AgeVerification encapsulates the result of a single age verification +// as part of a share +type AgeVerification struct { + Age int + CheckType string + Result bool + Attribute *yotiprotoattr.Attribute +} + +// NewAgeVerification constructs an AgeVerification from a protobuffer +func NewAgeVerification(attr *yotiprotoattr.Attribute) (verification AgeVerification, err error) { + split := strings.Split(attr.Name, ":") + verification.Age, err = strconv.Atoi(split[1]) + verification.CheckType = split[0] + + if string(attr.Value) == "true" { + verification.Result = true + } else { + verification.Result = false + } + + verification.Attribute = attr + + return +} diff --git a/digitalidentity/attribute/age_verifications_test.go b/digitalidentity/attribute/age_verifications_test.go new file mode 100644 index 000000000..b3a6e0863 --- /dev/null +++ b/digitalidentity/attribute/age_verifications_test.go @@ -0,0 +1,42 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestNewAgeVerification_ValueTrue(t *testing.T) { + attribute := &yotiprotoattr.Attribute{ + Name: "age_over:18", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + ageVerification, err := NewAgeVerification(attribute) + + assert.NilError(t, err) + + assert.Equal(t, ageVerification.Age, 18) + assert.Equal(t, ageVerification.CheckType, "age_over") + assert.Equal(t, ageVerification.Result, true) +} + +func TestNewAgeVerification_ValueFalse(t *testing.T) { + attribute := &yotiprotoattr.Attribute{ + Name: "age_under:30", + Value: []byte("false"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + ageVerification, err := NewAgeVerification(attribute) + + assert.NilError(t, err) + + assert.Equal(t, ageVerification.Age, 30) + assert.Equal(t, ageVerification.CheckType, "age_under") + assert.Equal(t, ageVerification.Result, false) +} diff --git a/digitalidentity/attribute/anchor/anchor_parser.go b/digitalidentity/attribute/anchor/anchor_parser.go new file mode 100644 index 000000000..d1476c4f5 --- /dev/null +++ b/digitalidentity/attribute/anchor/anchor_parser.go @@ -0,0 +1,110 @@ +package anchor + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" + "google.golang.org/protobuf/proto" +) + +type anchorExtension struct { + Extension string `asn1:"tag:0,utf8"` +} + +var ( + sourceOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 47127, 1, 1, 1} + verifierOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 47127, 1, 1, 2} +) + +// ParseAnchors takes a slice of protobuf anchors, parses them, and returns a slice of Yoti SDK Anchors +func ParseAnchors(protoAnchors []*yotiprotoattr.Anchor) []*Anchor { + var processedAnchors []*Anchor + for _, protoAnchor := range protoAnchors { + parsedCerts := parseCertificates(protoAnchor.OriginServerCerts) + + anchorType, extension := getAnchorValuesFromCertificate(parsedCerts) + + parsedSignedTimestamp, err := parseSignedTimestamp(protoAnchor.SignedTimeStamp) + if err != nil { + continue + } + + processedAnchor := newAnchor(anchorType, parsedCerts, parsedSignedTimestamp, protoAnchor.SubType, extension) + + processedAnchors = append(processedAnchors, processedAnchor) + } + + return processedAnchors +} + +func getAnchorValuesFromCertificate(parsedCerts []*x509.Certificate) (anchorType Type, extension string) { + defaultAnchorType := TypeUnknown + + for _, cert := range parsedCerts { + for _, ext := range cert.Extensions { + var ( + value string + err error + ) + parsedAnchorType, value, err := parseExtension(ext) + if err != nil { + continue + } else if parsedAnchorType == TypeUnknown { + continue + } + return parsedAnchorType, value + } + } + + return defaultAnchorType, "" +} + +func parseExtension(ext pkix.Extension) (anchorType Type, val string, err error) { + anchorType = TypeUnknown + + switch { + case ext.Id.Equal(sourceOID): + anchorType = TypeSource + case ext.Id.Equal(verifierOID): + anchorType = TypeVerifier + default: + return anchorType, "", nil + } + + var ae anchorExtension + _, err = asn1.Unmarshal(ext.Value, &ae) + switch { + case err != nil: + return anchorType, "", fmt.Errorf("unable to unmarshal extension: %v", err) + case len(ae.Extension) == 0: + return anchorType, "", errors.New("empty extension") + default: + val = ae.Extension + } + + return anchorType, val, nil +} + +func parseSignedTimestamp(rawBytes []byte) (*yotiprotocom.SignedTimestamp, error) { + signedTimestamp := &yotiprotocom.SignedTimestamp{} + if err := proto.Unmarshal(rawBytes, signedTimestamp); err != nil { + return signedTimestamp, err + } + + return signedTimestamp, nil +} + +func parseCertificates(rawCerts [][]byte) (result []*x509.Certificate) { + for _, cert := range rawCerts { + parsedCertificate, _ := x509.ParseCertificate(cert) + + result = append(result, parsedCertificate) + } + + return result +} diff --git a/digitalidentity/attribute/anchor/anchor_parser_test.go b/digitalidentity/attribute/anchor/anchor_parser_test.go new file mode 100644 index 000000000..13849a3d6 --- /dev/null +++ b/digitalidentity/attribute/anchor/anchor_parser_test.go @@ -0,0 +1,147 @@ +package anchor + +import ( + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" +) + +func assertServerCertSerialNo(t *testing.T, expectedSerialNo string, actualSerialNo *big.Int) { + expectedSerialNoBigInt := new(big.Int) + expectedSerialNoBigInt, ok := expectedSerialNoBigInt.SetString(expectedSerialNo, 10) + assert.Assert(t, ok, "Unexpected error when setting string as big int") + + assert.Equal(t, expectedSerialNoBigInt.Cmp(actualSerialNo), 0) // 0 == equivalent +} + +func createAnchorSliceFromTestFile(t *testing.T, filename string) []*yotiprotoattr.Anchor { + anchorBytes := test.DecodeTestFile(t, filename) + + protoAnchor := &yotiprotoattr.Anchor{} + err2 := proto.Unmarshal(anchorBytes, protoAnchor) + assert.NilError(t, err2) + + protoAnchors := append([]*yotiprotoattr.Anchor{}, protoAnchor) + + return protoAnchors +} + +func TestAnchorParser_parseExtension_ShouldErrorForInvalidExtension(t *testing.T) { + invalidExt := pkix.Extension{ + Id: sourceOID, + } + + _, _, err := parseExtension(invalidExt) + + assert.Check(t, err != nil) + assert.Error(t, err, "unable to unmarshal extension: asn1: syntax error: sequence truncated") +} + +func TestAnchorParser_Passport(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_passport.txt") + + parsedAnchors := ParseAnchors(anchorSlice) + + actualAnchor := parsedAnchors[0] + + assert.Equal(t, actualAnchor.Type(), TypeSource) + + expectedDate := time.Date(2018, time.April, 12, 13, 14, 32, 835537e3, time.UTC) + actualDate := actualAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "OCR" + assert.Equal(t, actualAnchor.SubType(), expectedSubType) + + expectedValue := "PASSPORT" + assert.Equal(t, actualAnchor.Value(), expectedValue) + + actualSerialNo := actualAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "277870515583559162487099305254898397834", actualSerialNo) +} + +func TestAnchorParser_DrivingLicense(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_driving_license.txt") + + parsedAnchors := ParseAnchors(anchorSlice) + resultAnchor := parsedAnchors[0] + + assert.Equal(t, resultAnchor.Type(), TypeSource) + + expectedDate := time.Date(2018, time.April, 11, 12, 13, 3, 923537e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "" + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + + expectedValue := "DRIVING_LICENCE" + assert.Equal(t, resultAnchor.Value(), expectedValue) + + actualSerialNo := resultAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "46131813624213904216516051554755262812", actualSerialNo) +} + +func TestAnchorParser_UnknownAnchor(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_unknown.txt") + + resultAnchor := ParseAnchors(anchorSlice)[0] + + expectedDate := time.Date(2019, time.March, 5, 10, 45, 11, 840037e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "TEST UNKNOWN SUB TYPE" + expectedType := TypeUnknown + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + assert.Equal(t, resultAnchor.Type(), expectedType) + assert.Equal(t, resultAnchor.Value(), "") +} + +func TestAnchorParser_YotiAdmin(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_yoti_admin.txt") + + resultAnchor := ParseAnchors(anchorSlice)[0] + + assert.Equal(t, resultAnchor.Type(), TypeVerifier) + + expectedDate := time.Date(2018, time.April, 11, 12, 13, 4, 95238e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "" + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + + expectedValue := "YOTI_ADMIN" + assert.Equal(t, resultAnchor.Value(), expectedValue) + + actualSerialNo := resultAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "256616937783084706710155170893983549581", actualSerialNo) +} + +func TestAnchors_None(t *testing.T) { + var anchorSlice []*Anchor + + sources := GetSources(anchorSlice) + assert.Equal(t, len(sources), 0, "GetSources should not return anything with empty anchors") + + verifiers := GetVerifiers(anchorSlice) + assert.Equal(t, len(verifiers), 0, "GetVerifiers should not return anything with empty anchors") +} + +func TestAnchorParser_InvalidSignedTimestamp(t *testing.T) { + var protoAnchors []*yotiprotoattr.Anchor + protoAnchors = append(protoAnchors, &yotiprotoattr.Anchor{ + SignedTimeStamp: []byte("invalidProto"), + }) + parsedAnchors := ParseAnchors(protoAnchors) + + var expectedAnchors []*Anchor + assert.DeepEqual(t, expectedAnchors, parsedAnchors) +} diff --git a/digitalidentity/attribute/anchor/anchors.go b/digitalidentity/attribute/anchor/anchors.go new file mode 100644 index 000000000..839a6e116 --- /dev/null +++ b/digitalidentity/attribute/anchor/anchors.go @@ -0,0 +1,105 @@ +package anchor + +import ( + "crypto/x509" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" +) + +// Anchor is the metadata associated with an attribute. It describes how an attribute has been provided +// to Yoti (SOURCE Anchor) and how it has been verified (VERIFIER Anchor). +// If an attribute has only one SOURCE Anchor with the value set to +// "USER_PROVIDED" and zero VERIFIER Anchors, then the attribute +// is a self-certified one. +type Anchor struct { + anchorType Type + originServerCerts []*x509.Certificate + signedTimestamp SignedTimestamp + subtype string + value string +} + +func newAnchor(anchorType Type, originServerCerts []*x509.Certificate, signedTimestamp *yotiprotocom.SignedTimestamp, subtype string, value string) *Anchor { + return &Anchor{ + anchorType: anchorType, + originServerCerts: originServerCerts, + signedTimestamp: convertSignedTimestamp(signedTimestamp), + subtype: subtype, + value: value, + } +} + +// Type Anchor type, based on the Object Identifier (OID) +type Type int + +const ( + // TypeUnknown - default value + TypeUnknown Type = 1 + iota + // TypeSource - how the anchor has been sourced + TypeSource + // TypeVerifier - how the anchor has been verified + TypeVerifier +) + +// Type of the Anchor - most likely either SOURCE or VERIFIER, but it's +// possible that new Anchor types will be added in future. +func (a Anchor) Type() Type { + return a.anchorType +} + +// OriginServerCerts are the X.509 certificate chain(DER-encoded ASN.1) +// from the service that assigned the attribute. +// +// The first certificate in the chain holds the public key that can be +// used to verify the Signature field; any following entries (zero or +// more) are for intermediate certificate authorities (in order). +// +// The last certificate in the chain must be verified against the Yoti root +// CA certificate. An extension in the first certificate holds the main artifact type, +// e.g. “PASSPORT”, which can be retrieved with .Value(). +func (a Anchor) OriginServerCerts() []*x509.Certificate { + return a.originServerCerts +} + +// SignedTimestamp is the time at which the signature was created. The +// message associated with the timestamp is the marshaled form of +// AttributeSigning (i.e. the same message that is signed in the +// Signature field). This method returns the SignedTimestamp +// object, the actual timestamp as a *time.Time can be called with +// .Timestamp() on the result of this function. +func (a Anchor) SignedTimestamp() SignedTimestamp { + return a.signedTimestamp +} + +// SubType is an indicator of any specific processing method, or +// subcategory, pertaining to an artifact. For example, for a passport, this would be +// either "NFC" or "OCR". +func (a Anchor) SubType() string { + return a.subtype +} + +// Value identifies the provider that either sourced or verified the attribute value. +// The range of possible values is not limited. For a SOURCE anchor, expect a value like +// PASSPORT, DRIVING_LICENSE. For a VERIFIER anchor, expect a value like YOTI_ADMIN. +func (a Anchor) Value() string { + return a.value +} + +// GetSources returns the anchors which identify how and when an attribute value was acquired. +func GetSources(anchors []*Anchor) (sources []*Anchor) { + return filterAnchors(anchors, TypeSource) +} + +// GetVerifiers returns the anchors which identify how and when an attribute value was verified by another provider. +func GetVerifiers(anchors []*Anchor) (sources []*Anchor) { + return filterAnchors(anchors, TypeVerifier) +} + +func filterAnchors(anchors []*Anchor, anchorType Type) (result []*Anchor) { + for _, v := range anchors { + if v.anchorType == anchorType { + result = append(result, v) + } + } + return result +} diff --git a/digitalidentity/attribute/anchor/anchors_test.go b/digitalidentity/attribute/anchor/anchors_test.go new file mode 100644 index 000000000..ed5287ed3 --- /dev/null +++ b/digitalidentity/attribute/anchor/anchors_test.go @@ -0,0 +1,20 @@ +package anchor + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestFilterAnchors_FilterSources(t *testing.T) { + anchorSlice := []*Anchor{ + {subtype: "a", anchorType: TypeSource}, + {subtype: "b", anchorType: TypeVerifier}, + {subtype: "c", anchorType: TypeSource}, + } + sources := filterAnchors(anchorSlice, TypeSource) + assert.Equal(t, len(sources), 2) + assert.Equal(t, sources[0].subtype, "a") + assert.Equal(t, sources[1].subtype, "c") + +} diff --git a/digitalidentity/attribute/anchor/signed_timestamp.go b/digitalidentity/attribute/anchor/signed_timestamp.go new file mode 100644 index 000000000..2081b7d6c --- /dev/null +++ b/digitalidentity/attribute/anchor/signed_timestamp.go @@ -0,0 +1,35 @@ +package anchor + +import ( + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" +) + +// SignedTimestamp is the object which contains a timestamp +type SignedTimestamp struct { + version int32 + timestamp *time.Time +} + +func convertSignedTimestamp(protoSignedTimestamp *yotiprotocom.SignedTimestamp) SignedTimestamp { + uintTimestamp := protoSignedTimestamp.Timestamp + intTimestamp := int64(uintTimestamp) + unixTime := time.Unix(intTimestamp/1e6, (intTimestamp%1e6)*1e3) + + return SignedTimestamp{ + version: protoSignedTimestamp.Version, + timestamp: &unixTime, + } +} + +// Version indicates both the version of the protobuf message in use, +// as well as the specific hash algorithms. +func (s SignedTimestamp) Version() int32 { + return s.version +} + +// Timestamp is a point in time, to the nearest microsecond. +func (s SignedTimestamp) Timestamp() *time.Time { + return s.timestamp +} diff --git a/digitalidentity/attribute/attribute_details.go b/digitalidentity/attribute/attribute_details.go new file mode 100644 index 000000000..a380150b7 --- /dev/null +++ b/digitalidentity/attribute/attribute_details.go @@ -0,0 +1,48 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" +) + +// attributeDetails is embedded in each attribute for fields common to all +// attributes +type attributeDetails struct { + name string + contentType string + anchors []*anchor.Anchor + id *string +} + +// Name gets the attribute name +func (a attributeDetails) Name() string { + return a.name +} + +// ID gets the attribute ID +func (a attributeDetails) ID() *string { + return a.id +} + +// ContentType gets the attribute's content type description +func (a attributeDetails) ContentType() string { + return a.contentType +} + +// Anchors are the metadata associated with an attribute. They describe +// how an attribute has been provided to Yoti (SOURCE Anchor) and how +// it has been verified (VERIFIER Anchor). +func (a attributeDetails) Anchors() []*anchor.Anchor { + return a.anchors +} + +// Sources returns the anchors which identify how and when an attribute value +// was acquired. +func (a attributeDetails) Sources() []*anchor.Anchor { + return anchor.GetSources(a.anchors) +} + +// Verifiers returns the anchors which identify how and when an attribute value +// was verified by another provider. +func (a attributeDetails) Verifiers() []*anchor.Anchor { + return anchor.GetVerifiers(a.anchors) +} diff --git a/digitalidentity/attribute/attribute_test.go b/digitalidentity/attribute/attribute_test.go new file mode 100644 index 000000000..67b6c2b2e --- /dev/null +++ b/digitalidentity/attribute/attribute_test.go @@ -0,0 +1,36 @@ +package attribute + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestNewThirdPartyAttribute(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_third_party.txt") + + stringAttribute := NewString(protoAttribute) + + assert.Equal(t, stringAttribute.Value(), "test-third-party-attribute-0") + assert.Equal(t, stringAttribute.Name(), "com.thirdparty.id") + + assert.Equal(t, stringAttribute.Sources()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Sources()[0].SubType(), "orgName") + + assert.Equal(t, stringAttribute.Verifiers()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Verifiers()[0].SubType(), "orgName") +} + +func TestAttribute_DateOfBirth(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_date_of_birth.txt") + + dateOfBirthAttribute, err := NewDate(protoAttribute) + + assert.NilError(t, err) + + expectedDateOfBirth := time.Date(1970, time.December, 01, 0, 0, 0, 0, time.UTC) + actualDateOfBirth := dateOfBirthAttribute.Value() + + assert.Assert(t, actualDateOfBirth.Equal(expectedDateOfBirth)) +} diff --git a/digitalidentity/attribute/date_attribute.go b/digitalidentity/attribute/date_attribute.go new file mode 100644 index 000000000..cdc55ce3e --- /dev/null +++ b/digitalidentity/attribute/date_attribute.go @@ -0,0 +1,39 @@ +package attribute + +import ( + "time" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// DateAttribute is a Yoti attribute which returns a date as *time.Time for its value +type DateAttribute struct { + attributeDetails + value *time.Time +} + +// NewDate creates a new Date attribute +func NewDate(a *yotiprotoattr.Attribute) (*DateAttribute, error) { + parsedTime, err := time.Parse("2006-01-02", string(a.Value)) + if err != nil { + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &DateAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: &parsedTime, + }, nil +} + +// Value returns the value of the TimeAttribute as *time.Time +func (a *DateAttribute) Value() *time.Time { + return a.value +} diff --git a/digitalidentity/attribute/date_attribute_test.go b/digitalidentity/attribute/date_attribute_test.go new file mode 100644 index 000000000..24807c93b --- /dev/null +++ b/digitalidentity/attribute/date_attribute_test.go @@ -0,0 +1,44 @@ +package attribute + +import ( + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestTimeAttribute_NewDate_DateOnly(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Value: []byte("2011-12-25"), + } + + timeAttribute, err := NewDate(&proto) + assert.NilError(t, err) + + assert.Equal(t, *timeAttribute.Value(), time.Date(2011, 12, 25, 0, 0, 0, 0, time.UTC)) +} + +func TestTimeAttribute_DateOfBirth(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_date_of_birth.txt") + + dateOfBirthAttribute, err := NewDate(protoAttribute) + + assert.NilError(t, err) + + expectedDateOfBirth := time.Date(1970, time.December, 01, 0, 0, 0, 0, time.UTC) + actualDateOfBirth := dateOfBirthAttribute.Value() + + assert.Assert(t, actualDateOfBirth.Equal(expectedDateOfBirth)) +} + +func TestNewTime_ShouldReturnErrorForInvalidDate(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Name: "example", + Value: []byte("2006-60-20"), + ContentType: yotiprotoattr.ContentType_DATE, + } + attribute, err := NewDate(&proto) + assert.Check(t, attribute == nil) + assert.ErrorContains(t, err, "month out of range") +} diff --git a/digitalidentity/attribute/definition.go b/digitalidentity/attribute/definition.go new file mode 100644 index 000000000..b0d4b8a4e --- /dev/null +++ b/digitalidentity/attribute/definition.go @@ -0,0 +1,31 @@ +package attribute + +import ( + "encoding/json" +) + +// Definition contains information about the attribute(s) issued by a third party. +type Definition struct { + name string +} + +// Name of the attribute to be issued. +func (a Definition) Name() string { + return a.name +} + +// MarshalJSON returns encoded json +func (a Definition) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Name string `json:"name"` + }{ + Name: a.name, + }) +} + +// NewAttributeDefinition returns a new AttributeDefinition +func NewAttributeDefinition(s string) Definition { + return Definition{ + name: s, + } +} diff --git a/digitalidentity/attribute/definition_test.go b/digitalidentity/attribute/definition_test.go new file mode 100644 index 000000000..b209e023a --- /dev/null +++ b/digitalidentity/attribute/definition_test.go @@ -0,0 +1,18 @@ +package attribute + +import ( + "encoding/json" + "fmt" +) + +func ExampleDefinition_MarshalJSON() { + exampleDefinition := NewAttributeDefinition("exampleDefinition") + marshalledJSON, err := json.Marshal(exampleDefinition) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"exampleDefinition"} +} diff --git a/digitalidentity/attribute/document_details_attribute.go b/digitalidentity/attribute/document_details_attribute.go new file mode 100644 index 000000000..a18ccabad --- /dev/null +++ b/digitalidentity/attribute/document_details_attribute.go @@ -0,0 +1,87 @@ +package attribute + +import ( + "fmt" + "strings" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +const ( + documentDetailsDateFormatConst = "2006-01-02" +) + +// DocumentDetails represents information extracted from a document provided by the user +type DocumentDetails struct { + DocumentType string + IssuingCountry string + DocumentNumber string + ExpirationDate *time.Time + IssuingAuthority string +} + +// DocumentDetailsAttribute wraps a document details with anchor data +type DocumentDetailsAttribute struct { + attributeDetails + value DocumentDetails +} + +// Value returns the document details struct attached to this attribute +func (attr *DocumentDetailsAttribute) Value() DocumentDetails { + return attr.value +} + +// NewDocumentDetails creates a DocumentDetailsAttribute which wraps a +// DocumentDetails with anchor data +func NewDocumentDetails(a *yotiprotoattr.Attribute) (*DocumentDetailsAttribute, error) { + parsedAnchors := anchor.ParseAnchors(a.Anchors) + details := DocumentDetails{} + err := details.Parse(string(a.Value)) + if err != nil { + return nil, err + } + + return &DocumentDetailsAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: details, + }, nil +} + +// Parse fills a DocumentDetails object from a raw string +func (details *DocumentDetails) Parse(data string) error { + dataSlice := strings.Split(data, " ") + + if len(dataSlice) < 3 { + return fmt.Errorf("Document Details data is invalid, %s", data) + } + for _, section := range dataSlice { + if section == "" { + return fmt.Errorf("Document Details data is invalid %s", data) + } + } + + details.DocumentType = dataSlice[0] + details.IssuingCountry = dataSlice[1] + details.DocumentNumber = dataSlice[2] + if len(dataSlice) > 3 && dataSlice[3] != "-" { + expirationDateData, dateErr := time.Parse(documentDetailsDateFormatConst, dataSlice[3]) + + if dateErr == nil { + details.ExpirationDate = &expirationDateData + } else { + return dateErr + } + } + if len(dataSlice) > 4 { + details.IssuingAuthority = dataSlice[4] + } + + return nil +} diff --git a/digitalidentity/attribute/document_details_attribute_test.go b/digitalidentity/attribute/document_details_attribute_test.go new file mode 100644 index 000000000..bf2e7dee9 --- /dev/null +++ b/digitalidentity/attribute/document_details_attribute_test.go @@ -0,0 +1,185 @@ +package attribute + +import ( + "fmt" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func ExampleDocumentDetails_Parse() { + raw := "PASSPORT GBR 1234567 2022-09-12" + details := DocumentDetails{} + err := details.Parse(raw) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Printf( + "Document Type: %s, Issuing Country: %s, Document Number: %s, Expiration Date: %s", + details.DocumentType, + details.IssuingCountry, + details.DocumentNumber, + details.ExpirationDate, + ) + // Output: Document Type: PASSPORT, Issuing Country: GBR, Document Number: 1234567, Expiration Date: 2022-09-12 00:00:00 +0000 UTC +} + +func ExampleNewDocumentDetails() { + proto := yotiprotoattr.Attribute{ + Name: "exampleDocumentDetails", + Value: []byte("PASSPORT GBR 1234567 2022-09-12"), + ContentType: yotiprotoattr.ContentType_STRING, + } + attribute, err := NewDocumentDetails(&proto) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Printf( + "Document Type: %s, With %d Anchors", + attribute.Value().DocumentType, + len(attribute.Anchors()), + ) + // Output: Document Type: PASSPORT, With 0 Anchors +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithoutExpiry(t *testing.T) { + drivingLicenceGBR := "PASS_CARD GBR 1234abc - DVLA" + + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "PASS_CARD") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Assert(t, details.ExpirationDate == nil) + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseRedactedAadhar(t *testing.T) { + aadhaar := "AADHAAR IND ****1234 2016-05-01" + details := DocumentDetails{} + err := details.Parse(aadhaar) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "AADHAAR") + assert.Equal(t, details.DocumentNumber, "****1234") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "IND") + assert.Equal(t, details.IssuingAuthority, "") +} + +func TestDocumentDetailsShouldParseSpecialCharacters(t *testing.T) { + testData := [][]string{ + {"type country **** - authority", "****"}, + {"type country ~!@#$%^&*()-_=+[]{}|;':,./<>? - authority", "~!@#$%^&*()-_=+[]{}|;':,./<>?"}, + {"type country \"\" - authority", "\"\""}, + {"type country \\ - authority", "\\"}, + {"type country \" - authority", "\""}, + {"type country '' - authority", "''"}, + {"type country ' - authority", "'"}, + } + for _, row := range testData { + details := DocumentDetails{} + err := details.Parse(row[0]) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentNumber, row[1]) + } +} + +func TestDocumentDetailsShouldFailOnDoubleSpace(t *testing.T) { + data := "AADHAAR IND ****1234" + details := DocumentDetails{} + err := details.Parse(data) + assert.Check(t, err != nil) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithExtraAttribute(t *testing.T) { + drivingLicenceGBR := "DRIVING_LICENCE GBR 1234abc 2016-05-01 DVLA someThirdAttribute" + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "DRIVING_LICENCE") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithAllOptionalAttributes(t *testing.T) { + drivingLicenceGBR := "DRIVING_LICENCE GBR 1234abc 2016-05-01 DVLA" + + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "DRIVING_LICENCE") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseAadhaar(t *testing.T) { + aadhaar := "AADHAAR IND 1234abc 2016-05-01" + + details := DocumentDetails{} + err := details.Parse(aadhaar) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "AADHAAR") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "IND") +} + +func TestDocumentDetailsShouldParsePassportWithMandatoryFieldsOnly(t *testing.T) { + passportGBR := "PASSPORT GBR 1234abc" + + details := DocumentDetails{} + err := details.Parse(passportGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "PASSPORT") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Assert(t, details.ExpirationDate == nil) + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "") +} + +func TestDocumentDetailsShouldErrorOnEmptyString(t *testing.T) { + empty := "" + + details := DocumentDetails{} + err := details.Parse(empty) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldErrorIfLessThan3Words(t *testing.T) { + corrupt := "PASS_CARD GBR" + details := DocumentDetails{} + err := details.Parse(corrupt) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldErrorForInvalidExpirationDate(t *testing.T) { + corrupt := "PASSPORT GBR 1234abc X016-05-01" + details := DocumentDetails{} + err := details.Parse(corrupt) + assert.ErrorContains(t, err, "cannot parse") +} diff --git a/digitalidentity/attribute/generic_attribute.go b/digitalidentity/attribute/generic_attribute.go new file mode 100644 index 000000000..c729e30bc --- /dev/null +++ b/digitalidentity/attribute/generic_attribute.go @@ -0,0 +1,38 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// GenericAttribute is a Yoti attribute which returns a generic value +type GenericAttribute struct { + attributeDetails + value interface{} +} + +// NewGeneric creates a new generic attribute +func NewGeneric(a *yotiprotoattr.Attribute) *GenericAttribute { + value, err := parseValue(a.ContentType, a.Value) + + if err != nil { + return nil + } + + var parsedAnchors = anchor.ParseAnchors(a.Anchors) + + return &GenericAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: value, + } +} + +// Value returns the value of the GenericAttribute as an interface +func (a *GenericAttribute) Value() interface{} { + return a.value +} diff --git a/digitalidentity/attribute/generic_attribute_test.go b/digitalidentity/attribute/generic_attribute_test.go new file mode 100644 index 000000000..e2daae8a9 --- /dev/null +++ b/digitalidentity/attribute/generic_attribute_test.go @@ -0,0 +1,39 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestNewGeneric_ShouldParseUnknownTypeAsString(t *testing.T) { + value := []byte("value") + protoAttr := yotiprotoattr.Attribute{ + ContentType: yotiprotoattr.ContentType_UNDEFINED, + Value: value, + } + parsed := NewGeneric(&protoAttr) + + stringValue, ok := parsed.Value().(string) + assert.Check(t, ok) + + assert.Equal(t, stringValue, string(value)) +} + +func TestGeneric_ContentType(t *testing.T) { + attribute := GenericAttribute{ + attributeDetails: attributeDetails{ + contentType: "contentType", + }, + } + + assert.Equal(t, attribute.ContentType(), "contentType") +} + +func TestNewGeneric_ShouldReturnNilForInvalidProtobuf(t *testing.T) { + invalid := NewGeneric(&yotiprotoattr.Attribute{ + ContentType: yotiprotoattr.ContentType_JSON, + }) + assert.Check(t, invalid == nil) +} diff --git a/digitalidentity/attribute/helper_test.go b/digitalidentity/attribute/helper_test.go new file mode 100644 index 000000000..47c28eae9 --- /dev/null +++ b/digitalidentity/attribute/helper_test.go @@ -0,0 +1,21 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" +) + +func createAttributeFromTestFile(t *testing.T, filename string) *yotiprotoattr.Attribute { + attributeBytes := test.DecodeTestFile(t, filename) + + attributeStruct := &yotiprotoattr.Attribute{} + + err2 := proto.Unmarshal(attributeBytes, attributeStruct) + assert.NilError(t, err2) + + return attributeStruct +} diff --git a/digitalidentity/attribute/image_attribute.go b/digitalidentity/attribute/image_attribute.go new file mode 100644 index 000000000..fd9d7f144 --- /dev/null +++ b/digitalidentity/attribute/image_attribute.go @@ -0,0 +1,53 @@ +package attribute + +import ( + "errors" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// ImageAttribute is a Yoti attribute which returns an image as its value +type ImageAttribute struct { + attributeDetails + value media.Media +} + +// NewImage creates a new Image attribute +func NewImage(a *yotiprotoattr.Attribute) (*ImageAttribute, error) { + imageValue, err := parseImageValue(a.ContentType, a.Value) + if err != nil { + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &ImageAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: imageValue, + }, nil +} + +// Value returns the value of the ImageAttribute as media.Media +func (a *ImageAttribute) Value() media.Media { + return a.value +} + +func parseImageValue(contentType yotiprotoattr.ContentType, byteValue []byte) (media.Media, error) { + switch contentType { + case yotiprotoattr.ContentType_JPEG: + return media.JPEGImage(byteValue), nil + + case yotiprotoattr.ContentType_PNG: + return media.PNGImage(byteValue), nil + + default: + return nil, errors.New("cannot create Image with unsupported type") + } +} diff --git a/digitalidentity/attribute/image_attribute_test.go b/digitalidentity/attribute/image_attribute_test.go new file mode 100644 index 000000000..2fe620f6c --- /dev/null +++ b/digitalidentity/attribute/image_attribute_test.go @@ -0,0 +1,106 @@ +package attribute + +import ( + "encoding/base64" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestImageAttribute_Image_Png(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Image_Jpeg(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Image_Default(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Base64Selfie_Png(t *testing.T) { + attributeName := consts.AttrSelfie + imageBytes := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: imageBytes, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(imageBytes) + + expectedBase64Selfie := "data:image/png;base64," + base64ImageExpectedValue + + base64Selfie := selfie.Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestImageAttribute_Base64URL_Jpeg(t *testing.T) { + attributeName := consts.AttrSelfie + imageBytes := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: imageBytes, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(imageBytes) + + expectedBase64Selfie := "data:image/jpeg;base64," + base64ImageExpectedValue + + base64Selfie := selfie.Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} diff --git a/digitalidentity/attribute/image_slice_attribute.go b/digitalidentity/attribute/image_slice_attribute.go new file mode 100644 index 000000000..de507ab6a --- /dev/null +++ b/digitalidentity/attribute/image_slice_attribute.go @@ -0,0 +1,69 @@ +package attribute + +import ( + "errors" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// ImageSliceAttribute is a Yoti attribute which returns a slice of images as its value +type ImageSliceAttribute struct { + attributeDetails + value []media.Media +} + +// NewImageSlice creates a new ImageSlice attribute +func NewImageSlice(a *yotiprotoattr.Attribute) (*ImageSliceAttribute, error) { + if a.ContentType != yotiprotoattr.ContentType_MULTI_VALUE { + return nil, errors.New("creating an Image Slice attribute with content types other than MULTI_VALUE is not supported") + } + + parsedMultiValue, err := parseMultiValue(a.Value) + + if err != nil { + return nil, err + } + + var imageSliceValue []media.Media + if parsedMultiValue != nil { + imageSliceValue, err = CreateImageSlice(parsedMultiValue) + if err != nil { + return nil, err + } + } + + return &ImageSliceAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: anchor.ParseAnchors(a.Anchors), + id: &a.EphemeralId, + }, + value: imageSliceValue, + }, nil +} + +// CreateImageSlice takes a slice of Items, and converts them into a slice of images +func CreateImageSlice(items []*Item) (result []media.Media, err error) { + for _, item := range items { + + switch i := item.Value.(type) { + case media.PNGImage: + result = append(result, i) + case media.JPEGImage: + result = append(result, i) + default: + return nil, fmt.Errorf("unexpected item type %T", i) + } + } + + return result, nil +} + +// Value returns the value of the ImageSliceAttribute +func (a *ImageSliceAttribute) Value() []media.Media { + return a.value +} diff --git a/digitalidentity/attribute/image_slice_attribute_test.go b/digitalidentity/attribute/image_slice_attribute_test.go new file mode 100644 index 000000000..2c3009260 --- /dev/null +++ b/digitalidentity/attribute/image_slice_attribute_test.go @@ -0,0 +1,61 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func assertIsExpectedImage(t *testing.T, image media.Media, imageMIMEType string, expectedBase64URLLast10 string) { + assert.Equal(t, image.MIME(), imageMIMEType) + + actualBase64URL := image.Base64URL() + + ActualBase64URLLast10Chars := actualBase64URL[len(actualBase64URL)-10:] + + assert.Equal(t, ActualBase64URLLast10Chars, expectedBase64URLLast10) +} + +func assertIsExpectedDocumentImagesAttribute(t *testing.T, actualDocumentImages []media.Media, anchor *anchor.Anchor) { + + assert.Equal(t, len(actualDocumentImages), 2, "This Document Images attribute should have two images") + + assertIsExpectedImage(t, actualDocumentImages[0], media.ImageTypeJPEG, "vWgD//2Q==") + assertIsExpectedImage(t, actualDocumentImages[1], media.ImageTypeJPEG, "38TVEH/9k=") + + expectedValue := "NATIONAL_ID" + assert.Equal(t, anchor.Value(), expectedValue) + + expectedSubType := "STATE_ID" + assert.Equal(t, anchor.SubType(), expectedSubType) +} + +func TestAttribute_NewImageSlice(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + + documentImagesAttribute, err := NewImageSlice(protoAttribute) + + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, documentImagesAttribute.Value(), documentImagesAttribute.Anchors()[0]) +} + +func TestAttribute_ImageSliceNotCreatedWithNonMultiValueType(t *testing.T) { + attributeName := "attributeName" + attributeValueString := "value" + attributeValue := []byte(attributeValueString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + _, err := NewImageSlice(attr) + + assert.Assert(t, err != nil, "Expected error when creating image slice from attribute which isn't of multi-value type") +} diff --git a/digitalidentity/attribute/issuance_details.go b/digitalidentity/attribute/issuance_details.go new file mode 100644 index 000000000..381d4e99e --- /dev/null +++ b/digitalidentity/attribute/issuance_details.go @@ -0,0 +1,86 @@ +package attribute + +import ( + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoshare" + "google.golang.org/protobuf/proto" +) + +// IssuanceDetails contains information about the attribute(s) issued by a third party +type IssuanceDetails struct { + token string + expiryDate *time.Time + attributes []Definition +} + +// Token is the issuance token that can be used to retrieve the user's stored details. +// These details will be used to issue attributes on behalf of an organisation to that user. +func (i IssuanceDetails) Token() string { + return i.token +} + +// ExpiryDate is the timestamp at which the request for the attribute value +// from third party will expire. Will be nil if not provided. +func (i IssuanceDetails) ExpiryDate() *time.Time { + return i.expiryDate +} + +// Attributes information about the attributes the third party would like to issue. +func (i IssuanceDetails) Attributes() []Definition { + return i.attributes +} + +// ParseIssuanceDetails takes the Third Party Attribute object and converts it into an IssuanceDetails struct +func ParseIssuanceDetails(thirdPartyAttributeBytes []byte) (*IssuanceDetails, error) { + thirdPartyAttributeStruct := &yotiprotoshare.ThirdPartyAttribute{} + if err := proto.Unmarshal(thirdPartyAttributeBytes, thirdPartyAttributeStruct); err != nil { + return nil, fmt.Errorf("unable to parse ThirdPartyAttribute value: %q. Error: %q", string(thirdPartyAttributeBytes), err) + } + + var issuingAttributesProto = thirdPartyAttributeStruct.GetIssuingAttributes() + var issuingAttributeDefinitions = parseIssuingAttributeDefinitions(issuingAttributesProto.GetDefinitions()) + + expiryDate, dateParseErr := parseExpiryDate(issuingAttributesProto.ExpiryDate) + + var issuanceTokenBytes = thirdPartyAttributeStruct.GetIssuanceToken() + + if len(issuanceTokenBytes) == 0 { + return nil, errors.New("Issuance Token is invalid") + } + + base64EncodedToken := base64.StdEncoding.EncodeToString(issuanceTokenBytes) + + return &IssuanceDetails{ + token: base64EncodedToken, + expiryDate: expiryDate, + attributes: issuingAttributeDefinitions, + }, dateParseErr +} + +func parseIssuingAttributeDefinitions(definitions []*yotiprotoshare.Definition) (issuingAttributes []Definition) { + for _, definition := range definitions { + attributeDefinition := Definition{ + name: definition.Name, + } + issuingAttributes = append(issuingAttributes, attributeDefinition) + } + + return issuingAttributes +} + +func parseExpiryDate(expiryDateString string) (*time.Time, error) { + if expiryDateString == "" { + return nil, nil + } + + parsedTime, err := time.Parse(time.RFC3339Nano, expiryDateString) + if err != nil { + return nil, err + } + + return &parsedTime, err +} diff --git a/digitalidentity/attribute/issuance_details_test.go b/digitalidentity/attribute/issuance_details_test.go new file mode 100644 index 000000000..462d863ef --- /dev/null +++ b/digitalidentity/attribute/issuance_details_test.go @@ -0,0 +1,145 @@ +package attribute + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoshare" + "google.golang.org/protobuf/proto" + + "gotest.tools/v3/assert" + + is "gotest.tools/v3/assert/cmp" +) + +func TestShouldParseThirdPartyAttributeCorrectly(t *testing.T) { + var thirdPartyAttributeBytes = test.GetTestFileBytes(t, "../../test/fixtures/test_third_party_issuance_details.txt") + issuanceDetails, err := ParseIssuanceDetails(thirdPartyAttributeBytes) + + assert.NilError(t, err) + assert.Equal(t, issuanceDetails.Attributes()[0].Name(), "com.thirdparty.id") + assert.Equal(t, issuanceDetails.Token(), "c29tZUlzc3VhbmNlVG9rZW4=") + assert.Equal(t, + issuanceDetails.ExpiryDate().Format("2006-01-02T15:04:05.000Z"), + "2019-10-15T22:04:05.123Z") +} + +func TestShouldLogWarningIfErrorInParsingExpiryDate(t *testing.T) { + var tokenValue = "41548a175dfaw" + thirdPartyAttribute := &yotiprotoshare.ThirdPartyAttribute{ + IssuanceToken: []byte(tokenValue), + IssuingAttributes: &yotiprotoshare.IssuingAttributes{ + ExpiryDate: "2006-13-02T15:04:05.000Z", + }, + } + + marshalled, err := proto.Marshal(thirdPartyAttribute) + + assert.NilError(t, err) + + var tokenBytes = []byte(tokenValue) + var expectedBase64Token = base64.StdEncoding.EncodeToString(tokenBytes) + + result, err := ParseIssuanceDetails(marshalled) + assert.Equal(t, expectedBase64Token, result.Token()) + assert.Assert(t, is.Nil(result.ExpiryDate())) + assert.Equal(t, "parsing time \"2006-13-02T15:04:05.000Z\": month out of range", err.Error()) +} + +func TestIssuanceDetails_parseExpiryDate_ShouldParseAllRFC3339Formats(t *testing.T) { + table := []struct { + Input string + Expected time.Time + }{ + { + Input: "2006-01-02T22:04:05Z", + Expected: time.Date(2006, 01, 02, 22, 4, 5, 0, time.UTC), + }, + { + Input: "2010-05-20T10:44:25Z", + Expected: time.Date(2010, 5, 20, 10, 44, 25, 0, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.1Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 100e6, time.UTC), + }, + { + Input: "2012-03-06T04:20:07.5Z", + Expected: time.Date(2012, 3, 6, 4, 20, 7, 500e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.12Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 120e6, time.UTC), + }, + { + Input: "2013-03-04T20:43:55.56Z", + Expected: time.Date(2013, 3, 4, 20, 43, 55, 560e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.123Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123e6, time.UTC), + }, + { + Input: "2007-04-07T17:34:11.784Z", + Expected: time.Date(2007, 4, 7, 17, 34, 11, 784e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.1234Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123400e3, time.UTC), + }, + { + Input: "2017-09-14T16:54:30.4784Z", + Expected: time.Date(2017, 9, 14, 16, 54, 30, 478400e3, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.12345Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123450e3, time.UTC), + }, + { + Input: "2009-06-07T14:20:30.74622Z", + Expected: time.Date(2009, 6, 7, 14, 20, 30, 746220e3, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.123456Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123456e3, time.UTC), + }, + { + Input: "2008-10-25T06:50:55.643562Z", + Expected: time.Date(2008, 10, 25, 6, 50, 55, 643562e3, time.UTC), + }, + { + Input: "2002-10-02T10:00:00-05:00", + Expected: time.Date(2002, 10, 2, 10, 0, 0, 0, time.FixedZone("-0500", -5*60*60)), + }, + { + Input: "2002-10-02T10:00:00+11:00", + Expected: time.Date(2002, 10, 2, 10, 0, 0, 0, time.FixedZone("+1100", 11*60*60)), + }, + { + Input: "1920-03-13T19:50:53.999999Z", + Expected: time.Date(1920, 3, 13, 19, 50, 53, 999999e3, time.UTC), + }, + { + Input: "1920-03-13T19:50:54.000001Z", + Expected: time.Date(1920, 3, 13, 19, 50, 54, 1e3, time.UTC), + }, + } + + for _, row := range table { + func(input string, expected time.Time) { + expiryDate, err := parseExpiryDate(input) + assert.NilError(t, err) + assert.Equal(t, expiryDate.UTC(), expected.UTC()) + }(row.Input, row.Expected) + } +} + +func TestInvalidProtobufThrowsError(t *testing.T) { + result, err := ParseIssuanceDetails([]byte("invalid")) + + assert.Assert(t, is.Nil(result)) + + assert.ErrorContains(t, err, "unable to parse ThirdPartyAttribute value") +} diff --git a/digitalidentity/attribute/item.go b/digitalidentity/attribute/item.go new file mode 100644 index 000000000..3efd2b966 --- /dev/null +++ b/digitalidentity/attribute/item.go @@ -0,0 +1,14 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// Item is a structure which contains information about an attribute value +type Item struct { + // ContentType is the content of the item. + ContentType yotiprotoattr.ContentType + + // Value is the underlying data of the item. + Value interface{} +} diff --git a/digitalidentity/attribute/json_attribute.go b/digitalidentity/attribute/json_attribute.go new file mode 100644 index 000000000..be40920df --- /dev/null +++ b/digitalidentity/attribute/json_attribute.go @@ -0,0 +1,58 @@ +package attribute + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// JSONAttribute is a Yoti attribute which returns an interface as its value +type JSONAttribute struct { + attributeDetails + // value returns the value of a JSON attribute in the form of an interface + value map[string]interface{} +} + +// NewJSON creates a new JSON attribute +func NewJSON(a *yotiprotoattr.Attribute) (*JSONAttribute, error) { + var interfaceValue map[string]interface{} + decoder := json.NewDecoder(bytes.NewReader(a.Value)) + decoder.UseNumber() + err := decoder.Decode(&interfaceValue) + if err != nil { + err = fmt.Errorf("unable to parse JSON value: %q. Error: %q", a.Value, err) + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &JSONAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: interfaceValue, + }, nil +} + +// unmarshallJSON unmarshalls JSON into an interface +func unmarshallJSON(byteValue []byte) (result map[string]interface{}, err error) { + var unmarshalledJSON map[string]interface{} + err = json.Unmarshal(byteValue, &unmarshalledJSON) + + if err != nil { + return nil, err + } + + return unmarshalledJSON, err +} + +// Value returns the value of the JSONAttribute as an interface. +func (a *JSONAttribute) Value() map[string]interface{} { + return a.value +} diff --git a/digitalidentity/attribute/json_attribute_test.go b/digitalidentity/attribute/json_attribute_test.go new file mode 100644 index 000000000..427563735 --- /dev/null +++ b/digitalidentity/attribute/json_attribute_test.go @@ -0,0 +1,76 @@ +package attribute + +import ( + "fmt" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func ExampleNewJSON() { + proto := yotiprotoattr.Attribute{ + Name: "exampleJSON", + Value: []byte(`{"foo":"bar"}`), + ContentType: yotiprotoattr.ContentType_JSON, + } + attribute, err := NewJSON(&proto) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + fmt.Println(attribute.Value()) + // Output: map[foo:bar] +} + +func TestNewJSON_ShouldReturnNilForInvalidJSON(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Name: "exampleJSON", + Value: []byte("Not a json document"), + ContentType: yotiprotoattr.ContentType_JSON, + } + attribute, err := NewJSON(&proto) + assert.Check(t, attribute == nil) + assert.ErrorContains(t, err, "unable to parse JSON value") +} + +func TestYotiClient_UnmarshallJSONValue_InvalidValueThrowsError(t *testing.T) { + invalidStructuredAddress := []byte("invalidBool") + + _, err := unmarshallJSON(invalidStructuredAddress) + + assert.Assert(t, err != nil) +} + +func TestYotiClient_UnmarshallJSONValue_ValidValue(t *testing.T) { + const ( + countryIso = "IND" + nestedValue = "NestedValue" + ) + + var structuredAddress = []byte(` + { + "address_format": 2, + "building": "House No.86-A", + "state": "Punjab", + "postal_code": "141012", + "country_iso": "` + countryIso + `", + "country": "India", + "formatted_address": "House No.86-A\nRajgura Nagar\nLudhina\nPunjab\n141012\nIndia", + "1": + { + "1-1": + { + "1-1-1": "` + nestedValue + `" + } + } + } + `) + + parsedStructuredAddress, err := unmarshallJSON(structuredAddress) + assert.NilError(t, err, "Failed to parse structured address") + + actualCountryIso := parsedStructuredAddress["country_iso"] + + assert.Equal(t, countryIso, actualCountryIso) +} diff --git a/digitalidentity/attribute/multivalue_attribute.go b/digitalidentity/attribute/multivalue_attribute.go new file mode 100644 index 000000000..926141f97 --- /dev/null +++ b/digitalidentity/attribute/multivalue_attribute.go @@ -0,0 +1,90 @@ +package attribute + +import ( + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" +) + +// MultiValueAttribute is a Yoti attribute which returns a multi-valued attribute +type MultiValueAttribute struct { + attributeDetails + items []*Item +} + +// NewMultiValue creates a new MultiValue attribute +func NewMultiValue(a *yotiprotoattr.Attribute) (*MultiValueAttribute, error) { + attributeItems, err := parseMultiValue(a.Value) + + if err != nil { + return nil, err + } + + return &MultiValueAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: anchor.ParseAnchors(a.Anchors), + id: &a.EphemeralId, + }, + items: attributeItems, + }, nil +} + +// parseMultiValue recursively unmarshals and converts Multi Value bytes into a slice of Items +func parseMultiValue(data []byte) ([]*Item, error) { + var attributeItems []*Item + protoMultiValueStruct, err := unmarshallMultiValue(data) + + if err != nil { + return nil, err + } + + for _, multiValueItem := range protoMultiValueStruct.Values { + var value *Item + if multiValueItem.ContentType == yotiprotoattr.ContentType_MULTI_VALUE { + parsedInnerMultiValueItems, err := parseMultiValue(multiValueItem.Data) + + if err != nil { + return nil, fmt.Errorf("unable to parse multi-value data: %v", err) + } + + value = &Item{ + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Value: parsedInnerMultiValueItems, + } + } else { + itemValue, err := parseValue(multiValueItem.ContentType, multiValueItem.Data) + + if err != nil { + return nil, fmt.Errorf("unable to parse data within a multi-value attribute. Content type: %q, data: %q, error: %v", + multiValueItem.ContentType, multiValueItem.Data, err) + } + + value = &Item{ + ContentType: multiValueItem.ContentType, + Value: itemValue, + } + } + attributeItems = append(attributeItems, value) + } + + return attributeItems, nil +} + +func unmarshallMultiValue(bytes []byte) (*yotiprotoattr.MultiValue, error) { + multiValueStruct := &yotiprotoattr.MultiValue{} + + if err := proto.Unmarshal(bytes, multiValueStruct); err != nil { + return nil, fmt.Errorf("unable to parse MULTI_VALUE value: %q. Error: %q", string(bytes), err) + } + + return multiValueStruct, nil +} + +// Value returns the value of the MultiValueAttribute as a string +func (a *MultiValueAttribute) Value() []*Item { + return a.items +} diff --git a/digitalidentity/attribute/multivalue_attribute_test.go b/digitalidentity/attribute/multivalue_attribute_test.go new file mode 100644 index 000000000..15a24f998 --- /dev/null +++ b/digitalidentity/attribute/multivalue_attribute_test.go @@ -0,0 +1,157 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func marshallMultiValue(t *testing.T, multiValue *yotiprotoattr.MultiValue) []byte { + marshalled, err := proto.Marshal(multiValue) + + assert.NilError(t, err) + + return marshalled +} + +func createMultiValueAttribute(t *testing.T, multiValueItemSlice []*yotiprotoattr.MultiValue_Value) (*MultiValueAttribute, error) { + var multiValueStruct = &yotiprotoattr.MultiValue{ + Values: multiValueItemSlice, + } + + var marshalledMultiValueData = marshallMultiValue(t, multiValueStruct) + attributeName := "nestedMultiValue" + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: marshalledMultiValueData, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + return NewMultiValue(protoAttribute) +} + +func TestAttribute_MultiValueNotCreatedWithNonMultiValueType(t *testing.T) { + attributeName := "attributeName" + attributeValueString := "value" + attributeValue := []byte(attributeValueString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + _, err := NewMultiValue(attr) + + assert.Assert(t, err != nil, "Expected error when creating multi value from attribute which isn't of multi-value type") +} + +func TestAttribute_NewMultiValue(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + + multiValueAttribute, err := NewMultiValue(protoAttribute) + + assert.NilError(t, err) + + documentImagesAttributeItems, err := CreateImageSlice(multiValueAttribute.Value()) + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, documentImagesAttributeItems, multiValueAttribute.Anchors()[0]) +} + +func TestAttribute_InvalidMultiValueNotReturned(t *testing.T) { + var invalidMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_DATE, + Data: []byte("invalid"), + } + + var stringMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_STRING, + Data: []byte("string"), + } + + var multiValueItemSlice = []*yotiprotoattr.MultiValue_Value{invalidMultiValueItem, stringMultiValueItem} + + var multiValueStruct = &yotiprotoattr.MultiValue{ + Values: multiValueItemSlice, + } + + var marshalledMultiValueData = marshallMultiValue(t, multiValueStruct) + attributeName := "nestedMultiValue" + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: marshalledMultiValueData, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + multiValueAttr, err := NewMultiValue(protoAttribute) + assert.Check(t, err != nil) + + assert.Assert(t, is.Nil(multiValueAttr)) +} + +func TestAttribute_NestedMultiValue(t *testing.T) { + var innerMultiValueProtoValue = createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt").Value + + var stringMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_STRING, + Data: []byte("string"), + } + + var multiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Data: innerMultiValueProtoValue, + } + + var multiValueItemSlice = []*yotiprotoattr.MultiValue_Value{stringMultiValueItem, multiValueItem} + + multiValueAttribute, err := createMultiValueAttribute(t, multiValueItemSlice) + + assert.NilError(t, err) + + for key, value := range multiValueAttribute.Value() { + switch key { + case 0: + value0 := value.Value + + assert.Equal(t, value0.(string), "string") + case 1: + value1 := value.Value + + innerItems, ok := value1.([]*Item) + assert.Assert(t, ok) + + for innerKey, item := range innerItems { + switch innerKey { + case 0: + assertIsExpectedImage(t, item.Value.(media.Media), media.ImageTypeJPEG, "vWgD//2Q==") + + case 1: + assertIsExpectedImage(t, item.Value.(media.Media), media.ImageTypeJPEG, "38TVEH/9k=") + } + } + } + } +} + +func TestAttribute_MultiValueGenericGetter(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + multiValueAttribute, err := NewMultiValue(protoAttribute) + assert.NilError(t, err) + + // We need to cast, since GetAttribute always returns generic attributes + multiValueAttributeValue := multiValueAttribute.Value() + imageSlice, err := CreateImageSlice(multiValueAttributeValue) + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, imageSlice, multiValueAttribute.Anchors()[0]) +} diff --git a/digitalidentity/attribute/parser.go b/digitalidentity/attribute/parser.go new file mode 100644 index 000000000..d6635957e --- /dev/null +++ b/digitalidentity/attribute/parser.go @@ -0,0 +1,56 @@ +package attribute + +import ( + "fmt" + "strconv" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +func parseValue(contentType yotiprotoattr.ContentType, byteValue []byte) (interface{}, error) { + switch contentType { + case yotiprotoattr.ContentType_DATE: + parsedTime, err := time.Parse("2006-01-02", string(byteValue)) + + if err == nil { + return &parsedTime, nil + } + + return nil, fmt.Errorf("unable to parse date value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_JSON: + unmarshalledJSON, err := unmarshallJSON(byteValue) + + if err == nil { + return unmarshalledJSON, nil + } + + return nil, fmt.Errorf("unable to parse JSON value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_STRING: + return string(byteValue), nil + + case yotiprotoattr.ContentType_MULTI_VALUE: + return parseMultiValue(byteValue) + + case yotiprotoattr.ContentType_INT: + var stringValue = string(byteValue) + intValue, err := strconv.Atoi(stringValue) + if err == nil { + return intValue, nil + } + + return nil, fmt.Errorf("unable to parse INT value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_JPEG, + yotiprotoattr.ContentType_PNG: + return parseImageValue(contentType, byteValue) + + case yotiprotoattr.ContentType_UNDEFINED: + return string(byteValue), nil + + default: + return string(byteValue), nil + } +} diff --git a/digitalidentity/attribute/parser_test.go b/digitalidentity/attribute/parser_test.go new file mode 100644 index 000000000..cc9f3d8b3 --- /dev/null +++ b/digitalidentity/attribute/parser_test.go @@ -0,0 +1,16 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestParseValue_ShouldParseInt(t *testing.T) { + parsed, err := parseValue(yotiprotoattr.ContentType_INT, []byte("7")) + assert.NilError(t, err) + integer, ok := parsed.(int) + assert.Check(t, ok) + assert.Equal(t, integer, 7) +} diff --git a/digitalidentity/attribute/string_attribute.go b/digitalidentity/attribute/string_attribute.go new file mode 100644 index 000000000..73346b9af --- /dev/null +++ b/digitalidentity/attribute/string_attribute.go @@ -0,0 +1,32 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// StringAttribute is a Yoti attribute which returns a string as its value +type StringAttribute struct { + attributeDetails + value string +} + +// NewString creates a new String attribute +func NewString(a *yotiprotoattr.Attribute) *StringAttribute { + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &StringAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: string(a.Value), + } +} + +// Value returns the value of the StringAttribute as a string +func (a *StringAttribute) Value() string { + return a.value +} diff --git a/digitalidentity/attribute/string_attribute_test.go b/digitalidentity/attribute/string_attribute_test.go new file mode 100644 index 000000000..828df2016 --- /dev/null +++ b/digitalidentity/attribute/string_attribute_test.go @@ -0,0 +1,22 @@ +package attribute + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestStringAttribute_NewThirdPartyAttribute(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_third_party.txt") + + stringAttribute := NewString(protoAttribute) + + assert.Equal(t, stringAttribute.Value(), "test-third-party-attribute-0") + assert.Equal(t, stringAttribute.Name(), "com.thirdparty.id") + + assert.Equal(t, stringAttribute.Sources()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Sources()[0].SubType(), "orgName") + + assert.Equal(t, stringAttribute.Verifiers()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Verifiers()[0].SubType(), "orgName") +} diff --git a/digitalidentity/base_profile.go b/digitalidentity/base_profile.go new file mode 100644 index 000000000..693441d0a --- /dev/null +++ b/digitalidentity/base_profile.go @@ -0,0 +1,75 @@ +package digitalidentity + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +type baseProfile struct { + attributeSlice []*yotiprotoattr.Attribute +} + +// GetAttribute retrieve an attribute by name on the Yoti profile. Will return nil if attribute is not present. +func (p baseProfile) GetAttribute(attributeName string) *attribute.GenericAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewGeneric(a) + } + } + return nil +} + +// GetAttributeByID retrieve an attribute by ID on the Yoti profile. Will return nil if attribute is not present. +func (p baseProfile) GetAttributeByID(attributeID string) *attribute.GenericAttribute { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewGeneric(a) + } + } + return nil +} + +// GetAttributes retrieve a list of attributes by name on the Yoti profile. Will return an empty list of attribute is not present. +func (p baseProfile) GetAttributes(attributeName string) []*attribute.GenericAttribute { + var attributes []*attribute.GenericAttribute + for _, a := range p.attributeSlice { + if a.Name == attributeName { + attributes = append(attributes, attribute.NewGeneric(a)) + } + } + return attributes +} + +// GetStringAttribute retrieves a string attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetStringAttribute(attributeName string) *attribute.StringAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewString(a) + } + } + return nil +} + +// GetImageAttribute retrieves an image attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetImageAttribute(attributeName string) *attribute.ImageAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + imageAttribute, err := attribute.NewImage(a) + + if err == nil { + return imageAttribute + } + } + } + return nil +} + +// GetJSONAttribute retrieves a JSON attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetJSONAttribute(attributeName string) (*attribute.JSONAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewJSON(a) + } + } + return nil, nil +} diff --git a/digitalidentity/qr_code.go b/digitalidentity/qr_code.go new file mode 100644 index 000000000..bff20b64c --- /dev/null +++ b/digitalidentity/qr_code.go @@ -0,0 +1,6 @@ +package digitalidentity + +type QrCode struct { + Id string `json:"id"` + Uri string `json:"uri"` +} diff --git a/digitalidentity/receipt.go b/digitalidentity/receipt.go new file mode 100644 index 000000000..8c9b9aa45 --- /dev/null +++ b/digitalidentity/receipt.go @@ -0,0 +1,19 @@ +package digitalidentity + +type Content struct { + Profile []byte `json:"profile"` + ExtraData []byte `json:"extraData"` +} + +type ReceiptResponse struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + RememberMeID string `json:"rememberMeId,omitempty"` + ParentRememberMeID string `json:"parentRememberMeId,omitempty"` + Content *Content `json:"content,omitempty"` + OtherPartyContent *Content `json:"otherPartyContent,omitempty"` + WrappedItemKeyId string `json:"wrappedItemKeyId"` + WrappedKey []byte `json:"wrappedKey"` + Error string `json:"error"` +} diff --git a/digitalidentity/receipt_item_key.go b/digitalidentity/receipt_item_key.go new file mode 100644 index 000000000..7e805d876 --- /dev/null +++ b/digitalidentity/receipt_item_key.go @@ -0,0 +1,7 @@ +package digitalidentity + +type ReceiptItemKeyResponse struct { + ID string `json:"id"` + Iv []byte `json:"iv"` + Value []byte `json:"value"` +} diff --git a/digitalidentity/requests/request.go b/digitalidentity/requests/request.go index 02be33331..e5bbeabaa 100644 --- a/digitalidentity/requests/request.go +++ b/digitalidentity/requests/request.go @@ -9,7 +9,9 @@ import ( // Execute makes a request to the specified endpoint, with an optional payload func Execute(httpClient HttpClient, request *http.Request) (response *http.Response, err error) { + if response, err = doRequest(request, httpClient); err != nil { + return } diff --git a/digitalidentity/requests/signed_message.go b/digitalidentity/requests/signed_message.go index 0a3d16af5..02e2116f9 100644 --- a/digitalidentity/requests/signed_message.go +++ b/digitalidentity/requests/signed_message.go @@ -220,3 +220,14 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { return request, err } + +func Base64ToBase64URL(base64Str string) string { + decoded, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return "" + } + + base64URL := base64.URLEncoding.EncodeToString(decoded) + + return base64URL +} diff --git a/digitalidentity/service.go b/digitalidentity/service.go index 3fb2f99c9..444a65600 100644 --- a/digitalidentity/service.go +++ b/digitalidentity/service.go @@ -7,11 +7,19 @@ import ( "io" "net/http" + "github.com/getyoti/yoti-go-sdk/v3/cryptoutil" "github.com/getyoti/yoti-go-sdk/v3/digitalidentity/requests" + "github.com/getyoti/yoti-go-sdk/v3/extra" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" ) const identitySessionCreationEndpoint = "/v2/sessions" const identitySessionRetrieval = "/v2/sessions/%s" +const identitySessionQrCodeCreation = "/v2/sessions/%s/qr-codes" +const identitySessionQrCodeRetrieval = "/v2/qr-codes/%s" +const identitySessionReceiptRetrieval = "/v2/receipts/%s" +const identitySessionReceiptKeyRetrieval = "/v2/wrapped-item-keys/%s" // CreateShareSession creates session using the supplied session specification func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *ShareSessionRequest, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*ShareSession, error) { @@ -32,19 +40,20 @@ func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *Sha Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get signed request: %v", err) } response, err := requests.Execute(httpClient, request) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute request: %v", err) } defer response.Body.Close() shareSession := &ShareSession{} + responseBytes, err := io.ReadAll(response.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read response body: %v", err) } err = json.Unmarshal(responseBytes, shareSession) return shareSession, err @@ -63,20 +72,232 @@ func GetShareSession(httpClient requests.HttpClient, sessionID string, clientSdk Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get signed request: %v", err) } response, err := requests.Execute(httpClient, request) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute request: %v", err) } defer response.Body.Close() shareSession := &ShareSession{} responseBytes, err := io.ReadAll(response.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read response body: %v", err) } err = json.Unmarshal(responseBytes, shareSession) return shareSession, err } + +// CreateShareQrCode generates a sharing qr code using the supplied sessionID parameter +func CreateShareQrCode(httpClient requests.HttpClient, sessionID string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*QrCode, error) { + endpoint := fmt.Sprintf(identitySessionQrCodeCreation, sessionID) + + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodPost, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: requests.AuthHeader(clientSdkId), + Body: nil, + Params: map[string]string{"sdkID": clientSdkId}, + }.Request() + if err != nil { + return nil, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + + defer response.Body.Close() + qrCode := &QrCode{} + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + err = json.Unmarshal(responseBytes, qrCode) + return qrCode, err +} + +// GetShareSessionQrCode is used to fetch the qr code by id. +func GetShareSessionQrCode(httpClient requests.HttpClient, qrCodeId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (fetchedQrCode ShareSessionQrCode, err error) { + endpoint := fmt.Sprintf(identitySessionQrCodeRetrieval, qrCodeId) + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to execute request: %v", err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to read response body: %v", err) + } + + err = json.Unmarshal(responseBytes, &fetchedQrCode) + + return fetchedQrCode, err +} + +// GetReceipt fetches receipt info using a receipt id. +func getReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receipt ReceiptResponse, err error) { + receiptUrl := requests.Base64ToBase64URL(receiptId) + endpoint := fmt.Sprintf(identitySessionReceiptRetrieval, receiptUrl) + + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return receipt, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return receipt, fmt.Errorf("failed to execute request: %v", err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return receipt, fmt.Errorf("failed to read response body: %v", err) + } + + err = json.Unmarshal(responseBytes, &receipt) + + return receipt, err +} + +// GetReceiptItemKey retrieves the receipt item key for a receipt item key id. +func getReceiptItemKey(httpClient requests.HttpClient, receiptItemKeyId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receiptItemKey ReceiptItemKeyResponse, err error) { + endpoint := fmt.Sprintf(identitySessionReceiptKeyRetrieval, receiptItemKeyId) + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return receiptItemKey, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return receiptItemKey, err + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return receiptItemKey, err + } + + err = json.Unmarshal(responseBytes, &receiptItemKey) + + return receiptItemKey, err +} + +func GetShareReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receipt SharedReceiptResponse, err error) { + receiptResponse, err := getReceipt(httpClient, receiptId, clientSdkId, apiUrl, key) + if err != nil { + return receipt, fmt.Errorf("failed to get receipt: %v", err) + } + + itemKeyId := receiptResponse.WrappedItemKeyId + + encryptedItemKeyResponse, err := getReceiptItemKey(httpClient, itemKeyId, clientSdkId, apiUrl, key) + if err != nil { + return receipt, fmt.Errorf("failed to get receipt item key: %v", err) + } + + receiptContentKey, err := cryptoutil.UnwrapReceiptKey(receiptResponse.WrappedKey, encryptedItemKeyResponse.Value, encryptedItemKeyResponse.Iv, key) + if err != nil { + return receipt, fmt.Errorf("failed to unwrap receipt content key: %v", err) + } + + attrData, aextra, err := decryptReceiptContent(receiptResponse.Content, receiptContentKey) + if err != nil { + return receipt, fmt.Errorf("failed to decrypt receipt content: %v", err) + } + + applicationProfile := newApplicationProfile(attrData) + extraDataValue, err := extra.NewExtraData(aextra) + if err != nil { + return receipt, fmt.Errorf("failed to build application extra data: %v", err) + } + + uattrData, uextra, err := decryptReceiptContent(receiptResponse.OtherPartyContent, receiptContentKey) + if err != nil { + return receipt, fmt.Errorf("failed to decrypt receipt other party content: %v", err) + } + + userProfile := newUserProfile(uattrData) + userExtraDataValue, err := extra.NewExtraData(uextra) + if err != nil { + return receipt, fmt.Errorf("failed to build other party extra data: %v", err) + } + + return SharedReceiptResponse{ + ID: receiptResponse.ID, + SessionID: receiptResponse.SessionID, + RememberMeID: receiptResponse.RememberMeID, + ParentRememberMeID: receiptResponse.ParentRememberMeID, + Timestamp: receiptResponse.Timestamp, + UserContent: UserContent{ + UserProfile: userProfile, + ExtraData: userExtraDataValue, + }, + ApplicationContent: ApplicationContent{ + ApplicationProfile: applicationProfile, + ExtraData: extraDataValue, + }, + Error: receiptResponse.Error, + }, nil +} + +func decryptReceiptContent(content *Content, key []byte) (attrData *yotiprotoattr.AttributeList, aextra []byte, err error) { + + if content != nil { + if len(content.Profile) > 0 { + aattr, err := cryptoutil.DecryptReceiptContent(content.Profile, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to decrypt content profile: %v", err) + } + + attrData = &yotiprotoattr.AttributeList{} + if err := proto.Unmarshal(aattr, attrData); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal attribute list: %v", err) + } + } + + if len(content.ExtraData) > 0 { + aextra, err = cryptoutil.DecryptReceiptContent(content.ExtraData, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to decrypt receipt content extra data: %v", err) + } + } + + } + + return attrData, aextra, nil +} diff --git a/digitalidentity/service_test.go b/digitalidentity/service_test.go index f1ddc6eaf..dbf5e133b 100644 --- a/digitalidentity/service_test.go +++ b/digitalidentity/service_test.go @@ -110,3 +110,39 @@ func TestGetShareSession(t *testing.T) { assert.NilError(t, err) } + +func TestCreateShareQrCode(t *testing.T) { + key := test.GetValidKey("../test/test-key.pem") + mockSessionID := "SOME_SESSION_ID" + + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil + }, + } + + _, err := CreateShareQrCode(client, mockSessionID, "sdkId", "https://apiurl", key) + assert.NilError(t, err) +} + +func TestGetQrCode(t *testing.T) { + key := test.GetValidKey("../test/test-key.pem") + mockQrId := "SOME_QR_CODE_ID" + mockClientSdkId := "SOME_CLIENT_SDK_ID" + mockApiUrl := "https://example.com/api" + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil + }, + } + + _, err := GetShareSessionQrCode(client, mockQrId, mockClientSdkId, mockApiUrl, key) + assert.NilError(t, err) + +} diff --git a/digitalidentity/share_receipt.go b/digitalidentity/share_receipt.go new file mode 100644 index 000000000..70c2d4cf3 --- /dev/null +++ b/digitalidentity/share_receipt.go @@ -0,0 +1,24 @@ +package digitalidentity + +import "github.com/getyoti/yoti-go-sdk/v3/extra" + +type SharedReceiptResponse struct { + ID string + SessionID string + RememberMeID string + ParentRememberMeID string + Timestamp string + Error string + UserContent UserContent + ApplicationContent ApplicationContent +} + +type ApplicationContent struct { + ApplicationProfile ApplicationProfile + ExtraData *extra.Data +} + +type UserContent struct { + UserProfile UserProfile + ExtraData *extra.Data +} diff --git a/digitalidentity/share_retrieve_qr.go b/digitalidentity/share_retrieve_qr.go new file mode 100644 index 000000000..fe737d9b9 --- /dev/null +++ b/digitalidentity/share_retrieve_qr.go @@ -0,0 +1,10 @@ +package digitalidentity + +type ShareSessionFetchedQrCode struct { + ID string `json:"id"` + Expiry string `json:"expiry"` + Policy string `json:"policy"` + Extensions []interface{} `json:"extensions"` + Session ShareSessionCreated `json:"session"` + RedirectURI string `json:"redirectUri"` +} diff --git a/digitalidentity/share_session_created.go b/digitalidentity/share_session_created.go new file mode 100644 index 000000000..86d60e3fc --- /dev/null +++ b/digitalidentity/share_session_created.go @@ -0,0 +1,8 @@ +package digitalidentity + +// ShareSessionCreated Share Session QR Result +type ShareSessionCreated struct { + ID string `json:"id"` + Satus string `json:"status"` + Expiry string `json:"expiry"` +} diff --git a/digitalidentity/share_session_qr_code.go b/digitalidentity/share_session_qr_code.go new file mode 100644 index 000000000..5f98caa1c --- /dev/null +++ b/digitalidentity/share_session_qr_code.go @@ -0,0 +1,14 @@ +package digitalidentity + +type ShareSessionQrCode struct { + ID string `json:"id"` + Expiry string `json:"expiry"` + Policy string `json:"policy"` + Extensions []interface{} `json:"extensions"` + Session struct { + ID string `json:"id"` + Status string `json:"status"` + Expiry string `json:"expiry"` + } `json:"session"` + RedirectURI string `json:"redirectUri"` +} diff --git a/digitalidentity/user_profile.go b/digitalidentity/user_profile.go new file mode 100644 index 000000000..32a4d282a --- /dev/null +++ b/digitalidentity/user_profile.go @@ -0,0 +1,182 @@ +package digitalidentity + +import ( + "strings" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// UserProfile represents the details retrieved for a particular user. Consists of +// Yoti attributes: a small piece of information about a Yoti user such as a +// photo of the user or the user's date of birth. +type UserProfile struct { + baseProfile +} + +// Creates a new Profile struct +func newUserProfile(attributes *yotiprotoattr.AttributeList) UserProfile { + return UserProfile{ + baseProfile{ + attributeSlice: createAttributeSlice(attributes), + }, + } +} + +func createAttributeSlice(protoAttributeList *yotiprotoattr.AttributeList) (result []*yotiprotoattr.Attribute) { + if protoAttributeList != nil { + result = append(result, protoAttributeList.Attributes...) + } + + return result +} + +// Selfie is a photograph of the user. Will be nil if not provided by Yoti. +func (p UserProfile) Selfie() *attribute.ImageAttribute { + return p.GetImageAttribute(consts.AttrSelfie) +} + +// GetSelfieAttributeByID retrieve a Selfie attribute by ID on the Yoti profile. +// This attribute is a photograph of the user. +// Will return nil if attribute is not present. +func (p UserProfile) GetSelfieAttributeByID(attributeID string) (*attribute.ImageAttribute, error) { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewImage(a) + } + } + return nil, nil +} + +// GivenNames corresponds to secondary names in passport, and first/middle names in English. Will be nil if not provided by Yoti. +func (p UserProfile) GivenNames() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrGivenNames) +} + +// FamilyName corresponds to primary name in passport, and surname in English. Will be nil if not provided by Yoti. +func (p UserProfile) FamilyName() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrFamilyName) +} + +// FullName represents the user's full name. +// If family_name/given_names are present, the value will be equal to the string 'given_names + " " family_name'. +// Will be nil if not provided by Yoti. +func (p UserProfile) FullName() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrFullName) +} + +// MobileNumber represents the user's mobile phone number, as verified at registration time. +// The value will be a number in E.164 format (i.e. '+' for international prefix and no spaces, e.g. "+447777123456"). +// Will be nil if not provided by Yoti. +func (p UserProfile) MobileNumber() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrMobileNumber) +} + +// EmailAddress represents the user's verified email address. Will be nil if not provided by Yoti. +func (p UserProfile) EmailAddress() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrEmailAddress) +} + +// DateOfBirth represents the user's date of birth. Will be nil if not provided by Yoti. +// Has an err value which will be filled if there is an error parsing the date. +func (p UserProfile) DateOfBirth() (*attribute.DateAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDateOfBirth { + return attribute.NewDate(a) + } + } + return nil, nil +} + +// Address represents the user's address. Will be nil if not provided by Yoti. +func (p UserProfile) Address() *attribute.StringAttribute { + addressAttribute := p.GetStringAttribute(consts.AttrAddress) + if addressAttribute == nil { + return ensureAddressProfile(&p) + } + + return addressAttribute +} + +// StructuredPostalAddress represents the user's address in a JSON format. +// Will be nil if not provided by Yoti. This can be accessed as a +// map[string]string{} using a type assertion, e.g.: +// structuredPostalAddress := structuredPostalAddressAttribute.Value().(map[string]string{}) +func (p UserProfile) StructuredPostalAddress() (*attribute.JSONAttribute, error) { + return p.GetJSONAttribute(consts.AttrStructuredPostalAddress) +} + +// Gender corresponds to the gender in the registered document; the value will be one of the strings "MALE", "FEMALE", "TRANSGENDER" or "OTHER". +// Will be nil if not provided by Yoti. +func (p UserProfile) Gender() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrGender) +} + +// Nationality corresponds to the nationality in the passport. +// The value is an ISO-3166-1 alpha-3 code with ICAO9303 (passport) extensions. +// Will be nil if not provided by Yoti. +func (p UserProfile) Nationality() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrNationality) +} + +// DocumentImages returns a slice of document images cropped from the image in the capture page. +// There can be multiple images as per the number of regions in the capture in this attribute. +// Will be nil if not provided by Yoti. +func (p UserProfile) DocumentImages() (*attribute.ImageSliceAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDocumentImages { + return attribute.NewImageSlice(a) + } + } + return nil, nil +} + +// GetDocumentImagesAttributeByID retrieve a Document Images attribute by ID on the Yoti profile. +// This attribute consists of a slice of document images cropped from the image in the capture page. +// There can be multiple images as per the number of regions in the capture in this attribute. +// Will return nil if attribute is not present. +func (p UserProfile) GetDocumentImagesAttributeByID(attributeID string) (*attribute.ImageSliceAttribute, error) { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewImageSlice(a) + } + } + return nil, nil +} + +// DocumentDetails represents information extracted from a document provided by the user. +// Will be nil if not provided by Yoti. +func (p UserProfile) DocumentDetails() (*attribute.DocumentDetailsAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDocumentDetails { + return attribute.NewDocumentDetails(a) + } + } + return nil, nil +} + +// IdentityProfileReport represents the JSON object containing identity assertion and the +// verification report. Will be nil if not provided by Yoti. +func (p UserProfile) IdentityProfileReport() (*attribute.JSONAttribute, error) { + return p.GetJSONAttribute(consts.AttrIdentityProfileReport) +} + +// AgeVerifications returns a slice of age verifications for the user. +// Will be an empty slice if not provided by Yoti. +func (p UserProfile) AgeVerifications() (out []attribute.AgeVerification, err error) { + ageUnderString := strings.Replace(consts.AttrAgeUnder, "%d", "", -1) + ageOverString := strings.Replace(consts.AttrAgeOver, "%d", "", -1) + + for _, a := range p.attributeSlice { + if strings.HasPrefix(a.Name, ageUnderString) || + strings.HasPrefix(a.Name, ageOverString) { + verification, err := attribute.NewAgeVerification(a) + if err != nil { + return nil, err + } + out = append(out, verification) + } + } + return out, err +} diff --git a/digitalidentity/user_profile_test.go b/digitalidentity/user_profile_test.go new file mode 100644 index 000000000..b81b78ee6 --- /dev/null +++ b/digitalidentity/user_profile_test.go @@ -0,0 +1,704 @@ +package digitalidentity + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/file" + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +const ( + attributeName = "test_attribute_name" + attributeValueString = "value" + + documentImagesAttributeID = "document-images-attribute-id-123" + selfieAttributeID = "selfie-attribute-id-123" + fullNameAttributeID = "full-name-id-123" +) + +var attributeValue = []byte(attributeValueString) + +func getUserProfile() UserProfile { + userProfile := createProfileWithMultipleAttributes( + createDocumentImagesAttribute(documentImagesAttributeID), + createSelfieAttribute(yotiprotoattr.ContentType_JPEG, selfieAttributeID), + createStringAttribute("full_name", []byte("John Smith"), []*yotiprotoattr.Anchor{}, fullNameAttributeID)) + + return userProfile +} + +func ExampleUserProfile_GetAttributeByID() { + userProfile := getUserProfile() + fullNameAttribute := userProfile.GetAttributeByID("full-name-id-123") + value := fullNameAttribute.Value().(string) + + fmt.Println(value) + // Output: John Smith +} + +func ExampleUserProfile_GetDocumentImagesAttributeByID() { + userProfile := getUserProfile() + documentImagesAttribute, err := userProfile.GetDocumentImagesAttributeByID("document-images-attribute-id-123") + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(*documentImagesAttribute.ID()) + // Output: document-images-attribute-id-123 +} + +func ExampleUserProfile_GetSelfieAttributeByID() { + userProfile := getUserProfile() + selfieAttribute, err := userProfile.GetSelfieAttributeByID("selfie-attribute-id-123") + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(*selfieAttribute.ID()) + // Output: selfie-attribute-id-123 +} + +func createProfileWithSingleAttribute(attr *yotiprotoattr.Attribute) UserProfile { + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, attr) + + return UserProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } +} + +func createAppProfileWithSingleAttribute(attr *yotiprotoattr.Attribute) ApplicationProfile { + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, attr) + + return ApplicationProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } +} + +func createProfileWithMultipleAttributes(list ...*yotiprotoattr.Attribute) UserProfile { + return UserProfile{ + baseProfile{ + attributeSlice: list, + }, + } +} + +func TestProfile_AgeVerifications(t *testing.T) { + ageOver14 := &yotiprotoattr.Attribute{ + Name: "age_over:14", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + ageUnder18 := &yotiprotoattr.Attribute{ + Name: "age_under:18", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + ageOver18 := &yotiprotoattr.Attribute{ + Name: "age_over:18", + Value: []byte("false"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithMultipleAttributes(ageOver14, ageUnder18, ageOver18) + ageVerifications, err := profile.AgeVerifications() + + assert.NilError(t, err) + assert.Equal(t, len(ageVerifications), 3) + + assert.Equal(t, ageVerifications[0].Age, 14) + assert.Equal(t, ageVerifications[0].CheckType, "age_over") + assert.Equal(t, ageVerifications[0].Result, true) + + assert.Equal(t, ageVerifications[1].Age, 18) + assert.Equal(t, ageVerifications[1].CheckType, "age_under") + assert.Equal(t, ageVerifications[1].Result, true) + + assert.Equal(t, ageVerifications[2].Age, 18) + assert.Equal(t, ageVerifications[2].CheckType, "age_over") + assert.Equal(t, ageVerifications[2].Result, false) +} + +func TestProfile_GetAttribute_EmptyString(t *testing.T) { + emptyString := "" + attributeValue := []byte(emptyString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Name(), attributeName) + assert.Equal(t, att.Value().(string), emptyString) +} + +func TestProfile_GetApplicationAttribute(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createProfileWithSingleAttribute(attr) + applicationAttribute := appProfile.GetAttribute(attributeName) + assert.Equal(t, applicationAttribute.Name(), attributeName) +} + +func TestProfile_GetApplicationName(t *testing.T) { + attributeValue := "APPLICATION NAME" + var attr = &yotiprotoattr.Attribute{ + Name: "application_name", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationName().Value()) +} + +func TestProfile_GetApplicationURL(t *testing.T) { + attributeValue := "APPLICATION URL" + var attr = &yotiprotoattr.Attribute{ + Name: "application_url", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationURL().Value()) +} + +func TestProfile_GetApplicationLogo(t *testing.T) { + attributeValue := "APPLICATION LOGO" + var attr = &yotiprotoattr.Attribute{ + Name: "application_logo", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, 16, len(appProfile.ApplicationLogo().Value().Data())) +} + +func TestProfile_GetApplicationBGColor(t *testing.T) { + attributeValue := "BG VALUE" + var attr = &yotiprotoattr.Attribute{ + Name: "application_receipt_bgcolor", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationReceiptBgColor().Value()) +} + +func TestProfile_GetAttribute_Int(t *testing.T) { + intValues := [5]int{0, 1, 123, -10, -1} + + for _, integer := range intValues { + assertExpectedIntegerIsReturned(t, integer) + } +} + +func assertExpectedIntegerIsReturned(t *testing.T, intValue int) { + intAsString := strconv.Itoa(intValue) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: []byte(intAsString), + ContentType: yotiprotoattr.ContentType_INT, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Value().(int), intValue) +} + +func TestProfile_GetAttribute_InvalidInt_ReturnsNil(t *testing.T) { + invalidIntValue := "1985-01-01" + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: []byte(invalidIntValue), + ContentType: yotiprotoattr.ContentType_INT, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + + att := result.GetAttribute(attributeName) + + assert.Assert(t, is.Nil(att)) +} + +func TestProfile_EmptyStringIsAllowed(t *testing.T) { + emptyString := "" + attrValue := []byte(emptyString) + + var attr = &yotiprotoattr.Attribute{ + Name: consts.AttrGender, + Value: attrValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.Gender() + + assert.Equal(t, att.Value(), emptyString) +} + +func TestProfile_GetAttribute_Time(t *testing.T) { + dateStringValue := "1985-01-01" + expectedDate := time.Date(1985, time.January, 1, 0, 0, 0, 0, time.UTC) + + attributeValueTime := []byte(dateStringValue) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValueTime, + ContentType: yotiprotoattr.ContentType_DATE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, expectedDate, att.Value().(*time.Time).UTC()) +} + +func TestProfile_GetAttribute_Jpeg(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.GetAttribute(attributeName) + + expected := media.JPEGImage(attributeValue) + result := att.Value().(media.JPEGImage) + + assert.DeepEqual(t, expected, result) + assert.Equal(t, expected.Base64URL(), result.Base64URL()) +} + +func TestProfile_GetAttribute_Png(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.GetAttribute(attributeName) + + expected := media.PNGImage(attributeValue) + result := att.Value().(media.PNGImage) + + assert.DeepEqual(t, expected, result) + assert.Equal(t, expected.Base64URL(), result.Base64URL()) +} + +func TestProfile_GetAttribute_Bool(t *testing.T) { + var initialBoolValue = true + attrValue := []byte(strconv.FormatBool(initialBoolValue)) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attrValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + boolValue, err := strconv.ParseBool(att.Value().(string)) + + assert.NilError(t, err) + assert.Equal(t, initialBoolValue, boolValue) +} + +func TestProfile_GetAttribute_JSON(t *testing.T) { + addressFormat := "2" + + var structuredAddressBytes = []byte(` + { + "address_format": "` + addressFormat + `", + "building": "House No.86-A" + }`) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: structuredAddressBytes, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + retrievedAttributeMap := att.Value().(map[string]interface{}) + actualAddressFormat := retrievedAttributeMap["address_format"] + + assert.Equal(t, actualAddressFormat, addressFormat) +} + +func TestProfile_GetAttribute_Undefined(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Name(), attributeName) + assert.Equal(t, att.Value().(string), attributeValueString) +} + +func TestProfile_GetAttribute_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result := userProfile.GetAttribute("attributeName") + + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetAttributeByID(t *testing.T) { + attributeID := "att-id-123" + + var attr1 = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: attributeID, + } + var attr2 = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: "non-matching-attribute-ID", + } + + profile := createProfileWithMultipleAttributes(attr1, attr2) + + result := profile.GetAttributeByID(attributeID) + assert.DeepEqual(t, result.ID(), &attributeID) +} + +func TestProfile_GetAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result := userProfile.GetAttributeByID("attributeName") + + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetDocumentImagesAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result, err := userProfile.GetDocumentImagesAttributeByID("attributeName") + assert.NilError(t, err) + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetSelfieAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result, err := userProfile.GetSelfieAttributeByID("attributeName") + assert.NilError(t, err) + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_StringAttribute(t *testing.T) { + nationalityName := consts.AttrNationality + + var as = &yotiprotoattr.Attribute{ + Name: nationalityName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(as) + + assert.Equal(t, result.Nationality().Value(), attributeValueString) + + assert.Equal(t, result.Nationality().ContentType(), yotiprotoattr.ContentType_STRING.String()) +} + +func TestProfile_AttributeProperty_RetrievesAttribute(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.Equal(t, selfie.Name(), consts.AttrSelfie) + assert.DeepEqual(t, attributeValue, selfie.Value().Data()) + assert.Equal(t, selfie.ContentType(), yotiprotoattr.ContentType_PNG.String()) +} + +func TestProfile_DocumentDetails_RetrievesAttribute(t *testing.T) { + documentDetailsName := consts.AttrDocumentDetails + attributeValue := []byte("PASSPORT GBR 1234567") + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: documentDetailsName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: make([]*yotiprotoattr.Anchor, 0), + } + + result := createProfileWithSingleAttribute(protoAttribute) + documentDetails, err := result.DocumentDetails() + assert.NilError(t, err) + + assert.Equal(t, documentDetails.Value().DocumentType, "PASSPORT") +} + +func TestProfile_DocumentImages_RetrievesAttribute(t *testing.T) { + protoAttribute := createDocumentImagesAttribute("attr-id") + + result := createProfileWithSingleAttribute(protoAttribute) + documentImages, err := result.DocumentImages() + assert.NilError(t, err) + + assert.Equal(t, documentImages.Name(), consts.AttrDocumentImages) +} + +func TestProfile_AttributesReturnsNilWhenNotPresent(t *testing.T) { + documentImagesName := consts.AttrDocumentImages + multiValue, err := proto.Marshal(&yotiprotoattr.MultiValue{}) + assert.NilError(t, err) + + protoAttribute := &yotiprotoattr.Attribute{ + Name: documentImagesName, + Value: multiValue, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: make([]*yotiprotoattr.Anchor, 0), + } + + result := createProfileWithSingleAttribute(protoAttribute) + + DoB, err := result.DateOfBirth() + assert.Check(t, DoB == nil) + assert.Check(t, err == nil) + assert.Check(t, result.Address() == nil) +} + +func TestMissingPostalAddress_UsesFormattedAddress(t *testing.T) { + var formattedAddressText = `House No.86-A\nRajgura Nagar\nLudhina\nPunjab\n141012\nIndia` + + var structuredAddressBytes = []byte(` + { + "address_format": 2, + "building": "House No.86-A", + "formatted_address": "` + formattedAddressText + `" + } + `) + + var jsonAttribute = &yotiprotoattr.Attribute{ + Name: consts.AttrStructuredPostalAddress, + Value: structuredAddressBytes, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(jsonAttribute) + + ensureAddressProfile(&profile) + + escapedFormattedAddressText := strings.Replace(formattedAddressText, `\n`, "\n", -1) + + profileAddress := profile.Address().Value() + assert.Equal(t, profileAddress, escapedFormattedAddressText, "Address does not equal the expected formatted address.") + + structuredPostalAddress, err := profile.StructuredPostalAddress() + assert.NilError(t, err) + assert.Equal(t, structuredPostalAddress.ContentType(), "JSON") +} + +func TestAttributeImage_Image_Png(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} + +func TestAttributeImage_Image_Jpeg(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_JPEG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} + +func TestAttributeImage_Image_Default(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} +func TestAttributeImage_Base64Selfie_Png(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(attributeValue) + expectedBase64Selfie := "data:image/png;base64," + base64ImageExpectedValue + base64Selfie := result.Selfie().Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestAttributeImage_Base64URL_Jpeg(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_JPEG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(attributeValue) + + expectedBase64Selfie := "data:image/jpeg;base64," + base64ImageExpectedValue + + base64Selfie := result.Selfie().Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestProfile_IdentityProfileReport_RetrievesAttribute(t *testing.T) { + identityProfileReportJSON, err := file.ReadFile("../test/fixtures/RTWIdentityProfileReport.json") + assert.NilError(t, err) + + var attr = &yotiprotoattr.Attribute{ + Name: consts.AttrIdentityProfileReport, + Value: identityProfileReportJSON, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att, err := result.IdentityProfileReport() + assert.NilError(t, err) + + retrievedIdentityProfile := att.Value() + gotProof := retrievedIdentityProfile["proof"] + + assert.Equal(t, gotProof, "") +} + +func TestProfileAllowsMultipleAttributesWithSameName(t *testing.T) { + firstAttribute := createStringAttribute("full_name", []byte("some_value"), []*yotiprotoattr.Anchor{}, "id") + secondAttribute := createStringAttribute("full_name", []byte("some_other_value"), []*yotiprotoattr.Anchor{}, "id") + + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, firstAttribute, secondAttribute) + + var profile = UserProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } + + var fullNames = profile.GetAttributes("full_name") + + assert.Assert(t, is.Equal(len(fullNames), 2)) + assert.Assert(t, is.Equal(fullNames[0].Value().(string), "some_value")) + assert.Assert(t, is.Equal(fullNames[1].Value().(string), "some_other_value")) +} + +func createStringAttribute(name string, value []byte, anchors []*yotiprotoattr.Anchor, attributeID string) *yotiprotoattr.Attribute { + return &yotiprotoattr.Attribute{ + Name: name, + Value: value, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: anchors, + EphemeralId: attributeID, + } +} + +func createSelfieAttribute(contentType yotiprotoattr.ContentType, attributeID string) *yotiprotoattr.Attribute { + var attributeImage = &yotiprotoattr.Attribute{ + Name: consts.AttrSelfie, + Value: attributeValue, + ContentType: contentType, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: attributeID, + } + return attributeImage +} + +func createDocumentImagesAttribute(attributeID string) *yotiprotoattr.Attribute { + multiValue, err := proto.Marshal(&yotiprotoattr.MultiValue{}) + if err != nil { + panic(err) + } + + protoAttribute := &yotiprotoattr.Attribute{ + Name: consts.AttrDocumentImages, + Value: multiValue, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: make([]*yotiprotoattr.Anchor, 0), + EphemeralId: attributeID, + } + return protoAttribute +} diff --git a/go.mod b/go.mod index 457305c5c..c1c87abbf 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ require ( gotest.tools/v3 v3.3.0 ) -require github.com/google/go-cmp v0.5.5 // indirect - +require github.com/google/go-cmp v0.5.5 // indirect go 1.19 diff --git a/go.sum b/go.sum index 29f9b4e49..7b9993976 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= -github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=