From 5f816bc4a51ff6f2e1d38d2272c494afa7ad0637 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Tue, 20 Oct 2026 22:17:50 -0700 Subject: Move auth related functions into a new package --- roseh.moe.go | 193 ++++++----------------------------------------------------- 1 file changed, 18 insertions(+), 175 deletions(-) diff --git a/roseh.moe.go b/roseh.moe.go index e8fb802..54b4ea6 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -22,17 +22,12 @@ import ( "bytes" "compress/gzip" "context" - "crypto" - "crypto/ecdsa" "crypto/hmac" "crypto/rand" - "crypto/rsa" "crypto/sha256" - "crypto/x509" "embed" "encoding/base64" "encoding/binary" - "encoding/json/v2" "encoding/xml" "flag" "fmt" @@ -55,6 +50,7 @@ import ( "unicode" "github.com/skip2/go-qrcode" + "roseh.moe/pkg/roseh.moe/internal/auth" "roseh.moe/pkg/roseh.moe/internal/hole" "roseh.moe/pkg/wordlist" ) @@ -104,40 +100,6 @@ func urlFlag(name, def, doc string) *url.URL { return u } -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 -} - -var secretKey []byte - func loadSecrets() error { fileBytes, err := os.ReadFile(*secretsFile) if err != nil { @@ -152,7 +114,7 @@ func loadSecrets() error { if secretsConfig.SecretKey == "" { return fmt.Errorf("%s: missing SecretKey", *secretsFile) } - if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { + if auth.SecretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) } return nil @@ -173,15 +135,10 @@ type commandConfig struct { Multi bool `xml:",attr"` } -type passkey struct { - ID string `xml:",attr"` - Key string `xml:",attr"` -} - type serviceConfiguration struct { PackageRedirects []packageConfig `xml:"Redirects>Package"` CommandRedirects []commandConfig `xml:"Redirects>Command"` - Passkey []passkey + Passkey []auth.Passkey passkeys map[string][]byte } @@ -213,35 +170,9 @@ func loadConfig() (*serviceConfiguration, error) { if err := xml.Unmarshal(fileBytes, config); err != nil { return nil, err } - names := make(map[string]bool) - config.passkeys = make(map[string][]byte) - namesByID := make(map[string]string) - for _, passkey := range config.Passkey { - if names[passkey.ID] { - return nil, fmt.Errorf("%s: duplicate name %q", *configFile, 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("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) - } - if len(keyBytes) < 16 { - return nil, fmt.Errorf("%s: invalid key for passkey %q: bad user ID: too short", *configFile, passkey.ID) - } - id, key := keyBytes[:16], keyBytes[16:] - if _, err := sigVerifier(key); err != nil { - return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) - } - if _, ok := config.passkeys[string(id)]; ok { - return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[string(id)], passkey.ID, id) - } - config.passkeys[string(id)] = key - namesByID[string(id)] = passkey.ID + config.passkeys, err = auth.LoadPasskeys(config.Passkey) + if err != nil { + return nil, fmt.Errorf("%s: %s", *configFile, err) } slices.SortFunc(config.PackageRedirects, func(x, y packageConfig) int { return strings.Compare(x.Name, y.Name) }) slices.SortFunc(config.CommandRedirects, func(x, y commandConfig) int { return strings.Compare(x.Name, y.Name) }) @@ -407,7 +338,7 @@ func sharedFileHandler(w http.ResponseWriter, r *http.Request) { } func qrHandler(w http.ResponseWriter, r *http.Request) { - text, ok := verifyHMAC(r.FormValue("t"), "qr") + text, ok := auth.VerifyHMAC(r.FormValue("t"), "qr") if !ok { http.Error(w, "Bad signature", http.StatusBadRequest) return @@ -485,7 +416,7 @@ func wormholeUpload(w http.ResponseWriter, r *http.Request) { downloadURL := selfURL.String() + "/wormhole/" + holeName if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ URL: downloadURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")), + QR: "/qr.png?t=" + url.QueryEscape(auth.SignMsg(downloadURL, "qr")), }); err != nil { log.Printf("Warning: wormholeSend: %s", err) } @@ -517,31 +448,6 @@ func wormholeDownload(w http.ResponseWriter, r *http.Request) { } } -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 -} - const ( authVersion = "authv3" authCookieName = "__Secure-a" @@ -569,7 +475,7 @@ type authToken struct { func attachCookie(w http.ResponseWriter, tok *authToken) { buf := binary.AppendVarint(nil, tok.expiry.Unix()) buf = append(buf, tok.userID...) - token := signMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) + token := auth.SignMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) http.SetCookie(w, makeHTTPCookie(token, tok.expiry)) } @@ -578,7 +484,7 @@ func cookieAuth(r *http.Request) bool { if err != nil { return false } - msg, ok := verifyHMAC(cookie.Value, authVersion) + msg, ok := auth.VerifyHMAC(cookie.Value, authVersion) if !ok { return false } @@ -628,13 +534,6 @@ func verifyRedirect(redirect string) bool { return false } -const challengeExpiry = 10 * time.Minute - -var ( - challengeMu sync.Mutex - challenges = make(map[string]time.Time) -) - var ( //go:embed templates/login.html.template loginTemplateString string @@ -654,19 +553,13 @@ func serveLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, redirect, http.StatusSeeOther) return } - buf := make([]byte, 32) - rand.Read(buf) - now := time.Now() - challengeMu.Lock() - if len(challenges) >= 100 { - challengeMu.Unlock() + challenge, ok := auth.NewChallenge() + if !ok { http.Error(w, "Too many active requests", http.StatusTooManyRequests) return } - challenges[string(buf)] = now - challengeMu.Unlock() if err := loginTemplate.Execute(w, loginTemplateArgs{ - Challenge: base64.StdEncoding.EncodeToString(buf), + Challenge: base64.StdEncoding.EncodeToString(challenge), }); err != nil { log.Printf("Warning: login: %s", err) } @@ -699,51 +592,8 @@ func login(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal error", http.StatusInternalServerError) return } - pubKey, ok := config.passkeys[string(user)] - if !ok { - http.Error(w, "No user with the given ID", http.StatusBadRequest) - return - } - clientDataHash := sha256.Sum256(clientData) - if err := verifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { - http.Error(w, "Challenge failed", http.StatusBadRequest) - return - } - 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 { - http.Error(w, "Bad client data: invalid JSON", http.StatusBadRequest) - return - } - challenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) - if err != nil { - http.Error(w, "Bad client data: bad challenge", http.StatusBadRequest) - return - } - challengeMu.Lock() - initiated, ok := challenges[string(challenge)] - delete(challenges, string(challenge)) - challengeMu.Unlock() - if !ok { - http.Error(w, "Request already consumed", http.StatusConflict) - return - } - if time.Since(initiated) > challengeExpiry { - http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) - return - } - if cd.Type != "webauthn.get" || cd.Origin != selfURL.String() || cd.CrossOrigin { - http.Error(w, "Bad client data", http.StatusBadRequest) - return - } - 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) { - http.Error(w, "Bad authenticator data", http.StatusBadRequest) + if err := auth.Login(user, authData, clientData, sig, config.passkeys, selfURL); err != nil { + http.Error(w, "Failed challenge", http.StatusBadRequest) return } attachCookie(w, &authToken{ @@ -861,7 +711,7 @@ func serveOTP(w http.ResponseWriter, r *http.Request) { otpURL := selfURL.String() + "/otp/" + l if err := otpTemplate.Execute(w, otpTemplateArgs{ URL: otpURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")), + QR: "/qr.png?t=" + url.QueryEscape(auth.SignMsg(otpURL, "qr")), }); err != nil { log.Printf("Warning: serveOTP: %s", err) } @@ -1243,15 +1093,8 @@ func main() { }() go func() { - for now := range time.NewTicker(time.Hour).C { - log.Printf("Cleaning expired challenges") - challengeMu.Lock() - for challenge, initiated := range challenges { - if now.Sub(initiated) > challengeExpiry { - delete(challenges, challenge) - } - } - challengeMu.Unlock() + for range time.NewTicker(time.Hour).C { + auth.CleanChallenges() } }() -- cgit v1.3.1