summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/auth/auth.go215
-rw-r--r--internal/hole/hole.go278
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
+}