From bcbb629db87b6bc0e9a7880ec4ee1c90d284a6c7 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Sun, 5 Oct 2025 21:58:44 -0700 Subject: Bring back the notepad --- go.mod | 4 + internal/pwhash/pwhash.go | 44 ++++++ roseh.moe.go | 253 +++++++++++++++++++++++++++++++++- static/styles.css | 8 -- templates/login.html.template | 1 + templates/note.html.template | 25 ++-- templates/upload.html.template | 7 - templates/wormhole-send.html.template | 14 -- tools/finditers/finditers.go | 42 ++++++ tools/hashpw/hashpw.go | 32 +++++ tools/secret-key/secret-key.go | 14 ++ 11 files changed, 405 insertions(+), 39 deletions(-) create mode 100644 internal/pwhash/pwhash.go delete mode 100644 templates/upload.html.template delete mode 100644 templates/wormhole-send.html.template create mode 100644 tools/finditers/finditers.go create mode 100644 tools/hashpw/hashpw.go create mode 100644 tools/secret-key/secret-key.go diff --git a/go.mod b/go.mod index 4e975bb..5e702ba 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module gitlab.com/rhogenson/roseh.moe go 1.24.0 + +require golang.org/x/term v0.35.0 + +require golang.org/x/sys v0.36.0 // indirect diff --git a/internal/pwhash/pwhash.go b/internal/pwhash/pwhash.go new file mode 100644 index 0000000..c3c11ff --- /dev/null +++ b/internal/pwhash/pwhash.go @@ -0,0 +1,44 @@ +package pwhash + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "errors" +) + +const defaultIter = 12504615 // from tools/finditers + +func HashIter(pw string, salt []byte, iter int) ([]byte, error) { + return pbkdf2.Key(sha512.New, pw, salt, iter, 32) +} + +func Hash(pw string) ([]byte, error) { + buf := make([]byte, 40) + salt := buf[32:] + rand.Read(salt) + hash, err := HashIter(pw, salt, defaultIter) + if err != nil { + return nil, err + } + copy(buf, hash) + return buf, nil +} + +var errBadPassword = errors.New("bad password") + +func Check(pwHash []byte, pw string) error { + if len(pwHash) < 32 { + return errBadPassword + } + wantHash, salt := pwHash[:32], pwHash[32:] + gotHash, err := HashIter(pw, salt, defaultIter) + if err != nil { + return err + } + if subtle.ConstantTimeCompare(gotHash, wantHash) == 0 { + return errBadPassword + } + return nil +} diff --git a/roseh.moe.go b/roseh.moe.go index bf0a90b..78c0625 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -1,8 +1,13 @@ package main import ( + "bytes" + "crypto/hmac" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "embed" + "encoding/base64" "encoding/binary" "flag" "fmt" @@ -10,18 +15,45 @@ import ( "io" "log" "net/http" + "os" "strings" "sync" "time" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" ) var ( - port = flag.Int("port", 42069, "port to listen on") - selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server") + port = flag.Int("port", 42069, "port to listen on") + selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server") + secretsFile = flag.String("secrets", "secrets", "secrets file") serverStartTime = time.Now() ) +var notepadPassword, secretKey []byte + +func loadSecrets() error { + secrets, err := os.ReadFile(*secretsFile) + if err != nil { + return err + } + for _, line := range bytes.Split(bytes.TrimSuffix(secrets, []byte("\n")), []byte("\n")) { + if pw, ok := bytes.CutPrefix(line, []byte("notepad-password=")); ok { + notepadPassword, err = base64.RawURLEncoding.AppendDecode(nil, pw) + if err != nil { + return err + } + } else if key, ok := bytes.CutPrefix(line, []byte("secret-key=")); ok { + secretKey, err = base64.RawURLEncoding.AppendDecode(nil, key) + if err != nil { + return err + } + } + } + return nil +} + var ( //go:embed templates/404.html.template notFoundString string @@ -215,14 +247,231 @@ func wormholeReady(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "event: ready\ndata:\n\n") } +func sign(msg []byte, info string) []byte { + mac := hmac.New(sha256.New, secretKey) + mac.Write(binary.AppendVarint(nil, int64(len(info)))) + io.WriteString(mac, info) + mac.Write(msg) + return mac.Sum(msg) +} + +func verify(msg []byte, info string) ([]byte, bool) { + if len(msg) < sha256.Size { + return nil, false + } + msg, messageMAC := msg[:len(msg)-sha256.Size], msg[len(msg)-sha256.Size:] + mac := hmac.New(sha256.New, secretKey) + mac.Write(binary.AppendVarint(nil, int64(len(info)))) + io.WriteString(mac, info) + mac.Write(msg) + expectedMAC := mac.Sum(nil) + if !hmac.Equal(messageMAC, expectedMAC) { + return nil, false + } + return msg, true +} + +func makeToken() (string, error) { + b, err := time.Now().MarshalBinary() + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(sign(b, "auth")), nil +} + +const cookieExpiration = 180 * 24 * time.Hour + +func checkToken(token string) bool { + authCookie, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return false + } + msg, ok := verify(authCookie, "auth") + if !ok { + return false + } + var t time.Time + if err := t.UnmarshalBinary(msg); err != nil { + return false + } + return time.Since(t) < cookieExpiration +} + +func attachCookie(w http.ResponseWriter) error { + token, err := makeToken() + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: token, + Path: "/", + Expires: time.Now().Add(cookieExpiration), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, + }) + return nil +} + +func cookieAuth(w http.ResponseWriter, r *http.Request) (string, bool) { + const csrfTokenLen = 32 + var csrfToken string + if csrfCookie, err := r.Cookie("csrf-token"); err == nil { + csrfToken = csrfCookie.Value + } else { + buf := make([]byte, csrfTokenLen) + rand.Read(buf) + csrfToken = base64.RawURLEncoding.EncodeToString(buf) + http.SetCookie(w, &http.Cookie{ + Name: "csrf-token", + Value: csrfToken, + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, + }) + } + + cookie, err := r.Cookie("auth") + if err != nil { + return csrfToken, false + } + if !checkToken(cookie.Value) { + return csrfToken, false + } + attachCookie(w) + return csrfToken, true +} + +func checkCSRFToken(r *http.Request) error { + cookie, err := r.Cookie("csrf-token") + if err != nil { + return err + } + cookieHash := sha256.Sum256([]byte(cookie.Value)) + formValueHash := sha256.Sum256([]byte(r.FormValue("csrf-token"))) + if subtle.ConstantTimeCompare(cookieHash[:], formValueHash[:]) == 0 { + return fmt.Errorf("bad CSRF token") + } + return nil +} + +var ( + //go:embed templates/login.html.template + loginTemplateString string + loginTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(loginTemplateString)).Lookup("outline") +) + +type loginTemplateArgs struct { + CSRFToken string + Redirect string + Error bool +} + +func executeLoginTemplate(w io.Writer, csrfToken, redirect string) { + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Redirect: base64.RawURLEncoding.EncodeToString(sign([]byte(redirect), "redirect")), + CSRFToken: csrfToken, + }); err != nil { + log.Printf("Warning: login: %s", err) + } +} + +func login(w http.ResponseWriter, r *http.Request) { + if err := checkCSRFToken(r); err != nil { + http.Error(w, "bad CSRF token", http.StatusBadRequest) + return + } + if err := pwhash.Check(notepadPassword, r.FormValue("password")); err != nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Error: true, + Redirect: r.FormValue("redirect"), + CSRFToken: r.FormValue("csrf-token"), + }); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + attachCookie(w) + redirect := "/" + if b, err := base64.RawURLEncoding.DecodeString(r.FormValue("redirect")); err == nil { + if r, ok := verify(b, "redirect"); ok { + redirect = string(r) + } + } + http.Redirect(w, r, redirect, http.StatusSeeOther) +} + +var ( + //go:embed templates/note.html.template + notepadString string + notepadTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(notepadString)).Lookup("outline") + + notepadContentsMu sync.Mutex + notepadContents string +) + +type notepadTemplateArgs struct { + Content string + CSRFToken string +} + +func notepad(w http.ResponseWriter, r *http.Request) { + csrfToken, ok := cookieAuth(w, r) + if !ok { + executeLoginTemplate(w, csrfToken, "/notepad") + return + } + notepadContentsMu.Lock() + currentContent := notepadContents + notepadContentsMu.Unlock() + if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent, CSRFToken: csrfToken}); err != nil { + log.Printf("Warning: notepad: %s", err) + } +} + +func saveNote(w http.ResponseWriter, r *http.Request) error { + if err := checkCSRFToken(r); err != nil { + return err + } + if _, ok := cookieAuth(w, r); !ok { + return fmt.Errorf("not logged in") + } + newContent := r.FormValue("content") + notepadContentsMu.Lock() + notepadContents = newContent + notepadContentsMu.Unlock() + return nil +} + +func autosave(w http.ResponseWriter, r *http.Request) { + msg := "Saved ✓" + if err := saveNote(w, r); err != nil { + msg = fmt.Sprintf("Failed to save: %s", err) + } + if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", msg); err != nil { + log.Printf("Warning: autosave: %s", err) + } +} + func main() { flag.Parse() + if err := loadSecrets(); err != nil { + log.Fatal(err) + } + http.HandleFunc("GET /pong", pong) http.HandleFunc("GET /wormhole", wormhole) http.HandleFunc("POST /wormhole/{hole}", wormholeSend) http.HandleFunc("GET /wormhole/{hole}", wormholeRecv) http.HandleFunc("GET /wormhole/{hole}/ready", wormholeReady) + http.HandleFunc("POST /login", login) + http.HandleFunc("GET /notepad", notepad) + http.HandleFunc("POST /notepad", autosave) http.HandleFunc("GET /static/", static) http.HandleFunc("GET /favicon.ico", favicon) http.HandleFunc("GET /{$}", index) diff --git a/static/styles.css b/static/styles.css index 90dafc1..f95be8d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -95,11 +95,3 @@ input.password { border-radius: 15px; box-shadow: 0 0 10px rgba(255, 105, 180, 0.5); } - -.hidden { - opacity: 0; -} - -#save-indicator { - transition: opacity 0.3s ease-in-out; -} diff --git a/templates/login.html.template b/templates/login.html.template index 8d1ec9b..a197e8e 100644 --- a/templates/login.html.template +++ b/templates/login.html.template @@ -5,6 +5,7 @@

Incorrect password

{{end}}
+ diff --git a/templates/note.html.template b/templates/note.html.template index fe6d073..07c2d8e 100644 --- a/templates/note.html.template +++ b/templates/note.html.template @@ -1,5 +1,5 @@ {{define "head"}} - + Notepad @@ -8,15 +8,24 @@
-
- {{block "saveIndicator" ""}} - {{.}} - {{end}} -
- + {{block "saveIndicator" ""}} + {{.}} + {{end}}
-
diff --git a/templates/upload.html.template b/templates/upload.html.template deleted file mode 100644 index f920f23..0000000 --- a/templates/upload.html.template +++ /dev/null @@ -1,7 +0,0 @@ -{{define "head"}}Upload{{end}} - - - - - -
diff --git a/templates/wormhole-send.html.template b/templates/wormhole-send.html.template deleted file mode 100644 index 5b06c5f..0000000 --- a/templates/wormhole-send.html.template +++ /dev/null @@ -1,14 +0,0 @@ -{{define "head"}} - - - Wormhole -{{end}} - -
- -
diff --git a/tools/finditers/finditers.go b/tools/finditers/finditers.go new file mode 100644 index 0000000..31a7e33 --- /dev/null +++ b/tools/finditers/finditers.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/hex" + "fmt" + "sort" + "testing" + "time" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" +) + +func mustHex(t testing.TB, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("Invalid hex %q: %s", s, err) + } + return b +} + +var iterations int + +func BenchmarkHashIter(b *testing.B) { + const pw = "atypical evasion foyer roulette throng awning stability exchange humorless vowed" + salt := mustHex(b, "4250af599e07cde7") + for b.Loop() { + pwhash.HashIter(pw, salt, iterations) + } +} + +func main() { + const targetDuration = 5 * time.Second + for iterations = 4096; time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) < targetDuration; iterations *= 2 { + } + lo := iterations / 2 + hi := iterations + fmt.Println(lo + sort.Search(hi-lo, func(i int) bool { + iterations = lo + i + return time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) > targetDuration + })) +} diff --git a/tools/hashpw/hashpw.go b/tools/hashpw/hashpw.go new file mode 100644 index 0000000..c668622 --- /dev/null +++ b/tools/hashpw/hashpw.go @@ -0,0 +1,32 @@ +package main + +import ( + "encoding/base64" + "fmt" + "os" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" + "golang.org/x/term" +) + +func hashpw() error { + fmt.Fprint(os.Stderr, "Enter password: ") + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + hash, err := pwhash.Hash(string(password)) + if err != nil { + return err + } + fmt.Printf("notepad-password=%s\n", base64.RawURLEncoding.EncodeToString(hash)) + return nil +} + +func main() { + if err := hashpw(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tools/secret-key/secret-key.go b/tools/secret-key/secret-key.go new file mode 100644 index 0000000..969828a --- /dev/null +++ b/tools/secret-key/secret-key.go @@ -0,0 +1,14 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +func main() { + buf := make([]byte, sha256.BlockSize) + rand.Read(buf) + fmt.Printf("secret-key=%s\n", base64.RawURLEncoding.EncodeToString(buf)) +} -- cgit v1.3.1