From 014b84ec226b8a98d1aa5cfa54a847e6eae6030f Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Sat, 4 Oct 2025 16:38:40 -0700 Subject: Allow http multipart upload --- internal/cryptoutil/oae2.go | 73 ++++++++++++----- roseh.moe.go | 182 +++++++++++++++++++++++++++++++++-------- templates/login.html.template | 1 + templates/upload.html.template | 7 ++ tools/notes/notes.go | 36 ++++++-- 5 files changed, 238 insertions(+), 61 deletions(-) create mode 100644 templates/upload.html.template diff --git a/internal/cryptoutil/oae2.go b/internal/cryptoutil/oae2.go index 8eb18fb..2310039 100644 --- a/internal/cryptoutil/oae2.go +++ b/internal/cryptoutil/oae2.go @@ -16,14 +16,11 @@ import ( // Online Authenticated Encryption from https://eprint.iacr.org/2015/189.pdf const ( - aeadOverhead = 16 - aesKeySize = 32 - noncePrefixSize = 3 - gcmNonceSize = 12 - headerSize = aesKeySize + noncePrefixSize - cacheSize = 192 * 1024 - encryptedSegmentSize = cacheSize - 1 - segmentSize = encryptedSegmentSize - aeadOverhead + aeadOverhead = 16 + aesKeySize = 32 + noncePrefixSize = 3 + gcmNonceSize = 12 + headerSize = aesKeySize + noncePrefixSize ) // An EncryptionKey is used for encrypting and decrypting data. A key can be any @@ -96,30 +93,57 @@ func (o *oae2) decryptBlock(out, block []byte, lastBlock bool) ([]byte, error) { // to the segment size, so it's important to call Close to flush the // final segment. type EncryptingWriter struct { - w io.Writer - oae2 oae2 + w io.Writer + oae2 oae2 + segmentSize int initialized bool err error buf []byte } +type opts struct { + additionalData []byte + segmentSize int +} + +var defaultOpts = opts{segmentSize: 192*1024 - 1 - aeadOverhead} + +type Option func(*opts) + +func WithAdditionalData(ad []byte) Option { + return func(opts *opts) { + opts.additionalData = ad + } +} + +func WithSegmentSize(segmentSize int) Option { + return func(opts *opts) { + opts.segmentSize = segmentSize + } +} + // NewWriter returns a new EncryptingWriter that writes to w. The additionalData // will be authenticated with the first segment, but is not written to w. The // same additional data must be provided when decrypting. -func (k EncryptionKey) NewWriter(w io.Writer, additionalData []byte) *EncryptingWriter { +func (k EncryptionKey) NewWriter(w io.Writer, options ...Option) *EncryptingWriter { + opts := defaultOpts + for _, o := range options { + o(&opts) + } return &EncryptingWriter{ w: w, oae2: oae2{ key: k, - additionalData: additionalData, + additionalData: opts.additionalData, }, + segmentSize: opts.segmentSize, } } func (w *EncryptingWriter) initialize() error { w.initialized = true - w.buf = make([]byte, 0, encryptedSegmentSize) + w.buf = make([]byte, 0, w.segmentSize+aeadOverhead) header := make([]byte, headerSize) rand.Read(header) if w.err = w.oae2.initialize(header); w.err != nil { @@ -141,7 +165,7 @@ func (w *EncryptingWriter) Write(buf []byte) (int, error) { r := bytes.NewReader(buf) nn := 0 for r.Len() > 0 { - if len(w.buf) == segmentSize { + if len(w.buf) == w.segmentSize { var encrypted []byte if encrypted, w.err = w.oae2.encryptBlock(w.buf[:0], w.buf, false); w.err != nil { return nn, w.err @@ -151,7 +175,7 @@ func (w *EncryptingWriter) Write(buf []byte) (int, error) { } w.buf = w.buf[:0] } - n, _ := r.Read(w.buf[len(w.buf):segmentSize]) + n, _ := r.Read(w.buf[len(w.buf):w.segmentSize]) w.buf = w.buf[:len(w.buf)+n] nn += n } @@ -187,8 +211,9 @@ func (w *EncryptingWriter) Close() error { // A DecryptingReader decrypts data using the STREAM construction. type DecryptingReader struct { - r *bufio.Reader - oae2 oae2 + r *bufio.Reader + oae2 oae2 + segmentSize int initialized bool decryptedBuf bytes.Buffer @@ -196,13 +221,18 @@ type DecryptingReader struct { // NewReader returns a new DecryptingWriter that decrypts data from r. The // additionaData must be the same that was provided when encrypting. -func (k EncryptionKey) NewReader(r io.Reader, additionalData []byte) *DecryptingReader { +func (k EncryptionKey) NewReader(r io.Reader, options ...Option) *DecryptingReader { + opts := defaultOpts + for _, o := range options { + o(&opts) + } return &DecryptingReader{ - r: bufio.NewReaderSize(r, encryptedSegmentSize+1), + r: bufio.NewReaderSize(r, opts.segmentSize+aeadOverhead+1), oae2: oae2{ key: k, - additionalData: additionalData, + additionalData: opts.additionalData, }, + segmentSize: opts.segmentSize, } } @@ -215,12 +245,13 @@ func (r *DecryptingReader) initialize() error { return err } r.r.Discard(len(header)) - r.decryptedBuf = *bytes.NewBuffer(make([]byte, 0, segmentSize)) + r.decryptedBuf = *bytes.NewBuffer(make([]byte, 0, r.segmentSize)) r.initialized = true return nil } func (r *DecryptingReader) fillBuf() error { + encryptedSegmentSize := r.segmentSize + aeadOverhead // Peek one extra byte to make sure if this is the last segment block, readErr := r.r.Peek(encryptedSegmentSize + 1) if len(block) == 0 { diff --git a/roseh.moe.go b/roseh.moe.go index 33122d2..988938d 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -173,7 +173,7 @@ func attachCookie(w http.ResponseWriter) error { http.SetCookie(w, &http.Cookie{ Name: "auth", Value: token, - Path: "/notepad", + Path: "/", Expires: time.Now().Add(cookieExpiration), Secure: true, HttpOnly: true, @@ -205,7 +205,7 @@ func cookieAuth(w http.ResponseWriter, r *http.Request) (string, bool) { http.SetCookie(w, &http.Cookie{ Name: "csrf-token", Value: csrfToken, - Path: "/notepad", + Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, @@ -233,6 +233,33 @@ func setEncryptionKey(rawKey cryptoutil.RawKey) error { return nil } +type redirectTarget int + +const ( + redirectDefault redirectTarget = iota + redirectNotepad + redirectUpload +) + +func parseRedirectTarget(s string) redirectTarget { + i, err := strconv.Atoi(s) + if err != nil { + return redirectDefault + } + return redirectTarget(i) +} + +func (t redirectTarget) path() string { + switch t { + case redirectNotepad: + return "/notepad" + case redirectUpload: + return "/notes/upload" + default: + return "/" + } +} + var ( //go:embed templates/login.html.template loginString string @@ -240,13 +267,14 @@ var ( ) type loginTemplateArgs struct { - Error bool + Error bool + Redirect redirectTarget } func login(w http.ResponseWriter, r *http.Request) { rawKey, err := notepadPassword.CheckPassword(r.FormValue("password")) if err != nil { - if err := loginTemplate.Execute(w, loginTemplateArgs{Error: true}); err != nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{Error: true, Redirect: parseRedirectTarget(r.FormValue("redirect"))}); err != nil { log.Printf("Warning: login: %s", err) } return @@ -256,7 +284,7 @@ func login(w http.ResponseWriter, r *http.Request) { return } attachCookie(w) - http.Redirect(w, r, "/notepad", http.StatusSeeOther) + http.Redirect(w, r, parseRedirectTarget(r.FormValue("redirect")).path(), http.StatusSeeOther) } func readNotepad(key cryptoutil.EncryptionKey) (string, error) { @@ -265,7 +293,7 @@ func readNotepad(key cryptoutil.EncryptionKey) (string, error) { return "", err } defer f.Close() - notepad, err := io.ReadAll(key.NewReader(f, []byte("notepad"))) + notepad, err := io.ReadAll(key.NewReader(f, cryptoutil.WithAdditionalData([]byte("notepad")))) if err != nil { return "", err } @@ -286,7 +314,7 @@ type notepadTemplateArgs struct { func notepad(w http.ResponseWriter, r *http.Request) { csrfToken, ok := cookieAuth(w, r) if !ok { - if err := loginTemplate.Execute(w, loginTemplateArgs{}); err != nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectNotepad}); err != nil { log.Printf("Warning: login: %s", err) } return @@ -295,7 +323,7 @@ func notepad(w http.ResponseWriter, r *http.Request) { key := encryptionKey encryptionKeyMu.Unlock() if key == nil { - if err := loginTemplate.Execute(w, loginTemplateArgs{}); err != nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectNotepad}); err != nil { log.Printf("Warning: login: %s", err) } return @@ -327,7 +355,7 @@ func saveNote(w http.ResponseWriter, r *http.Request) error { return err } defer f.Close() - encryptingWriter := key.NewWriter(f, []byte("notepad")) + encryptingWriter := key.NewWriter(f, cryptoutil.WithAdditionalData([]byte("notepad"))) if _, err := encryptingWriter.Write([]byte(r.FormValue("content"))); err != nil { os.Remove(f.Name()) return err @@ -414,7 +442,7 @@ func dumpFile(w io.Writer, key cryptoutil.EncryptionKey, name string) error { return err } defer f.Close() - _, err = io.Copy(w, key.NewReader(f, []byte("chunks/"+filepath.Base(name)))) + _, err = io.Copy(w, key.NewReader(f, cryptoutil.WithAdditionalData([]byte("chunks/"+filepath.Base(name))))) return err } @@ -428,6 +456,27 @@ var ( wordList = strings.Split(strings.TrimSuffix(wordListString, "\n"), "\n") ) +func newNoteName() (*os.File, error) { + for n := 1; ; n++ { + buf := make([]byte, 2*n) + rand.Read(buf) + words := make([]string, n) + for i := range words { + words[i] = wordList[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + name := strings.Join(words, "-") + var err error + f, err := os.OpenFile(*notepadDir+"/notes/"+name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + continue + } + return nil, fmt.Errorf("create note: create note file: %s", err) + } + return f, nil + } +} + func createNote(req *api.CreateNoteRequest) (*api.CreateNoteResponse, error) { type continuationToken struct { Prefix string @@ -479,7 +528,7 @@ func createNote(req *api.CreateNoteRequest) (*api.CreateNoteResponse, error) { return nil, err } } - encryptingWriter := key.NewWriter(f, []byte("chunks/"+name)) + encryptingWriter := key.NewWriter(f, cryptoutil.WithAdditionalData([]byte("chunks/"+name))) if _, err := encryptingWriter.Write(req.Chunk); err != nil { return nil, err } @@ -499,27 +548,12 @@ func createNote(req *api.CreateNoteRequest) (*api.CreateNoteResponse, error) { if err := os.MkdirAll(*notepadDir+"/notes", 0700); err != nil { return nil, err } - var finalNote *os.File - for n := 1; ; n++ { - buf := make([]byte, 2*n) - rand.Read(buf) - words := make([]string, n) - for i := range words { - words[i] = wordList[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] - } - name := strings.Join(words, "-") - var err error - finalNote, err = os.OpenFile(*notepadDir+"/notes/"+name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - if errors.Is(err, os.ErrExist) { - continue - } - return nil, fmt.Errorf("create note: create note file: %s", err) - } - defer finalNote.Close() - break + finalNote, err := newNoteName() + if err != nil { + return nil, err } - writer := key.NewWriter(finalNote, []byte("notes/"+filepath.Base(finalNote.Name()))) + defer finalNote.Close() + writer := key.NewWriter(finalNote, cryptoutil.WithAdditionalData([]byte("notes/"+filepath.Base(finalNote.Name())))) for i := range contToken.I { if err := dumpFile(writer, key, *notepadDir+"/chunks/"+chunkName(contToken.Prefix, i)); err != nil { return nil, err @@ -568,7 +602,7 @@ func readNote(stream *encoder, req *api.ReadNoteRequest) error { if key == nil { return fmt.Errorf("%w: need login", api.PermissionDenied) } - _, err = io.Copy(&readNoteResponseWriter{stream: stream}, key.NewReader(f, []byte("notes/"+req.Note))) + _, err = io.Copy(&readNoteResponseWriter{stream: stream}, key.NewReader(f, cryptoutil.WithAdditionalData([]byte("notes/"+req.Note)))) return err } @@ -624,6 +658,87 @@ func gobRespStreamMiddleware[Request any](next func(*encoder, *Request) error) h } } +var ( + //go:embed templates/upload.html.template + uploadTemplateString string + uploadTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(uploadTemplateString)).Lookup("outline") +) + +type uploadTemplateArgs struct { + CSRFToken string +} + +func uploadPage(w http.ResponseWriter, r *http.Request) { + csrfToken, ok := cookieAuth(w, r) + if !ok { + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectUpload}); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + encryptionKeyMu.Lock() + key := encryptionKey + encryptionKeyMu.Unlock() + if key == nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectUpload}); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + if err := uploadTemplate.Execute(w, uploadTemplateArgs{CSRFToken: csrfToken}); err != nil { + log.Printf("Warning: upload: %s", err) + } +} + +func uploadNote(w http.ResponseWriter, r *http.Request) { + if csrfToken, err := r.Cookie("csrf-token"); err != nil || subtle.ConstantTimeCompare([]byte(csrfToken.Value), []byte(r.FormValue("csrf-token"))) == 0 { + http.Error(w, "bad CSRF token", http.StatusForbidden) + return + } + if _, ok := cookieAuth(w, r); !ok { + log.Printf("asdf: bad auth") + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectUpload}); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + encryptionKeyMu.Lock() + key := encryptionKey + encryptionKeyMu.Unlock() + if key == nil { + log.Printf("asdf: key nil") + if err := loginTemplate.Execute(w, loginTemplateArgs{Redirect: redirectUpload}); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + noteFile, err := newNoteName() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer noteFile.Close() + contentReader, _, err := r.FormFile("content") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writer := key.NewWriter(noteFile, cryptoutil.WithAdditionalData([]byte("notes/"+filepath.Base(noteFile.Name())))) + if _, err := io.Copy(writer, contentReader); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := writer.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := noteFile.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "upload! pog\n%s", filepath.Base(noteFile.Name())) +} + func main() { flag.Parse() @@ -636,6 +751,9 @@ func main() { http.HandleFunc("POST /login", login) http.HandleFunc("GET /notepad", notepad) http.HandleFunc("POST /notepad/autosave", autosave) + http.HandleFunc("GET /notes/upload", uploadPage) + http.HandleFunc("POST /notes/upload", uploadNote) + // http.HandleFunc("GET /notes/{note}", readNoteContent) http.HandleFunc("POST /api/login", apiLogin) http.HandleFunc("GET /api/list-notes", gobReqRespMiddleware(listNotes)) http.HandleFunc("POST /api/create-note", gobReqRespMiddleware(createNote)) diff --git a/templates/login.html.template b/templates/login.html.template index 0cf3469..8d1ec9b 100644 --- a/templates/login.html.template +++ b/templates/login.html.template @@ -5,6 +5,7 @@

Incorrect password

{{end}}
+
diff --git a/templates/upload.html.template b/templates/upload.html.template new file mode 100644 index 0000000..f920f23 --- /dev/null +++ b/templates/upload.html.template @@ -0,0 +1,7 @@ +{{define "head"}}Upload{{end}} + +
+ + + +
diff --git a/tools/notes/notes.go b/tools/notes/notes.go index f22b7f9..9f190f6 100644 --- a/tools/notes/notes.go +++ b/tools/notes/notes.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "os" + "time" "github.com/google/subcommands" "gitlab.com/rhogenson/roseh.moe/internal/api" @@ -20,10 +21,12 @@ var serverURL = flag.String("url", "https://roseh.moe", "server url") const tokenCache = "/dev/shm/roseh.moe-upload-token" +var errUnavailable = errors.New("unavailable") + func readGobResp[Response any](req *http.Request) (*Response, error) { httpResp, err := http.DefaultClient.Do(req) if err != nil { - return nil, fmt.Errorf("read gob response: http: %s", err) + return nil, fmt.Errorf("%w: read gob response: http: %s", errUnavailable, err) } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { @@ -45,9 +48,9 @@ func readGobResp[Response any](req *http.Request) (*Response, error) { } func login(ctx context.Context) (*api.LoginResponse, error) { - fmt.Print("Enter password:") + fmt.Fprint(os.Stderr, "Enter password:") password, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() + fmt.Fprintln(os.Stderr) if err != nil { return nil, err } @@ -214,11 +217,28 @@ type createNoteRequestStreamWriter struct { } func (w *createNoteRequestStreamWriter) Write(buf []byte) (int, error) { - resp, err := createNote(w.ctx, &api.CreateNoteRequest{ - ContinuationToken: w.continuationToken, - Chunk: buf, - More: true, - }) + var ( + resp *api.CreateNoteResponse + err error + ) + delay := time.Second + for i := 0; i < 3; i++ { + resp, err = createNote(w.ctx, &api.CreateNoteRequest{ + ContinuationToken: w.continuationToken, + Chunk: buf, + More: true, + }) + if !errors.Is(err, errUnavailable) { + break + } + fmt.Fprintf(os.Stderr, "Warning: %s (retrying)\n", err) + select { + case <-time.After(delay): + case <-w.ctx.Done(): + return 0, w.ctx.Err() + } + delay *= 2 + } if err != nil { return 0, err } -- cgit v1.3.1