summaryrefslogtreecommitdiffstats
path: root/roseh.moe.go
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2026-10-18 16:55:33 -0700
committerRose Hogenson <rosehogenson@posteo.net>2026-10-20 15:35:16 -0700
commit6a4c8c1a9424420369ec1560da1c26db7fbff4e4 (patch)
treeba6d13b9ff32dfa50cf4eaf0c0f033b43e1d1708 /roseh.moe.go
parentAllow for the clock to be slightly behind (diff)
downloadroseh.moe-6a4c8c1a9424420369ec1560da1c26db7fbff4e4.tar.zst
Completely overhaul the auth again
Now we're all in on passkeys (ooh fancy)
Diffstat (limited to 'roseh.moe.go')
-rw-r--r--roseh.moe.go420
1 files changed, 269 insertions, 151 deletions
diff --git a/roseh.moe.go b/roseh.moe.go
index 0369740..8e2c999 100644
--- a/roseh.moe.go
+++ b/roseh.moe.go
@@ -24,12 +24,15 @@ 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"
@@ -58,7 +61,6 @@ import (
"github.com/skip2/go-qrcode"
"roseh.moe/pkg/oae2"
- "roseh.moe/pkg/roseh.moe/internal/pwhash"
"roseh.moe/pkg/wordlist"
)
@@ -104,7 +106,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 +150,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 +179,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[int64][]byte
}
var (
@@ -188,6 +219,32 @@ func loadConfig() (*serviceConfiguration, error) {
if err := xml.Unmarshal(fileBytes, config); err != nil {
return nil, err
}
+ names := make(map[string]bool)
+ config.passkeys = make(map[int64][]byte)
+ namesByID := make(map[int64]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 device %q: %s", *configFile, passkey.ID, err)
+ }
+ id, n := binary.Varint(keyBytes)
+ if n <= 0 {
+ return nil, fmt.Errorf("%s: invalid key for device %q: bad device ID", *configFile, passkey.ID)
+ }
+ key := keyBytes[n:]
+ if _, err := sigVerifier(key); err != nil {
+ return nil, fmt.Errorf("%s: invalid key for device %q: %s", *configFile, passkey.ID, err)
+ }
+ if _, ok := config.passkeys[id]; ok {
+ return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[id], passkey.ID, id)
+ }
+ config.passkeys[id] = key
+ namesByID[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,87 +747,44 @@ 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:] {
- }
- 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
-}
-
-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 unmarshalTime(b []byte) (time.Time, bool) {
- u, ok := unmarshalInt(b)
- if !ok {
- return time.Time{}, 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
-}
-
-const authVersion = "authv2"
-
-func makeToken() string {
- t := base64.RawURLEncoding.EncodeToString(marshalTime(time.Now()))
- return t + mac(t, authVersion)
-}
-
-const authCookieName = "a"
+const (
+ authVersion = "authv3"
+ authCookieName = "a"
+)
-func attachCookie(w http.ResponseWriter) {
+func attachCookie(w http.ResponseWriter, expiry time.Time) {
+ token := signMsg(base64.RawURLEncoding.EncodeToString(binary.AppendVarint(nil, expiry.Unix())), authVersion)
http.SetCookie(w, &http.Cookie{
Name: authCookieName,
- Value: makeToken(),
+ Value: token,
Path: "/",
Domain: selfURL.Hostname(),
- Expires: time.Now().Add(longLoginTimeout),
+ Expires: expiry,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
@@ -773,105 +792,122 @@ func attachCookie(w http.ResponseWriter) {
})
}
-func cookieAuth(r *http.Request) (time.Duration, bool) {
+func cookieAuth(r *http.Request) bool {
cookie, err := r.Cookie(authCookieName)
if err != nil {
- return 0, false
+ return false
}
- msg, ok := verify(cookie.Value, authVersion)
+ msg, ok := verifyHMAC(cookie.Value, authVersion)
if !ok {
- return 0, false
+ return false
}
msgBytes, err := base64.RawURLEncoding.DecodeString(msg)
if err != nil {
- return 0, false
+ return false
}
- t, ok := unmarshalTime(msgBytes)
- if !ok {
- return 0, false
+ expiryUnix, n := binary.Varint(msgBytes)
+ if n <= 0 {
+ return false
}
- age := time.Since(t)
- if age > longLoginTimeout {
- return 0, false
+ expiry := time.Unix(expiryUnix, 0)
+ if time.Now().After(expiry) {
+ return false
}
- return age, true
-}
-
-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)
+ return true
}
-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))
-}
-
-func checkPassword(password string) int {
- hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize])
- if err != nil {
- return 0
- }
- return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:])
-}
+var (
+ challengeMu sync.Mutex
+ challenge []byte
+ challengePosed time.Time
+)
var (
//go:embed templates/login.html.template
loginTemplateString string
- loginTemplate = template.Must(outlinedTemplate(loginTemplateString))
+ loginTemplate = template.Must(template.New("login").Parse(loginTemplateString))
)
type loginTemplateArgs struct {
- Quick bool
- Error bool
-}
-
-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)
+ Challenge string
}
-const (
- quickLoginTimeout = 24 * time.Hour
- longLoginTimeout = 300 * 24 * time.Hour
-)
-
func serveLogin(w http.ResponseWriter, r *http.Request) {
- _, cookieOK := cookieAuth(r)
+ buf := make([]byte, 32)
+ rand.Read(buf)
+ challengeMu.Lock()
+ challenge = buf
+ challengePosed = time.Now()
+ challengeMu.Unlock()
if err := loginTemplate.Execute(w, loginTemplateArgs{
- Quick: cookieOK,
+ Challenge: base64.StdEncoding.EncodeToString(buf),
}); err != nil {
log.Printf("Warning: login: %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"))
+ challengeMu.Lock()
+ myChallenge := challenge
+ challenge = nil
+ posed := challengePosed
+ challengeMu.Unlock()
+ if len(myChallenge) == 0 {
+ http.Error(w, "Request already consumed", http.StatusConflict)
+ return
}
- if otpMatches&passwordMatches == 0 {
- if err := loginTemplate.Execute(w, loginTemplateArgs{
- Quick: cookieOK,
- Error: true,
- }); err != nil {
- log.Printf("Warning: login: %s", err)
- }
+ if time.Since(posed) > 10*time.Minute {
+ http.Error(w, "Challenge is too old. Please try again", http.StatusConflict)
return
}
- attachCookie(w)
- redirect, ok := verify(r.FormValue("redirect"), "redirect")
+ userBytes, err := base64.StdEncoding.DecodeString(r.FormValue("user"))
+ if err != nil {
+ http.Error(w, "Bad user", http.StatusBadRequest)
+ return
+ }
+ user, n := binary.Varint(userBytes)
+ if n <= 0 {
+ http.Error(w, "Bad user", http.StatusBadRequest)
+ return
+ }
+ authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data"))
+ if err != nil {
+ http.Error(w, "Bad auth data", http.StatusBadRequest)
+ return
+ }
+ clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data"))
+ if err != nil {
+ http.Error(w, "Bad client data: invalid base64", http.StatusBadRequest)
+ return
+ }
+ challengeBase64 := base64.RawURLEncoding.EncodeToString(myChallenge)
+ if !strings.Contains(string(clientData), challengeBase64) {
+ http.Error(w, "Bad client data: bad challenge", 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[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
+ }
+ attachCookie(w, time.Now().Add(30*24*time.Hour))
+ redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect")
if !ok {
redirect = "/"
}
@@ -879,6 +915,86 @@ func login(w http.ResponseWriter, r *http.Request) {
}
var (
+ //go:embed templates/register/register.html.template
+ registerTemplateString string
+ registerTemplate = template.Must(outlinedTemplate(registerTemplateString))
+)
+
+type registerTemplateArgs struct {
+ ID string
+ Challenge string
+}
+
+func serveRegister(w http.ResponseWriter, r *http.Request) {
+ serviceConfig, err := loadConfig()
+ if err != nil {
+ log.Printf("Error: failed to load service config: %s", err)
+ http.Error(w, "Internal error", http.StatusInternalServerError)
+ return
+ }
+ var maxID int64
+ for id := range serviceConfig.passkeys {
+ maxID = max(maxID, id)
+ }
+ myID := maxID + 1
+ buf := make([]byte, 32)
+ rand.Read(buf)
+ if err := registerTemplate.Execute(w, registerTemplateArgs{
+ ID: base64.StdEncoding.EncodeToString(binary.AppendVarint(nil, myID)),
+ Challenge: base64.StdEncoding.EncodeToString(buf),
+ }); err != nil {
+ log.Printf("Warning: serveRegister: %s", err)
+ }
+}
+
+var (
+ //go:embed templates/register/register-success.html.template
+ registerSuccessTemplateString string
+ registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString))
+)
+
+type registerSuccessTemplateArgs struct {
+ ID string
+ Key string
+}
+
+func register(w http.ResponseWriter, r *http.Request) {
+ username := r.FormValue("name")
+ if username == "" {
+ http.Error(w, "Passkey name is required", 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
+ }
+ for _, passkey := range config.Passkey {
+ if passkey.ID == username {
+ http.Error(w, "Passkey name already used", 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)
+ }
+}
+
+var (
//go:embed templates/note.html.template
notepadString string
notepadTemplate = template.Must(outlinedTemplate(notepadString))
@@ -1128,8 +1244,7 @@ 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 {
@@ -1139,7 +1254,8 @@ func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
}
@@ -1241,6 +1357,8 @@ 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 /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 /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})