diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | assets/angry.png | bin | 3905042 -> 0 bytes | |||
| -rw-r--r-- | assets/angry.xcf | bin | 153335 -> 0 bytes | |||
| -rw-r--r-- | default.nix | 2 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | internal/pwhash/pwhash.go | 36 | ||||
| -rw-r--r-- | roseh.moe.go | 569 | ||||
| -rw-r--r-- | roseh.moe_test.go | 18 | ||||
| -rw-r--r-- | static/angry.webp | bin | 15356 -> 0 bytes | |||
| -rw-r--r-- | static/fart.mp3 | bin | 12356 -> 0 bytes | |||
| -rw-r--r-- | static/styles.css | 3 | ||||
| -rw-r--r-- | templates/login.html.template | 46 | ||||
| -rw-r--r-- | templates/otp.html.template | 25 | ||||
| -rw-r--r-- | templates/register/register-success.html.template | 28 | ||||
| -rw-r--r-- | templates/register/register.html.template | 66 | ||||
| -rw-r--r-- | tools/auth/auth.go | 68 | ||||
| -rw-r--r-- | tools/finditer/finditer.go | 60 | ||||
| -rw-r--r-- | tools/hashpw/hashpw.go | 53 |
19 files changed, 558 insertions, 426 deletions
@@ -1,7 +1,4 @@ /roseh.moe -/auth -/finditer -/hashpw /result /secrets.xml /config.xml diff --git a/assets/angry.png b/assets/angry.png Binary files differdeleted file mode 100644 index 68ff462..0000000 --- a/assets/angry.png +++ /dev/null diff --git a/assets/angry.xcf b/assets/angry.xcf Binary files differdeleted file mode 100644 index 7a163f7..0000000 --- a/assets/angry.xcf +++ /dev/null diff --git a/default.nix b/default.nix index 9f2aeba..3c46f12 100644 --- a/default.nix +++ b/default.nix @@ -16,5 +16,5 @@ pkgs.buildGoModule rec { # remember to bump this hash when your dependencies change. # vendorHash = pkgs.lib.fakeHash; - vendorHash = "sha256-TiWLi3xPKHXmxHp0r94RtwLNPGKuP4ZpPjDzmmqN97Y="; + vendorHash = "sha256-iSJd4A8ztJVTaYe4VPcZzLHDMxJVO0RyTKPRzs4IhtE="; } @@ -4,9 +4,6 @@ go 1.26.0 require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - golang.org/x/term v0.47.0 roseh.moe/pkg/oae2 v0.3.1 roseh.moe/pkg/wordlist v1.0.2 ) - -require golang.org/x/sys v0.49.0 // indirect @@ -1,9 +1,5 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= -golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= roseh.moe/pkg/oae2 v0.3.1 h1:HW6GxhntwDSSBup87mDlWRUNaAhkrlJ0x+F3ncnmlbQ= roseh.moe/pkg/oae2 v0.3.1/go.mod h1:SZ5K2nvKreVdhHVNxujxcZLOnjhVi+qPi2XkB18dR8g= roseh.moe/pkg/wordlist v1.0.2 h1:riB2RqCU5zXfCQjaPHYTXV/688FV9/Bp6Hnb0IAkP9c= diff --git a/internal/pwhash/pwhash.go b/internal/pwhash/pwhash.go deleted file mode 100644 index 5a2082d..0000000 --- a/internal/pwhash/pwhash.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 pwhash - -import ( - "crypto/pbkdf2" - "crypto/sha512" -) - -const SaltSize = 8 - -const defaultIter = 12175430 // from tools/finditer - -func HashIter(password string, salt []byte, iter int) ([]byte, error) { - return pbkdf2.Key(sha512.New, password, salt, iter, 32) -} - -func Hash(password string, salt []byte) ([]byte, error) { - return HashIter(password, salt, defaultIter) -} diff --git a/roseh.moe.go b/roseh.moe.go index 0369740..bd07aed 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -24,15 +24,19 @@ import ( "cmp" "compress/gzip" "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" "crypto/hkdf" "crypto/hmac" "crypto/rand" - "crypto/sha1" + "crypto/rsa" "crypto/sha256" - "crypto/subtle" + "crypto/x509" "embed" "encoding/base64" "encoding/binary" + "encoding/json" "encoding/xml" "errors" "flag" @@ -58,7 +62,6 @@ import ( "github.com/skip2/go-qrcode" "roseh.moe/pkg/oae2" - "roseh.moe/pkg/roseh.moe/internal/pwhash" "roseh.moe/pkg/wordlist" ) @@ -104,7 +107,43 @@ func urlFlag(name, def, doc string) *url.URL { return u } -var notepadPassword, secretKey, authenticatorKey []byte +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 + case ed25519.PublicKey: + return func(msg, sig []byte) bool { + return ed25519.Verify(key, msg, sig) + }, 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) @@ -112,31 +151,17 @@ func loadSecrets() error { return err } var secretsConfig struct { - NotepadPassword string `xml:",attr"` - SecretKey string `xml:",attr"` - AuthKey string `xml:",attr"` + SecretKey string `xml:",attr"` } if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil { return fmt.Errorf("%s: %s", *secretsFile, err) } - if secretsConfig.NotepadPassword == "" { - return fmt.Errorf("%s: missing NotepadPassword", *secretsFile) - } if secretsConfig.SecretKey == "" { return fmt.Errorf("%s: missing SecretKey", *secretsFile) } - if secretsConfig.AuthKey == "" { - return fmt.Errorf("%s: missing AuthKey", *secretsFile) - } - if notepadPassword, err = base64.StdEncoding.DecodeString(secretsConfig.NotepadPassword); err != nil { - return fmt.Errorf("%s: bad NotepadPassword (try base64)", *secretsFile) - } if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) } - if authenticatorKey, err = base64.StdEncoding.DecodeString(secretsConfig.AuthKey); err != nil { - return fmt.Errorf("%s: bad AuthKey (try base64)", *secretsFile) - } return nil } @@ -155,9 +180,16 @@ 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 + passkeys map[string][]byte } var ( @@ -188,6 +220,31 @@ 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(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 + } 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) }) serviceConfigMu.Lock() @@ -212,6 +269,11 @@ var ( "host": func() string { return selfURL.Host }, + "xmlEscape": func(s string) string { + buf := new(strings.Builder) + xml.EscapeText(buf, []byte(s)) + return buf.String() + }, }).Parse(outlineString)) ) @@ -347,7 +409,7 @@ func sharedFileHandler(w http.ResponseWriter, r *http.Request) { } func qrHandler(w http.ResponseWriter, r *http.Request) { - text, ok := verify(r.FormValue("t"), "qr") + text, ok := verifyHMAC(r.FormValue("t"), "qr") if !ok { http.Error(w, "Bad signature", http.StatusBadRequest) return @@ -605,7 +667,7 @@ func wormholeUpload(w http.ResponseWriter, r *http.Request) { downloadURL := selfURL.String() + "/wormhole/" + hole if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ URL: downloadURL, - QR: "/qr.png?t=" + url.QueryEscape(downloadURL) + mac(downloadURL, "qr"), + QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")), }); err != nil { log.Printf("Warning: wormholeSend: %s", err) } @@ -685,197 +747,346 @@ func cleanHole() error { } } -func mac(msg string, purpose string) string { +func mac(msg []byte, purpose string) []byte { mac := hmac.New(sha256.New, secretKey) io.WriteString(mac, purpose) io.WriteString(mac, ":") - io.WriteString(mac, msg) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + mac.Write(msg) + return mac.Sum(nil) +} + +func signMsg(msg, purpose string) string { + return msg + base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) } -func verify(signedMsg, purpose string) (string, bool) { +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 := mac(msg, purpose) + expectedMAC := base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { return "", false } return msg, true } -func marshalInt(x uint64) []byte { - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, x) - for ; len(buf) > 0 && buf[0] == 0; buf = buf[1:] { +const ( + authVersion = "authv3" + authCookieName = "__Secure-a" +) + +func makeHTTPCookie(token string, expiry time.Time) *http.Cookie { + return &http.Cookie{ + Name: authCookieName, + Value: token, + Path: "/", + Domain: selfURL.Hostname(), + Expires: expiry, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, } - return buf } -func unmarshalInt(b []byte) (uint64, bool) { - if len(b) > 8 { - return 0, false - } - buf := make([]byte, 8) - copy(buf[8-len(b):], b) - return binary.BigEndian.Uint64(buf), true +type authToken struct { + expiry time.Time + userID []byte } -func marshalTime(t time.Time) []byte { - t = t.UTC() - year, month, day := t.Date() - hour, minute, second := t.Clock() - u := uint64(year) - u = u<<4 | uint64(month) - u = u<<5 | uint64(day) - u = u<<5 | uint64(hour) - u = u<<6 | uint64(minute) - u = u<<6 | uint64(second) - return marshalInt(u) +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) + http.SetCookie(w, makeHTTPCookie(token, tok.expiry)) } -func unmarshalTime(b []byte) (time.Time, bool) { - u, ok := unmarshalInt(b) +func cookieAuth(r *http.Request) bool { + cookie, err := r.Cookie(authCookieName) + if err != nil { + return false + } + msg, ok := verifyHMAC(cookie.Value, authVersion) if !ok { - return time.Time{}, false + return false } - second, u := int(u&0x3f), u>>6 - minute, u := int(u&0x3f), u>>6 - hour, u := int(u&0x1f), u>>5 - day, u := int(u&0x1f), u>>5 - month, u := time.Month(u&0xf), u>>4 - year := int(u) - return time.Date(year, month, day, hour, minute, second, 0, time.UTC), true + msgBytes, err := base64.RawURLEncoding.DecodeString(msg) + if err != nil { + return false + } + expiryUnix, n := binary.Varint(msgBytes) + if n <= 0 { + return false + } + msgBytes = msgBytes[n:] + expiry := time.Unix(expiryUnix, 0) + if time.Now().After(expiry) { + return false + } + if len(msgBytes) > 0 { + if len(msgBytes) < 16 { + return false + } + userID := msgBytes + config, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + return false + } + if _, ok := config.passkeys[string(userID)]; !ok { + return false + } + } + return true } -const authVersion = "authv2" +var ( + challengeMu sync.Mutex + challenge []byte + challengePosed time.Time +) -func makeToken() string { - t := base64.RawURLEncoding.EncodeToString(marshalTime(time.Now())) - return t + mac(t, authVersion) -} +var ( + //go:embed templates/login.html.template + loginTemplateString string + loginTemplate = template.Must(template.New("login").Parse(loginTemplateString)) +) -const authCookieName = "a" +type loginTemplateArgs struct { + Challenge string +} -func attachCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: authCookieName, - Value: makeToken(), - Path: "/", - Domain: selfURL.Hostname(), - Expires: time.Now().Add(longLoginTimeout), - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Partitioned: true, - }) +func serveLogin(w http.ResponseWriter, r *http.Request) { + if cookieAuth(r) { + redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect") + if !ok { + redirect = "/" + } + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + buf := make([]byte, 32) + rand.Read(buf) + challengeMu.Lock() + challenge = buf + challengePosed = time.Now() + challengeMu.Unlock() + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Challenge: base64.StdEncoding.EncodeToString(buf), + }); err != nil { + log.Printf("Warning: login: %s", err) + } } -func cookieAuth(r *http.Request) (time.Duration, bool) { - cookie, err := r.Cookie(authCookieName) +func login(w http.ResponseWriter, r *http.Request) { + challengeMu.Lock() + myChallenge := challenge + challenge = nil + posed := challengePosed + challengeMu.Unlock() + if len(myChallenge) == 0 { + http.Error(w, "Request already consumed", http.StatusConflict) + return + } + if time.Since(posed) > 10*time.Minute { + http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) + return + } + user, err := base64.StdEncoding.DecodeString(r.FormValue("user")) if err != nil { - return 0, false + http.Error(w, "Bad user", http.StatusBadRequest) + return } - msg, ok := verify(cookie.Value, authVersion) - if !ok { - return 0, false + authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data")) + if err != nil { + http.Error(w, "Bad auth data", http.StatusBadRequest) + return } - msgBytes, err := base64.RawURLEncoding.DecodeString(msg) + clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data")) if err != nil { - return 0, false + http.Error(w, "Bad client data: invalid base64", 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 } - t, ok := unmarshalTime(msgBytes) + challengeBase64 := base64.RawURLEncoding.EncodeToString(myChallenge) + if cd.Type != "webauthn.get" || cd.Challenge != challengeBase64 || 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) + return + } + sig, err := base64.StdEncoding.DecodeString(r.FormValue("sig")) + if err != nil { + http.Error(w, "Bad sig", http.StatusBadRequest) + return + } + config, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + pubKey, ok := config.passkeys[string(user)] if !ok { - return 0, false + http.Error(w, "No user with the given ID", http.StatusBadRequest) + return } - age := time.Since(t) - if age > longLoginTimeout { - return 0, false + clientDataHash := sha256.Sum256(clientData) + if err := verifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { + http.Error(w, "Challenge failed", http.StatusBadRequest) + return } - return age, true + attachCookie(w, &authToken{ + expiry: time.Now().Add(30 * 24 * time.Hour), + userID: user, + }) + redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect") + if !ok { + redirect = "/" + } + http.Redirect(w, r, redirect, http.StatusSeeOther) } -func totp(t int64) []byte { - ts := make([]byte, 8) - binary.BigEndian.PutUint64(ts, uint64(t)) - hash := hmac.New(sha1.New, authenticatorKey) - hash.Write(ts) - mac := hash.Sum(nil) - offset := mac[len(mac)-1] & 0xf - n := int32(mac[offset])&0x7f<<24 | - int32(mac[offset+1])<<16 | - int32(mac[offset+2])<<8 | - int32(mac[offset+3]) - return strconv.AppendInt(nil, int64(n%1_000_000), 10) -} +var ( + //go:embed templates/register/register.html.template + registerTemplateString string + registerTemplate = template.Must(outlinedTemplate(registerTemplateString)) +) -func checkOTP(auth string) int { - now := time.Now().Unix() / 30 - return subtle.ConstantTimeCompare([]byte(auth), totp(now-1)) | subtle.ConstantTimeCompare([]byte(auth), totp(now)) | subtle.ConstantTimeCompare([]byte(auth), totp(now+1)) +type registerTemplateArgs struct { + ID string + Challenge string } -func checkPassword(password string) int { - hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize]) - if err != nil { - return 0 +func serveRegister(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 32+16) + rand.Read(buf) + challenge, userID := buf[:32], buf[32:] + if err := registerTemplate.Execute(w, registerTemplateArgs{ + ID: base64.StdEncoding.EncodeToString(userID), + Challenge: base64.StdEncoding.EncodeToString(challenge), + }); err != nil { + log.Printf("Warning: serveRegister: %s", err) } - return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:]) } var ( - //go:embed templates/login.html.template - loginTemplateString string - loginTemplate = template.Must(outlinedTemplate(loginTemplateString)) + //go:embed templates/register/register-success.html.template + registerSuccessTemplateString string + registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString)) ) -type loginTemplateArgs struct { - Quick bool - Error bool +type registerSuccessTemplateArgs struct { + ID string + Key string } -func redirectLogin(w http.ResponseWriter, r *http.Request, redirect string) { - redirect += mac(redirect, "redirect") - http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther) +func register(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("name") + if username == "" { + http.Error(w, "Passkey name is required", http.StatusBadRequest) + return + } + userID, err := base64.StdEncoding.DecodeString(r.FormValue("id")) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + key, err := base64.StdEncoding.DecodeString(r.FormValue("key")) + if err != nil { + http.Error(w, "Invalid key", http.StatusBadRequest) + return + } + if err := registerSuccessTemplate.Execute(w, registerSuccessTemplateArgs{ + ID: r.FormValue("name"), + Key: base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key)), + }); err != nil { + log.Printf("Warning: register: %s", err) + } } -const ( - quickLoginTimeout = 24 * time.Hour - longLoginTimeout = 300 * 24 * time.Hour +var ( + otpMu sync.Mutex + otpLink string + otpGenerated time.Time ) -func serveLogin(w http.ResponseWriter, r *http.Request) { - _, cookieOK := cookieAuth(r) - if err := loginTemplate.Execute(w, loginTemplateArgs{ - Quick: cookieOK, +var ( + //go:embed templates/otp.html.template + otpTemplateString string + otpTemplate = template.Must(outlinedTemplate(otpTemplateString)) +) + +type otpTemplateArgs struct { + URL string + QR string +} + +func serveOTP(w http.ResponseWriter, r *http.Request) { + const nWords = 5 + buf := make([]byte, 2*nWords) + rand.Read(buf) + words := make([]string, nWords) + for i := range len(buf) / 2 { + words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + l := strings.Join(words, "-") + otpMu.Lock() + otpLink = l + otpGenerated = time.Now() + otpMu.Unlock() + otpURL := selfURL.String() + "/otp/" + l + if err := otpTemplate.Execute(w, otpTemplateArgs{ + URL: otpURL, + QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")), }); err != nil { - log.Printf("Warning: login: %s", err) + log.Printf("Warning: serveOTP: %s", err) } } -func login(w http.ResponseWriter, r *http.Request) { - otpMatches := checkOTP(r.FormValue("auth")) - passwordMatches := 1 - _, cookieOK := cookieAuth(r) - if !cookieOK { - passwordMatches = checkPassword(r.FormValue("password")) +func otpLogin(w http.ResponseWriter, r *http.Request) { + otpMu.Lock() + myOTP := otpLink + otpLink = "" + generated := otpGenerated + otpMu.Unlock() + reqOTP := r.PathValue("otp") + if reqOTP == "" { + notFound(w, r) + return } - if otpMatches&passwordMatches == 0 { - if err := loginTemplate.Execute(w, loginTemplateArgs{ - Quick: cookieOK, - Error: true, - }); err != nil { - log.Printf("Warning: login: %s", err) - } + myOTPHash := sha256.Sum256([]byte(myOTP)) + reqOTPHash := sha256.Sum256([]byte(reqOTP)) + if !hmac.Equal(myOTPHash[:], reqOTPHash[:]) { + notFound(w, r) return } - attachCookie(w) - redirect, ok := verify(r.FormValue("redirect"), "redirect") - if !ok { - redirect = "/" + if time.Since(generated) > 10*time.Minute { + http.Error(w, "OTP is too old. Please generate a new link", http.StatusConflict) + return } - http.Redirect(w, r, redirect, http.StatusSeeOther) + attachCookie(w, &authToken{expiry: time.Now().Add(24 * time.Hour)}) + fmt.Fprintf(w, "Temporary 24 hour token issued. You are now logged in.\n") +} + +func logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, makeHTTPCookie("!", time.Now())) + fmt.Fprintf(w, "You are now logged out.\n") } var ( @@ -1128,18 +1339,26 @@ type authHandler struct { } func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if age, ok := cookieAuth(r); !ok || age > quickLoginTimeout { - log.Printf("Warning: ok age = %v %v", ok, age) + if !cookieAuth(r) { if h.noRedirect { http.Error(w, "not logged in", http.StatusUnauthorized) } else { u := *r.URL - u.Host = r.Host + switch r.Host { + case + "cinema." + selfURL.Host, + "downloads." + selfURL.Host: + + u.Host = r.Host + default: + u.Host = selfURL.Host + } u.Scheme = "http" if *https != "" { u.Scheme = "https" } - redirectLogin(w, r, u.String()) + redirect := signMsg(u.String(), "redirect") + http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther) } return } @@ -1179,6 +1398,35 @@ func (h *loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.h.ServeHTTP(w, r) } +type removeAuthCookieHandler struct { + h http.Handler +} + +type hstsHandler struct { + h http.Handler +} + +func (h *hstsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if *https != "" { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + h.h.ServeHTTP(w, r) +} + +func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + /*var cookies []*http.Cookie + for _, c := range r.Cookies() { + if c.Name != authCookieName { + cookies = append(cookies, c) + } + } + r.Header.Del("Cookie") + for _, c := range cookies { + r.AddCookie(c) + }*/ + h.h.ServeHTTP(w, r) +} + func main() { flag.Parse() @@ -1241,6 +1489,11 @@ func main() { mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload) mux.Handle("GET /login", &timeoutHandler{h: http.HandlerFunc(serveLogin), timeout: 5 * time.Minute}) mux.Handle("POST /login", &timeoutHandler{h: http.HandlerFunc(login), timeout: 5 * time.Minute}) + mux.Handle("GET /logout", &timeoutHandler{h: http.HandlerFunc(logout), timeout: 5 * time.Minute}) + mux.Handle("GET /register", &timeoutHandler{h: http.HandlerFunc(serveRegister), timeout: 5 * time.Minute}) + mux.Handle("POST /register", &timeoutHandler{h: http.HandlerFunc(register), timeout: 5 * time.Minute}) + mux.Handle("GET /otp", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(serveOTP)}, timeout: 5 * time.Minute}) + mux.Handle("GET /otp/{otp}", &timeoutHandler{h: http.HandlerFunc(otpLogin), timeout: 5 * time.Minute}) mux.Handle("GET /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(notepad)}, timeout: 5 * time.Minute}) mux.Handle("POST /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(autosave), noRedirect: true}, timeout: 5 * time.Minute}) mux.Handle("GET /cmd/{$}", &timeoutHandler{h: http.HandlerFunc(cmdIndex), timeout: 5 * time.Minute}) @@ -1255,17 +1508,17 @@ func main() { mux.Handle("/api/", apiMux) mux.Handle("/", &timeoutHandler{h: http.HandlerFunc(sharedFileHandler), timeout: 5 * time.Minute}) serverMux := http.NewServeMux() - serverMux.Handle(selfURL.Hostname()+"/", http.NewCrossOriginProtection().Handler(&gzipHandler{mux})) + serverMux.Handle(selfURL.Hostname()+"/", &gzipHandler{mux}) jellyfinURL, err := url.Parse("http://127.0.0.1:8096") if err != nil { log.Fatal(err) } - serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{ + serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(jellyfinURL) r.SetXForwarded() }, - }}) + }}}) codeMux := http.NewServeMux() cgitStaticFiles := http.FileServer(http.Dir(*cgit)) codeMux.Handle("/cgit.css", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) @@ -1281,20 +1534,20 @@ func main() { Env: []string{"CGIT_CONFIG=" + cgitConfigAbsPath}, } cgitCGI.PathLocationHandler = cgitCGI - codeMux.Handle("/", &timeoutHandler{h: cgitCGI, timeout: 5 * time.Minute}) + codeMux.Handle("/", &timeoutHandler{h: &removeAuthCookieHandler{cgitCGI}, timeout: 5 * time.Minute}) serverMux.Handle("code."+selfURL.Hostname()+"/", &gzipHandler{codeMux}) transmissionURL, err := url.Parse("http://127.0.0.1:9091") if err != nil { log.Fatal(err) } - serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{ + serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(transmissionURL) r.SetXForwarded() }, - }}) + }}}) server := &http.Server{ - Handler: &loggingHandler{serverMux}, + Handler: &loggingHandler{http.NewCrossOriginProtection().Handler(&hstsHandler{serverMux})}, ReadHeaderTimeout: 5 * time.Minute, IdleTimeout: 10 * time.Minute, diff --git a/roseh.moe_test.go b/roseh.moe_test.go deleted file mode 100644 index cf41cd0..0000000 --- a/roseh.moe_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -func TestMarshalTime(t *testing.T) { - now := time.Now().Truncate(time.Second) - b := marshalTime(now) - got, ok := unmarshalTime(b) - if !ok { - t.Fatalf("Failed round trip") - } - if !got.Equal(now) { - t.Errorf("Failed round trip. unmarshalTime(%v) = %v, want %v", b, got, now) - } -} diff --git a/static/angry.webp b/static/angry.webp Binary files differdeleted file mode 100644 index 6fa91df..0000000 --- a/static/angry.webp +++ /dev/null diff --git a/static/fart.mp3 b/static/fart.mp3 Binary files differdeleted file mode 100644 index 1717d9d..0000000 --- a/static/fart.mp3 +++ /dev/null diff --git a/static/styles.css b/static/styles.css index 3f8ad7d..c0ffb2e 100644 --- a/static/styles.css +++ b/static/styles.css @@ -119,7 +119,7 @@ input.password { font-size: 18px; } -input.submit { +img.submit { margin-top: 20px; padding: 0; background: #f5f5f5; @@ -128,6 +128,7 @@ input.submit { border-radius: 15px; box-shadow: 0 0 10px rgba(255, 105, 180, 0.5); font-size: 25px; + cursor: pointer; } form.wormhole { diff --git a/templates/login.html.template b/templates/login.html.template index 279927b..ae0874a 100644 --- a/templates/login.html.template +++ b/templates/login.html.template @@ -17,25 +17,29 @@ -- <https://www.gnu.org/licenses/>. --> -<title>Login</title> +<!doctype html> -<section> -{{- if .Error}} - <p class="login-error"><img class="inline" width="150" height="163" src="/static/angry.webp">Incorrect password -{{- end}} - <form class="password-form" method="post"> - <table> - {{- if not .Quick}} - <tr class="password-form-row"> - <td><label class="password-label" for="password">Enter password</label> - <td><input id="password" class="password" type="password" name="password" autofocus> - {{- end}} - <tr class="password-form-row"> - <td><label class="password-label" for="auth">Google authenticator code</label> - <td><input id="auth" class="password" name="auth"> - <tr> - <td> - <td><input type="image" width="100" height="71" class="submit" src="/static/point.webp" alt="Submit"> - </table> - </form> -</section> +<form id="form" method="post"> + <input id="user" type="hidden" name="user"> + <input id="auth-data" type="hidden" name="auth-data"> + <input id="client-data" type="hidden" name="client-data"> + <input id="sig" type="hidden" name="sig"> +</form> + +<script> +async function main() { + const publicKeyCredential = await navigator.credentials.get({publicKey: { + challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, + }}); + const rsp = publicKeyCredential.response; + document.getElementById("user").value = new Uint8Array(rsp.userHandle).toBase64(); + document.getElementById("auth-data").value = new Uint8Array(rsp.authenticatorData).toBase64(); + document.getElementById("client-data").value = new Uint8Array(rsp.clientDataJSON).toBase64(); + document.getElementById("sig").value = new Uint8Array(rsp.signature).toBase64(); + document.getElementById("form").submit(); +} + +main(); +</script> + +This page requires JavaScriptâ„¢ to be enabled diff --git a/templates/otp.html.template b/templates/otp.html.template new file mode 100644 index 0000000..b20d71c --- /dev/null +++ b/templates/otp.html.template @@ -0,0 +1,25 @@ +<!-- 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/>. + --> + +<title>OTP</title> + +<div style="display:flex;flex-direction:column;align-items:center;font-size:50px"> + <p>Your OTP link: <a href="{{.URL}}">{{.URL}}</a></p> + <img src="{{.QR}}" alt="QR code for {{.URL}}" width="500px"> +</div> diff --git a/templates/register/register-success.html.template b/templates/register/register-success.html.template new file mode 100644 index 0000000..847f97a --- /dev/null +++ b/templates/register/register-success.html.template @@ -0,0 +1,28 @@ +<!-- 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/>. + --> + +<title>Registered</title> + +<section> + <h1>Registered!</h1> + <p>A device key has been generated, but it must be authorized on + the server:</p> + <pre> <Passkey ID="{{.ID | xmlEscape}}" + Key="{{.Key | xmlEscape}}"/></pre> +</section> diff --git a/templates/register/register.html.template b/templates/register/register.html.template new file mode 100644 index 0000000..833a1a7 --- /dev/null +++ b/templates/register/register.html.template @@ -0,0 +1,66 @@ +<!-- 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/>. + --> + +<title>Register</title> + +<section> + <form id="form" class="password-form" method="post"> + <input id="id" type="hidden" name="id" value="{{.ID}}"> + <input id="key" type="hidden" name="key"> + <table> + <tr class="password-form-row"> + <td><label class="password-label" for="name">Enter a name for this passkey</label> + <td><input id="name" class="password" name="name"> + <tr> + <td> + <td> + <img width="100" height="71" class="submit" src="/static/point.webp" alt="Submit" + onClick="generateKey()"> + </table> + </form> +</section> + +<script> +async function generateKey() { + const name = document.getElementById("name").value; + const publicKeyCredential = await navigator.credentials.create({publicKey: { + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + }, + challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, + pubKeyCredParams: [ + {alg: -9, type: "public-key"}, + {alg: -257, type: "public-key"}, + {alg: -7, type: "public-key"}, + {alg: -8, type: "public-key"}, + ], + rp: { + name: "roseh.moe", + }, + user: { + displayName: name, + id: Uint8Array.fromBase64(document.getElementById("id").value).buffer, + name: name, + }, + }}); + document.getElementById("key").value = new Uint8Array(publicKeyCredential.response.getPublicKey()).toBase64(); + document.getElementById("form").submit(); +} +</script> diff --git a/tools/auth/auth.go b/tools/auth/auth.go deleted file mode 100644 index 6268ba8..0000000 --- a/tools/auth/auth.go +++ /dev/null @@ -1,68 +0,0 @@ -// 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 main - -import ( - "encoding/base32" - "encoding/base64" - "encoding/xml" - "flag" - "fmt" - "os" - - "github.com/skip2/go-qrcode" -) - -func run() error { - args := flag.Args() - if len(args) != 1 { - return fmt.Errorf("usage: auth <config file>") - } - fileBytes, err := os.ReadFile(args[0]) - if err != nil { - return err - } - var config struct { - AuthKey string `xml:",attr"` - } - if err := xml.Unmarshal(fileBytes, &config); err != nil { - return err - } - if config.AuthKey == "" { - return fmt.Errorf("%s: missing AuthKey", args[0]) - } - authKey, err := base64.StdEncoding.DecodeString(config.AuthKey) - if err != nil { - return err - } - qrCode, err := qrcode.New(fmt.Sprintf("otpauth://totp/roseh.moe:rose?secret=%s&issuer=roseh.moe", base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(authKey)), qrcode.Medium) - if err != nil { - return err - } - fmt.Print(qrCode.ToSmallString(false)) - return nil -} - -func main() { - flag.Parse() - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/tools/finditer/finditer.go b/tools/finditer/finditer.go deleted file mode 100644 index f5bf75f..0000000 --- a/tools/finditer/finditer.go +++ /dev/null @@ -1,60 +0,0 @@ -// 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 main - -import ( - "encoding/hex" - "fmt" - "sort" - "testing" - "time" - - "roseh.moe/pkg/roseh.moe/internal/pwhash" -) - -func mustHex(s string) []byte { - b, err := hex.DecodeString(s) - if err != nil { - panic(err) - } - return b -} - -var salt = mustHex("34539b5dd2591415") - -var iter int - -func BenchmarkHashIter(b *testing.B) { - const password = "wavy upend 4z jk linda k zing four atop pi" - for b.Loop() { - pwhash.HashIter(password, salt, iter) - } -} - -func main() { - const targetTime = 5 * time.Second - for iter = 8192; time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) < targetTime; iter *= 2 { - } - lo := iter / 2 - hi := iter - fmt.Println(lo + sort.Search(hi-lo, func(n int) bool { - iter = lo + n - return time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) > targetTime - })) -} diff --git a/tools/hashpw/hashpw.go b/tools/hashpw/hashpw.go deleted file mode 100644 index 8244b64..0000000 --- a/tools/hashpw/hashpw.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 main - -import ( - "crypto/rand" - "encoding/base64" - "fmt" - "os" - - "golang.org/x/term" - "roseh.moe/pkg/roseh.moe/internal/pwhash" -) - -func hashpw() error { - fmt.Fprint(os.Stderr, "Enter password: ") - pw, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) - if err != nil { - return err - } - salt := make([]byte, pwhash.SaltSize) - rand.Read(salt) - pwHash, err := pwhash.Hash(string(pw), salt) - if err != nil { - return err - } - fmt.Printf("NotepadPassword=\"%s\"\n", base64.StdEncoding.EncodeToString(append(salt, pwHash...))) - return nil -} - -func main() { - if err := hashpw(); err != nil { - fmt.Fprintf(os.Stderr, "hashpw: %s\n", err) - os.Exit(1) - } -} |
