summaryrefslogtreecommitdiffstats
path: root/roseh.moe.go
diff options
context:
space:
mode:
Diffstat (limited to 'roseh.moe.go')
-rw-r--r--roseh.moe.go253
1 files changed, 251 insertions, 2 deletions
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)