diff options
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | internal/auth/auth.go | 215 | ||||
| -rw-r--r-- | internal/hole/hole.go | 278 | ||||
| -rw-r--r-- | roseh.moe.go | 504 | ||||
| -rw-r--r-- | templates/login.html.template | 24 | ||||
| -rw-r--r-- | templates/register/register-success.html.template | 2 | ||||
| -rw-r--r-- | templates/register/register.html.template | 51 |
7 files changed, 610 insertions, 466 deletions
@@ -1,6 +1,6 @@ module roseh.moe/pkg/roseh.moe -go 1.26.0 +go 1.27 require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..7f8f8d9 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,215 @@ +// Copyright 2026 Rose Hogenson <r@roseh.moe> +// +// This file is part of roseh.moe. +// +// roseh.moe is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// roseh.moe is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with roseh.moe. If not, see +// <https://www.gnu.org/licenses/>. + +package auth + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json/v2" + "fmt" + "io" + "log" + "net/url" + "slices" + "strings" + "sync" + "time" + "unicode" +) + +var SecretKey []byte + +func MAC(msg []byte, purpose string) []byte { + mac := hmac.New(sha256.New, SecretKey) + io.WriteString(mac, purpose) + io.WriteString(mac, ":") + mac.Write(msg) + return mac.Sum(nil) +} + +func SignMsg(msg, purpose string) string { + return msg + base64.RawURLEncoding.EncodeToString(MAC([]byte(msg), purpose)) +} + +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 := base64.RawURLEncoding.EncodeToString(MAC([]byte(msg), purpose)) + if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { + return "", false + } + return msg, true +} + +type Passkey struct { + ID string `xml:",attr"` + Key string `xml:",attr"` +} + +func LoadPasskeys(passkeys []Passkey) (map[string][]byte, error) { + names := make(map[string]bool) + out := make(map[string][]byte) + namesByID := make(map[string]string) + for _, passkey := range passkeys { + if names[passkey.ID] { + return nil, fmt.Errorf("duplicate name %q", passkey.ID) + } + names[passkey.ID] = true + keyBytes, err := base64.RawURLEncoding.DecodeString(strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, passkey.Key)) + if err != nil { + return nil, fmt.Errorf("invalid key for passkey %q: %s", passkey.ID, err) + } + if len(keyBytes) < 16 { + return nil, fmt.Errorf("invalid key for passkey %q: bad user ID: too short", passkey.ID) + } + id, key := keyBytes[:16], keyBytes[16:] + if _, err := sigVerifier(key); err != nil { + return nil, fmt.Errorf("invalid key for passkey %q: %s", passkey.ID, err) + } + if _, ok := out[string(id)]; ok { + return nil, fmt.Errorf("passkeys %q and %q have duplicate ID %d", namesByID[string(id)], passkey.ID, id) + } + out[string(id)] = key + namesByID[string(id)] = passkey.ID + } + return out, nil +} + +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 + 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 +} + +const challengeExpiry = 10 * time.Minute + +var ( + challengeMu sync.Mutex + challenges = make(map[string]time.Time) +) + +func NewChallenge() ([]byte, bool) { + buf := make([]byte, 32) + rand.Read(buf) + now := time.Now() + challengeMu.Lock() + if len(challenges) >= 100 { + challengeMu.Unlock() + return nil, false + } + challenges[string(buf)] = now + challengeMu.Unlock() + return buf, true +} + +func Login(user, authData, clientData, sig []byte, passkeys map[string][]byte, selfURL *url.URL) error { + pubKey, ok := passkeys[string(user)] + if !ok { + return fmt.Errorf("no user with the given ID") + } + clientDataHash := sha256.Sum256(clientData) + if err := VerifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { + return fmt.Errorf("challenge failed: %s", err) + } + var cd struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + CrossOrigin bool `json:"crossOrigin"` + } + if err := json.Unmarshal(clientData, &cd); err != nil { + return fmt.Errorf("bad client data: invalid JSON: %s", err) + } + challenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) + if err != nil { + return fmt.Errorf("bad client data: bad challenge: %s", err) + } + challengeMu.Lock() + initiated, ok := challenges[string(challenge)] + delete(challenges, string(challenge)) + challengeMu.Unlock() + if !ok { + return fmt.Errorf("request already consumed") + } + if time.Since(initiated) > challengeExpiry { + return fmt.Errorf("challenge is too old; please try again") + } + if cd.Type != "webauthn.get" || cd.Origin != selfURL.String() || cd.CrossOrigin { + return fmt.Errorf("bad client data") + } + expectedRPHash := sha256.Sum256([]byte(selfURL.Hostname())) + const flagUserPresence, flagUserVerification = 0x01, 0x04 + if len(authData) < 37 || !bytes.Equal(authData[:32], expectedRPHash[:]) || authData[32]&(flagUserPresence|flagUserVerification) != (flagUserPresence|flagUserVerification) { + return fmt.Errorf("bad authenticator data") + } + return nil +} + +func CleanChallenges() { + log.Printf("Cleaning expired challenges") + now := time.Now() + challengeMu.Lock() + for challenge, initiated := range challenges { + if now.Sub(initiated) > challengeExpiry { + delete(challenges, challenge) + } + } + challengeMu.Unlock() +} diff --git a/internal/hole/hole.go b/internal/hole/hole.go new file mode 100644 index 0000000..3749f9b --- /dev/null +++ b/internal/hole/hole.go @@ -0,0 +1,278 @@ +// Copyright 2026 Rose Hogenson <r@roseh.moe> +// +// This file is part of roseh.moe. +// +// roseh.moe is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// roseh.moe is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with roseh.moe. If not, see +// <https://www.gnu.org/licenses/>. + +package hole + +import ( + "archive/tar" + "cmp" + "compress/gzip" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" + "log" + "mime/multipart" + "os" + "strings" + "time" + + "roseh.moe/pkg/oae2" + "roseh.moe/pkg/wordlist" +) + +var HoleTempDir string + +func makeHole() string { + const nWords = 10 + buf := make([]byte, 2*nWords) + rand.Read(buf) + words := make([]string, nWords) + for i := range words { + words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + return strings.Join(words, "-") +} + +const blockSize = 4 * 1024 * 1024 + +type holeFileWriter struct { + *oae2.Writer + f *os.File +} + +func (w *holeFileWriter) Close() error { + return cmp.Or(w.Writer.Close(), w.f.Close()) +} + +func writeHoleFile(filename string) (_ string, _ *holeFileWriter, err error) { + hole := makeHole() + primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) + if err != nil { + return "", nil, err + } + f, err := os.Create(fmt.Sprintf("%s/%x", HoleTempDir, primaryKey)) + if err != nil { + return "", nil, err + } + defer func() { + if err != nil { + f.Close() + } + }() + w := oae2.NewWriter(f, []byte(hole), blockSize, nil) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(len(filename))) + if _, err := w.Write(buf); err != nil { + return "", nil, err + } + if _, err := io.WriteString(w, filename); err != nil { + return "", nil, err + } + return hole, &holeFileWriter{w, f}, nil +} + +type offsetReader struct { + io.ReadSeeker + offset int +} + +func (r *offsetReader) Seek(offset int64, whence int) (int64, error) { + if whence == io.SeekStart { + offset += int64(r.offset) + } + n, err := r.ReadSeeker.Seek(offset, whence) + return n - int64(r.offset), err +} + +type HoleFileReader struct { + *offsetReader + F *os.File +} + +func (r *HoleFileReader) Close() error { + return r.F.Close() +} + +var ErrNoHole = errors.New("no such hole") + +func Open(hole string) (_ string, _ *HoleFileReader, err error) { + primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) + if err != nil { + return "", nil, err + } + f, err := os.Open(fmt.Sprintf("%s/%x", HoleTempDir, primaryKey)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", nil, ErrNoHole + } + return "", nil, err + } + defer func() { + if err != nil { + f.Close() + } + }() + r := oae2.NewReader(f, []byte(hole), blockSize, nil) + buf := make([]byte, 8) + if _, err := io.ReadFull(r, buf); err != nil { + return "", nil, err + } + fileNameLen := binary.BigEndian.Uint64(buf) + fileName := make([]byte, fileNameLen) + if _, err := io.ReadFull(r, fileName); err != nil { + return "", nil, err + } + return string(fileName), &HoleFileReader{&offsetReader{r, 8 + int(fileNameLen)}, f}, nil +} + +func writeHoleFileToTarFile(tarWriter *tar.Writer, hole string, now time.Time) error { + fileName, f, err := Open(hole) + if err != nil { + return fmt.Errorf("read back hole file: %s", err) + } + defer f.Close() + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return fmt.Errorf("read back hole file: %s", err) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("read back hole file: %s", err) + } + if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/" + fileName, Mode: 0644, Size: size, ModTime: now, Format: tar.FormatPAX}); err != nil { + return err + } + if _, err := io.Copy(tarWriter, f); err != nil { + return fmt.Errorf("write file to tar file: %s", err) + } + return nil +} + +var ErrNoFile = errors.New("no file") + +func Upload(reader *multipart.Reader) (string, error) { + var holes []string + for { + part, err := reader.NextPart() + if err != nil { + break + } + if part.FormName() != "file" || part.FileName() == "" { + continue + } + hole, err := WriteSingleFile(part.FileName(), part) + if err != nil { + return "", err + } + holes = append(holes, hole) + } + if len(holes) == 0 { + return "", ErrNoFile + } + if len(holes) == 1 { + return holes[0], nil + } + tarFileHole, tarHoleWriter, err := writeHoleFile("wormhole-files.tar.gz") + if err != nil { + return "", fmt.Errorf("make tar file: %s", err) + } + defer tarHoleWriter.Close() + gzipWriter, err := gzip.NewWriterLevel(tarHoleWriter, gzip.BestSpeed) + if err != nil { + return "", err + } + tarWriter := tar.NewWriter(gzipWriter) + now := time.Now() + if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/", Mode: 0755, ModTime: now, Format: tar.FormatPAX}); err != nil { + return "", fmt.Errorf("create tar file main directory: %s", err) + } + for _, hole := range holes { + if err := writeHoleFileToTarFile(tarWriter, hole, now); err != nil { + return "", err + } + } + if err := tarWriter.Close(); err != nil { + return "", fmt.Errorf("write tar file: %s", err) + } + if err := gzipWriter.Close(); err != nil { + return "", fmt.Errorf("gzip tar file: %s", err) + } + if err := tarHoleWriter.Close(); err != nil { + return "", fmt.Errorf("write tar hole file: %s", err) + } + return tarFileHole, nil +} + +func WriteSingleFile(filename string, r io.Reader) (string, error) { + hole, holeWriter, err := writeHoleFile(filename) + if err != nil { + return "", fmt.Errorf("write hole file: %s", err) + } + defer holeWriter.Close() + if _, err := io.Copy(holeWriter, r); err != nil { + return "", fmt.Errorf("write hole file: %s", err) + } + if err := holeWriter.Close(); err != nil { + return "", fmt.Errorf("write hole file: %s", err) + } + return hole, nil +} + +func Clean() error { + log.Printf("Cleaning hole...") + holeDir, err := os.Open(HoleTempDir) + if err != nil { + return err + } + defer holeDir.Close() + deadline := time.Now().Add(-time.Hour) + for { + files, err := holeDir.ReadDir(1024) + for _, file := range files { + stat, err := file.Info() + if err != nil { + log.Printf("Error: clean hole: %s", err) + continue + } + if !stat.ModTime().Before(deadline) { + continue + } + if err := os.Remove(HoleTempDir + "/" + file.Name()); err != nil { + log.Printf("Error: clean hole: %s", err) + continue + } + } + if err != nil { + if err != io.EOF { + return err + } + return nil + } + } +} + +func Init() error { + if err := os.MkdirAll(HoleTempDir, 0700); err != nil { + return err + } + return nil +} diff --git a/roseh.moe.go b/roseh.moe.go index bd07aed..54b4ea6 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -19,33 +19,22 @@ package main import ( - "archive/tar" "bytes" - "cmp" "compress/gzip" "context" - "crypto" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/hkdf" "crypto/hmac" "crypto/rand" - "crypto/rsa" "crypto/sha256" - "crypto/x509" "embed" "encoding/base64" "encoding/binary" - "encoding/json" "encoding/xml" - "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" - "mime/multipart" "net/http" "net/http/cgi" "net/http/httputil" @@ -61,7 +50,8 @@ import ( "unicode" "github.com/skip2/go-qrcode" - "roseh.moe/pkg/oae2" + "roseh.moe/pkg/roseh.moe/internal/auth" + "roseh.moe/pkg/roseh.moe/internal/hole" "roseh.moe/pkg/wordlist" ) @@ -71,13 +61,16 @@ var ( configFile = flag.String("config", "config.xml", "configuration file (XML, baby!!)") https = flag.String("https", "", "directory containing cert.pem and key.pem, or empty string to use unencrypted http") httpRedirect = flag.String("http-redirect", "", "port to serve HTTP redirect") - holeTempDir = flag.String("hole-temp-dir", "/tmp/hole", "directory to store temporary files for the wormhole") nezuko = flag.String("nezuko", "/mnt/nezuko", "nezuko partition") cgit = flag.String("cgit", "", "directory containing cgit.cgi") cgitConfig = flag.String("cgit-config", "", "cgit config file") v = flag.Bool("v", true, "verbose") ) +func init() { + flag.StringVar(&hole.HoleTempDir, "hole-temp-dir", "/tmp/hole", "directory to store temporary files for the wormhole") +} + type urlValue struct { URL *url.URL } @@ -107,44 +100,6 @@ func urlFlag(name, def, doc string) *url.URL { return u } -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) if err != nil { @@ -159,7 +114,7 @@ func loadSecrets() error { if secretsConfig.SecretKey == "" { return fmt.Errorf("%s: missing SecretKey", *secretsFile) } - if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { + if auth.SecretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) } return nil @@ -180,15 +135,10 @@ 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 + Passkey []auth.Passkey passkeys map[string][]byte } @@ -220,30 +170,9 @@ func loadConfig() (*serviceConfiguration, error) { if err := xml.Unmarshal(fileBytes, config); err != nil { return nil, err } - names := make(map[string]bool) - config.passkeys = make(map[string][]byte) - namesByID := make(map[string]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 passkey %q: %s", *configFile, passkey.ID, err) - } - if len(keyBytes) < 16 { - return nil, fmt.Errorf("%s: invalid key for passkey %q: bad user ID: too short", *configFile, passkey.ID) - } - id, key := keyBytes[:16], keyBytes[16:] - if _, err := sigVerifier(key); err != nil { - return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) - } - if _, ok := config.passkeys[string(id)]; ok { - return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[string(id)], passkey.ID, id) - } - config.passkeys[string(id)] = key - namesByID[string(id)] = passkey.ID + config.passkeys, err = auth.LoadPasskeys(config.Passkey) + if err != nil { + return nil, fmt.Errorf("%s: %s", *configFile, err) } 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) }) @@ -409,7 +338,7 @@ func sharedFileHandler(w http.ResponseWriter, r *http.Request) { } func qrHandler(w http.ResponseWriter, r *http.Request) { - text, ok := verifyHMAC(r.FormValue("t"), "qr") + text, ok := auth.VerifyHMAC(r.FormValue("t"), "qr") if !ok { http.Error(w, "Bad signature", http.StatusBadRequest) return @@ -433,118 +362,15 @@ type wormholeTemplateArgs struct { Err string } -func makeHole() string { - const nWords = 10 - buf := make([]byte, 2*nWords) - rand.Read(buf) - words := make([]string, nWords) - for i := range words { - words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] - } - return strings.Join(words, "-") -} - -const blockSize = 4 * 1024 * 1024 - -type holeFileWriter struct { - *oae2.Writer - f *os.File -} - -func (w *holeFileWriter) Close() error { - return cmp.Or(w.Writer.Close(), w.f.Close()) -} - -func writeHoleFile(filename string) (_ string, _ *holeFileWriter, err error) { - hole := makeHole() - primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) - if err != nil { - return "", nil, err - } - f, err := os.Create(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) - if err != nil { - return "", nil, err - } - defer func() { - if err != nil { - f.Close() - } - }() - w := oae2.NewWriter(f, []byte(hole), blockSize, nil) - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, uint64(len(filename))) - if _, err := w.Write(buf); err != nil { - return "", nil, err - } - if _, err := io.WriteString(w, filename); err != nil { - return "", nil, err - } - return hole, &holeFileWriter{w, f}, nil -} - -type offsetReader struct { - io.ReadSeeker - offset int -} - -func (r *offsetReader) Seek(offset int64, whence int) (int64, error) { - if whence == io.SeekStart { - offset += int64(r.offset) - } - n, err := r.ReadSeeker.Seek(offset, whence) - return n - int64(r.offset), err -} - -type holeFileReader struct { - *offsetReader - f *os.File -} - -func (r *holeFileReader) Close() error { - return r.f.Close() -} - -var errNoHole = errors.New("no such hole") - -func openHoleFile(hole string) (_ string, _ *holeFileReader, err error) { - primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) - if err != nil { - return "", nil, err - } - f, err := os.Open(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return "", nil, errNoHole - } - return "", nil, err - } - defer func() { - if err != nil { - f.Close() - } - }() - r := oae2.NewReader(f, []byte(hole), blockSize, nil) - buf := make([]byte, 8) - if _, err := io.ReadFull(r, buf); err != nil { - return "", nil, err - } - fileNameLen := binary.BigEndian.Uint64(buf) - fileName := make([]byte, fileNameLen) - if _, err := io.ReadFull(r, fileName); err != nil { - return "", nil, err - } - return string(fileName), &holeFileReader{&offsetReader{r, 8 + int(fileNameLen)}, f}, nil -} - -func readHoleFile(w http.ResponseWriter, req *http.Request, hole string) error { - fileName, r, err := openHoleFile(hole) +func readHoleFile(w http.ResponseWriter, req *http.Request, holeName string) error { + fileName, r, err := hole.Open(holeName) if err != nil { return err } defer r.Close() w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+fileName) var modTime time.Time - if stat, err := r.f.Stat(); err == nil { + if stat, err := r.F.Stat(); err == nil { modTime = stat.ModTime() } http.ServeContent(w, req, fileName, modTime, r) @@ -557,83 +383,6 @@ func wormhole(w http.ResponseWriter, r *http.Request) { } } -func writeHoleFileToTarFile(tarWriter *tar.Writer, hole string, now time.Time) error { - fileName, f, err := openHoleFile(hole) - if err != nil { - return fmt.Errorf("read back hole file: %s", err) - } - defer f.Close() - size, err := f.Seek(0, io.SeekEnd) - if err != nil { - return fmt.Errorf("read back hole file: %s", err) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("read back hole file: %s", err) - } - if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/" + fileName, Mode: 0644, Size: size, ModTime: now, Format: tar.FormatPAX}); err != nil { - return err - } - if _, err := io.Copy(tarWriter, f); err != nil { - return fmt.Errorf("write file to tar file: %s", err) - } - return nil -} - -var errNoFile = errors.New("no file") - -func uploadWormhole(reader *multipart.Reader) (string, error) { - var holes []string - for { - part, err := reader.NextPart() - if err != nil { - break - } - if part.FormName() != "file" || part.FileName() == "" { - continue - } - hole, err := writeSingleHoleFile(part.FileName(), part) - if err != nil { - return "", err - } - holes = append(holes, hole) - } - if len(holes) == 0 { - return "", errNoFile - } - if len(holes) == 1 { - return holes[0], nil - } - tarFileHole, tarHoleWriter, err := writeHoleFile("wormhole-files.tar.gz") - if err != nil { - return "", fmt.Errorf("make tar file: %s", err) - } - defer tarHoleWriter.Close() - gzipWriter, err := gzip.NewWriterLevel(tarHoleWriter, gzip.BestSpeed) - if err != nil { - return "", err - } - tarWriter := tar.NewWriter(gzipWriter) - now := time.Now() - if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/", Mode: 0755, ModTime: now, Format: tar.FormatPAX}); err != nil { - return "", fmt.Errorf("create tar file main directory: %s", err) - } - for _, hole := range holes { - if err := writeHoleFileToTarFile(tarWriter, hole, now); err != nil { - return "", err - } - } - if err := tarWriter.Close(); err != nil { - return "", fmt.Errorf("write tar file: %s", err) - } - if err := gzipWriter.Close(); err != nil { - return "", fmt.Errorf("gzip tar file: %s", err) - } - if err := tarHoleWriter.Close(); err != nil { - return "", fmt.Errorf("write tar hole file: %s", err) - } - return tarFileHole, nil -} - var ( //go:embed templates/wormhole-success.html.template wormholeSuccessTemplateString string @@ -651,9 +400,9 @@ func wormholeUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Not a multipart/form-data request: %s", err), http.StatusBadRequest) return } - hole, err := uploadWormhole(reader) + holeName, err := hole.Upload(reader) if err != nil { - if err == errNoFile { + if err == hole.ErrNoFile { w.WriteHeader(http.StatusBadRequest) if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Err: "don't forget to pick a file"}); err != nil { log.Printf("Warning: wormholeTemplate: %s", err) @@ -664,32 +413,17 @@ func wormholeUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal error", http.StatusInternalServerError) return } - downloadURL := selfURL.String() + "/wormhole/" + hole + downloadURL := selfURL.String() + "/wormhole/" + holeName if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ URL: downloadURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")), + QR: "/qr.png?t=" + url.QueryEscape(auth.SignMsg(downloadURL, "qr")), }); err != nil { log.Printf("Warning: wormholeSend: %s", err) } } -func writeSingleHoleFile(filename string, r io.Reader) (string, error) { - hole, holeWriter, err := writeHoleFile(filename) - if err != nil { - return "", fmt.Errorf("write hole file: %s", err) - } - defer holeWriter.Close() - if _, err := io.Copy(holeWriter, r); err != nil { - return "", fmt.Errorf("write hole file: %s", err) - } - if err := holeWriter.Close(); err != nil { - return "", fmt.Errorf("write hole file: %s", err) - } - return hole, nil -} - func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { - hole, err := writeSingleHoleFile(r.PathValue("name"), r.Body) + hole, err := hole.WriteSingleFile(r.PathValue("name"), r.Body) if err != nil { log.Printf("Error: failed to write hole file: %s", err) http.Error(w, "Failed to write hole file", http.StatusInternalServerError) @@ -704,7 +438,7 @@ func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { func wormholeDownload(w http.ResponseWriter, r *http.Request) { if err := readHoleFile(w, r, r.PathValue("hole")); err != nil { - if err == errNoHole { + if err == hole.ErrNoHole { notFound(w, r) return } @@ -714,64 +448,6 @@ func wormholeDownload(w http.ResponseWriter, r *http.Request) { } } -func cleanHole() error { - log.Printf("Cleaning hole...") - holeDir, err := os.Open(*holeTempDir) - if err != nil { - return err - } - defer holeDir.Close() - deadline := time.Now().Add(-time.Hour) - for { - files, err := holeDir.ReadDir(1024) - for _, file := range files { - stat, err := file.Info() - if err != nil { - log.Printf("Error: clean hole: %s", err) - continue - } - if !stat.ModTime().Before(deadline) { - continue - } - if err := os.Remove(*holeTempDir + "/" + file.Name()); err != nil { - log.Printf("Error: clean hole: %s", err) - continue - } - } - if err != nil { - if err != io.EOF { - return err - } - return nil - } - } -} - -func mac(msg []byte, purpose string) []byte { - mac := hmac.New(sha256.New, secretKey) - io.WriteString(mac, purpose) - io.WriteString(mac, ":") - mac.Write(msg) - return mac.Sum(nil) -} - -func signMsg(msg, purpose string) string { - return msg + base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) -} - -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 := base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) - if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { - return "", false - } - return msg, true -} - const ( authVersion = "authv3" authCookieName = "__Secure-a" @@ -799,7 +475,7 @@ type authToken struct { func attachCookie(w http.ResponseWriter, tok *authToken) { buf := binary.AppendVarint(nil, tok.expiry.Unix()) buf = append(buf, tok.userID...) - token := signMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) + token := auth.SignMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) http.SetCookie(w, makeHTTPCookie(token, tok.expiry)) } @@ -808,7 +484,7 @@ func cookieAuth(r *http.Request) bool { if err != nil { return false } - msg, ok := verifyHMAC(cookie.Value, authVersion) + msg, ok := auth.VerifyHMAC(cookie.Value, authVersion) if !ok { return false } @@ -842,11 +518,21 @@ func cookieAuth(r *http.Request) bool { return true } -var ( - challengeMu sync.Mutex - challenge []byte - challengePosed time.Time -) +func verifyRedirect(redirect string) bool { + u, err := url.Parse(redirect) + if err != nil { + return false + } + switch u.Host { + case + "cinema." + selfURL.Host, + "downloads." + selfURL.Host, + selfURL.Host: + + return true + } + return false +} var ( //go:embed templates/login.html.template @@ -860,40 +546,26 @@ type loginTemplateArgs struct { func serveLogin(w http.ResponseWriter, r *http.Request) { if cookieAuth(r) { - redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect") - if !ok { + redirect := r.FormValue("redirect") + if !verifyRedirect(redirect) { redirect = "/" } http.Redirect(w, r, redirect, http.StatusSeeOther) return } - buf := make([]byte, 32) - rand.Read(buf) - challengeMu.Lock() - challenge = buf - challengePosed = time.Now() - challengeMu.Unlock() + challenge, ok := auth.NewChallenge() + if !ok { + http.Error(w, "Too many active requests", http.StatusTooManyRequests) + return + } if err := loginTemplate.Execute(w, loginTemplateArgs{ - Challenge: base64.StdEncoding.EncodeToString(buf), + Challenge: base64.StdEncoding.EncodeToString(challenge), }); err != nil { log.Printf("Warning: login: %s", err) } } func login(w http.ResponseWriter, r *http.Request) { - challengeMu.Lock() - myChallenge := challenge - challenge = nil - posed := challengePosed - challengeMu.Unlock() - if len(myChallenge) == 0 { - http.Error(w, "Request already consumed", http.StatusConflict) - return - } - if time.Since(posed) > 10*time.Minute { - http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) - return - } user, err := base64.StdEncoding.DecodeString(r.FormValue("user")) if err != nil { http.Error(w, "Bad user", http.StatusBadRequest) @@ -909,27 +581,6 @@ func login(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad client data: invalid base64", http.StatusBadRequest) return } - var cd struct { - Type string `json:"type"` - Challenge string `json:"challenge"` - Origin string `json:"origin"` - CrossOrigin bool `json:"crossOrigin"` - } - if err := json.Unmarshal(clientData, &cd); err != nil { - http.Error(w, "Bad client data: invalid JSON", http.StatusBadRequest) - return - } - challengeBase64 := base64.RawURLEncoding.EncodeToString(myChallenge) - if cd.Type != "webauthn.get" || cd.Challenge != challengeBase64 || cd.Origin != selfURL.String() || cd.CrossOrigin { - http.Error(w, "Bad client data", http.StatusBadRequest) - return - } - expectedRPHash := sha256.Sum256([]byte(selfURL.Hostname())) - const flagUserPresence, flagUserVerification = 0x01, 0x04 - if len(authData) < 37 || !bytes.Equal(authData[:32], expectedRPHash[:]) || authData[32]&(flagUserPresence|flagUserVerification) != (flagUserPresence|flagUserVerification) { - http.Error(w, "Bad authenticator data", http.StatusBadRequest) - return - } sig, err := base64.StdEncoding.DecodeString(r.FormValue("sig")) if err != nil { http.Error(w, "Bad sig", http.StatusBadRequest) @@ -941,22 +592,16 @@ func login(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal error", http.StatusInternalServerError) return } - pubKey, ok := config.passkeys[string(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) + if err := auth.Login(user, authData, clientData, sig, config.passkeys, selfURL); err != nil { + http.Error(w, "Failed challenge", http.StatusBadRequest) return } attachCookie(w, &authToken{ expiry: time.Now().Add(30 * 24 * time.Hour), userID: user, }) - redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect") - if !ok { + redirect := r.FormValue("redirect") + if !verifyRedirect(redirect) { redirect = "/" } http.Redirect(w, r, redirect, http.StatusSeeOther) @@ -985,6 +630,19 @@ func serveRegister(w http.ResponseWriter, r *http.Request) { } } +func wrap(s string) string { + out := new(strings.Builder) + for s != "" { + if out.Len() > 0 { + out.WriteString("\n\t ") + } + n := min(50, len(s)) + out.WriteString(s[:n]) + s = s[n:] + } + return out.String() +} + var ( //go:embed templates/register/register-success.html.template registerSuccessTemplateString string @@ -1014,7 +672,7 @@ func register(w http.ResponseWriter, r *http.Request) { } if err := registerSuccessTemplate.Execute(w, registerSuccessTemplateArgs{ ID: r.FormValue("name"), - Key: base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key)), + Key: wrap(base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key))), }); err != nil { log.Printf("Warning: register: %s", err) } @@ -1053,7 +711,7 @@ func serveOTP(w http.ResponseWriter, r *http.Request) { otpURL := selfURL.String() + "/otp/" + l if err := otpTemplate.Execute(w, otpTemplateArgs{ URL: otpURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")), + QR: "/qr.png?t=" + url.QueryEscape(auth.SignMsg(otpURL, "qr")), }); err != nil { log.Printf("Warning: serveOTP: %s", err) } @@ -1344,21 +1002,9 @@ func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "not logged in", http.StatusUnauthorized) } else { u := *r.URL - switch r.Host { - case - "cinema." + selfURL.Host, - "downloads." + selfURL.Host: - - u.Host = r.Host - default: - u.Host = selfURL.Host - } - u.Scheme = "http" - if *https != "" { - u.Scheme = "https" - } - redirect := signMsg(u.String(), "redirect") - http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther) + u.Host = r.Host + u.Scheme = selfURL.Scheme + http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(u.String()), http.StatusSeeOther) } return } @@ -1414,7 +1060,7 @@ func (h *hstsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - /*var cookies []*http.Cookie + var cookies []*http.Cookie for _, c := range r.Cookies() { if c.Name != authCookieName { cookies = append(cookies, c) @@ -1423,7 +1069,7 @@ func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque r.Header.Del("Cookie") for _, c := range cookies { r.AddCookie(c) - }*/ + } h.h.ServeHTTP(w, r) } @@ -1434,18 +1080,24 @@ func main() { log.Fatal(err) } - if err := os.MkdirAll(*holeTempDir, 0700); err != nil { + if err := hole.Init(); err != nil { log.Fatal(err) } go func() { for range time.NewTicker(2 * time.Hour).C { - if err := cleanHole(); err != nil { + if err := hole.Clean(); err != nil { log.Printf("Error: clean hole: %s", err) } } }() + go func() { + for range time.NewTicker(time.Hour).C { + auth.CleanChallenges() + } + }() + mux := http.NewServeMux() indexTemplate, err := outlinedTemplate(indexString) if err != nil { diff --git a/templates/login.html.template b/templates/login.html.template index ae0874a..2a498a5 100644 --- a/templates/login.html.template +++ b/templates/login.html.template @@ -27,19 +27,19 @@ </form> <script> -async function main() { - const publicKeyCredential = await navigator.credentials.get({publicKey: { - challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, - }}); - const rsp = publicKeyCredential.response; - document.getElementById("user").value = new Uint8Array(rsp.userHandle).toBase64(); - document.getElementById("auth-data").value = new Uint8Array(rsp.authenticatorData).toBase64(); - document.getElementById("client-data").value = new Uint8Array(rsp.clientDataJSON).toBase64(); - document.getElementById("sig").value = new Uint8Array(rsp.signature).toBase64(); - document.getElementById("form").submit(); -} + async function main() { + const publicKeyCredential = await navigator.credentials.get({publicKey: { + challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, + }}); + const rsp = publicKeyCredential.response; + document.getElementById("user").value = new Uint8Array(rsp.userHandle).toBase64(); + document.getElementById("auth-data").value = new Uint8Array(rsp.authenticatorData).toBase64(); + document.getElementById("client-data").value = new Uint8Array(rsp.clientDataJSON).toBase64(); + document.getElementById("sig").value = new Uint8Array(rsp.signature).toBase64(); + document.getElementById("form").submit(); + } -main(); + main(); </script> This page requires JavaScriptâ„¢ to be enabled diff --git a/templates/register/register-success.html.template b/templates/register/register-success.html.template index 847f97a..d9c64e1 100644 --- a/templates/register/register-success.html.template +++ b/templates/register/register-success.html.template @@ -24,5 +24,5 @@ <p>A device key has been generated, but it must be authorized on the server:</p> <pre> <Passkey ID="{{.ID | xmlEscape}}" - Key="{{.Key | xmlEscape}}"/></pre> + Key="{{.Key}}"/></pre> </section> diff --git a/templates/register/register.html.template b/templates/register/register.html.template index 833a1a7..d97b585 100644 --- a/templates/register/register.html.template +++ b/templates/register/register.html.template @@ -37,30 +37,29 @@ </section> <script> -async function generateKey() { - const name = document.getElementById("name").value; - const publicKeyCredential = await navigator.credentials.create({publicKey: { - authenticatorSelection: { - residentKey: "required", - userVerification: "required", - }, - challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, - pubKeyCredParams: [ - {alg: -9, type: "public-key"}, - {alg: -257, type: "public-key"}, - {alg: -7, type: "public-key"}, - {alg: -8, type: "public-key"}, - ], - rp: { - name: "roseh.moe", - }, - user: { - displayName: name, - id: Uint8Array.fromBase64(document.getElementById("id").value).buffer, - name: name, - }, - }}); - document.getElementById("key").value = new Uint8Array(publicKeyCredential.response.getPublicKey()).toBase64(); - document.getElementById("form").submit(); -} + async function generateKey() { + const name = document.getElementById("name").value; + const publicKeyCredential = await navigator.credentials.create({publicKey: { + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + }, + challenge: Uint8Array.fromBase64("{{.Challenge}}").buffer, + pubKeyCredParams: [ + {alg: -9, type: "public-key"}, + {alg: -257, type: "public-key"}, + {alg: -7, type: "public-key"}, + ], + rp: { + name: "roseh.moe", + }, + user: { + displayName: name, + id: Uint8Array.fromBase64(document.getElementById("id").value).buffer, + name: name, + }, + }}); + document.getElementById("key").value = new Uint8Array(publicKeyCredential.response.getPublicKey()).toBase64(); + document.getElementById("form").submit(); + } </script> |
