diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92673aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +icons/ +json_icon/ +flaticon_colored_svgs/ diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..4431aca --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,6 @@ +version: "3" + +tasks: + sql: + cmds: + - sqlc generate \ No newline at end of file diff --git a/api/nkode_api.go b/api/nkode_api.go new file mode 100644 index 0000000..9c80a90 --- /dev/null +++ b/api/nkode_api.go @@ -0,0 +1,266 @@ +package api + +import ( + "fmt" + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/email" + "git.infra.nkode.tech/dkelly/nkode-core/entities" + "git.infra.nkode.tech/dkelly/nkode-core/repository" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "github.com/google/uuid" + "github.com/patrickmn/go-cache" + "log" + "os" + "time" +) + +const ( + sessionExpiration = 5 * time.Minute + sessionCleanupInterval = 10 * time.Minute +) + +type NKodeAPI struct { + Db repository.CustomerUserRepository + SignupSessionCache *cache.Cache + EmailQueue *email.Queue +} + +func NewNKodeAPI(db repository.CustomerUserRepository, queue *email.Queue) NKodeAPI { + return NKodeAPI{ + Db: db, + EmailQueue: queue, + SignupSessionCache: cache.New(sessionExpiration, sessionCleanupInterval), + } +} + +func (n *NKodeAPI) CreateNewCustomer(nkodePolicy entities.NKodePolicy, id *entities.CustomerId) (*entities.CustomerId, error) { + newCustomer, err := entities.NewCustomer(nkodePolicy) + if id != nil { + newCustomer.Id = *id + } + if err != nil { + return nil, err + } + err = n.Db.CreateCustomer(*newCustomer) + + if err != nil { + return nil, err + } + return &newCustomer.Id, nil +} + +func (n *NKodeAPI) GenerateSignupResetInterface(userEmail entities.UserEmail, customerId entities.CustomerId, kp entities.KeypadDimension, reset bool) (*entities.SignupResetInterface, error) { + user, err := n.Db.GetUser(userEmail, customerId) + if err != nil { + return nil, err + } + if user != nil && !reset { + log.Printf("user %s already exists", string(userEmail)) + return nil, config.ErrUserAlreadyExists + } + svgIdxInterface, err := n.Db.RandomSvgIdxInterface(kp) + if err != nil { + return nil, err + } + signupSession, err := entities.NewSignupResetSession(userEmail, kp, customerId, svgIdxInterface, reset) + if err != nil { + return nil, err + } + if err := n.SignupSessionCache.Add(signupSession.Id.String(), *signupSession, sessionExpiration); err != nil { + return nil, err + } + svgInterface, err := n.Db.GetSvgStringInterface(signupSession.LoginUserInterface.SvgId) + + if err != nil { + return nil, err + } + resp := entities.SignupResetInterface{ + UserIdxInterface: signupSession.SetIdxInterface, + SvgInterface: svgInterface, + SessionId: uuid.UUID(signupSession.Id).String(), + Colors: signupSession.Colors, + } + return &resp, nil +} + +func (n *NKodeAPI) SetNKode(customerId entities.CustomerId, sessionId entities.SessionId, keySelection entities.KeySelection) (entities.IdxInterface, error) { + _, err := n.Db.GetCustomer(customerId) + + if err != nil { + return nil, err + } + session, exists := n.SignupSessionCache.Get(sessionId.String()) + if !exists { + log.Printf("session id does not exist %s", sessionId) + return nil, config.ErrSignupSessionDNE + } + userSession, ok := session.(entities.UserSignSession) + if !ok { + // handle the case where the type assertion fails + return nil, config.ErrSignupSessionDNE + } + confirmInterface, err := userSession.SetUserNKode(keySelection) + if err != nil { + return nil, err + } + n.SignupSessionCache.Set(sessionId.String(), userSession, sessionExpiration) + return confirmInterface, nil +} + +func (n *NKodeAPI) ConfirmNKode(customerId entities.CustomerId, sessionId entities.SessionId, keySelection entities.KeySelection) error { + session, exists := n.SignupSessionCache.Get(sessionId.String()) + if !exists { + log.Printf("session id does not exist %s", sessionId) + return config.ErrSignupSessionDNE + } + userSession, ok := session.(entities.UserSignSession) + if !ok { + // handle the case where the type assertion fails + return config.ErrSignupSessionDNE + } + customer, err := n.Db.GetCustomer(customerId) + if err != nil { + return err + } + passcode, err := userSession.DeducePasscode(keySelection) + if err != nil { + return err + } + if err = customer.IsValidNKode(userSession.Kp, passcode); err != nil { + return err + } + user, err := entities.NewUser(*customer, string(userSession.UserEmail), passcode, userSession.LoginUserInterface, userSession.Kp) + if err != nil { + return err + } + if userSession.Reset { + err = n.Db.UpdateUserNKode(*user) + } else { + err = n.Db.WriteNewUser(*user) + } + n.SignupSessionCache.Delete(userSession.Id.String()) + return err +} + +func (n *NKodeAPI) GetLoginInterface(userEmail entities.UserEmail, customerId entities.CustomerId) (*entities.LoginInterface, error) { + user, err := n.Db.GetUser(userEmail, customerId) + if err != nil { + return nil, err + } + if user == nil { + log.Printf("user %s for customer %s dne", userEmail, customerId) + return nil, config.ErrUserForCustomerDNE + } + svgInterface, err := n.Db.GetSvgStringInterface(user.Interface.SvgId) + if err != nil { + return nil, err + } + resp := entities.LoginInterface{ + UserIdxInterface: user.Interface.IdxInterface, + SvgInterface: svgInterface, + NumbOfKeys: user.Kp.NumbOfKeys, + AttrsPerKey: user.Kp.AttrsPerKey, + Colors: entities.SetColors, + } + return &resp, nil +} + +func (n *NKodeAPI) Login(customerId entities.CustomerId, userEmail entities.UserEmail, keySelection entities.KeySelection) (*security.AuthenticationTokens, error) { + customer, err := n.Db.GetCustomer(customerId) + if err != nil { + return nil, err + } + user, err := n.Db.GetUser(userEmail, customerId) + if err != nil { + return nil, err + } + if user == nil { + log.Printf("user %s for customer %s dne", userEmail, customerId) + return nil, config.ErrUserForCustomerDNE + } + passcode, err := entities.ValidKeyEntry(*user, *customer, keySelection) + if err != nil { + return nil, err + } + + if user.Renew { + err = n.Db.RefreshUserPasscode(*user, passcode, customer.Attributes) + if err != nil { + return nil, err + } + } + jwtToken, err := security.NewAuthenticationTokens(string(user.Email), uuid.UUID(customerId)) + if err != nil { + return nil, err + } + if err = n.Db.UpdateUserRefreshToken(user.Id, jwtToken.RefreshToken); err != nil { + return nil, err + } + if err = user.Interface.LoginShuffle(); err != nil { + return nil, err + } + if err = n.Db.UpdateUserInterface(user.Id, user.Interface); err != nil { + return nil, err + } + return &jwtToken, nil +} + +func (n *NKodeAPI) RenewAttributes(customerId entities.CustomerId) error { + return n.Db.Renew(customerId) +} + +func (n *NKodeAPI) RandomSvgInterface() ([]string, error) { + return n.Db.RandomSvgInterface(entities.KeypadMax) +} + +func (n *NKodeAPI) RefreshToken(userEmail entities.UserEmail, customerId entities.CustomerId, refreshToken string) (string, error) { + user, err := n.Db.GetUser(userEmail, customerId) + if err != nil { + return "", err + } + if user == nil { + log.Printf("user %s for customer %s dne", userEmail, customerId) + return "", config.ErrUserForCustomerDNE + } + if user.RefreshToken != refreshToken { + return "", config.ErrRefreshTokenInvalid + } + refreshClaims, err := security.ParseRegisteredClaimToken(refreshToken) + if err != nil { + return "", err + } + if err = security.ClaimExpired(*refreshClaims); err != nil { + return "", err + } + newAccessClaims := security.NewAccessClaim(string(userEmail), uuid.UUID(customerId)) + return security.EncodeAndSignClaims(newAccessClaims) +} + +func (n *NKodeAPI) ResetNKode(userEmail entities.UserEmail, customerId entities.CustomerId) error { + user, err := n.Db.GetUser(userEmail, customerId) + if err != nil { + return fmt.Errorf("error getting user in rest nkode %v", err) + } + + if user == nil { + return nil + } + + nkodeResetJwt, err := security.ResetNKodeToken(string(userEmail), uuid.UUID(customerId)) + if err != nil { + return err + } + frontendHost := os.Getenv("FRONTEND_HOST") + if frontendHost == "" { + frontendHost = config.FrontendHost + } + htmlBody := fmt.Sprintf("
Click the link to reset your nKode.
Reset nKode", frontendHost, nkodeResetJwt) + email := email.Email{ + Sender: "no-reply@nkode.tech", + Recipient: string(userEmail), + Subject: "nKode Reset", + Content: htmlBody, + } + n.EmailQueue.AddEmail(email) + return nil +} diff --git a/api/nkode_api_test.go b/api/nkode_api_test.go new file mode 100644 index 0000000..4d10418 --- /dev/null +++ b/api/nkode_api_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "git.infra.nkode.tech/dkelly/nkode-core/email" + "git.infra.nkode.tech/dkelly/nkode-core/entities" + "git.infra.nkode.tech/dkelly/nkode-core/repository" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "git.infra.nkode.tech/dkelly/nkode-core/sqlc" + "github.com/stretchr/testify/assert" + "log" + "os" + "testing" +) + +func TestNKodeAPI(t *testing.T) { + //db1 := NewInMemoryDb() + //testNKodeAPI(t, &db1) + + dbPath := os.Getenv("TEST_DB") + ctx := context.Background() + sqliteDb, err := sqlc.OpenSqliteDb(dbPath) + assert.NoError(t, err) + + queue, err := sqlc.NewQueue(sqliteDb, ctx) + assert.NoError(t, err) + queue.Start() + defer func(queue *sqlc.Queue) { + if err := queue.Stop(); err != nil { + log.Fatal(err) + } + }(queue) + sqlitedb := repository.NewSqliteRepository(queue, ctx) + testNKodeAPI(t, &sqlitedb) + + //if _, err := os.Stat(dbPath); err == nil { + // err = os.Remove(dbPath) + // assert.NoError(t, err) + //} else { + // assert.NoError(t, err) + //} +} + +func testNKodeAPI(t *testing.T, db repository.CustomerUserRepository) { + bufferSize := 100 + emailsPerSec := 14 + testClient := email.TestEmailClient{} + queue := email.NewEmailQueue(bufferSize, emailsPerSec, &testClient) + queue.Start() + defer queue.Stop() + attrsPerKey := 5 + numbOfKeys := 4 + for idx := 0; idx < 1; idx++ { + userEmail := entities.UserEmail("test_username" + security.GenerateRandomString(12) + "@example.com") + passcodeLen := 4 + nkodePolicy := entities.NewDefaultNKodePolicy() + keypadSize := entities.KeypadDimension{AttrsPerKey: attrsPerKey, NumbOfKeys: numbOfKeys} + nkodeApi := NewNKodeAPI(db, queue) + customerId, err := nkodeApi.CreateNewCustomer(nkodePolicy, nil) + assert.NoError(t, err) + signupResponse, err := nkodeApi.GenerateSignupResetInterface(userEmail, *customerId, keypadSize, false) + assert.NoError(t, err) + setInterface := signupResponse.UserIdxInterface + sessionIdStr := signupResponse.SessionId + sessionId, err := entities.SessionIdFromString(sessionIdStr) + assert.NoError(t, err) + keypadSize = entities.KeypadDimension{AttrsPerKey: numbOfKeys, NumbOfKeys: numbOfKeys} + userPasscode := setInterface[:passcodeLen] + setKeySelect, err := entities.SelectKeyByAttrIdx(setInterface, userPasscode, keypadSize) + assert.NoError(t, err) + confirmInterface, err := nkodeApi.SetNKode(*customerId, sessionId, setKeySelect) + assert.NoError(t, err) + confirmKeySelect, err := entities.SelectKeyByAttrIdx(confirmInterface, userPasscode, keypadSize) + err = nkodeApi.ConfirmNKode(*customerId, sessionId, confirmKeySelect) + assert.NoError(t, err) + + keypadSize = entities.KeypadDimension{AttrsPerKey: attrsPerKey, NumbOfKeys: numbOfKeys} + loginInterface, err := nkodeApi.GetLoginInterface(userEmail, *customerId) + assert.NoError(t, err) + loginKeySelection, err := entities.SelectKeyByAttrIdx(loginInterface.UserIdxInterface, userPasscode, keypadSize) + assert.NoError(t, err) + _, err = nkodeApi.Login(*customerId, userEmail, loginKeySelection) + assert.NoError(t, err) + + err = nkodeApi.RenewAttributes(*customerId) + assert.NoError(t, err) + + loginInterface, err = nkodeApi.GetLoginInterface(userEmail, *customerId) + assert.NoError(t, err) + loginKeySelection, err = entities.SelectKeyByAttrIdx(loginInterface.UserIdxInterface, userPasscode, keypadSize) + assert.NoError(t, err) + _, err = nkodeApi.Login(*customerId, userEmail, loginKeySelection) + assert.NoError(t, err) + + /// Reset nKode + attrsPerKey = 6 + keypadSize = entities.KeypadDimension{AttrsPerKey: attrsPerKey, NumbOfKeys: numbOfKeys} + resetResponse, err := nkodeApi.GenerateSignupResetInterface(userEmail, *customerId, keypadSize, true) + assert.NoError(t, err) + setInterface = resetResponse.UserIdxInterface + sessionIdStr = resetResponse.SessionId + sessionId, err = entities.SessionIdFromString(sessionIdStr) + assert.NoError(t, err) + keypadSize = entities.KeypadDimension{AttrsPerKey: numbOfKeys, NumbOfKeys: numbOfKeys} + userPasscode = setInterface[:passcodeLen] + setKeySelect, err = entities.SelectKeyByAttrIdx(setInterface, userPasscode, keypadSize) + assert.NoError(t, err) + confirmInterface, err = nkodeApi.SetNKode(*customerId, sessionId, setKeySelect) + assert.NoError(t, err) + confirmKeySelect, err = entities.SelectKeyByAttrIdx(confirmInterface, userPasscode, keypadSize) + err = nkodeApi.ConfirmNKode(*customerId, sessionId, confirmKeySelect) + assert.NoError(t, err) + + keypadSize = entities.KeypadDimension{AttrsPerKey: attrsPerKey, NumbOfKeys: numbOfKeys} + loginInterface2, err := nkodeApi.GetLoginInterface(userEmail, *customerId) + assert.NoError(t, err) + loginKeySelection, err = entities.SelectKeyByAttrIdx(loginInterface2.UserIdxInterface, userPasscode, keypadSize) + assert.NoError(t, err) + _, err = nkodeApi.Login(*customerId, userEmail, loginKeySelection) + assert.NoError(t, err) + signupResponse, err = nkodeApi.GenerateSignupResetInterface(userEmail, *customerId, keypadSize, false) + assert.Error(t, err) + } +} diff --git a/cmd/nkode/nkode.go b/cmd/nkode/nkode.go new file mode 100644 index 0000000..3ee5324 --- /dev/null +++ b/cmd/nkode/nkode.go @@ -0,0 +1,258 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + _ "github.com/mattn/go-sqlite3" // Import the SQLite3 driver + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" +) + +type Icon struct { + Body string `json:"body"` + Width *int `json:"width,omitempty"` +} + +// Root Define the Root struct to represent the entire JSON structure +type Root struct { + Prefix string `json:"prefix"` + Icons map[string]Icon `json:"icons"` + Height int `json:"height"` +} + +func main() { + testDbPath := os.Getenv("TEST_DB_PATH") + dbPath := os.Getenv("DB_PATH") + dbPaths := []string{testDbPath, dbPath} + flaticonSvgDir := os.Getenv("SVG_DIR") + //dbPath := "/Users/donov/Desktop/nkode.db" + //dbPaths := []string{dbPath} + //outputStr := MakeSvgFiles() + for _, path := range dbPaths { + MakeTables(path) + FlaticonToSqlite(path, flaticonSvgDir) + //SvgToSqlite(path, outputStr) + } +} + +func FlaticonToSqlite(dbPath string, svgDir string) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // Open the directory + files, err := os.ReadDir(svgDir) + if err != nil { + log.Fatal(err) + } + + for _, file := range files { + // Check if it is a regular file (not a directory) and has a .svg extension + if file.IsDir() || filepath.Ext(file.Name()) != ".svg" { + continue + } + filePath := filepath.Join(svgDir, file.Name()) + + // Read the file contents + content, err := os.ReadFile(filePath) + if err != nil { + log.Println("Error reading file:", filePath, err) + continue + } + + // Print the file name and first few bytes of the file content + insertSql := ` +INSERT INTO svg_icon (svg) +VALUES (?) +` + _, err = db.Exec(insertSql, string(content)) + if err != nil { + log.Fatal(err) + } + } + +} + +func SvgToSqlite(dbPath string, outputStr string) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + lines := strings.Split(outputStr, "\n") + insertSql := ` +INSERT INTO svg_icon (svg) +VALUES (?) +` + for _, line := range lines { + if line == "" { + continue + } + _, err := db.Exec(insertSql, line) + if err != nil { + log.Fatal(err) + } + } + +} + +func MakeSvgFiles() string { + jsonFiles, err := GetAllFiles("./core/sqlite-init/json") + if err != nil { + log.Fatalf("Error getting JSON files: %v", err) + } + + if len(jsonFiles) == 0 { + log.Fatal("No JSON files found in ./json folder") + } + + var outputStr string + strSet := make(map[string]struct{}) + for _, filename := range jsonFiles { + fileData, err := LoadJson(filename) + if err != nil { + log.Print("Error loading JSON file: ", err) + continue + } + height := fileData.Height + for name, icon := range fileData.Icons { + + width := height + parts := strings.Split(name, "-") + if len(parts) <= 0 { + log.Print(name, " has no parts") + continue + } + part0 := parts[0] + _, exists := strSet[part0] + if exists { + continue + } + if icon.Width != nil { + width = *icon.Width + } + strSet[part0] = struct{}{} + if icon.Body == "" { + continue + } + outputStr = fmt.Sprintf("%s\n", outputStr, width, height, icon.Body) + } + } + return outputStr +} + +func GetAllFiles(dir string) ([]string, error) { + // Use ioutil.ReadDir to list all files in the directory + files, err := ioutil.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("unable to read directory: %v", err) + } + + // Create a slice to hold the JSON filenames + var jsonFiles []string + + // Loop through the files and filter out JSON files + for _, file := range files { + if !file.IsDir() && filepath.Ext(file.Name()) == ".json" { + jsonFiles = append(jsonFiles, filepath.Join(dir, file.Name())) + } + } + + return jsonFiles, nil +} + +func LoadJson(filename string) (*Root, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %v", filename, err) + } + + var root Root + err = json.Unmarshal(data, &root) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) + } + + return &root, nil +} + +func MakeTables(dbPath string) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + createTable := ` +PRAGMA journal_mode=WAL; +--PRAGMA busy_timeout = 5000; -- Wait up to 5 seconds +--PRAGMA synchronous = NORMAL; -- Reduce sync frequency for less locking +--PRAGMA cache_size = -16000; -- Increase cache size (16MB)PRAGMA + +CREATE TABLE IF NOT EXISTS customer ( + id TEXT NOT NULL PRIMARY KEY + ,max_nkode_len INTEGER NOT NULL + ,min_nkode_len INTEGER NOT NULL + ,distinct_sets INTEGER NOT NULL + ,distinct_attributes INTEGER NOT NULL + ,lock_out INTEGER NOT NULL + ,expiration INTEGER NOT NULL + ,attribute_values BLOB NOT NULL + ,set_values BLOB NOT NULL + ,last_renew TEXT NOT NULL + ,created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY + ,email TEXT NOT NULL +-- first_name TEXT NOT NULL +-- last_name TEXT NOT NULL + ,renew INT NOT NULL + ,refresh_token TEXT + ,customer_id TEXT NOT NULL + +-- Enciphered Passcode + ,code TEXT NOT NULL + ,mask TEXT NOT NULL + +-- Keypad Dimensions + ,attributes_per_key INT NOT NULL + ,number_of_keys INT NOT NULL + +-- User Keys + ,alpha_key BLOB NOT NULL + ,set_key BLOB NOT NULL + ,pass_key BLOB NOT NULL + ,mask_key BLOB NOT NULL + ,salt BLOB NOT NULL + ,max_nkode_len INT NOT NULL + +-- User Interface + ,idx_interface BLOB NOT NULL + ,svg_id_interface BLOB NOT NULL + + ,last_login TEXT NULL + ,created_at TEXT + + ,FOREIGN KEY (customer_id) REFERENCES customer(id) + ,UNIQUE(customer_id, email) +); + +CREATE TABLE IF NOT EXISTS svg_icon ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,svg TEXT NOT NULL +); + +` + _, err = db.Exec(createTable) + if err != nil { + log.Fatal(err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..78dba4f --- /dev/null +++ b/config/config.go @@ -0,0 +1,5 @@ +package config + +const ( + FrontendHost = "https://nkode.tech" +) diff --git a/config/constants.go b/config/constants.go new file mode 100644 index 0000000..54d18ac --- /dev/null +++ b/config/constants.go @@ -0,0 +1,66 @@ +package config + +import ( + "errors" + "net/http" +) + +var ( + ErrInvalidNKodeLength = errors.New("invalid nKode length") + ErrInvalidNKodeIdx = errors.New("invalid passcode attribute index") + ErrTooFewDistinctSet = errors.New("too few distinct sets") + ErrTooFewDistinctAttributes = errors.New("too few distinct attributes") + ErrEmailAlreadySent = errors.New("email already sent") + ErrClaimExpOrNil = errors.New("claim expired or nil") + ErrInvalidJwt = errors.New("invalid jwt") + ErrInvalidKeypadDimensions = errors.New("keypad dimensions out of range") + ErrUserAlreadyExists = errors.New("user already exists") + ErrSignupSessionDNE = errors.New("signup session does not exist") + ErrUserForCustomerDNE = errors.New("user does not exist") + ErrRefreshTokenInvalid = errors.New("refresh token invalid") + ErrCustomerDne = errors.New("customer does not exist") + ErrSvgDne = errors.New("svg ") + ErrStoppingDatabase = errors.New("stopping database") + ErrSqliteTx = errors.New("sqlite begin, exec, query, or commit error. see logs") + ErrEmptySvgTable = errors.New("empty svg_icon table") + ErrKeyIndexOutOfRange = errors.New("one or more keys is out of range") + ErrAttributeIndexOutOfRange = errors.New("attribute index out of range") + ErrInternalValidKeyEntry = errors.New("internal validation error") + ErrUserMaskTooLong = errors.New("user mask length exceeds max nkode length") + ErrInterfaceNotDispersible = errors.New("interface is not dispersible") + ErrIncompleteUserSignupSession = errors.New("incomplete user signup session") + ErrSetConfirmSignupMismatch = errors.New("set and confirm nkode are not the same") + ErrKeypadIsNotDispersible = errors.New("keypad is not dispersible") + ErrInvalidNKode = errors.New("invalid nKode") + ErrStringIsNotAnSVG = errors.New("string is not an svg") +) + +var HttpErrMap = map[error]int{ + ErrInvalidNKodeLength: http.StatusBadRequest, + ErrInvalidNKodeIdx: http.StatusBadRequest, + ErrTooFewDistinctSet: http.StatusBadRequest, + ErrTooFewDistinctAttributes: http.StatusBadRequest, + ErrEmailAlreadySent: http.StatusBadRequest, + ErrClaimExpOrNil: http.StatusForbidden, + ErrInvalidJwt: http.StatusForbidden, + ErrInvalidKeypadDimensions: http.StatusBadRequest, + ErrUserAlreadyExists: http.StatusBadRequest, + ErrSignupSessionDNE: http.StatusBadRequest, + ErrUserForCustomerDNE: http.StatusBadRequest, + ErrRefreshTokenInvalid: http.StatusForbidden, + ErrCustomerDne: http.StatusBadRequest, + ErrSvgDne: http.StatusBadRequest, + ErrStoppingDatabase: http.StatusInternalServerError, + ErrSqliteTx: http.StatusInternalServerError, + ErrEmptySvgTable: http.StatusInternalServerError, + ErrKeyIndexOutOfRange: http.StatusBadRequest, + ErrAttributeIndexOutOfRange: http.StatusInternalServerError, + ErrInternalValidKeyEntry: http.StatusInternalServerError, + ErrUserMaskTooLong: http.StatusInternalServerError, + ErrInterfaceNotDispersible: http.StatusInternalServerError, + ErrIncompleteUserSignupSession: http.StatusBadRequest, + ErrSetConfirmSignupMismatch: http.StatusBadRequest, + ErrKeypadIsNotDispersible: http.StatusInternalServerError, + ErrInvalidNKode: http.StatusBadRequest, + ErrStringIsNotAnSVG: http.StatusInternalServerError, +} diff --git a/email/queue.go b/email/queue.go new file mode 100644 index 0000000..57ad73f --- /dev/null +++ b/email/queue.go @@ -0,0 +1,150 @@ +package email + +import ( + "context" + "fmt" + config2 "git.infra.nkode.tech/dkelly/nkode-core/config" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ses" + "github.com/aws/aws-sdk-go-v2/service/ses/types" + "github.com/patrickmn/go-cache" + "log" + "sync" + "time" +) + +type Client interface { + SendEmail(Email) error +} + +type Email struct { + Sender string + Recipient string + Subject string + Content string +} + +type TestEmailClient struct{} + +func (c *TestEmailClient) SendEmail(email Email) error { + fmt.Printf("Sending email to %s\n", email.Recipient) + return nil +} + +type SESClient struct { + ResetCache *cache.Cache +} + +const ( + emailRetryExpiration = 5 * time.Minute + sesCleanupInterval = 10 * time.Minute +) + +func NewSESClient() SESClient { + return SESClient{ + ResetCache: cache.New(emailRetryExpiration, sesCleanupInterval), + } +} + +func (s *SESClient) SendEmail(email Email) error { + if _, exists := s.ResetCache.Get(email.Recipient); exists { + log.Printf("email already sent to %s with subject %s", email.Recipient, email.Subject) + return config2.ErrEmailAlreadySent + } + + cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion("us-east-1")) + if err != nil { + errMsg := fmt.Sprintf("unable to load SDK config, %v", err) + log.Print(errMsg) + return err + } + + sesClient := ses.NewFromConfig(cfg) + + input := &ses.SendEmailInput{ + Destination: &types.Destination{ + ToAddresses: []string{email.Recipient}, + }, + Message: &types.Message{ + Body: &types.Body{ + Html: &types.Content{ + Data: aws.String(email.Content), + }, + }, + Subject: &types.Content{ + Data: aws.String(email.Subject), + }, + }, + Source: aws.String(email.Sender), + } + + if err = s.ResetCache.Add(email.Recipient, nil, emailRetryExpiration); err != nil { + return err + } + + resp, err := sesClient.SendEmail(context.TODO(), input) + if err != nil { + s.ResetCache.Delete(email.Recipient) + errMsg := fmt.Sprintf("failed to send email, %v", err) + log.Print(errMsg) + return err + } + + fmt.Printf("UserEmail sent successfully, Message ID: %s\n", *resp.MessageId) + return nil +} + +type Queue struct { + stop bool + emailQueue chan Email + rateLimit <-chan time.Time + client Client + wg sync.WaitGroup + FailedSendCount int +} + +func NewEmailQueue(bufferSize int, emailsPerSecond int, client Client) *Queue { + rateLimit := time.Tick(time.Second / time.Duration(emailsPerSecond)) + + return &Queue{ + stop: false, + emailQueue: make(chan Email, bufferSize), + rateLimit: rateLimit, + client: client, + FailedSendCount: 0, + } +} + +func (q *Queue) AddEmail(email Email) { + if q.stop { + log.Printf("email %s with subject %s not add. Stopping queue", email.Recipient, email.Subject) + return + } + q.wg.Add(1) + q.emailQueue <- email +} + +func (q *Queue) Start() { + q.stop = false + go func() { + for email := range q.emailQueue { + <-q.rateLimit + q.sendEmail(email) + q.wg.Done() + } + }() +} + +func (q *Queue) sendEmail(email Email) { + if err := q.client.SendEmail(email); err != nil { + q.FailedSendCount += 1 + log.Printf("Failed to send email to %s: %v\n", email.Recipient, err) + } +} + +func (q *Queue) Stop() { + q.stop = true + q.wg.Wait() + close(q.emailQueue) +} diff --git a/email/queue_test.go b/email/queue_test.go new file mode 100644 index 0000000..9baee84 --- /dev/null +++ b/email/queue_test.go @@ -0,0 +1,29 @@ +package email + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEmailQueue(t *testing.T) { + queue := NewEmailQueue(100, 14, &TestEmailClient{}) + + // Start the queue processing + queue.Start() + + // Enqueue some emails + for i := 1; i <= 28; i++ { + email := Email{ + Sender: "test@example.com", + Recipient: fmt.Sprintf("user%d@example.com", i), + Subject: "test subject", + Content: "This is a test email", + } + queue.AddEmail(email) + } + // Stop the queue after all emails are processed + queue.Stop() + + assert.Equal(t, queue.FailedSendCount, 0) +} diff --git a/entities/customer.go b/entities/customer.go new file mode 100644 index 0000000..916f8b9 --- /dev/null +++ b/entities/customer.go @@ -0,0 +1,102 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "git.infra.nkode.tech/dkelly/nkode-core/sqlc" + "git.infra.nkode.tech/dkelly/nkode-core/utils" + "github.com/DonovanKelly/sugar-n-spice/set" + "github.com/google/uuid" +) + +type Customer struct { + Id CustomerId + NKodePolicy NKodePolicy + Attributes CustomerAttributes +} + +func NewCustomer(nkodePolicy NKodePolicy) (*Customer, error) { + customerAttrs, err := NewCustomerAttributes() + if err != nil { + return nil, err + } + customer := Customer{ + Id: CustomerId(uuid.New()), + NKodePolicy: nkodePolicy, + Attributes: *customerAttrs, + } + + return &customer, nil +} + +func (c *Customer) IsValidNKode(kp KeypadDimension, passcodeAttrIdx []int) error { + nkodeLen := len(passcodeAttrIdx) + if nkodeLen < c.NKodePolicy.MinNkodeLen || nkodeLen > c.NKodePolicy.MaxNkodeLen { + return config.ErrInvalidNKodeLength + } + + if validIdx := kp.ValidateAttributeIndices(passcodeAttrIdx); !validIdx { + return config.ErrInvalidNKodeIdx + } + passcodeSetVals := make(set.Set[uint64]) + passcodeAttrVals := make(set.Set[uint64]) + attrVals, err := c.Attributes.AttrValsForKp(kp) + if err != nil { + return err + } + for idx := 0; idx < nkodeLen; idx++ { + attrVal := attrVals[passcodeAttrIdx[idx]] + setVal, err := c.Attributes.GetAttrSetVal(attrVal, kp) + if err != nil { + return err + } + passcodeSetVals.Add(setVal) + passcodeAttrVals.Add(attrVal) + } + + if passcodeSetVals.Size() < c.NKodePolicy.DistinctSets { + return config.ErrTooFewDistinctSet + } + + if passcodeAttrVals.Size() < c.NKodePolicy.DistinctAttributes { + return config.ErrTooFewDistinctAttributes + } + return nil +} + +func (c *Customer) RenewKeys() ([]uint64, []uint64, error) { + oldAttrs := make([]uint64, len(c.Attributes.AttrVals)) + oldSets := make([]uint64, len(c.Attributes.SetVals)) + + copy(oldAttrs, c.Attributes.AttrVals) + copy(oldSets, c.Attributes.SetVals) + + if err := c.Attributes.Renew(); err != nil { + return nil, nil, err + } + attrsXor, err := security.XorLists(oldAttrs, c.Attributes.AttrVals) + if err != nil { + return nil, nil, err + } + setXor, err := security.XorLists(oldSets, c.Attributes.SetVals) + if err != nil { + return nil, nil, err + } + return setXor, attrsXor, nil +} + +func (c *Customer) ToSqlcCreateCustomerParams() sqlc.CreateCustomerParams { + return sqlc.CreateCustomerParams{ + ID: uuid.UUID(c.Id).String(), + MaxNkodeLen: int64(c.NKodePolicy.MaxNkodeLen), + MinNkodeLen: int64(c.NKodePolicy.MinNkodeLen), + DistinctSets: int64(c.NKodePolicy.DistinctSets), + DistinctAttributes: int64(c.NKodePolicy.DistinctAttributes), + LockOut: int64(c.NKodePolicy.LockOut), + Expiration: int64(c.NKodePolicy.Expiration), + AttributeValues: c.Attributes.AttrBytes(), + SetValues: c.Attributes.SetBytes(), + LastRenew: utils.TimeStamp(), + CreatedAt: utils.TimeStamp(), + } +} diff --git a/entities/customer_attributes.go b/entities/customer_attributes.go new file mode 100644 index 0000000..54e090b --- /dev/null +++ b/entities/customer_attributes.go @@ -0,0 +1,94 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/security" + "log" +) + +type CustomerAttributes struct { + AttrVals []uint64 + SetVals []uint64 +} + +func NewCustomerAttributes() (*CustomerAttributes, error) { + attrVals, err := security.GenerateRandomNonRepeatingUint64(KeypadMax.TotalAttrs()) + if err != nil { + log.Print("unable to generate attribute vals: ", err) + return nil, err + } + setVals, err := security.GenerateRandomNonRepeatingUint64(KeypadMax.AttrsPerKey) + if err != nil { + log.Print("unable to generate set vals: ", err) + return nil, err + } + + customerAttrs := CustomerAttributes{ + AttrVals: attrVals, + SetVals: setVals, + } + return &customerAttrs, nil +} + +func NewCustomerAttributesFromBytes(attrBytes []byte, setBytes []byte) CustomerAttributes { + return CustomerAttributes{ + AttrVals: security.ByteArrToUint64Arr(attrBytes), + SetVals: security.ByteArrToUint64Arr(setBytes), + } +} + +func (c *CustomerAttributes) Renew() error { + attrVals, err := security.GenerateRandomNonRepeatingUint64(KeypadMax.TotalAttrs()) + if err != nil { + return err + } + setVals, err := security.GenerateRandomNonRepeatingUint64(KeypadMax.AttrsPerKey) + if err != nil { + return err + } + c.AttrVals = attrVals + c.SetVals = setVals + return nil +} + +func (c *CustomerAttributes) IndexOfAttr(attrVal uint64) (int, error) { + // TODO: should this be mapped instead? + return security.IndexOf[uint64](c.AttrVals, attrVal) +} + +func (c *CustomerAttributes) IndexOfSet(setVal uint64) (int, error) { + // TODO: should this be mapped instead? + return security.IndexOf[uint64](c.SetVals, setVal) +} + +func (c *CustomerAttributes) GetAttrSetVal(attrVal uint64, userKeypad KeypadDimension) (uint64, error) { + indexOfAttr, err := c.IndexOfAttr(attrVal) + if err != nil { + return 0, err + } + setIdx := indexOfAttr % userKeypad.AttrsPerKey + return c.SetVals[setIdx], nil +} + +func (c *CustomerAttributes) AttrValsForKp(userKp KeypadDimension) ([]uint64, error) { + err := userKp.IsValidKeypadDimension() + if err != nil { + return nil, err + } + return c.AttrVals[:userKp.TotalAttrs()], nil +} + +func (c *CustomerAttributes) SetValsForKp(userKp KeypadDimension) ([]uint64, error) { + err := userKp.IsValidKeypadDimension() + if err != nil { + return nil, err + } + return c.SetVals[:userKp.AttrsPerKey], nil +} + +func (c *CustomerAttributes) AttrBytes() []byte { + return security.Uint64ArrToByteArr(c.AttrVals) +} + +func (c *CustomerAttributes) SetBytes() []byte { + return security.Uint64ArrToByteArr(c.SetVals) +} diff --git a/entities/customer_test.go b/entities/customer_test.go new file mode 100644 index 0000000..89cf83f --- /dev/null +++ b/entities/customer_test.go @@ -0,0 +1,57 @@ +package entities + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestCustomer(t *testing.T) { + testNewCustomerAttributes(t) + testCustomerValidKeyEntry(t) + testCustomerIsValidNKode(t) +} + +func testNewCustomerAttributes(t *testing.T) { + _, nil := NewCustomerAttributes() + assert.NoError(t, nil) +} + +func testCustomerValidKeyEntry(t *testing.T) { + kp := KeypadDimension{AttrsPerKey: 10, NumbOfKeys: 9} + nkodePolicy := NewDefaultNKodePolicy() + customer, err := NewCustomer(nkodePolicy) + assert.NoError(t, err) + mockSvgInterface := make(SvgIdInterface, kp.TotalAttrs()) + userInterface, err := NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + userEmail := "testing@example.com" + passcodeIdx := []int{0, 1, 2, 3} + user, err := NewUser(*customer, userEmail, passcodeIdx, *userInterface, kp) + assert.NoError(t, err) + userLoginInterface, err := user.GetLoginInterface() + assert.NoError(t, err) + selectedKeys, err := SelectKeyByAttrIdx(userLoginInterface, passcodeIdx, kp) + assert.NoError(t, err) + validatedPasscode, err := ValidKeyEntry(*user, *customer, selectedKeys) + assert.NoError(t, err) + assert.Equal(t, len(validatedPasscode), len(passcodeIdx)) + for idx := range validatedPasscode { + assert.Equal(t, validatedPasscode[idx], passcodeIdx[idx]) + } +} + +func testCustomerIsValidNKode(t *testing.T) { + kp := KeypadDimension{AttrsPerKey: 10, NumbOfKeys: 7} + nkodePolicy := NewDefaultNKodePolicy() + customer, err := NewCustomer(nkodePolicy) + assert.NoError(t, err) + mockSvgInterface := make(SvgIdInterface, kp.TotalAttrs()) + userInterface, err := NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + userEmail := "testing123@example.com" + passcodeIdx := []int{0, 1, 2, 3} + user, err := NewUser(*customer, userEmail, passcodeIdx, *userInterface, kp) + assert.NoError(t, err) + err = customer.IsValidNKode(user.Kp, passcodeIdx) + assert.NoError(t, err) +} diff --git a/entities/keypad_dimension.go b/entities/keypad_dimension.go new file mode 100644 index 0000000..373a883 --- /dev/null +++ b/entities/keypad_dimension.go @@ -0,0 +1,55 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "github.com/DonovanKelly/sugar-n-spice/all" +) + +type KeypadDimension struct { + AttrsPerKey int `json:"attrs_per_key"` + NumbOfKeys int `json:"numb_of_keys"` +} + +func (kp *KeypadDimension) TotalAttrs() int { + return kp.AttrsPerKey * kp.NumbOfKeys +} + +func (kp *KeypadDimension) IsDispersable() bool { + return kp.AttrsPerKey <= kp.NumbOfKeys +} + +func (kp *KeypadDimension) IsValidKeypadDimension() error { + if KeypadMin.AttrsPerKey > kp.AttrsPerKey || KeypadMax.AttrsPerKey < kp.AttrsPerKey || KeypadMin.NumbOfKeys > kp.NumbOfKeys || KeypadMax.NumbOfKeys < kp.NumbOfKeys { + return config.ErrInvalidKeypadDimensions + } + return nil +} + +func (kp *KeypadDimension) ValidKeySelections(selectedKeys []int) bool { + return all.All[int](selectedKeys, func(idx int) bool { + return 0 <= idx && idx < kp.NumbOfKeys + }) +} + +func (kp *KeypadDimension) ValidateAttributeIndices(attrIndicies []int) bool { + return all.All[int](attrIndicies, func(i int) bool { + return i >= 0 && i < kp.TotalAttrs() + }) +} + +var ( + KeypadMax = KeypadDimension{ + AttrsPerKey: 16, + NumbOfKeys: 15, + } + + KeypadMin = KeypadDimension{ + AttrsPerKey: 5, + NumbOfKeys: 4, + } + + KeypadDefault = KeypadDimension{ + AttrsPerKey: 14, + NumbOfKeys: 7, + } +) diff --git a/entities/keypad_utils.go b/entities/keypad_utils.go new file mode 100644 index 0000000..6d41ddd --- /dev/null +++ b/entities/keypad_utils.go @@ -0,0 +1,23 @@ +package entities + +import ( + "errors" + "fmt" + "git.infra.nkode.tech/dkelly/nkode-core/security" +) + +func SelectKeyByAttrIdx(interfaceUser []int, passcodeIdxs []int, keypadSize KeypadDimension) ([]int, error) { + selectedKeys := make([]int, len(passcodeIdxs)) + for idx := range passcodeIdxs { + attrIdx, err := security.IndexOf[int](interfaceUser, passcodeIdxs[idx]) + if err != nil { + return nil, err + } + keyNumb := attrIdx / keypadSize.AttrsPerKey + if keyNumb >= keypadSize.NumbOfKeys { + return nil, errors.New(fmt.Sprintf("index key number: %d out of range 0-%d", keyNumb, keypadSize.NumbOfKeys-1)) + } + selectedKeys[idx] = keyNumb + } + return selectedKeys, nil +} diff --git a/entities/models.go b/entities/models.go new file mode 100644 index 0000000..fdaa57b --- /dev/null +++ b/entities/models.go @@ -0,0 +1,101 @@ +package entities + +import ( + "fmt" + "github.com/google/uuid" + "net/mail" + "strings" +) + +type KeySelection []int + +type CustomerId uuid.UUID + +func CustomerIdToString(customerId CustomerId) string { + customerUuid := uuid.UUID(customerId) + return customerUuid.String() +} + +type SessionId uuid.UUID +type UserId uuid.UUID + +func UserIdFromString(userId string) UserId { + id, err := uuid.Parse(userId) + if err != nil { + fmt.Errorf("unable to parse user id %+v", err) + } + return UserId(id) +} + +func (s *SessionId) String() string { + id := uuid.UUID(*s) + return id.String() +} + +type UserEmail string + +func ParseEmail(email string) (UserEmail, error) { + _, err := mail.ParseAddress(email) + if err != nil { + return "", err + } + return UserEmail(strings.ToLower(email)), err + +} + +type IdxInterface []int +type SvgIdInterface []int + +func SessionIdFromString(sessionId string) (SessionId, error) { + id, err := uuid.Parse(sessionId) + if err != nil { + return SessionId{}, err + } + + return SessionId(id), nil +} + +type EncipheredNKode struct { + Code string + Mask string +} + +type RGBColor struct { + Red int `json:"red"` + Green int `json:"green"` + Blue int `json:"blue"` +} + +var SetColors = []RGBColor{ + {0, 0, 0}, // Black + {255, 0, 0}, // Red + {0, 128, 0}, // Dark Green + {0, 0, 255}, // Blue + {244, 200, 60}, // Yellow + {255, 0, 255}, // Magenta + {0, 200, 200}, // Cyan + {127, 0, 127}, // Purple + {232, 92, 13}, // Orange + {0, 127, 127}, // Teal + {127, 127, 0}, // Olive + {127, 0, 0}, // Dark Red + {128, 128, 128}, // Gray + {228, 102, 102}, // Dark Purple + {185, 17, 240}, // Salmon + {16, 200, 100}, // Green +} + +type SignupResetInterface struct { + SessionId string `json:"session_id"` + UserIdxInterface IdxInterface `json:"user_interface"` + SvgInterface []string `json:"svg_interface"` + Colors []RGBColor `json:"colors"` +} + +type LoginInterface struct { + UserIdxInterface IdxInterface `json:"user_interface"` + SvgInterface []string `json:"svg_interface"` + AttrsPerKey int `json:"attrs_per_key"` + NumbOfKeys int `json:"numb_of_keys"` + Colors []RGBColor `json:"colors"` +} diff --git a/entities/policy.go b/entities/policy.go new file mode 100644 index 0000000..1798cb7 --- /dev/null +++ b/entities/policy.go @@ -0,0 +1,34 @@ +package entities + +import "git.infra.nkode.tech/dkelly/nkode-core/config" + +type NKodePolicy struct { + MaxNkodeLen int `json:"max_nkode_len"` + MinNkodeLen int `json:"min_nkode_len"` + DistinctSets int `json:"distinct_sets"` + DistinctAttributes int `json:"distinct_attributes"` + LockOut int `json:"lock_out"` + Expiration int `json:"expiration"` // seconds, -1 no expiration +} + +func NewDefaultNKodePolicy() NKodePolicy { + return NKodePolicy{ + MinNkodeLen: 4, + MaxNkodeLen: 4, + DistinctSets: 0, + DistinctAttributes: 4, + LockOut: 5, + Expiration: -1, + } +} + +func (p *NKodePolicy) ValidLength(nkodeLen int) error { + + if nkodeLen < p.MinNkodeLen || nkodeLen > p.MaxNkodeLen { + return config.ErrInvalidNKodeLength + } + // TODO: validate Max > Min + // Validate lockout + // Add Lockout To User + return nil +} diff --git a/entities/user.go b/entities/user.go new file mode 100644 index 0000000..c701bc3 --- /dev/null +++ b/entities/user.go @@ -0,0 +1,142 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "github.com/google/uuid" + "log" +) + +type User struct { + Id UserId + CustomerId CustomerId + Email UserEmail + EncipheredPasscode EncipheredNKode + Kp KeypadDimension + CipherKeys UserCipherKeys + Interface UserInterface + Renew bool + RefreshToken string +} + +func (u *User) DecipherMask(setVals []uint64, passcodeLen int) ([]uint64, error) { + return u.CipherKeys.DecipherMask(u.EncipheredPasscode.Mask, setVals, passcodeLen) +} + +func (u *User) RenewKeys(setXor []uint64, attrXor []uint64) error { + u.Renew = true + var err error + u.CipherKeys.SetKey, err = security.XorLists(setXor[:u.Kp.AttrsPerKey], u.CipherKeys.SetKey) + if err != nil { + return err + } + u.CipherKeys.AlphaKey, err = security.XorLists(attrXor[:u.Kp.TotalAttrs()], u.CipherKeys.AlphaKey) + return err +} + +func (u *User) RefreshPasscode(passcodeAttrIdx []int, customerAttributes CustomerAttributes) error { + setVals, err := customerAttributes.SetValsForKp(u.Kp) + if err != nil { + return err + } + newKeys, err := NewUserCipherKeys(&u.Kp, setVals, u.CipherKeys.MaxNKodeLen) + if err != nil { + return err + } + encipheredPasscode, err := newKeys.EncipherNKode(passcodeAttrIdx, customerAttributes) + if err != nil { + return err + } + + u.CipherKeys = *newKeys + u.EncipheredPasscode = *encipheredPasscode + u.Renew = false + return nil +} + +func (u *User) GetLoginInterface() ([]int, error) { + return u.Interface.IdxInterface, nil +} + +func ValidKeyEntry(user User, customer Customer, selectedKeys []int) ([]int, error) { + if validKeys := user.Kp.ValidKeySelections(selectedKeys); !validKeys { + + return nil, config.ErrKeyIndexOutOfRange + } + + passcodeLen := len(selectedKeys) + if err := customer.NKodePolicy.ValidLength(passcodeLen); err != nil { + return nil, err + } + + setVals, err := customer.Attributes.SetValsForKp(user.Kp) + if err != nil { + log.Printf("fatal error in validate key entry;invalid user keypad dimensions for user %s with error %v", user.Email, err) + return nil, config.ErrInternalValidKeyEntry + } + + passcodeSetVals, err := user.DecipherMask(setVals, passcodeLen) + if err != nil { + log.Printf("fatal error in validate key entry;something when wrong deciphering mask;user email %s; error %v", user.Email, err) + return nil, config.ErrInternalValidKeyEntry + } + presumedAttrIdxVals := make([]int, passcodeLen) + + for idx := range presumedAttrIdxVals { + keyNumb := selectedKeys[idx] + setIdx, err := customer.Attributes.IndexOfSet(passcodeSetVals[idx]) + if err != nil { + log.Printf("fatal error in validate key entry;something when wrong getting the IndexOfSet;user email %s; error %v", user.Email, err) + return nil, config.ErrInternalValidKeyEntry + } + selectedAttrIdx, err := user.Interface.GetAttrIdxByKeyNumbSetIdx(setIdx, keyNumb) + if err != nil { + log.Printf("fatal error in validate key entry;something when wrong getting the GetAttrIdxByKeyNumbSetIdx;user email %s; error %v", user.Email, err) + return nil, config.ErrInternalValidKeyEntry + } + presumedAttrIdxVals[idx] = selectedAttrIdx + } + err = customer.IsValidNKode(user.Kp, presumedAttrIdxVals) + if err != nil { + return nil, err + } + attrVals, err := customer.Attributes.AttrValsForKp(user.Kp) + if err != nil { + return nil, err + } + err = user.CipherKeys.ValidPassword(user.EncipheredPasscode.Code, presumedAttrIdxVals, attrVals) + if err != nil { + return nil, err + } + + return presumedAttrIdxVals, nil +} + +func NewUser(customer Customer, userEmail string, passcodeIdx []int, ui UserInterface, kp KeypadDimension) (*User, error) { + _, err := ParseEmail(userEmail) + if err != nil { + return nil, err + } + setVals, err := customer.Attributes.SetValsForKp(kp) + if err != nil { + return nil, err + } + newKeys, err := NewUserCipherKeys(&kp, setVals, customer.NKodePolicy.MaxNkodeLen) + if err != nil { + return nil, err + } + encipheredNKode, err := newKeys.EncipherNKode(passcodeIdx, customer.Attributes) + if err != nil { + return nil, err + } + newUser := User{ + Id: UserId(uuid.New()), + Email: UserEmail(userEmail), + EncipheredPasscode: *encipheredNKode, + CipherKeys: *newKeys, + Interface: ui, + Kp: kp, + CustomerId: customer.Id, + } + return &newUser, nil +} diff --git a/entities/user_cipher_keys.go b/entities/user_cipher_keys.go new file mode 100644 index 0000000..22071ff --- /dev/null +++ b/entities/user_cipher_keys.go @@ -0,0 +1,193 @@ +package entities + +import ( + "crypto/sha256" + "errors" + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "golang.org/x/crypto/bcrypt" +) + +type UserCipherKeys struct { + AlphaKey []uint64 + SetKey []uint64 + PassKey []uint64 + MaskKey []uint64 + Salt []byte + MaxNKodeLen int + Kp *KeypadDimension +} + +func NewUserCipherKeys(kp *KeypadDimension, setVals []uint64, maxNKodeLen int) (*UserCipherKeys, error) { + err := kp.IsValidKeypadDimension() + if err != nil { + return nil, err + } + setKey, err := security.GenerateRandomNonRepeatingUint64(kp.AttrsPerKey) + if err != nil { + return nil, err + } + setKey, err = security.XorLists(setKey, setVals) + if err != nil { + return nil, err + } + + alphaKey, _ := security.GenerateRandomNonRepeatingUint64(kp.TotalAttrs()) + passKey, _ := security.GenerateRandomNonRepeatingUint64(maxNKodeLen) + maskKey, _ := security.GenerateRandomNonRepeatingUint64(maxNKodeLen) + salt, _ := security.RandomBytes(10) + userCipherKeys := UserCipherKeys{ + AlphaKey: alphaKey, + PassKey: passKey, + MaskKey: maskKey, + SetKey: setKey, + Salt: salt, + MaxNKodeLen: maxNKodeLen, + Kp: kp, + } + return &userCipherKeys, nil +} + +func (u *UserCipherKeys) PadUserMask(userMask []uint64, setVals []uint64) ([]uint64, error) { + if len(userMask) > u.MaxNKodeLen { + return nil, config.ErrUserMaskTooLong + } + paddedUserMask := make([]uint64, len(userMask)) + copy(paddedUserMask, userMask) + for i := len(userMask); i < u.MaxNKodeLen; i++ { + paddedUserMask = append(paddedUserMask, setVals[i%len(setVals)]) + } + return paddedUserMask, nil +} + +func (u *UserCipherKeys) ValidPassword(hashedPassword string, passcodeAttrIdx []int, attrVals []uint64) error { + hashBytes := []byte(hashedPassword) + passcodeCipher := u.encipherCode(passcodeAttrIdx, attrVals) + passwordDigest := u.saltAndDigest(passcodeCipher) + err := bcrypt.CompareHashAndPassword(hashBytes, passwordDigest) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return config.ErrInvalidNKode + } + return err + } + return nil +} + +func (u *UserCipherKeys) EncipherSaltHashCode(passcodeAttrIdx []int, attrVals []uint64) (string, error) { + passcodeCipher := u.encipherCode(passcodeAttrIdx, attrVals) + + passcodeDigest := u.saltAndDigest(passcodeCipher) + passcodeBytes, err := u.hashPasscode(passcodeDigest) + if err != nil { + return "", err + } + return string(passcodeBytes), nil +} + +func (u *UserCipherKeys) encipherCode(passcodeAttrIdx []int, attrVals []uint64) []uint64 { + passcodeLen := len(passcodeAttrIdx) + + passcodeCipher := make([]uint64, u.MaxNKodeLen) + for idx := 0; idx < passcodeLen; idx++ { + attrIdx := passcodeAttrIdx[idx] + alpha := u.AlphaKey[attrIdx] + attrVal := attrVals[attrIdx] + pass := u.PassKey[idx] + passcodeCipher[idx] = alpha ^ pass ^ attrVal + } + return passcodeCipher +} + +func (u *UserCipherKeys) saltAndDigest(passcode []uint64) []byte { + passcodeBytes := security.Uint64ArrToByteArr(passcode) + passcodeBytes = append(passcodeBytes, u.Salt...) + h := sha256.New() + h.Write(passcodeBytes) + + return h.Sum(nil) +} + +func (u *UserCipherKeys) hashPasscode(passcodeDigest []byte) ([]byte, error) { + hashedPassword, err := bcrypt.GenerateFromPassword(passcodeDigest, bcrypt.DefaultCost) + if err != nil { + return nil, err + } + return hashedPassword, nil +} + +func (u *UserCipherKeys) EncipherMask(passcodeSet []uint64, customerAttrs CustomerAttributes, userKp KeypadDimension) (string, error) { + setVals, err := customerAttrs.SetValsForKp(userKp) + if err != nil { + return "", err + } + paddedPasscodeSets, err := u.PadUserMask(passcodeSet, setVals) + if err != nil { + return "", err + } + + cipheredMask := make([]uint64, len(paddedPasscodeSets)) + for idx := range paddedPasscodeSets { + setIdx, err := customerAttrs.IndexOfSet(paddedPasscodeSets[idx]) + if err != nil { + return "", err + } + setKeyVal := u.SetKey[setIdx] + maskKeyVal := u.MaskKey[idx] + setVal := paddedPasscodeSets[idx] + cipheredMask[idx] = setKeyVal ^ maskKeyVal ^ setVal + } + mask := security.EncodeBase64Str(cipheredMask) + return mask, nil +} + +func (u *UserCipherKeys) DecipherMask(mask string, setVals []uint64, passcodeLen int) ([]uint64, error) { + decodedMask, err := security.DecodeBase64Str(mask) + if err != nil { + return nil, err + } + decipheredMask, err := security.XorLists(decodedMask, u.MaskKey) + if err != nil { + return nil, err + } + setKeyRandComponent, err := security.XorLists(setVals, u.SetKey) + if err != nil { + return nil, err + } + + passcodeSet := make([]uint64, passcodeLen) + for idx, setCipher := range decipheredMask[:passcodeLen] { + setIdx, err := security.IndexOf(setKeyRandComponent, setCipher) + if err != nil { + return nil, err + } + passcodeSet[idx] = setVals[setIdx] + } + return passcodeSet, nil +} + +func (u *UserCipherKeys) EncipherNKode(passcodeAttrIdx []int, customerAttrs CustomerAttributes) (*EncipheredNKode, error) { + attrVals, err := customerAttrs.AttrValsForKp(*u.Kp) + code, err := u.EncipherSaltHashCode(passcodeAttrIdx, attrVals) + if err != nil { + return nil, err + } + passcodeSet := make([]uint64, len(passcodeAttrIdx)) + + for idx := range passcodeSet { + passcodeAttr := attrVals[passcodeAttrIdx[idx]] + passcodeSet[idx], err = customerAttrs.GetAttrSetVal(passcodeAttr, *u.Kp) + if err != nil { + return nil, err + } + } + mask, err := u.EncipherMask(passcodeSet, customerAttrs, *u.Kp) + if err != nil { + return nil, err + } + encipheredCode := EncipheredNKode{ + Code: code, + Mask: mask, + } + return &encipheredCode, nil +} diff --git a/entities/user_interface.go b/entities/user_interface.go new file mode 100644 index 0000000..1c0e473 --- /dev/null +++ b/entities/user_interface.go @@ -0,0 +1,185 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "github.com/DonovanKelly/sugar-n-spice/set" + "log" +) + +type UserInterface struct { + IdxInterface IdxInterface + SvgId SvgIdInterface + Kp *KeypadDimension +} + +func NewUserInterface(kp *KeypadDimension, svgId SvgIdInterface) (*UserInterface, error) { + idxInterface := security.IdentityArray(kp.TotalAttrs()) + userInterface := UserInterface{ + IdxInterface: idxInterface, + SvgId: svgId, + Kp: kp, + } + if err := userInterface.RandomShuffle(); err != nil { + return nil, err + } + return &userInterface, nil +} + +func (u *UserInterface) RandomShuffle() error { + err := u.shuffleKeys() + + keypadView, err := u.InterfaceMatrix() + if err != nil { + return err + } + setView, err := security.MatrixTranspose(keypadView) + if err != nil { + return err + } + + for idx, set := range setView { + err := security.FisherYatesShuffle(&set) + if err != nil { + return nil + } + setView[idx] = set + } + + keypadView, err = security.MatrixTranspose(setView) + if err != nil { + return err + } + u.IdxInterface = security.MatrixToList(keypadView) + return nil +} + +func (u *UserInterface) InterfaceMatrix() ([][]int, error) { + return security.ListToMatrix(u.IdxInterface, u.Kp.AttrsPerKey) +} + +func (u *UserInterface) SetViewMatrix() ([][]int, error) { + keypadView, err := u.InterfaceMatrix() + if err != nil { + return nil, err + } + return security.MatrixTranspose(keypadView) +} + +func (u *UserInterface) DisperseInterface() error { + if !u.Kp.IsDispersable() { + return config.ErrInterfaceNotDispersible + } + + err := u.shuffleKeys() + if err != nil { + return err + } + err = u.randomAttributeRotation() + if err != nil { + return err + } + + return nil +} + +func (u *UserInterface) shuffleKeys() error { + userInterfaceMatrix, err := security.ListToMatrix(u.IdxInterface, u.Kp.AttrsPerKey) + if err != nil { + return err + } + err = security.FisherYatesShuffle[[]int](&userInterfaceMatrix) + if err != nil { + return err + } + u.IdxInterface = security.MatrixToList(userInterfaceMatrix) + return nil +} + +func (u *UserInterface) randomAttributeRotation() error { + userInterface, err := u.InterfaceMatrix() + if err != nil { + return err + } + + transposeUserInterface, err := security.MatrixTranspose(userInterface) + + attrRotation, err := security.RandomPermutation(len(transposeUserInterface)) + + if err != nil { + return err + } + for idx, attrSet := range transposeUserInterface { + rotation := attrRotation[idx] + transposeUserInterface[idx] = append(attrSet[rotation:], attrSet[:rotation]...) + } + userInterface, err = security.MatrixTranspose(transposeUserInterface) + if err != nil { + return err + } + u.IdxInterface = security.MatrixToList(userInterface) + return nil +} + +func (u *UserInterface) AttributeAdjacencyGraph() (map[int]set.Set[int], error) { + interfaceKeypad, err := u.InterfaceMatrix() + if err != nil { + return nil, err + } + graph := make(map[int]set.Set[int]) + + for _, key := range interfaceKeypad { + keySet := set.NewSetFromSlice(key) + for _, attr := range key { + attrAdjacency := keySet.Copy() + attrAdjacency.Remove(attr) + graph[attr] = attrAdjacency + } + } + return graph, nil +} + +func (u *UserInterface) LoginShuffle() error { + if err := u.shuffleKeys(); err != nil { + return err + } + keypadSet1, err := u.InterfaceMatrix() + if err = u.shuffleKeys(); err != nil { + return err + } + keypadSet2, err := u.InterfaceMatrix() + numbOfSelectedSets := u.Kp.AttrsPerKey / 2 + setIdxs, err := security.RandomPermutation(u.Kp.AttrsPerKey) + if err != nil { + return err + } + selectedSets := set.NewSetFromSlice[int](setIdxs[:numbOfSelectedSets]) + + for keyIdx, key := range keypadSet1 { + for idx := range key { + if selectedSets.Contains(idx) { + keypadSet1[keyIdx][idx] = keypadSet2[keyIdx][idx] + } + } + } + + u.IdxInterface = security.MatrixToList(keypadSet1) + return nil +} + +func (u *UserInterface) GetAttrIdxByKeyNumbSetIdx(setIdx int, keyNumb int) (int, error) { + if keyNumb < 0 || u.Kp.NumbOfKeys <= keyNumb { + log.Printf("keyNumb %d is out of range 0-%d", keyNumb, u.Kp.NumbOfKeys) + return -1, config.ErrKeyIndexOutOfRange + } + + if setIdx < 0 || u.Kp.AttrsPerKey <= setIdx { + log.Printf("setIdx %d is out of range 0-%d", setIdx, u.Kp.AttrsPerKey) + return -1, config.ErrAttributeIndexOutOfRange + } + keypadView, err := u.InterfaceMatrix() + if err != nil { + return -1, err + } + return keypadView[keyNumb][setIdx], nil +} diff --git a/entities/user_signup_session.go b/entities/user_signup_session.go new file mode 100644 index 0000000..fa9c9ff --- /dev/null +++ b/entities/user_signup_session.go @@ -0,0 +1,201 @@ +package entities + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "github.com/DonovanKelly/sugar-n-spice/all" + "github.com/DonovanKelly/sugar-n-spice/set" + "github.com/google/uuid" + "log" + "sort" +) + +type UserSignSession struct { + Id SessionId + CustomerId CustomerId + LoginUserInterface UserInterface + Kp KeypadDimension + SetIdxInterface IdxInterface + ConfirmIdxInterface IdxInterface + SetKeySelection KeySelection + UserEmail UserEmail + Reset bool + Expire int + Colors []RGBColor +} + +func NewSignupResetSession(userEmail UserEmail, kp KeypadDimension, customerId CustomerId, svgInterface SvgIdInterface, reset bool) (*UserSignSession, error) { + loginInterface, err := NewUserInterface(&kp, svgInterface) + if err != nil { + return nil, err + } + signupInterface, colors, err := signupInterface(*loginInterface, kp) + if err != nil { + return nil, err + } + session := UserSignSession{ + Id: SessionId(uuid.New()), + CustomerId: customerId, + LoginUserInterface: *loginInterface, + SetIdxInterface: signupInterface.IdxInterface, + ConfirmIdxInterface: nil, + SetKeySelection: nil, + UserEmail: userEmail, + Kp: kp, + Reset: reset, + Colors: colors, + } + + return &session, nil +} + +func (s *UserSignSession) DeducePasscode(confirmKeyEntry KeySelection) ([]int, error) { + validEntry := all.All[int](confirmKeyEntry, func(i int) bool { + return 0 <= i && i < s.Kp.NumbOfKeys + }) + + if !validEntry { + log.Printf("Invalid Key entry. One or more key index: %#v, not in range 0-%d", confirmKeyEntry, s.Kp.NumbOfKeys) + return nil, config.ErrKeyIndexOutOfRange + } + + if s.SetIdxInterface == nil { + log.Print("signup session set interface is nil") + return nil, config.ErrIncompleteUserSignupSession + } + + if s.ConfirmIdxInterface == nil { + log.Print("signup session confirm interface is nil") + return nil, config.ErrIncompleteUserSignupSession + } + + if s.SetKeySelection == nil { + log.Print("signup session set key entry is nil") + return nil, config.ErrIncompleteUserSignupSession + } + + if s.UserEmail == "" { + log.Print("signup session username is nil") + return nil, config.ErrIncompleteUserSignupSession + } + + if len(confirmKeyEntry) != len(s.SetKeySelection) { + log.Printf("confirm and set key entry length mismatch %d != %d", len(confirmKeyEntry), len(s.SetKeySelection)) + return nil, config.ErrSetConfirmSignupMismatch + } + + passcodeLen := len(confirmKeyEntry) + setKeyVals, err := s.getSelectedKeyVals(s.SetKeySelection, s.SetIdxInterface) + if err != nil { + return nil, err + } + confirmKeyVals, err := s.getSelectedKeyVals(confirmKeyEntry, s.ConfirmIdxInterface) + passcode := make([]int, passcodeLen) + + for idx := 0; idx < passcodeLen; idx++ { + setKey := set.NewSetFromSlice[int](setKeyVals[idx]) + confirmKey := set.NewSetFromSlice[int](confirmKeyVals[idx]) + intersection := setKey.Intersect(confirmKey) + if intersection.Size() < 1 { + log.Printf("set and confirm do not intersect at index %d", idx) + return nil, config.ErrSetConfirmSignupMismatch + } + if intersection.Size() > 1 { + log.Printf("set and confirm intersect at more than one point at index %d", idx) + return nil, config.ErrSetConfirmSignupMismatch + } + intersectionSlice := intersection.ToSlice() + passcode[idx] = intersectionSlice[0] + } + return passcode, nil +} + +func (s *UserSignSession) SetUserNKode(keySelection KeySelection) (IdxInterface, error) { + validKeySelection := all.All[int](keySelection, func(i int) bool { + return 0 <= i && i < s.Kp.NumbOfKeys + }) + if !validKeySelection { + log.Printf("one or key selection is out of range 0-%d", s.Kp.NumbOfKeys-1) + return nil, config.ErrKeyIndexOutOfRange + } + + s.SetKeySelection = keySelection + setKp := s.SignupKeypad() + setInterface := UserInterface{IdxInterface: s.SetIdxInterface, Kp: &setKp} + err := setInterface.DisperseInterface() + if err != nil { + return nil, err + } + s.ConfirmIdxInterface = setInterface.IdxInterface + return s.ConfirmIdxInterface, nil +} + +func (s *UserSignSession) getSelectedKeyVals(keySelections KeySelection, userInterface []int) ([][]int, error) { + signupKp := s.SignupKeypad() + keypadInterface, err := security.ListToMatrix(userInterface, signupKp.AttrsPerKey) + if err != nil { + return nil, err + } + keyVals := make([][]int, len(keySelections)) + + for idx, keyIdx := range keySelections { + keyVals[idx] = keypadInterface[keyIdx] + } + return keyVals, nil +} + +func signupInterface(baseUserInterface UserInterface, kp KeypadDimension) (*UserInterface, []RGBColor, error) { + // This method randomly drops sets from the base user interface so it is a square and dispersable matrix + if kp.IsDispersable() { + return nil, nil, config.ErrKeypadIsNotDispersible + } + err := baseUserInterface.RandomShuffle() + if err != nil { + return nil, nil, err + } + // attributes are arranged by key interfaceMatrix + interfaceMatrix, err := baseUserInterface.InterfaceMatrix() + if err != nil { + return nil, nil, err + } + // attributes are arranged by set + attrSetView, err := security.MatrixTranspose(interfaceMatrix) + if err != nil { + return nil, nil, err + } + + setIdxs := security.IdentityArray(kp.AttrsPerKey) + if err := security.FisherYatesShuffle[int](&setIdxs); err != nil { + return nil, nil, err + } + setIdxs = setIdxs[:kp.NumbOfKeys] + sort.Ints(setIdxs) + selectedSets := make([][]int, kp.NumbOfKeys) + selectedColors := make([]RGBColor, kp.NumbOfKeys) + + for idx, setIdx := range setIdxs { + selectedSets[idx] = attrSetView[setIdx] + selectedColors[idx] = SetColors[setIdx] + } + // convert set view back into key view + selectedSets, err = security.MatrixTranspose(selectedSets) + if err != nil { + return nil, nil, err + } + + signupUserInterface := UserInterface{ + IdxInterface: security.MatrixToList(selectedSets), + Kp: &KeypadDimension{ + AttrsPerKey: kp.NumbOfKeys, + NumbOfKeys: kp.NumbOfKeys, + }, + } + return &signupUserInterface, selectedColors, nil +} + +func (s *UserSignSession) SignupKeypad() KeypadDimension { + return KeypadDimension{ + AttrsPerKey: s.Kp.NumbOfKeys, + NumbOfKeys: s.Kp.NumbOfKeys, + } +} diff --git a/entities/user_test.go b/entities/user_test.go new file mode 100644 index 0000000..265e244 --- /dev/null +++ b/entities/user_test.go @@ -0,0 +1,133 @@ +package entities + +import ( + "github.com/DonovanKelly/sugar-n-spice/all" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestUserCipherKeys_EncipherSaltHashCode(t *testing.T) { + kp := KeypadDimension{AttrsPerKey: 10, NumbOfKeys: 8} + maxNKodeLen := 10 + customerAttrs, err := NewCustomerAttributes() + assert.NoError(t, err) + setVals, err := customerAttrs.SetValsForKp(kp) + assert.NoError(t, err) + attrVals, err := customerAttrs.AttrValsForKp(kp) + assert.NoError(t, err) + newUser, err := NewUserCipherKeys(&kp, setVals, maxNKodeLen) + assert.NoError(t, err) + passcodeIdx := []int{0, 1, 2, 3} + encipher0, err := newUser.EncipherSaltHashCode(passcodeIdx, attrVals) + assert.NoError(t, err) + err = newUser.ValidPassword(encipher0, passcodeIdx, attrVals) + assert.NoError(t, err) + + passcodeIdxInvalid := []int{1, 0, 3, 2} + assert.NoError(t, err) + err = newUser.ValidPassword(encipher0, passcodeIdxInvalid, attrVals) + assert.Error(t, err) +} + +func TestUserCipherKeys_EncipherDecipherMask(t *testing.T) { + kp := KeypadDimension{AttrsPerKey: 10, NumbOfKeys: 8} + maxNKodeLen := 10 + + customerAttrs, err := NewCustomerAttributes() + assert.NoError(t, err) + setVals, err := customerAttrs.SetValsForKp(kp) + assert.NoError(t, err) + attrVals, err := customerAttrs.AttrValsForKp(kp) + assert.NoError(t, err) + newUser, err := NewUserCipherKeys(&kp, setVals, maxNKodeLen) + assert.NoError(t, err) + passcodeIdx := []int{0, 1, 2, 3} + originalSetVals := make([]uint64, len(passcodeIdx)) + + for idx, val := range passcodeIdx { + attr := attrVals[val] + originalSetVals[idx], err = customerAttrs.GetAttrSetVal(attr, kp) + assert.NoError(t, err) + } + encipheredCode, err := newUser.EncipherNKode(passcodeIdx, *customerAttrs) + assert.NoError(t, err) + passcodeSetVals, err := newUser.DecipherMask(encipheredCode.Mask, setVals, len(passcodeIdx)) + assert.NoError(t, err) + + for idx, setVal := range passcodeSetVals { + assert.Equal(t, setVal, originalSetVals[idx]) + } +} + +func TestUserInterface_RandomShuffle(t *testing.T) { + kp := KeypadDimension{ + AttrsPerKey: 10, + NumbOfKeys: 8, + } + mockSvgInterface := make(SvgIdInterface, kp.TotalAttrs()) + userInterface, err := NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + userInterfaceCopy := make([]int, len(userInterface.IdxInterface)) + copy(userInterfaceCopy, userInterface.IdxInterface) + + err = userInterface.RandomShuffle() + assert.NoError(t, err) + + assert.Equal(t, len(userInterface.IdxInterface), len(userInterfaceCopy)) + equalCount := 0 + for idx, val := range userInterface.IdxInterface { + if val == userInterfaceCopy[idx] { + equalCount++ + } + } + assert.NotEqual(t, equalCount, len(userInterface.IdxInterface)) +} + +func TestUserInterface_DisperseInterface(t *testing.T) { + + for idx := 0; idx < 10000; idx++ { + kp := KeypadDimension{AttrsPerKey: 7, NumbOfKeys: 10} + mockSvgInterface := make(SvgIdInterface, kp.TotalAttrs()) + userInterface, err := NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + preDispersion, err := userInterface.AttributeAdjacencyGraph() + assert.NoError(t, err) + err = userInterface.DisperseInterface() + assert.NoError(t, err) + postDispersion, err := userInterface.AttributeAdjacencyGraph() + assert.Equal(t, len(postDispersion), len(preDispersion)) + for attr, adjAttrs := range preDispersion { + postAdjAttrs := postDispersion[attr] + assert.Equal(t, adjAttrs.Size(), postAdjAttrs.Size()) + assert.True(t, adjAttrs.IsDisjoint(postAdjAttrs)) + } + } +} + +func TestUserInterface_PartialInterfaceShuffle(t *testing.T) { + kp := KeypadDimension{AttrsPerKey: 7, NumbOfKeys: 10} + mockSvgInterface := make(SvgIdInterface, kp.TotalAttrs()) + userInterface, err := NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + preShuffle := userInterface.IdxInterface + err = userInterface.LoginShuffle() + assert.NoError(t, err) + postShuffle := userInterface.IdxInterface + + shuffleCompare := make([]bool, len(postShuffle)) + for idx, val := range preShuffle { + shuffleCompare[idx] = val == postShuffle[idx] + } + + allTrue := all.All[bool](shuffleCompare, func(n bool) bool { + return n == true + }) + assert.False(t, allTrue) + + allFalse := all.All[bool](shuffleCompare, func(n bool) bool { + return n == false + }) + + assert.False(t, allFalse) + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e5a9990 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module git.infra.nkode.tech/dkelly/nkode-core + +go 1.23.0 + +require ( + github.com/DonovanKelly/sugar-n-spice v1.0.1 + github.com/aws/aws-sdk-go-v2 v1.33.0 + github.com/aws/aws-sdk-go-v2/config v1.29.1 + github.com/aws/aws-sdk-go-v2/service/ses v1.29.6 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.24 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.32.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.54 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 // indirect + github.com/aws/smithy-go v1.22.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c477914 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/DonovanKelly/sugar-n-spice v1.0.1 h1:VsybiCHSziAqyPtbYF6GtkiJYYECWMHKN+EyEa6UVpA= +github.com/DonovanKelly/sugar-n-spice v1.0.1/go.mod h1:/HQWoablLFCwsa4gwfzVBu80cI5A3dyO1uCiB11sup0= +github.com/aws/aws-sdk-go-v2 v1.33.0 h1:Evgm4DI9imD81V0WwD+TN4DCwjUMdc94TrduMLbgZJs= +github.com/aws/aws-sdk-go-v2 v1.33.0/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.29.1 h1:JZhGawAyZ/EuJeBtbQYnaoftczcb2drR2Iq36Wgz4sQ= +github.com/aws/aws-sdk-go-v2/config v1.29.1/go.mod h1:7bR2YD5euaxBhzt2y/oDkt3uNRb6tjFp98GlTFueRwk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.54 h1:4UmqeOqJPvdvASZWrKlhzpRahAulBfyTJQUaYy4+hEI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.54/go.mod h1:RTdfo0P0hbbTxIhmQrOsC/PquBZGabEPnCaxxKRPSnI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 h1:5grmdTdMsovn9kPZPI23Hhvp0ZyNm5cRO+IZFIYiAfw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24/go.mod h1:zqi7TVKTswH3Ozq28PkmBmgzG1tona7mo9G2IJg4Cis= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 h1:igORFSiH3bfq4lxKFkTSYDhJEUCYo6C8VKiWJjYwQuQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28/go.mod h1:3So8EA/aAYm36L7XIvCVwLa0s5N0P7o2b1oqnx/2R4g= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 h1:1mOW9zAUMhTSrMDssEHS/ajx8JcAj/IcftzcmNlmVLI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28/go.mod h1:kGlXVIWDfvt2Ox5zEaNglmq0hXPHgQFNMix33Tw22jA= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 h1:TQmKDyETFGiXVhZfQ/I0cCFziqqX58pi4tKJGYGFSz0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9/go.mod h1:HVLPK2iHQBUx7HfZeOQSEu3v2ubZaAY2YPbAm5/WUyY= +github.com/aws/aws-sdk-go-v2/service/ses v1.29.6 h1:uc9MwzkhjIjV5abWaG6Ird83IcSrNVt62BSXG7WRwAw= +github.com/aws/aws-sdk-go-v2/service/ses v1.29.6/go.mod h1:t1rqt5llPOnzPnfHpciQZ3dZgyCsgfR7RHZ2ZFfZEWs= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 h1:kuIyu4fTT38Kj7YCC7ouNbVZSSpqkZ+LzIfhCr6Dg+I= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.11/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 h1:l+dgv/64iVlQ3WsBbnn+JSbkj01jIi+SM0wYsj3y/hY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 h1:BRVDbewN6VZcwr+FBOszDKvYeXY1kJ+GGMCcpghlw0U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.9/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/repository/customer_user_repository.go b/repository/customer_user_repository.go new file mode 100644 index 0000000..984f8db --- /dev/null +++ b/repository/customer_user_repository.go @@ -0,0 +1,20 @@ +package repository + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/entities" +) + +type CustomerUserRepository interface { + GetCustomer(entities.CustomerId) (*entities.Customer, error) + GetUser(entities.UserEmail, entities.CustomerId) (*entities.User, error) + CreateCustomer(entities.Customer) error + WriteNewUser(entities.User) error + UpdateUserNKode(entities.User) error + UpdateUserInterface(entities.UserId, entities.UserInterface) error + UpdateUserRefreshToken(entities.UserId, string) error + Renew(entities.CustomerId) error + RefreshUserPasscode(entities.User, []int, entities.CustomerAttributes) error + RandomSvgInterface(entities.KeypadDimension) ([]string, error) + RandomSvgIdxInterface(entities.KeypadDimension) (entities.SvgIdInterface, error) + GetSvgStringInterface(entities.SvgIdInterface) ([]string, error) +} diff --git a/repository/sqlite_repository.go b/repository/sqlite_repository.go new file mode 100644 index 0000000..9765e47 --- /dev/null +++ b/repository/sqlite_repository.go @@ -0,0 +1,401 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "git.infra.nkode.tech/dkelly/nkode-core/config" + "git.infra.nkode.tech/dkelly/nkode-core/entities" + "git.infra.nkode.tech/dkelly/nkode-core/security" + "git.infra.nkode.tech/dkelly/nkode-core/sqlc" + "git.infra.nkode.tech/dkelly/nkode-core/utils" + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + "log" +) + +type SqliteRepository struct { + Queue *sqlc.Queue + ctx context.Context +} + +func NewSqliteRepository(queue *sqlc.Queue, ctx context.Context) SqliteRepository { + return SqliteRepository{ + Queue: queue, + ctx: ctx, + } +} + +func (d *SqliteRepository) CreateCustomer(c entities.Customer) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.CreateCustomerParams) + if !ok { + return fmt.Errorf("invalid argument type: expected CreateCustomerParams") + } + return q.CreateCustomer(ctx, params) + } + + return d.Queue.EnqueueWriteTx(queryFunc, c.ToSqlcCreateCustomerParams()) +} + +func (d *SqliteRepository) WriteNewUser(u entities.User) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.CreateUserParams) + if !ok { + return fmt.Errorf("invalid argument type: expected CreateUserParams") + } + return q.CreateUser(ctx, params) + } + // Use the wrapped function in EnqueueWriteTx + + renew := 0 + if u.Renew { + renew = 1 + } + // Map entities.User to CreateUserParams + params := sqlc.CreateUserParams{ + ID: uuid.UUID(u.Id).String(), + Email: string(u.Email), + Renew: int64(renew), + RefreshToken: sql.NullString{String: u.RefreshToken, Valid: u.RefreshToken != ""}, + CustomerID: uuid.UUID(u.CustomerId).String(), + Code: u.EncipheredPasscode.Code, + Mask: u.EncipheredPasscode.Mask, + AttributesPerKey: int64(u.Kp.AttrsPerKey), + NumberOfKeys: int64(u.Kp.NumbOfKeys), + AlphaKey: security.Uint64ArrToByteArr(u.CipherKeys.AlphaKey), + SetKey: security.Uint64ArrToByteArr(u.CipherKeys.SetKey), + PassKey: security.Uint64ArrToByteArr(u.CipherKeys.PassKey), + MaskKey: security.Uint64ArrToByteArr(u.CipherKeys.MaskKey), + Salt: u.CipherKeys.Salt, + MaxNkodeLen: int64(u.CipherKeys.MaxNKodeLen), + IdxInterface: security.IntArrToByteArr(u.Interface.IdxInterface), + SvgIDInterface: security.IntArrToByteArr(u.Interface.SvgId), + CreatedAt: sql.NullString{String: utils.TimeStamp(), Valid: true}, + } + return d.Queue.EnqueueWriteTx(queryFunc, params) +} + +func (d *SqliteRepository) UpdateUserNKode(u entities.User) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.UpdateUserParams) + if !ok { + return fmt.Errorf("invalid argument type: expected UpdateUserParams") + } + return q.UpdateUser(ctx, params) + } + // Use the wrapped function in EnqueueWriteTx + renew := 0 + if u.Renew { + renew = 1 + } + params := sqlc.UpdateUserParams{ + Email: string(u.Email), + Renew: int64(renew), + RefreshToken: sql.NullString{String: u.RefreshToken, Valid: u.RefreshToken != ""}, + CustomerID: uuid.UUID(u.CustomerId).String(), + Code: u.EncipheredPasscode.Code, + Mask: u.EncipheredPasscode.Mask, + AttributesPerKey: int64(u.Kp.AttrsPerKey), + NumberOfKeys: int64(u.Kp.NumbOfKeys), + AlphaKey: security.Uint64ArrToByteArr(u.CipherKeys.AlphaKey), + SetKey: security.Uint64ArrToByteArr(u.CipherKeys.SetKey), + PassKey: security.Uint64ArrToByteArr(u.CipherKeys.PassKey), + MaskKey: security.Uint64ArrToByteArr(u.CipherKeys.MaskKey), + Salt: u.CipherKeys.Salt, + MaxNkodeLen: int64(u.CipherKeys.MaxNKodeLen), + IdxInterface: security.IntArrToByteArr(u.Interface.IdxInterface), + SvgIDInterface: security.IntArrToByteArr(u.Interface.SvgId), + } + return d.Queue.EnqueueWriteTx(queryFunc, params) +} + +func (d *SqliteRepository) UpdateUserInterface(id entities.UserId, ui entities.UserInterface) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.UpdateUserInterfaceParams) + if !ok { + return fmt.Errorf("invalid argument type: expected UpdateUserInterfaceParams") + } + return q.UpdateUserInterface(ctx, params) + } + params := sqlc.UpdateUserInterfaceParams{ + IdxInterface: security.IntArrToByteArr(ui.IdxInterface), + LastLogin: utils.TimeStamp(), + ID: uuid.UUID(id).String(), + } + + return d.Queue.EnqueueWriteTx(queryFunc, params) +} + +func (d *SqliteRepository) UpdateUserRefreshToken(id entities.UserId, refreshToken string) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.UpdateUserRefreshTokenParams) + if !ok { + return fmt.Errorf("invalid argument type: expected UpdateUserRefreshToken") + } + return q.UpdateUserRefreshToken(ctx, params) + } + params := sqlc.UpdateUserRefreshTokenParams{ + RefreshToken: sql.NullString{ + String: refreshToken, + Valid: true, + }, + ID: uuid.UUID(id).String(), + } + return d.Queue.EnqueueWriteTx(queryFunc, params) +} + +func (d *SqliteRepository) RenewCustomer(renewParams sqlc.RenewCustomerParams) error { + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.RenewCustomerParams) + if !ok { + + } + return q.RenewCustomer(ctx, params) + } + return d.Queue.EnqueueWriteTx(queryFunc, renewParams) +} + +func (d *SqliteRepository) Renew(id entities.CustomerId) error { + setXor, attrXor, err := d.renewCustomer(id) + if err != nil { + return err + } + customerId := entities.CustomerIdToString(id) + userRenewRows, err := d.Queue.Queries.GetUserRenew(d.ctx, customerId) + if err != nil { + return err + } + + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.RenewUserParams) + if !ok { + return fmt.Errorf("invalid argument type: expected RenewUserParams") + } + return q.RenewUser(ctx, params) + } + + for _, row := range userRenewRows { + user := entities.User{ + Id: entities.UserIdFromString(row.ID), + CustomerId: entities.CustomerId{}, + Email: "", + EncipheredPasscode: entities.EncipheredNKode{}, + Kp: entities.KeypadDimension{ + AttrsPerKey: int(row.AttributesPerKey), + NumbOfKeys: int(row.NumberOfKeys), + }, + CipherKeys: entities.UserCipherKeys{ + AlphaKey: security.ByteArrToUint64Arr(row.AlphaKey), + SetKey: security.ByteArrToUint64Arr(row.SetKey), + }, + Interface: entities.UserInterface{}, + Renew: false, + } + + if err = user.RenewKeys(setXor, attrXor); err != nil { + return err + } + params := sqlc.RenewUserParams{ + AlphaKey: security.Uint64ArrToByteArr(user.CipherKeys.AlphaKey), + SetKey: security.Uint64ArrToByteArr(user.CipherKeys.SetKey), + Renew: 1, + ID: uuid.UUID(user.Id).String(), + } + if err = d.Queue.EnqueueWriteTx(queryFunc, params); err != nil { + return err + } + } + return nil +} + +func (d *SqliteRepository) renewCustomer(id entities.CustomerId) ([]uint64, []uint64, error) { + customer, err := d.GetCustomer(id) + if err != nil { + return nil, nil, err + } + setXor, attrXor, err := customer.RenewKeys() + if err != nil { + return nil, nil, err + } + + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.RenewCustomerParams) + if !ok { + return fmt.Errorf("invalid argument type: expected RenewCustomerParams") + } + return q.RenewCustomer(ctx, params) + } + params := sqlc.RenewCustomerParams{ + AttributeValues: security.Uint64ArrToByteArr(customer.Attributes.AttrVals), + SetValues: security.Uint64ArrToByteArr(customer.Attributes.SetVals), + ID: uuid.UUID(customer.Id).String(), + } + + if err = d.Queue.EnqueueWriteTx(queryFunc, params); err != nil { + return nil, nil, err + } + return setXor, attrXor, nil +} + +func (d *SqliteRepository) RefreshUserPasscode(user entities.User, passcodeIdx []int, customerAttr entities.CustomerAttributes) error { + if err := user.RefreshPasscode(passcodeIdx, customerAttr); err != nil { + return err + } + queryFunc := func(q *sqlc.Queries, ctx context.Context, args any) error { + params, ok := args.(sqlc.RefreshUserPasscodeParams) + if !ok { + return fmt.Errorf("invalid argument type: expected RefreshUserPasscodeParams") + } + return q.RefreshUserPasscode(ctx, params) + } + params := sqlc.RefreshUserPasscodeParams{ + Renew: 0, + Code: user.EncipheredPasscode.Code, + Mask: user.EncipheredPasscode.Mask, + AlphaKey: security.Uint64ArrToByteArr(user.CipherKeys.AlphaKey), + SetKey: security.Uint64ArrToByteArr(user.CipherKeys.SetKey), + PassKey: security.Uint64ArrToByteArr(user.CipherKeys.PassKey), + MaskKey: security.Uint64ArrToByteArr(user.CipherKeys.MaskKey), + Salt: user.CipherKeys.Salt, + ID: uuid.UUID(user.Id).String(), + } + return d.Queue.EnqueueWriteTx(queryFunc, params) +} + +func (d *SqliteRepository) GetCustomer(id entities.CustomerId) (*entities.Customer, error) { + customer, err := d.Queue.Queries.GetCustomer(d.ctx, uuid.UUID(id).String()) + if err != nil { + return nil, err + } + + return &entities.Customer{ + Id: id, + NKodePolicy: entities.NKodePolicy{ + MaxNkodeLen: int(customer.MaxNkodeLen), + MinNkodeLen: int(customer.MinNkodeLen), + DistinctSets: int(customer.DistinctSets), + DistinctAttributes: int(customer.DistinctAttributes), + LockOut: int(customer.LockOut), + Expiration: int(customer.Expiration), + }, + Attributes: entities.NewCustomerAttributesFromBytes(customer.AttributeValues, customer.SetValues), + }, nil +} + +func (d *SqliteRepository) GetUser(email entities.UserEmail, customerId entities.CustomerId) (*entities.User, error) { + userRow, err := d.Queue.Queries.GetUser(d.ctx, sqlc.GetUserParams{ + Email: string(email), + CustomerID: uuid.UUID(customerId).String(), + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + kp := entities.KeypadDimension{ + AttrsPerKey: int(userRow.AttributesPerKey), + NumbOfKeys: int(userRow.NumberOfKeys), + } + + renew := false + if userRow.Renew == 1 { + renew = true + } + user := entities.User{ + Id: entities.UserIdFromString(userRow.ID), + CustomerId: customerId, + Email: email, + EncipheredPasscode: entities.EncipheredNKode{ + Code: userRow.Code, + Mask: userRow.Mask, + }, + Kp: kp, + CipherKeys: entities.UserCipherKeys{ + AlphaKey: security.ByteArrToUint64Arr(userRow.AlphaKey), + SetKey: security.ByteArrToUint64Arr(userRow.SetKey), + PassKey: security.ByteArrToUint64Arr(userRow.PassKey), + MaskKey: security.ByteArrToUint64Arr(userRow.MaskKey), + Salt: userRow.Salt, + MaxNKodeLen: int(userRow.MaxNkodeLen), + Kp: &kp, + }, + Interface: entities.UserInterface{ + IdxInterface: security.ByteArrToIntArr(userRow.IdxInterface), + SvgId: security.ByteArrToIntArr(userRow.SvgIDInterface), + Kp: &kp, + }, + Renew: renew, + RefreshToken: userRow.RefreshToken.String, + } + return &user, nil +} + +func (d *SqliteRepository) RandomSvgInterface(kp entities.KeypadDimension) ([]string, error) { + ids, err := d.getRandomIds(kp.TotalAttrs()) + if err != nil { + return nil, err + } + return d.getSvgsById(ids) +} + +func (d *SqliteRepository) RandomSvgIdxInterface(kp entities.KeypadDimension) (entities.SvgIdInterface, error) { + return d.getRandomIds(kp.TotalAttrs()) +} + +func (d *SqliteRepository) GetSvgStringInterface(idxs entities.SvgIdInterface) ([]string, error) { + return d.getSvgsById(idxs) +} + +func (d *SqliteRepository) getSvgsById(ids []int) ([]string, error) { + svgs := make([]string, len(ids)) + for idx, id := range ids { + svg, err := d.Queue.Queries.GetSvgId(d.ctx, int64(id)) + if err != nil { + return nil, err + } + svgs[idx] = svg + } + return svgs, nil +} + +func (d *SqliteRepository) getRandomIds(count int) ([]int, error) { + tx, err := d.Queue.Db.Begin() + if err != nil { + log.Print(err) + return nil, config.ErrSqliteTx + } + rows, err := tx.Query("SELECT COUNT(*) as count FROM svg_icon;") + if err != nil { + log.Print(err) + return nil, config.ErrSqliteTx + } + var tableLen int + if !rows.Next() { + return nil, config.ErrEmptySvgTable + } + + if err = rows.Scan(&tableLen); err != nil { + log.Print(err) + return nil, config.ErrSqliteTx + } + perm, err := security.RandomPermutation(tableLen) + + if err != nil { + return nil, err + } + + for idx := range perm { + perm[idx] += 1 + } + + if err = tx.Commit(); err != nil { + log.Print(err) + return nil, config.ErrSqliteTx + } + + return perm[:count], nil +} diff --git a/repository/sqlite_repository_test.go b/repository/sqlite_repository_test.go new file mode 100644 index 0000000..f2e860b --- /dev/null +++ b/repository/sqlite_repository_test.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + "git.infra.nkode.tech/dkelly/nkode-core/entities" + "git.infra.nkode.tech/dkelly/nkode-core/sqlc" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestNewSqliteDB(t *testing.T) { + dbPath := os.Getenv("TEST_DB") + // sql_driver.MakeTables(dbFile) + ctx := context.Background() + sqliteDb, err := sqlc.OpenSqliteDb(dbPath) + assert.NoError(t, err) + + queue, err := sqlc.NewQueue(sqliteDb, ctx) + assert.NoError(t, err) + + queue.Start() + defer queue.Stop() + db := NewSqliteRepository(queue, ctx) + assert.NoError(t, err) + testSignupLoginRenew(t, &db) + testSqliteDBRandomSvgInterface(t, &db) +} + +func testSignupLoginRenew(t *testing.T, db CustomerUserRepository) { + nkodePolicy := entities.NewDefaultNKodePolicy() + customerOrig, err := entities.NewCustomer(nkodePolicy) + assert.NoError(t, err) + err = db.CreateCustomer(*customerOrig) + assert.NoError(t, err) + customer, err := db.GetCustomer(customerOrig.Id) + assert.NoError(t, err) + assert.Equal(t, customerOrig, customer) + username := "test_user@example.com" + kp := entities.KeypadDefault + passcodeIdx := []int{0, 1, 2, 3} + mockSvgInterface := make(entities.SvgIdInterface, kp.TotalAttrs()) + ui, err := entities.NewUserInterface(&kp, mockSvgInterface) + assert.NoError(t, err) + userOrig, err := entities.NewUser(*customer, username, passcodeIdx, *ui, kp) + assert.NoError(t, err) + err = db.WriteNewUser(*userOrig) + assert.NoError(t, err) + user, err := db.GetUser(entities.UserEmail(username), customer.Id) + assert.NoError(t, err) + assert.Equal(t, userOrig, user) + + err = db.Renew(customer.Id) + assert.NoError(t, err) + +} + +func testSqliteDBRandomSvgInterface(t *testing.T, db CustomerUserRepository) { + kp := entities.KeypadMax + svgs, err := db.RandomSvgInterface(kp) + assert.NoError(t, err) + assert.Len(t, svgs, kp.TotalAttrs()) +} diff --git a/security/jwt_claims.go b/security/jwt_claims.go new file mode 100644 index 0000000..a832847 --- /dev/null +++ b/security/jwt_claims.go @@ -0,0 +1,123 @@ +package security + +import ( + "git.infra.nkode.tech/dkelly/nkode-core/config" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "log" + "os" + "time" +) + +type AuthenticationTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type ResetNKodeClaims struct { + Reset bool `json:"reset"` + jwt.RegisteredClaims +} + +const ( + accessTokenExp = 5 * time.Minute + refreshTokenExp = 30 * 24 * time.Hour + resetNKodeTokenExp = 5 * time.Minute +) + +var secret = getJwtSecret() + +func getJwtSecret() []byte { + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("No JWT_SECRET found") + } + + jwtBytes, err := ParseHexString(jwtSecret) + if err != nil { + log.Fatalf("error parsing jwt secret %v", err) + } + return jwtBytes +} + +func NewAuthenticationTokens(username string, customerId uuid.UUID) (AuthenticationTokens, error) { + accessClaims := NewAccessClaim(username, customerId) + + refreshClaims := jwt.RegisteredClaims{ + Subject: username, + Issuer: customerId.String(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExp)), + } + + accessJwt, err := EncodeAndSignClaims(accessClaims) + if err != nil { + return AuthenticationTokens{}, err + } + refreshJwt, err := EncodeAndSignClaims(refreshClaims) + + if err != nil { + return AuthenticationTokens{}, err + } + return AuthenticationTokens{ + AccessToken: accessJwt, + RefreshToken: refreshJwt, + }, nil +} + +func NewAccessClaim(username string, customerId uuid.UUID) jwt.RegisteredClaims { + return jwt.RegisteredClaims{ + Subject: username, + Issuer: customerId.String(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenExp)), + } +} + +func EncodeAndSignClaims(claims jwt.Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +func ParseRegisteredClaimToken(token string) (*jwt.RegisteredClaims, error) { + return parseJwt[*jwt.RegisteredClaims](token, &jwt.RegisteredClaims{}) +} + +func ParseRestNKodeToken(resetNKodeToken string) (*ResetNKodeClaims, error) { + return parseJwt[*ResetNKodeClaims](resetNKodeToken, &ResetNKodeClaims{}) +} + +func parseJwt[T *ResetNKodeClaims | *jwt.RegisteredClaims](tokenStr string, claim jwt.Claims) (T, error) { + token, err := jwt.ParseWithClaims(tokenStr, claim, func(token *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil { + log.Printf("error parsing refresh token: %v", err) + return nil, config.ErrInvalidJwt + } + claims, ok := token.Claims.(T) + if !ok { + return nil, config.ErrInvalidJwt + } + return claims, nil +} + +func ClaimExpired(claims jwt.RegisteredClaims) error { + if claims.ExpiresAt == nil { + return config.ErrClaimExpOrNil + } + if claims.ExpiresAt.Time.After(time.Now()) { + return nil + } + return config.ErrClaimExpOrNil +} + +func ResetNKodeToken(userEmail string, customerId uuid.UUID) (string, error) { + resetClaims := ResetNKodeClaims{ + true, + jwt.RegisteredClaims{ + Subject: userEmail, + Issuer: customerId.String(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(resetNKodeTokenExp)), + }, + } + return EncodeAndSignClaims(resetClaims) +} diff --git a/security/jwt_claims_test.go b/security/jwt_claims_test.go new file mode 100644 index 0000000..e2c1b50 --- /dev/null +++ b/security/jwt_claims_test.go @@ -0,0 +1,28 @@ +package security + +import ( + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestJwtClaims(t *testing.T) { + email := "testing@example.com" + customerId := uuid.New() + authTokens, err := NewAuthenticationTokens(email, customerId) + assert.NoError(t, err) + accessToken, err := ParseRegisteredClaimToken(authTokens.AccessToken) + assert.NoError(t, err) + assert.Equal(t, accessToken.Subject, email) + assert.NoError(t, ClaimExpired(*accessToken)) + refreshToken, err := ParseRegisteredClaimToken(authTokens.RefreshToken) + assert.NoError(t, err) + assert.Equal(t, refreshToken.Subject, email) + assert.NoError(t, ClaimExpired(*refreshToken)) + resetNKode, err := ResetNKodeToken(email, customerId) + assert.NoError(t, err) + resetToken, err := ParseRestNKodeToken(resetNKode) + assert.NoError(t, err) + assert.True(t, resetToken.Reset) + assert.Equal(t, resetToken.Subject, email) +} diff --git a/security/random.go b/security/random.go new file mode 100644 index 0000000..43a3593 --- /dev/null +++ b/security/random.go @@ -0,0 +1,289 @@ +package security + +import ( + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "github.com/DonovanKelly/sugar-n-spice/set" + "log" + "math/big" + r "math/rand" + "time" +) + +var ( + ErrFisherYatesShuffle = errors.New("unable to shuffle array") + ErrRandomBytes = errors.New("random bytes error") + ErrRandNonRepeatingUint64 = errors.New("list length must be less than 2^32") + ErrParseHexString = errors.New("parse hex string error") + ErrMatrixTranspose = errors.New("matrix cannot be nil or empty") + ErrListToMatrixNotDivisible = errors.New("list to matrix not possible") + ErrElementNotInArray = errors.New("element not in array") + ErrDecodeBase64Str = errors.New("decode base64 err") + ErrRandNonRepeatingInt = errors.New("list length must be less than 2^31") + ErrXorLengthMismatch = errors.New("xor length mismatch") +) + +func fisherYatesShuffle[T any](b *[]T) error { + for i := len(*b) - 1; i > 0; i-- { + bigJ, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + log.Print("fisher yates shuffle error: ", err) + return ErrFisherYatesShuffle + } + j := bigJ.Int64() + (*b)[i], (*b)[j] = (*b)[j], (*b)[i] + } + return nil +} + +func FisherYatesShuffle[T any](b *[]T) error { + return fisherYatesShuffle(b) +} + +func RandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + log.Print("error in random bytes: ", err) + return nil, ErrRandomBytes + } + return b, nil +} + +func RandomPermutation(n int) ([]int, error) { + perm := IdentityArray(n) + err := fisherYatesShuffle(&perm) + if err != nil { + return nil, err + } + return perm, nil +} + +func GenerateRandomUInt64() (uint64, error) { + randBytes, err := RandomBytes(8) + if err != nil { + return 0, err + } + val := binary.LittleEndian.Uint64(randBytes) + return val, nil +} + +func GenerateRandomInt() (int, error) { + randBytes, err := RandomBytes(8) + if err != nil { + return 0, err + } + val := int(binary.LittleEndian.Uint64(randBytes) & 0x7FFFFFFFFFFFFFFF) // Ensure it's positive + return val, nil +} + +func GenerateRandomNonRepeatingUint64(listLen int) ([]uint64, error) { + if listLen > int(1)<<32 { + return nil, ErrRandNonRepeatingUint64 + } + listSet := make(set.Set[uint64]) + for { + if listSet.Size() == listLen { + break + } + randNum, err := GenerateRandomUInt64() + if err != nil { + return nil, err + } + listSet.Add(randNum) + } + + data := listSet.ToSlice() + return data, nil +} + +func GenerateRandomNonRepeatingInt(listLen int) ([]int, error) { + if listLen > int(1)<<31 { + return nil, ErrRandNonRepeatingInt + } + listSet := make(set.Set[int]) + for { + if listSet.Size() == listLen { + break + } + randNum, err := GenerateRandomInt() + if err != nil { + return nil, err + } + listSet.Add(randNum) + } + + data := listSet.ToSlice() + return data, nil +} + +func XorLists(l0 []uint64, l1 []uint64) ([]uint64, error) { + if len(l0) != len(l1) { + log.Printf("list len mismatch %d, %d", len(l0), len(l1)) + return nil, ErrXorLengthMismatch + } + + xorList := make([]uint64, len(l0)) + for idx := 0; idx < len(l0); idx++ { + xorList[idx] = l0[idx] ^ l1[idx] + } + return xorList, nil +} + +func EncodeBase64Str(data []uint64) string { + dataBytes := Uint64ArrToByteArr(data) + encoded := base64.StdEncoding.EncodeToString(dataBytes) + return encoded +} + +func DecodeBase64Str(encoded string) ([]uint64, error) { + dataBytes, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + log.Print("error decoding base64 str: ", err) + return nil, ErrDecodeBase64Str + } + data := ByteArrToUint64Arr(dataBytes) + return data, nil +} + +func Uint64ArrToByteArr(intArr []uint64) []byte { + byteArr := make([]byte, len(intArr)*8) + for idx, val := range intArr { + startIdx := idx * 8 + endIdx := (idx + 1) * 8 + binary.LittleEndian.PutUint64(byteArr[startIdx:endIdx], val) + } + return byteArr +} + +func IntArrToByteArr(intArr []int) []byte { + byteArr := make([]byte, len(intArr)*4) + for idx, val := range intArr { + uval := uint32(val) + startIdx := idx * 4 + endIdx := (idx + 1) * 4 + binary.LittleEndian.PutUint32(byteArr[startIdx:endIdx], uval) + } + return byteArr +} + +func ByteArrToUint64Arr(byteArr []byte) []uint64 { + intArr := make([]uint64, len(byteArr)/8) + for idx := 0; idx < len(intArr); idx++ { + startIdx := idx * 8 + endIdx := (idx + 1) * 8 + intArr[idx] = binary.LittleEndian.Uint64(byteArr[startIdx:endIdx]) + } + return intArr +} + +func ByteArrToIntArr(byteArr []byte) []int { + intArr := make([]int, len(byteArr)/4) + for idx := 0; idx < len(intArr); idx++ { + startIdx := idx * 4 + endIdx := (idx + 1) * 4 + uval := binary.LittleEndian.Uint32(byteArr[startIdx:endIdx]) + intArr[idx] = int(uval) + } + return intArr +} + +func IndexOf[T uint64 | int](arr []T, el T) (int, error) { + for idx, val := range arr { + if val == el { + return idx, nil + } + } + return -1, ErrElementNotInArray +} + +func IdentityArray(arrLen int) []int { + identityArr := make([]int, arrLen) + + for idx := range identityArr { + identityArr[idx] = idx + } + return identityArr +} + +func ListToMatrix(listArr []int, numbCols int) ([][]int, error) { + if len(listArr)%numbCols != 0 { + log.Printf("Array is not evenly divisible by number of columns: %d mod %d = %d", len(listArr), numbCols, len(listArr)%numbCols) + return nil, ErrListToMatrixNotDivisible + } + numbRows := len(listArr) / numbCols + matrix := make([][]int, numbRows) + for idx := range matrix { + startIdx := idx * numbCols + endIdx := (idx + 1) * numbCols + matrix[idx] = listArr[startIdx:endIdx] + } + return matrix, nil +} + +func MatrixTranspose(matrix [][]int) ([][]int, error) { + if matrix == nil || len(matrix) == 0 { + log.Print("can't transpose nil or zero len matrix") + return nil, ErrMatrixTranspose + } + + rows := len(matrix) + cols := len((matrix)[0]) + + // Check if the matrix is not rectangular + for _, row := range matrix { + if len(row) != cols { + log.Print("all rows must have the same number of columns") + return nil, ErrMatrixTranspose + } + } + + transposed := make([][]int, cols) + for i := range transposed { + transposed[i] = make([]int, rows) + } + + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + transposed[j][i] = (matrix)[i][j] + } + } + + return transposed, nil +} + +func MatrixToList(matrix [][]int) []int { + var flat []int + for _, row := range matrix { + flat = append(flat, row...) + } + return flat +} + +func Choice[T any](items []T) T { + r.Seed(time.Now().UnixNano()) // Seed the random number generator + return items[r.Intn(len(items))] +} + +// GenerateRandomString creates a random string of a specified length. +func GenerateRandomString(length int) string { + charset := []rune("abcdefghijklmnopqrstuvwxyz0123456789") + b := make([]rune, length) + for i := range b { + b[i] = Choice[rune](charset) + } + return string(b) +} + +func ParseHexString(hexStr string) ([]byte, error) { + // Decode the hex string into bytes + bytes, err := hex.DecodeString(hexStr) + if err != nil { + log.Print("parse hex string err: ", err) + return nil, ErrParseHexString + } + return bytes, nil +} diff --git a/security/random_test.go b/security/random_test.go new file mode 100644 index 0000000..78b8c9d --- /dev/null +++ b/security/random_test.go @@ -0,0 +1,62 @@ +package security + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGenerateRandomNonRepeatingUint64(t *testing.T) { + arrLen := 100000 + randNumbs, err := GenerateRandomNonRepeatingUint64(arrLen) + assert.NoError(t, err) + + assert.Equal(t, len(randNumbs), arrLen) +} + +func TestGenerateRandomNonRepeatingInt(t *testing.T) { + arrLen := 100000 + randNumbs, err := GenerateRandomNonRepeatingInt(arrLen) + assert.NoError(t, err) + + assert.Equal(t, len(randNumbs), arrLen) +} + +func TestEncodeDecode(t *testing.T) { + testArr := []uint64{1, 2, 3, 4, 5, 6} + testEncode := EncodeBase64Str(testArr) + testDecode, err := DecodeBase64Str(testEncode) + assert.NoError(t, err) + assert.Equal(t, len(testArr), len(testDecode)) + for idx, val := range testArr { + assert.Equal(t, val, testDecode[idx]) + } +} + +func TestMatrixTranspose(t *testing.T) { + matrix := [][]int{ + {0, 1, 2}, + {3, 4, 5}, + } + expectedMatrixT := [][]int{ + {0, 3}, + {1, 4}, + {2, 5}, + } + expectedFlatMat := MatrixToList(expectedMatrixT) + matrixT, err := MatrixTranspose(matrix) + assert.NoError(t, err) + flatMat := MatrixToList(matrixT) + + assert.Equal(t, len(flatMat), len(expectedFlatMat)) + for idx := range flatMat { + assert.Equal(t, expectedFlatMat[idx], flatMat[idx]) + } +} + +func TestIntToByteAndBack(t *testing.T) { + origIntArr := []int{1, 2, 3, 4, 5} + byteArr := IntArrToByteArr(origIntArr) + intArr := ByteArrToIntArr(byteArr) + + assert.ElementsMatch(t, origIntArr, intArr) +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..323e48f --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,9 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "./sqlite/query.sql" + schema: "./sqlite/schema.sql" + gen: + go: + package: "sqlc" + out: "./pkg/nkode-core/sqlc" \ No newline at end of file diff --git a/sqlc/db.go b/sqlc/db.go new file mode 100644 index 0000000..2248616 --- /dev/null +++ b/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/sqlc/models.go b/sqlc/models.go new file mode 100644 index 0000000..d8f68cf --- /dev/null +++ b/sqlc/models.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "database/sql" +) + +type Customer struct { + ID string + MaxNkodeLen int64 + MinNkodeLen int64 + DistinctSets int64 + DistinctAttributes int64 + LockOut int64 + Expiration int64 + AttributeValues []byte + SetValues []byte + LastRenew string + CreatedAt string +} + +type SvgIcon struct { + ID int64 + Svg string +} + +type User struct { + ID string + Email string + Renew int64 + RefreshToken sql.NullString + CustomerID string + Code string + Mask string + AttributesPerKey int64 + NumberOfKeys int64 + AlphaKey []byte + SetKey []byte + PassKey []byte + MaskKey []byte + Salt []byte + MaxNkodeLen int64 + IdxInterface []byte + SvgIDInterface []byte + LastLogin interface{} + CreatedAt sql.NullString +} diff --git a/sqlc/query.sql.go b/sqlc/query.sql.go new file mode 100644 index 0000000..c11071c --- /dev/null +++ b/sqlc/query.sql.go @@ -0,0 +1,478 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: query.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const createCustomer = `-- name: CreateCustomer :exec +INSERT INTO customer ( + id + ,max_nkode_len + ,min_nkode_len + ,distinct_sets + ,distinct_attributes + ,lock_out + ,expiration + ,attribute_values + ,set_values + ,last_renew + ,created_at +) +VALUES (?,?,?,?,?,?,?,?,?,?,?) +` + +type CreateCustomerParams struct { + ID string + MaxNkodeLen int64 + MinNkodeLen int64 + DistinctSets int64 + DistinctAttributes int64 + LockOut int64 + Expiration int64 + AttributeValues []byte + SetValues []byte + LastRenew string + CreatedAt string +} + +func (q *Queries) CreateCustomer(ctx context.Context, arg CreateCustomerParams) error { + _, err := q.db.ExecContext(ctx, createCustomer, + arg.ID, + arg.MaxNkodeLen, + arg.MinNkodeLen, + arg.DistinctSets, + arg.DistinctAttributes, + arg.LockOut, + arg.Expiration, + arg.AttributeValues, + arg.SetValues, + arg.LastRenew, + arg.CreatedAt, + ) + return err +} + +const createUser = `-- name: CreateUser :exec +INSERT INTO user ( + id + ,email + ,renew + ,refresh_token + ,customer_id + ,code + ,mask + ,attributes_per_key + ,number_of_keys + ,alpha_key + ,set_key + ,pass_key + ,mask_key + ,salt + ,max_nkode_len + ,idx_interface + ,svg_id_interface + ,created_at +) +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +` + +type CreateUserParams struct { + ID string + Email string + Renew int64 + RefreshToken sql.NullString + CustomerID string + Code string + Mask string + AttributesPerKey int64 + NumberOfKeys int64 + AlphaKey []byte + SetKey []byte + PassKey []byte + MaskKey []byte + Salt []byte + MaxNkodeLen int64 + IdxInterface []byte + SvgIDInterface []byte + CreatedAt sql.NullString +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Email, + arg.Renew, + arg.RefreshToken, + arg.CustomerID, + arg.Code, + arg.Mask, + arg.AttributesPerKey, + arg.NumberOfKeys, + arg.AlphaKey, + arg.SetKey, + arg.PassKey, + arg.MaskKey, + arg.Salt, + arg.MaxNkodeLen, + arg.IdxInterface, + arg.SvgIDInterface, + arg.CreatedAt, + ) + return err +} + +const getCustomer = `-- name: GetCustomer :one +SELECT + max_nkode_len + ,min_nkode_len + ,distinct_sets + ,distinct_attributes + ,lock_out + ,expiration + ,attribute_values + ,set_values +FROM customer +WHERE id = ? +` + +type GetCustomerRow struct { + MaxNkodeLen int64 + MinNkodeLen int64 + DistinctSets int64 + DistinctAttributes int64 + LockOut int64 + Expiration int64 + AttributeValues []byte + SetValues []byte +} + +func (q *Queries) GetCustomer(ctx context.Context, id string) (GetCustomerRow, error) { + row := q.db.QueryRowContext(ctx, getCustomer, id) + var i GetCustomerRow + err := row.Scan( + &i.MaxNkodeLen, + &i.MinNkodeLen, + &i.DistinctSets, + &i.DistinctAttributes, + &i.LockOut, + &i.Expiration, + &i.AttributeValues, + &i.SetValues, + ) + return i, err +} + +const getSvgCount = `-- name: GetSvgCount :one +SELECT COUNT(*) as count FROM svg_icon +` + +func (q *Queries) GetSvgCount(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, getSvgCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getSvgId = `-- name: GetSvgId :one +SELECT svg +FROM svg_icon +WHERE id = ? +` + +func (q *Queries) GetSvgId(ctx context.Context, id int64) (string, error) { + row := q.db.QueryRowContext(ctx, getSvgId, id) + var svg string + err := row.Scan(&svg) + return svg, err +} + +const getUser = `-- name: GetUser :one +SELECT + id + ,renew + ,refresh_token + ,code + ,mask + ,attributes_per_key + ,number_of_keys + ,alpha_key + ,set_key + ,pass_key + ,mask_key + ,salt + ,max_nkode_len + ,idx_interface + ,svg_id_interface +FROM user +WHERE user.email = ? AND user.customer_id = ? +` + +type GetUserParams struct { + Email string + CustomerID string +} + +type GetUserRow struct { + ID string + Renew int64 + RefreshToken sql.NullString + Code string + Mask string + AttributesPerKey int64 + NumberOfKeys int64 + AlphaKey []byte + SetKey []byte + PassKey []byte + MaskKey []byte + Salt []byte + MaxNkodeLen int64 + IdxInterface []byte + SvgIDInterface []byte +} + +func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) (GetUserRow, error) { + row := q.db.QueryRowContext(ctx, getUser, arg.Email, arg.CustomerID) + var i GetUserRow + err := row.Scan( + &i.ID, + &i.Renew, + &i.RefreshToken, + &i.Code, + &i.Mask, + &i.AttributesPerKey, + &i.NumberOfKeys, + &i.AlphaKey, + &i.SetKey, + &i.PassKey, + &i.MaskKey, + &i.Salt, + &i.MaxNkodeLen, + &i.IdxInterface, + &i.SvgIDInterface, + ) + return i, err +} + +const getUserRenew = `-- name: GetUserRenew :many +SELECT + id + ,alpha_key + ,set_key + ,attributes_per_key + ,number_of_keys +FROM user +WHERE customer_id = ? +` + +type GetUserRenewRow struct { + ID string + AlphaKey []byte + SetKey []byte + AttributesPerKey int64 + NumberOfKeys int64 +} + +func (q *Queries) GetUserRenew(ctx context.Context, customerID string) ([]GetUserRenewRow, error) { + rows, err := q.db.QueryContext(ctx, getUserRenew, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserRenewRow + for rows.Next() { + var i GetUserRenewRow + if err := rows.Scan( + &i.ID, + &i.AlphaKey, + &i.SetKey, + &i.AttributesPerKey, + &i.NumberOfKeys, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const refreshUserPasscode = `-- name: RefreshUserPasscode :exec +UPDATE user +SET + renew = ? + ,code = ? + ,mask = ? + ,alpha_key = ? + ,set_key = ? + ,pass_key = ? + ,mask_key = ? + ,salt = ? +WHERE id = ? +` + +type RefreshUserPasscodeParams struct { + Renew int64 + Code string + Mask string + AlphaKey []byte + SetKey []byte + PassKey []byte + MaskKey []byte + Salt []byte + ID string +} + +func (q *Queries) RefreshUserPasscode(ctx context.Context, arg RefreshUserPasscodeParams) error { + _, err := q.db.ExecContext(ctx, refreshUserPasscode, + arg.Renew, + arg.Code, + arg.Mask, + arg.AlphaKey, + arg.SetKey, + arg.PassKey, + arg.MaskKey, + arg.Salt, + arg.ID, + ) + return err +} + +const renewCustomer = `-- name: RenewCustomer :exec +UPDATE customer +SET attribute_values = ?, set_values = ? +WHERE id = ? +` + +type RenewCustomerParams struct { + AttributeValues []byte + SetValues []byte + ID string +} + +func (q *Queries) RenewCustomer(ctx context.Context, arg RenewCustomerParams) error { + _, err := q.db.ExecContext(ctx, renewCustomer, arg.AttributeValues, arg.SetValues, arg.ID) + return err +} + +const renewUser = `-- name: RenewUser :exec +UPDATE user +SET alpha_key = ?, set_key = ?, renew = ? +WHERE id = ? +` + +type RenewUserParams struct { + AlphaKey []byte + SetKey []byte + Renew int64 + ID string +} + +func (q *Queries) RenewUser(ctx context.Context, arg RenewUserParams) error { + _, err := q.db.ExecContext(ctx, renewUser, + arg.AlphaKey, + arg.SetKey, + arg.Renew, + arg.ID, + ) + return err +} + +const updateUser = `-- name: UpdateUser :exec +UPDATE user +SET renew = ? + ,refresh_token = ? + ,code = ? + ,mask = ? + ,attributes_per_key = ? + ,number_of_keys = ? + ,alpha_key = ? + ,set_key = ? + ,pass_key = ? + ,mask_key = ? + ,salt = ? + ,max_nkode_len = ? + ,idx_interface = ? + ,svg_id_interface = ? +WHERE email = ? AND customer_id = ? +` + +type UpdateUserParams struct { + Renew int64 + RefreshToken sql.NullString + Code string + Mask string + AttributesPerKey int64 + NumberOfKeys int64 + AlphaKey []byte + SetKey []byte + PassKey []byte + MaskKey []byte + Salt []byte + MaxNkodeLen int64 + IdxInterface []byte + SvgIDInterface []byte + Email string + CustomerID string +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { + _, err := q.db.ExecContext(ctx, updateUser, + arg.Renew, + arg.RefreshToken, + arg.Code, + arg.Mask, + arg.AttributesPerKey, + arg.NumberOfKeys, + arg.AlphaKey, + arg.SetKey, + arg.PassKey, + arg.MaskKey, + arg.Salt, + arg.MaxNkodeLen, + arg.IdxInterface, + arg.SvgIDInterface, + arg.Email, + arg.CustomerID, + ) + return err +} + +const updateUserInterface = `-- name: UpdateUserInterface :exec +UPDATE user SET idx_interface = ?, last_login = ? WHERE id = ? +` + +type UpdateUserInterfaceParams struct { + IdxInterface []byte + LastLogin interface{} + ID string +} + +func (q *Queries) UpdateUserInterface(ctx context.Context, arg UpdateUserInterfaceParams) error { + _, err := q.db.ExecContext(ctx, updateUserInterface, arg.IdxInterface, arg.LastLogin, arg.ID) + return err +} + +const updateUserRefreshToken = `-- name: UpdateUserRefreshToken :exec +UPDATE user SET refresh_token = ? WHERE id = ? +` + +type UpdateUserRefreshTokenParams struct { + RefreshToken sql.NullString + ID string +} + +func (q *Queries) UpdateUserRefreshToken(ctx context.Context, arg UpdateUserRefreshTokenParams) error { + _, err := q.db.ExecContext(ctx, updateUserRefreshToken, arg.RefreshToken, arg.ID) + return err +} diff --git a/sqlc/sqlite_queue.go b/sqlc/sqlite_queue.go new file mode 100644 index 0000000..20c17e6 --- /dev/null +++ b/sqlc/sqlite_queue.go @@ -0,0 +1,93 @@ +package sqlc + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" +) + +const writeBufferSize = 100 + +type SqlcGeneric func(*Queries, context.Context, any) error + +type WriteTx struct { + ErrChan chan error + Query SqlcGeneric + Args interface{} +} + +type Queue struct { + Queries *Queries + Db *sql.DB + WriteQueue chan WriteTx + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +func NewQueue(sqlDb *sql.DB, ctx context.Context) (*Queue, error) { + ctx, cancel := context.WithCancel(context.Background()) + sqldb := &Queue{ + Queries: New(sqlDb), + Db: sqlDb, + WriteQueue: make(chan WriteTx, writeBufferSize), + ctx: ctx, + cancel: cancel, + } + + return sqldb, nil +} + +func (d *Queue) Start() { + d.wg.Add(1) + defer d.wg.Done() + go func() { + for { + select { + case <-d.ctx.Done(): + return + case writeTx := <-d.WriteQueue: + err := writeTx.Query(d.Queries, d.ctx, writeTx.Args) + writeTx.ErrChan <- err + } + } + }() +} + +func (d *Queue) Stop() error { + d.cancel() + d.wg.Wait() + close(d.WriteQueue) + return d.Db.Close() +} + +func (d *Queue) EnqueueWriteTx(queryFunc SqlcGeneric, args any) error { + select { + case <-d.ctx.Done(): + return errors.New("database is shutting down") + default: + } + + errChan := make(chan error, 1) + writeTx := WriteTx{ + Query: queryFunc, + Args: args, + ErrChan: errChan, + } + d.WriteQueue <- writeTx + return <-errChan +} + +func OpenSqliteDb(dbPath string) (*sql.DB, error) { + sqliteDb, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := sqliteDb.Ping(); err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + return sqliteDb, nil +} diff --git a/sqlite/query.sql b/sqlite/query.sql new file mode 100644 index 0000000..b3c50cf --- /dev/null +++ b/sqlite/query.sql @@ -0,0 +1,136 @@ +-- name: CreateCustomer :exec +INSERT INTO customer ( + id + ,max_nkode_len + ,min_nkode_len + ,distinct_sets + ,distinct_attributes + ,lock_out + ,expiration + ,attribute_values + ,set_values + ,last_renew + ,created_at +) +VALUES (?,?,?,?,?,?,?,?,?,?,?); + +-- name: CreateUser :exec +INSERT INTO user ( + id + ,email + ,renew + ,refresh_token + ,customer_id + ,code + ,mask + ,attributes_per_key + ,number_of_keys + ,alpha_key + ,set_key + ,pass_key + ,mask_key + ,salt + ,max_nkode_len + ,idx_interface + ,svg_id_interface + ,created_at +) +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + +-- name: UpdateUser :exec +UPDATE user +SET renew = ? + ,refresh_token = ? + ,code = ? + ,mask = ? + ,attributes_per_key = ? + ,number_of_keys = ? + ,alpha_key = ? + ,set_key = ? + ,pass_key = ? + ,mask_key = ? + ,salt = ? + ,max_nkode_len = ? + ,idx_interface = ? + ,svg_id_interface = ? +WHERE email = ? AND customer_id = ?; + +-- name: UpdateUserInterface :exec +UPDATE user SET idx_interface = ?, last_login = ? WHERE id = ?; + +-- name: UpdateUserRefreshToken :exec +UPDATE user SET refresh_token = ? WHERE id = ?; + +-- name: RenewCustomer :exec +UPDATE customer +SET attribute_values = ?, set_values = ? +WHERE id = ?; + +-- name: RenewUser :exec +UPDATE user +SET alpha_key = ?, set_key = ?, renew = ? +WHERE id = ?; + +-- name: RefreshUserPasscode :exec +UPDATE user +SET + renew = ? + ,code = ? + ,mask = ? + ,alpha_key = ? + ,set_key = ? + ,pass_key = ? + ,mask_key = ? + ,salt = ? +WHERE id = ?; + +-- name: GetUserRenew :many +SELECT + id + ,alpha_key + ,set_key + ,attributes_per_key + ,number_of_keys +FROM user +WHERE customer_id = ?; + +-- name: GetCustomer :one +SELECT + max_nkode_len + ,min_nkode_len + ,distinct_sets + ,distinct_attributes + ,lock_out + ,expiration + ,attribute_values + ,set_values +FROM customer +WHERE id = ?; + +-- name: GetUser :one +SELECT + id + ,renew + ,refresh_token + ,code + ,mask + ,attributes_per_key + ,number_of_keys + ,alpha_key + ,set_key + ,pass_key + ,mask_key + ,salt + ,max_nkode_len + ,idx_interface + ,svg_id_interface +FROM user +WHERE user.email = ? AND user.customer_id = ?; + +-- name: GetSvgId :one +SELECT svg +FROM svg_icon +WHERE id = ?; + +-- name: GetSvgCount :one +SELECT COUNT(*) as count FROM svg_icon; diff --git a/sqlite/schema.sql b/sqlite/schema.sql new file mode 100644 index 0000000..4e30249 --- /dev/null +++ b/sqlite/schema.sql @@ -0,0 +1,57 @@ +PRAGMA journal_mode=WAL; + + +CREATE TABLE IF NOT EXISTS customer ( + id TEXT NOT NULL PRIMARY KEY + ,max_nkode_len INTEGER NOT NULL + ,min_nkode_len INTEGER NOT NULL + ,distinct_sets INTEGER NOT NULL + ,distinct_attributes INTEGER NOT NULL + ,lock_out INTEGER NOT NULL + ,expiration INTEGER NOT NULL + ,attribute_values BLOB NOT NULL + ,set_values BLOB NOT NULL + ,last_renew TEXT NOT NULL + ,created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY + ,email TEXT NOT NULL +-- first_name TEXT NOT NULL +-- last_name TEXT NOT NULL + ,renew INT NOT NULL + ,refresh_token TEXT + ,customer_id TEXT NOT NULL + +-- Enciphered Passcode + ,code TEXT NOT NULL + ,mask TEXT NOT NULL + +-- Keypad Dimensions + ,attributes_per_key INT NOT NULL + ,number_of_keys INT NOT NULL + +-- User Keys + ,alpha_key BLOB NOT NULL + ,set_key BLOB NOT NULL + ,pass_key BLOB NOT NULL + ,mask_key BLOB NOT NULL + ,salt BLOB NOT NULL + ,max_nkode_len INT NOT NULL + +-- User Interface + ,idx_interface BLOB NOT NULL + ,svg_id_interface BLOB NOT NULL + + ,last_login TEXT NULL + ,created_at TEXT + + ,FOREIGN KEY (customer_id) REFERENCES customer(id) + ,UNIQUE(customer_id, email) +); + +CREATE TABLE IF NOT EXISTS svg_icon ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,svg TEXT NOT NULL +); diff --git a/utils/timestamp.go b/utils/timestamp.go new file mode 100644 index 0000000..4de32dc --- /dev/null +++ b/utils/timestamp.go @@ -0,0 +1,7 @@ +package utils + +import "time" + +func TimeStamp() string { + return time.Now().Format(time.RFC3339) +}