package auth import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" "time" "git.mstar.dev/mstar/goutils/sliceutils" "golang.org/x/crypto/argon2" "git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/models" ) // Len of salt for passwords in bytes const saltLen = 32 // Generate a random salt with the given nr of bytes func generateSalt(length int) ([]byte, error) { salt := make([]byte, length) if _, err := rand.Read(salt); err != nil { return nil, err } return salt, nil } // Hash a password with salt func hashPassword(password string) (hash []byte, err error) { salt, err := generateSalt(saltLen) if err != nil { return nil, err } hash = argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) hash = append(hash, salt...) // return bcrypt.GenerateFromPassword([]byte(password), 14) return hash, nil } // Check wether a password matches a hash func comparePassword(password string, hash []byte) bool { // Hash is actually hash(password)+salt salt := hash[len(hash)-saltLen:] return bytes.Equal(argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32), hash) } // Copied and adjusted from: https://bruinsslot.jp/post/golang-crypto/ // Encrypt some data using a key func Encrypt(key, data []byte) ([]byte, error) { key, salt, err := deriveKey(key, nil) if err != nil { return nil, err } blockCipher, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(blockCipher) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err = rand.Read(nonce); err != nil { return nil, err } ciphertext := gcm.Seal(nonce, nonce, data, nil) ciphertext = append(ciphertext, salt...) return ciphertext, nil } // Decrypt some data using a key func Decrypt(key, data []byte) ([]byte, error) { salt, data := data[len(data)-32:], data[:len(data)-32] key, _, err := deriveKey(key, salt) if err != nil { return nil, err } blockCipher, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(blockCipher) if err != nil { return nil, err } nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } return plaintext, nil } // Derive a key from a password and optionally salt. // Returns the hash and salt used func deriveKey(password, salt []byte) ([]byte, []byte, error) { if salt == nil { salt = make([]byte, 32) if _, err := rand.Read(salt); err != nil { return nil, nil, err } } key := argon2.IDKey(password, salt, 1, 64*1024, 4, 32) return key, salt, nil } // Calculate the expiration timestamp from the call of the function func calcAccessExpirationTimestamp() time.Time { // For now, the default expiration is one month after creation // though "never" might also be a good option return time.Now().Add(time.Hour * 24 * 30) } // Convert a list of authentication methods into a [LoginNextState] bitflag. // [isStart] determines whether to allow authentication methods that start // a login process or complete one func ConvertNewStorageAuthMethodsToLoginState( methods []models.AuthenticationMethodType, isStart bool, ) LoginNextState { translatedMethods := sliceutils.Map(methods, oneStorageAuthToLoginState) // Filter out only the valid methods for the current request valids := sliceutils.Filter(translatedMethods, func(t LoginNextState) bool { if isStart { return t == LoginStartPasskey || t == LoginStartPassword } else { return t == LoginNext2FaTotp || t == LoginNext2FaPasskey || t == LoginNext2FaMail } }) // And then compact them down into one bit flag return sliceutils.Compact( valids, func(acc, next LoginNextState) LoginNextState { return acc | next }, ) } // Translate one [models.AuthenticationMethodType] to one [LoginNextState] func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState { switch in { case models.AuthMethodGAuth: return LoginNext2FaTotp case models.AuthMethodMail: return LoginNext2FaMail case models.AuthMethodPasskey: return LoginStartPasskey case models.AuthMethodPasskey2fa: return LoginNext2FaPasskey case models.AuthMethodPassword: return LoginStartPassword default: return LoginUnknown } } // Check whether a given username can log in. // It only provides a yes/no answer and an error in case some check failed // due to unforseen circumstances. Though the error will always be ErrCantLogin // in case the known information (excluding database or other failures) prevents // a login // // TODO: Decide whether to include the reason for disallowed login func (a *Authenticator) canUsernameLogin(username string) (bool, error) { acc, err := dbgen.User.GetByUsername(username) if err != nil { return false, err } if !acc.FinishedRegistration { return false, ErrCantLogin } finalRole := storage.CollapseRolesIntoOne( sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role { return t.Role })...) if finalRole.CanLogin != nil && !*finalRole.CanLogin { return false, ErrCantLogin } return true, nil }