diff options
Diffstat (limited to 'internal/auth')
| -rw-r--r-- | internal/auth/auth.go | 215 |
1 files changed, 215 insertions, 0 deletions
diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..7f8f8d9 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,215 @@ +// Copyright 2026 Rose Hogenson <r@roseh.moe> +// +// This file is part of roseh.moe. +// +// roseh.moe is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// roseh.moe is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with roseh.moe. If not, see +// <https://www.gnu.org/licenses/>. + +package auth + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json/v2" + "fmt" + "io" + "log" + "net/url" + "slices" + "strings" + "sync" + "time" + "unicode" +) + +var SecretKey []byte + +func MAC(msg []byte, purpose string) []byte { + mac := hmac.New(sha256.New, SecretKey) + io.WriteString(mac, purpose) + io.WriteString(mac, ":") + mac.Write(msg) + return mac.Sum(nil) +} + +func SignMsg(msg, purpose string) string { + return msg + base64.RawURLEncoding.EncodeToString(MAC([]byte(msg), purpose)) +} + +func VerifyHMAC(signedMsg, purpose string) (string, bool) { + macSize := base64.RawURLEncoding.EncodedLen(sha256.Size) + if len(signedMsg) < macSize { + return "", false + } + msg, msgMAC := signedMsg[:len(signedMsg)-macSize], signedMsg[len(signedMsg)-macSize:] + expectedMAC := base64.RawURLEncoding.EncodeToString(MAC([]byte(msg), purpose)) + if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { + return "", false + } + return msg, true +} + +type Passkey struct { + ID string `xml:",attr"` + Key string `xml:",attr"` +} + +func LoadPasskeys(passkeys []Passkey) (map[string][]byte, error) { + names := make(map[string]bool) + out := make(map[string][]byte) + namesByID := make(map[string]string) + for _, passkey := range passkeys { + if names[passkey.ID] { + return nil, fmt.Errorf("duplicate name %q", passkey.ID) + } + names[passkey.ID] = true + keyBytes, err := base64.RawURLEncoding.DecodeString(strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, passkey.Key)) + if err != nil { + return nil, fmt.Errorf("invalid key for passkey %q: %s", passkey.ID, err) + } + if len(keyBytes) < 16 { + return nil, fmt.Errorf("invalid key for passkey %q: bad user ID: too short", passkey.ID) + } + id, key := keyBytes[:16], keyBytes[16:] + if _, err := sigVerifier(key); err != nil { + return nil, fmt.Errorf("invalid key for passkey %q: %s", passkey.ID, err) + } + if _, ok := out[string(id)]; ok { + return nil, fmt.Errorf("passkeys %q and %q have duplicate ID %d", namesByID[string(id)], passkey.ID, id) + } + out[string(id)] = key + namesByID[string(id)] = passkey.ID + } + return out, nil +} + +func sigVerifier(keyBytes []byte) (func(msg, sig []byte) bool, error) { + key, err := x509.ParsePKIXPublicKey(keyBytes) + if err != nil { + return nil, err + } + switch key := key.(type) { + case *ecdsa.PublicKey: + return func(msg, sig []byte) bool { + hash := sha256.Sum256(msg) + return ecdsa.VerifyASN1(key, hash[:], sig) + }, nil + case *rsa.PublicKey: + return func(msg, sig []byte) bool { + hash := sha256.Sum256(msg) + return rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], sig) == nil + }, nil + default: + return nil, fmt.Errorf("unsupported key type") + } +} + +func VerifyPubKey(pubKey, msg, sig []byte) error { + verify, err := sigVerifier(pubKey) + if err != nil { + return err + } + if !verify(msg, sig) { + return fmt.Errorf("message failed validation") + } + return nil +} + +const challengeExpiry = 10 * time.Minute + +var ( + challengeMu sync.Mutex + challenges = make(map[string]time.Time) +) + +func NewChallenge() ([]byte, bool) { + buf := make([]byte, 32) + rand.Read(buf) + now := time.Now() + challengeMu.Lock() + if len(challenges) >= 100 { + challengeMu.Unlock() + return nil, false + } + challenges[string(buf)] = now + challengeMu.Unlock() + return buf, true +} + +func Login(user, authData, clientData, sig []byte, passkeys map[string][]byte, selfURL *url.URL) error { + pubKey, ok := passkeys[string(user)] + if !ok { + return fmt.Errorf("no user with the given ID") + } + clientDataHash := sha256.Sum256(clientData) + if err := VerifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { + return fmt.Errorf("challenge failed: %s", err) + } + var cd struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + CrossOrigin bool `json:"crossOrigin"` + } + if err := json.Unmarshal(clientData, &cd); err != nil { + return fmt.Errorf("bad client data: invalid JSON: %s", err) + } + challenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) + if err != nil { + return fmt.Errorf("bad client data: bad challenge: %s", err) + } + challengeMu.Lock() + initiated, ok := challenges[string(challenge)] + delete(challenges, string(challenge)) + challengeMu.Unlock() + if !ok { + return fmt.Errorf("request already consumed") + } + if time.Since(initiated) > challengeExpiry { + return fmt.Errorf("challenge is too old; please try again") + } + if cd.Type != "webauthn.get" || cd.Origin != selfURL.String() || cd.CrossOrigin { + return fmt.Errorf("bad client data") + } + expectedRPHash := sha256.Sum256([]byte(selfURL.Hostname())) + const flagUserPresence, flagUserVerification = 0x01, 0x04 + if len(authData) < 37 || !bytes.Equal(authData[:32], expectedRPHash[:]) || authData[32]&(flagUserPresence|flagUserVerification) != (flagUserPresence|flagUserVerification) { + return fmt.Errorf("bad authenticator data") + } + return nil +} + +func CleanChallenges() { + log.Printf("Cleaning expired challenges") + now := time.Now() + challengeMu.Lock() + for challenge, initiated := range challenges { + if now.Sub(initiated) > challengeExpiry { + delete(challenges, challenge) + } + } + challengeMu.Unlock() +} |
