diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/auth/auth.go | 215 | ||||
| -rw-r--r-- | internal/hole/hole.go | 278 |
2 files changed, 493 insertions, 0 deletions
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 +} |
