summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2025-10-05 21:58:44 -0700
committerRose Hogenson <rosehogenson@posteo.net>2025-10-05 21:58:44 -0700
commitbcbb629db87b6bc0e9a7880ec4ee1c90d284a6c7 (patch)
tree7372c59802fb589f798a9e849d621dac158dc04b
parent83d80b0672be886e7c6ccfad60949dbddb1b4173 (diff)
downloadroseh.moe-bcbb629db87b6bc0e9a7880ec4ee1c90d284a6c7.tar.zst
Bring back the notepad
-rw-r--r--go.mod4
-rw-r--r--internal/pwhash/pwhash.go44
-rw-r--r--roseh.moe.go253
-rw-r--r--static/styles.css8
-rw-r--r--templates/login.html.template1
-rw-r--r--templates/note.html.template25
-rw-r--r--templates/upload.html.template7
-rw-r--r--templates/wormhole-send.html.template14
-rw-r--r--tools/finditers/finditers.go42
-rw-r--r--tools/hashpw/hashpw.go32
-rw-r--r--tools/secret-key/secret-key.go14
11 files changed, 405 insertions, 39 deletions
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 @@
<p class="login-error">Incorrect password</p>
{{end}}
<form class="password-form" action="/login" method="post">
+ <input type="hidden" name="csrf-token" value="{{.CSRFToken}}">
<input type="hidden" name="redirect" value="{{.Redirect}}">
<label class="password-label" for="password">Enter password</label>
<input id="password" class="password" type="password" name="password" autofocus>
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"}}
- <script src="/static/note.js"></script>
+ <script src="/static/htmx.min.js"></script>
<title>Notepad</title>
<meta name="viewport" content="interactive-widget=resizes-content"> <!-- https://stackoverflow.com/q/76026292 -->
@@ -8,15 +8,24 @@
<form id="form" method="post">
<input type="hidden" name="csrf-token" value="{{.CSRFToken}}">
<div class="save-indicator-container">
- <div id="save-indicator-wrapper">
- {{block "saveIndicator" ""}}
- <span id="save-indicator">{{.}}</span>
- {{end}}
- </div>
- <noscript><input type="submit" value="Save"></noscript>
+ {{block "saveIndicator" ""}}
+ <span id="save-indicator">{{.}}</span>
+ {{end}}
</div>
<div class="content-container">
- <textarea id="content" name="content" oninput="autosave()" autofocus>
+ <textarea
+ name="content"
+ hx-post="/notepad"
+ hx-on:input="
+ const indicator = htmx.find('#save-indicator');
+ htmx.addClass(indicator, 'htmx-indicator');
+ indicator.innerHTML = 'Saving...';
+ "
+ hx-trigger="input delay:1s"
+ hx-indicator="#save-indicator"
+ hx-target="#save-indicator"
+ hx-swap="outerHTML"
+ autofocus>
{{- .Content -}}
</textarea>
</div>
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"}}<title>Upload</title>{{end}}
-
-<form method="POST" enctype="multipart/form-data">
- <input type="hidden" name="csrf-token" value="{{.CSRFToken}}">
- <input type="file" name="content">
- <input type="submit" value="Upload">
-</form>
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"}}
- <script src="/static/htmx.min.js"></script>
- <script src="/static/htmx-ext-sse.js"></script>
- <title>Wormhole</title>
-{{end}}
-
-<form
- hx-ext="sse"
- sse-connect="/wormhole/{{.Hole}}/ready"
- hx-post="/wormhole/{{.Hole}}"
- hx-trigger="sse:receiver-ready"
- enctype="multipart/form-data">
- <input type="file" name="file">
-</form>
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))
+}