// Package security provides the password hasher and stateless session manager. package security import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" ) // BcryptHasher implements port.PasswordHasher. type BcryptHasher struct{ cost int } func NewBcryptHasher() *BcryptHasher { return &BcryptHasher{cost: bcrypt.DefaultCost} } func (h *BcryptHasher) Hash(plain string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(plain), h.cost) return string(b), err } func (h *BcryptHasher) Compare(hash, plain string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil } // HMACSessions implements port.SessionManager with signed, stateless tokens of // the form base64(userID|expiryUnix).base64(hmacSHA256). No server-side store. type HMACSessions struct{ key []byte } func NewHMACSessions(secret string) *HMACSessions { return &HMACSessions{key: []byte(secret)} } func (s *HMACSessions) Issue(userID string, ttl time.Duration) (string, error) { payload := userID + "|" + strconv.FormatInt(time.Now().Add(ttl).Unix(), 10) p := base64.RawURLEncoding.EncodeToString([]byte(payload)) return p + "." + s.sign(p), nil } func (s *HMACSessions) Verify(token string) (string, error) { p, sig, ok := strings.Cut(token, ".") if !ok { return "", errors.New("malformed token") } if !hmac.Equal([]byte(sig), []byte(s.sign(p))) { return "", errors.New("bad signature") } raw, err := base64.RawURLEncoding.DecodeString(p) if err != nil { return "", err } userID, expStr, ok := strings.Cut(string(raw), "|") if !ok { return "", errors.New("malformed payload") } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil { return "", err } if time.Now().Unix() > exp { return "", errors.New("session expired") } return userID, nil } func (s *HMACSessions) sign(payload string) string { mac := hmac.New(sha256.New, s.key) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) }