summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--assets/angry.pngbin3905042 -> 0 bytes
-rw-r--r--assets/angry.xcfbin153335 -> 0 bytes
-rw-r--r--default.nix2
-rw-r--r--go.mod3
-rw-r--r--go.sum4
-rw-r--r--internal/pwhash/pwhash.go36
-rw-r--r--roseh.moe.go569
-rw-r--r--roseh.moe_test.go18
-rw-r--r--static/angry.webpbin15356 -> 0 bytes
-rw-r--r--static/fart.mp3bin12356 -> 0 bytes
-rw-r--r--static/styles.css3
-rw-r--r--templates/login.html.template46
-rw-r--r--templates/otp.html.template25
-rw-r--r--templates/register/register-success.html.template28
-rw-r--r--templates/register/register.html.template66
-rw-r--r--tools/auth/auth.go68
-rw-r--r--tools/finditer/finditer.go60
-rw-r--r--tools/hashpw/hashpw.go53
19 files changed, 558 insertions, 426 deletions
diff --git a/.gitignore b/.gitignore
index 3ed378c..ffff036 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,4 @@
/roseh.moe
-/auth
-/finditer
-/hashpw
/result
/secrets.xml
/config.xml
diff --git a/assets/angry.png b/assets/angry.png
deleted file mode 100644
index 68ff462..0000000
--- a/assets/angry.png
+++ /dev/null
Binary files differ
diff --git a/assets/angry.xcf b/assets/angry.xcf
deleted file mode 100644
index 7a163f7..0000000
--- a/assets/angry.xcf
+++ /dev/null
Binary files differ
diff --git a/default.nix b/default.nix
index 9f2aeba..3c46f12 100644
--- a/default.nix
+++ b/default.nix
@@ -16,5 +16,5 @@ pkgs.buildGoModule rec {
# remember to bump this hash when your dependencies change.
# vendorHash = pkgs.lib.fakeHash;
- vendorHash = "sha256-TiWLi3xPKHXmxHp0r94RtwLNPGKuP4ZpPjDzmmqN97Y=";
+ vendorHash = "sha256-iSJd4A8ztJVTaYe4VPcZzLHDMxJVO0RyTKPRzs4IhtE=";
}
diff --git a/go.mod b/go.mod
index 6b9af98..033c5b1 100644
--- a/go.mod
+++ b/go.mod
@@ -4,9 +4,6 @@ go 1.26.0
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
- golang.org/x/term v0.47.0
roseh.moe/pkg/oae2 v0.3.1
roseh.moe/pkg/wordlist v1.0.2
)
-
-require golang.org/x/sys v0.49.0 // indirect
diff --git a/go.sum b/go.sum
index c6dfe9f..d221e92 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,5 @@
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
-golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
-golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
-golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
-golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
roseh.moe/pkg/oae2 v0.3.1 h1:HW6GxhntwDSSBup87mDlWRUNaAhkrlJ0x+F3ncnmlbQ=
roseh.moe/pkg/oae2 v0.3.1/go.mod h1:SZ5K2nvKreVdhHVNxujxcZLOnjhVi+qPi2XkB18dR8g=
roseh.moe/pkg/wordlist v1.0.2 h1:riB2RqCU5zXfCQjaPHYTXV/688FV9/Bp6Hnb0IAkP9c=
diff --git a/internal/pwhash/pwhash.go b/internal/pwhash/pwhash.go
deleted file mode 100644
index 5a2082d..0000000
--- a/internal/pwhash/pwhash.go
+++ /dev/null
@@ -1,36 +0,0 @@
-// 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 pwhash
-
-import (
- "crypto/pbkdf2"
- "crypto/sha512"
-)
-
-const SaltSize = 8
-
-const defaultIter = 12175430 // from tools/finditer
-
-func HashIter(password string, salt []byte, iter int) ([]byte, error) {
- return pbkdf2.Key(sha512.New, password, salt, iter, 32)
-}
-
-func Hash(password string, salt []byte) ([]byte, error) {
- return HashIter(password, salt, defaultIter)
-}
diff --git a/roseh.moe.go b/roseh.moe.go
index 0369740..bd07aed 100644
--- a/roseh.moe.go
+++ b/roseh.moe.go
@@ -24,15 +24,19 @@ import (
"cmp"
"compress/gzip"
"context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
"crypto/hkdf"
"crypto/hmac"
"crypto/rand"
- "crypto/sha1"
+ "crypto/rsa"
"crypto/sha256"
- "crypto/subtle"
+ "crypto/x509"
"embed"
"encoding/base64"
"encoding/binary"
+ "encoding/json"
"encoding/xml"
"errors"
"flag"
@@ -58,7 +62,6 @@ import (
"github.com/skip2/go-qrcode"
"roseh.moe/pkg/oae2"
- "roseh.moe/pkg/roseh.moe/internal/pwhash"
"roseh.moe/pkg/wordlist"
)
@@ -104,7 +107,43 @@ func urlFlag(name, def, doc string) *url.URL {
return u
}
-var notepadPassword, secretKey, authenticatorKey []byte
+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)
@@ -112,31 +151,17 @@ func loadSecrets() error {
return err
}
var secretsConfig struct {
- NotepadPassword string `xml:",attr"`
- SecretKey string `xml:",attr"`
- AuthKey string `xml:",attr"`
+ SecretKey string `xml:",attr"`
}
if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil {
return fmt.Errorf("%s: %s", *secretsFile, err)
}
- if secretsConfig.NotepadPassword == "" {
- return fmt.Errorf("%s: missing NotepadPassword", *secretsFile)
- }
if secretsConfig.SecretKey == "" {
return fmt.Errorf("%s: missing SecretKey", *secretsFile)
}
- if secretsConfig.AuthKey == "" {
- return fmt.Errorf("%s: missing AuthKey", *secretsFile)
- }
- if notepadPassword, err = base64.StdEncoding.DecodeString(secretsConfig.NotepadPassword); err != nil {
- return fmt.Errorf("%s: bad NotepadPassword (try base64)", *secretsFile)
- }
if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil {
return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile)
}
- if authenticatorKey, err = base64.StdEncoding.DecodeString(secretsConfig.AuthKey); err != nil {
- return fmt.Errorf("%s: bad AuthKey (try base64)", *secretsFile)
- }
return nil
}
@@ -155,9 +180,16 @@ 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
+ passkeys map[string][]byte
}
var (
@@ -188,6 +220,31 @@ 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
+ }
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) })
serviceConfigMu.Lock()
@@ -212,6 +269,11 @@ var (
"host": func() string {
return selfURL.Host
},
+ "xmlEscape": func(s string) string {
+ buf := new(strings.Builder)
+ xml.EscapeText(buf, []byte(s))
+ return buf.String()
+ },
}).Parse(outlineString))
)
@@ -347,7 +409,7 @@ func sharedFileHandler(w http.ResponseWriter, r *http.Request) {
}
func qrHandler(w http.ResponseWriter, r *http.Request) {
- text, ok := verify(r.FormValue("t"), "qr")
+ text, ok := verifyHMAC(r.FormValue("t"), "qr")
if !ok {
http.Error(w, "Bad signature", http.StatusBadRequest)
return
@@ -605,7 +667,7 @@ func wormholeUpload(w http.ResponseWriter, r *http.Request) {
downloadURL := selfURL.String() + "/wormhole/" + hole
if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{
URL: downloadURL,
- QR: "/qr.png?t=" + url.QueryEscape(downloadURL) + mac(downloadURL, "qr"),
+ QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")),
}); err != nil {
log.Printf("Warning: wormholeSend: %s", err)
}
@@ -685,197 +747,346 @@ func cleanHole() error {
}
}
-func mac(msg string, purpose string) string {
+func mac(msg []byte, purpose string) []byte {
mac := hmac.New(sha256.New, secretKey)
io.WriteString(mac, purpose)
io.WriteString(mac, ":")
- io.WriteString(mac, msg)
- return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ mac.Write(msg)
+ return mac.Sum(nil)
+}
+
+func signMsg(msg, purpose string) string {
+ return msg + base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose))
}
-func verify(signedMsg, purpose string) (string, bool) {
+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 := mac(msg, purpose)
+ expectedMAC := base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose))
if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) {
return "", false
}
return msg, true
}
-func marshalInt(x uint64) []byte {
- buf := make([]byte, 8)
- binary.BigEndian.PutUint64(buf, x)
- for ; len(buf) > 0 && buf[0] == 0; buf = buf[1:] {
+const (
+ authVersion = "authv3"
+ authCookieName = "__Secure-a"
+)
+
+func makeHTTPCookie(token string, expiry time.Time) *http.Cookie {
+ return &http.Cookie{
+ Name: authCookieName,
+ Value: token,
+ Path: "/",
+ Domain: selfURL.Hostname(),
+ Expires: expiry,
+ Secure: true,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ Partitioned: true,
}
- return buf
}
-func unmarshalInt(b []byte) (uint64, bool) {
- if len(b) > 8 {
- return 0, false
- }
- buf := make([]byte, 8)
- copy(buf[8-len(b):], b)
- return binary.BigEndian.Uint64(buf), true
+type authToken struct {
+ expiry time.Time
+ userID []byte
}
-func marshalTime(t time.Time) []byte {
- t = t.UTC()
- year, month, day := t.Date()
- hour, minute, second := t.Clock()
- u := uint64(year)
- u = u<<4 | uint64(month)
- u = u<<5 | uint64(day)
- u = u<<5 | uint64(hour)
- u = u<<6 | uint64(minute)
- u = u<<6 | uint64(second)
- return marshalInt(u)
+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)
+ http.SetCookie(w, makeHTTPCookie(token, tok.expiry))
}
-func unmarshalTime(b []byte) (time.Time, bool) {
- u, ok := unmarshalInt(b)
+func cookieAuth(r *http.Request) bool {
+ cookie, err := r.Cookie(authCookieName)
+ if err != nil {
+ return false
+ }
+ msg, ok := verifyHMAC(cookie.Value, authVersion)
if !ok {
- return time.Time{}, false
+ return false
}
- second, u := int(u&0x3f), u>>6
- minute, u := int(u&0x3f), u>>6
- hour, u := int(u&0x1f), u>>5
- day, u := int(u&0x1f), u>>5
- month, u := time.Month(u&0xf), u>>4
- year := int(u)
- return time.Date(year, month, day, hour, minute, second, 0, time.UTC), true
+ msgBytes, err := base64.RawURLEncoding.DecodeString(msg)
+ if err != nil {
+ return false
+ }
+ expiryUnix, n := binary.Varint(msgBytes)
+ if n <= 0 {
+ return false
+ }
+ msgBytes = msgBytes[n:]
+ expiry := time.Unix(expiryUnix, 0)
+ if time.Now().After(expiry) {
+ return false
+ }
+ if len(msgBytes) > 0 {
+ if len(msgBytes) < 16 {
+ return false
+ }
+ userID := msgBytes
+ config, err := loadConfig()
+ if err != nil {
+ log.Printf("Error: failed to load config: %s", err)
+ return false
+ }
+ if _, ok := config.passkeys[string(userID)]; !ok {
+ return false
+ }
+ }
+ return true
}
-const authVersion = "authv2"
+var (
+ challengeMu sync.Mutex
+ challenge []byte
+ challengePosed time.Time
+)
-func makeToken() string {
- t := base64.RawURLEncoding.EncodeToString(marshalTime(time.Now()))
- return t + mac(t, authVersion)
-}
+var (
+ //go:embed templates/login.html.template
+ loginTemplateString string
+ loginTemplate = template.Must(template.New("login").Parse(loginTemplateString))
+)
-const authCookieName = "a"
+type loginTemplateArgs struct {
+ Challenge string
+}
-func attachCookie(w http.ResponseWriter) {
- http.SetCookie(w, &http.Cookie{
- Name: authCookieName,
- Value: makeToken(),
- Path: "/",
- Domain: selfURL.Hostname(),
- Expires: time.Now().Add(longLoginTimeout),
- Secure: true,
- HttpOnly: true,
- SameSite: http.SameSiteStrictMode,
- Partitioned: true,
- })
+func serveLogin(w http.ResponseWriter, r *http.Request) {
+ if cookieAuth(r) {
+ redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect")
+ if !ok {
+ 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()
+ if err := loginTemplate.Execute(w, loginTemplateArgs{
+ Challenge: base64.StdEncoding.EncodeToString(buf),
+ }); err != nil {
+ log.Printf("Warning: login: %s", err)
+ }
}
-func cookieAuth(r *http.Request) (time.Duration, bool) {
- cookie, err := r.Cookie(authCookieName)
+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 {
- return 0, false
+ http.Error(w, "Bad user", http.StatusBadRequest)
+ return
}
- msg, ok := verify(cookie.Value, authVersion)
- if !ok {
- return 0, false
+ authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data"))
+ if err != nil {
+ http.Error(w, "Bad auth data", http.StatusBadRequest)
+ return
}
- msgBytes, err := base64.RawURLEncoding.DecodeString(msg)
+ clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data"))
if err != nil {
- return 0, false
+ 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
}
- t, ok := unmarshalTime(msgBytes)
+ 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)
+ return
+ }
+ config, err := loadConfig()
+ if err != nil {
+ log.Printf("Error: failed to load config: %s", err)
+ http.Error(w, "Internal error", http.StatusInternalServerError)
+ return
+ }
+ pubKey, ok := config.passkeys[string(user)]
if !ok {
- return 0, false
+ http.Error(w, "No user with the given ID", http.StatusBadRequest)
+ return
}
- age := time.Since(t)
- if age > longLoginTimeout {
- return 0, false
+ clientDataHash := sha256.Sum256(clientData)
+ if err := verifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil {
+ http.Error(w, "Challenge failed", http.StatusBadRequest)
+ return
}
- return age, true
+ attachCookie(w, &authToken{
+ expiry: time.Now().Add(30 * 24 * time.Hour),
+ userID: user,
+ })
+ redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect")
+ if !ok {
+ redirect = "/"
+ }
+ http.Redirect(w, r, redirect, http.StatusSeeOther)
}
-func totp(t int64) []byte {
- ts := make([]byte, 8)
- binary.BigEndian.PutUint64(ts, uint64(t))
- hash := hmac.New(sha1.New, authenticatorKey)
- hash.Write(ts)
- mac := hash.Sum(nil)
- offset := mac[len(mac)-1] & 0xf
- n := int32(mac[offset])&0x7f<<24 |
- int32(mac[offset+1])<<16 |
- int32(mac[offset+2])<<8 |
- int32(mac[offset+3])
- return strconv.AppendInt(nil, int64(n%1_000_000), 10)
-}
+var (
+ //go:embed templates/register/register.html.template
+ registerTemplateString string
+ registerTemplate = template.Must(outlinedTemplate(registerTemplateString))
+)
-func checkOTP(auth string) int {
- now := time.Now().Unix() / 30
- return subtle.ConstantTimeCompare([]byte(auth), totp(now-1)) | subtle.ConstantTimeCompare([]byte(auth), totp(now)) | subtle.ConstantTimeCompare([]byte(auth), totp(now+1))
+type registerTemplateArgs struct {
+ ID string
+ Challenge string
}
-func checkPassword(password string) int {
- hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize])
- if err != nil {
- return 0
+func serveRegister(w http.ResponseWriter, r *http.Request) {
+ buf := make([]byte, 32+16)
+ rand.Read(buf)
+ challenge, userID := buf[:32], buf[32:]
+ if err := registerTemplate.Execute(w, registerTemplateArgs{
+ ID: base64.StdEncoding.EncodeToString(userID),
+ Challenge: base64.StdEncoding.EncodeToString(challenge),
+ }); err != nil {
+ log.Printf("Warning: serveRegister: %s", err)
}
- return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:])
}
var (
- //go:embed templates/login.html.template
- loginTemplateString string
- loginTemplate = template.Must(outlinedTemplate(loginTemplateString))
+ //go:embed templates/register/register-success.html.template
+ registerSuccessTemplateString string
+ registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString))
)
-type loginTemplateArgs struct {
- Quick bool
- Error bool
+type registerSuccessTemplateArgs struct {
+ ID string
+ Key string
}
-func redirectLogin(w http.ResponseWriter, r *http.Request, redirect string) {
- redirect += mac(redirect, "redirect")
- http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther)
+func register(w http.ResponseWriter, r *http.Request) {
+ username := r.FormValue("name")
+ if username == "" {
+ http.Error(w, "Passkey name is required", http.StatusBadRequest)
+ return
+ }
+ userID, err := base64.StdEncoding.DecodeString(r.FormValue("id"))
+ if err != nil {
+ http.Error(w, "Invalid user ID", http.StatusBadRequest)
+ return
+ }
+ key, err := base64.StdEncoding.DecodeString(r.FormValue("key"))
+ if err != nil {
+ http.Error(w, "Invalid key", http.StatusBadRequest)
+ return
+ }
+ if err := registerSuccessTemplate.Execute(w, registerSuccessTemplateArgs{
+ ID: r.FormValue("name"),
+ Key: base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key)),
+ }); err != nil {
+ log.Printf("Warning: register: %s", err)
+ }
}
-const (
- quickLoginTimeout = 24 * time.Hour
- longLoginTimeout = 300 * 24 * time.Hour
+var (
+ otpMu sync.Mutex
+ otpLink string
+ otpGenerated time.Time
)
-func serveLogin(w http.ResponseWriter, r *http.Request) {
- _, cookieOK := cookieAuth(r)
- if err := loginTemplate.Execute(w, loginTemplateArgs{
- Quick: cookieOK,
+var (
+ //go:embed templates/otp.html.template
+ otpTemplateString string
+ otpTemplate = template.Must(outlinedTemplate(otpTemplateString))
+)
+
+type otpTemplateArgs struct {
+ URL string
+ QR string
+}
+
+func serveOTP(w http.ResponseWriter, r *http.Request) {
+ const nWords = 5
+ buf := make([]byte, 2*nWords)
+ rand.Read(buf)
+ words := make([]string, nWords)
+ for i := range len(buf) / 2 {
+ words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff]
+ }
+ l := strings.Join(words, "-")
+ otpMu.Lock()
+ otpLink = l
+ otpGenerated = time.Now()
+ otpMu.Unlock()
+ otpURL := selfURL.String() + "/otp/" + l
+ if err := otpTemplate.Execute(w, otpTemplateArgs{
+ URL: otpURL,
+ QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")),
}); err != nil {
- log.Printf("Warning: login: %s", err)
+ log.Printf("Warning: serveOTP: %s", err)
}
}
-func login(w http.ResponseWriter, r *http.Request) {
- otpMatches := checkOTP(r.FormValue("auth"))
- passwordMatches := 1
- _, cookieOK := cookieAuth(r)
- if !cookieOK {
- passwordMatches = checkPassword(r.FormValue("password"))
+func otpLogin(w http.ResponseWriter, r *http.Request) {
+ otpMu.Lock()
+ myOTP := otpLink
+ otpLink = ""
+ generated := otpGenerated
+ otpMu.Unlock()
+ reqOTP := r.PathValue("otp")
+ if reqOTP == "" {
+ notFound(w, r)
+ return
}
- if otpMatches&passwordMatches == 0 {
- if err := loginTemplate.Execute(w, loginTemplateArgs{
- Quick: cookieOK,
- Error: true,
- }); err != nil {
- log.Printf("Warning: login: %s", err)
- }
+ myOTPHash := sha256.Sum256([]byte(myOTP))
+ reqOTPHash := sha256.Sum256([]byte(reqOTP))
+ if !hmac.Equal(myOTPHash[:], reqOTPHash[:]) {
+ notFound(w, r)
return
}
- attachCookie(w)
- redirect, ok := verify(r.FormValue("redirect"), "redirect")
- if !ok {
- redirect = "/"
+ if time.Since(generated) > 10*time.Minute {
+ http.Error(w, "OTP is too old. Please generate a new link", http.StatusConflict)
+ return
}
- http.Redirect(w, r, redirect, http.StatusSeeOther)
+ attachCookie(w, &authToken{expiry: time.Now().Add(24 * time.Hour)})
+ fmt.Fprintf(w, "Temporary 24 hour token issued. You are now logged in.\n")
+}
+
+func logout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, makeHTTPCookie("!", time.Now()))
+ fmt.Fprintf(w, "You are now logged out.\n")
}
var (
@@ -1128,18 +1339,26 @@ type authHandler struct {
}
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if age, ok := cookieAuth(r); !ok || age > quickLoginTimeout {
- log.Printf("Warning: ok age = %v %v", ok, age)
+ if !cookieAuth(r) {
if h.noRedirect {
http.Error(w, "not logged in", http.StatusUnauthorized)
} else {
u := *r.URL
- u.Host = r.Host
+ 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"
}
- redirectLogin(w, r, u.String())
+ redirect := signMsg(u.String(), "redirect")
+ http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther)
}
return
}
@@ -1179,6 +1398,35 @@ func (h *loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.h.ServeHTTP(w, r)
}
+type removeAuthCookieHandler struct {
+ h http.Handler
+}
+
+type hstsHandler struct {
+ h http.Handler
+}
+
+func (h *hstsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if *https != "" {
+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ }
+ h.h.ServeHTTP(w, r)
+}
+
+func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ /*var cookies []*http.Cookie
+ for _, c := range r.Cookies() {
+ if c.Name != authCookieName {
+ cookies = append(cookies, c)
+ }
+ }
+ r.Header.Del("Cookie")
+ for _, c := range cookies {
+ r.AddCookie(c)
+ }*/
+ h.h.ServeHTTP(w, r)
+}
+
func main() {
flag.Parse()
@@ -1241,6 +1489,11 @@ func main() {
mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload)
mux.Handle("GET /login", &timeoutHandler{h: http.HandlerFunc(serveLogin), timeout: 5 * time.Minute})
mux.Handle("POST /login", &timeoutHandler{h: http.HandlerFunc(login), timeout: 5 * time.Minute})
+ mux.Handle("GET /logout", &timeoutHandler{h: http.HandlerFunc(logout), timeout: 5 * time.Minute})
+ mux.Handle("GET /register", &timeoutHandler{h: http.HandlerFunc(serveRegister), timeout: 5 * time.Minute})
+ mux.Handle("POST /register", &timeoutHandler{h: http.HandlerFunc(register), timeout: 5 * time.Minute})
+ mux.Handle("GET /otp", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(serveOTP)}, timeout: 5 * time.Minute})
+ mux.Handle("GET /otp/{otp}", &timeoutHandler{h: http.HandlerFunc(otpLogin), timeout: 5 * time.Minute})
mux.Handle("GET /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(notepad)}, timeout: 5 * time.Minute})
mux.Handle("POST /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(autosave), noRedirect: true}, timeout: 5 * time.Minute})
mux.Handle("GET /cmd/{$}", &timeoutHandler{h: http.HandlerFunc(cmdIndex), timeout: 5 * time.Minute})
@@ -1255,17 +1508,17 @@ func main() {
mux.Handle("/api/", apiMux)
mux.Handle("/", &timeoutHandler{h: http.HandlerFunc(sharedFileHandler), timeout: 5 * time.Minute})
serverMux := http.NewServeMux()
- serverMux.Handle(selfURL.Hostname()+"/", http.NewCrossOriginProtection().Handler(&gzipHandler{mux}))
+ serverMux.Handle(selfURL.Hostname()+"/", &gzipHandler{mux})
jellyfinURL, err := url.Parse("http://127.0.0.1:8096")
if err != nil {
log.Fatal(err)
}
- serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{
+ serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(jellyfinURL)
r.SetXForwarded()
},
- }})
+ }}})
codeMux := http.NewServeMux()
cgitStaticFiles := http.FileServer(http.Dir(*cgit))
codeMux.Handle("/cgit.css", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute})
@@ -1281,20 +1534,20 @@ func main() {
Env: []string{"CGIT_CONFIG=" + cgitConfigAbsPath},
}
cgitCGI.PathLocationHandler = cgitCGI
- codeMux.Handle("/", &timeoutHandler{h: cgitCGI, timeout: 5 * time.Minute})
+ codeMux.Handle("/", &timeoutHandler{h: &removeAuthCookieHandler{cgitCGI}, timeout: 5 * time.Minute})
serverMux.Handle("code."+selfURL.Hostname()+"/", &gzipHandler{codeMux})
transmissionURL, err := url.Parse("http://127.0.0.1:9091")
if err != nil {
log.Fatal(err)
}
- serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{
+ serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(transmissionURL)
r.SetXForwarded()
},
- }})
+ }}})
server := &http.Server{
- Handler: &loggingHandler{serverMux},
+ Handler: &loggingHandler{http.NewCrossOriginProtection().Handler(&hstsHandler{serverMux})},
ReadHeaderTimeout: 5 * time.Minute,
IdleTimeout: 10 * time.Minute,
diff --git a/roseh.moe_test.go b/roseh.moe_test.go
deleted file mode 100644
index cf41cd0..0000000
--- a/roseh.moe_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-import (
- "testing"
- "time"
-)
-
-func TestMarshalTime(t *testing.T) {
- now := time.Now().Truncate(time.Second)
- b := marshalTime(now)
- got, ok := unmarshalTime(b)
- if !ok {
- t.Fatalf("Failed round trip")
- }
- if !got.Equal(now) {
- t.Errorf("Failed round trip. unmarshalTime(%v) = %v, want %v", b, got, now)
- }
-}
diff --git a/static/angry.webp b/static/angry.webp
deleted file mode 100644
index 6fa91df..0000000
--- a/static/angry.webp
+++ /dev/null
Binary files differ
diff --git a/static/fart.mp3 b/static/fart.mp3
deleted file mode 100644
index 1717d9d..0000000
--- a/static/fart.mp3
+++ /dev/null
Binary files differ
diff --git a/static/styles.css b/static/styles.css
index 3f8ad7d..c0ffb2e 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -119,7 +119,7 @@ input.password {
font-size: 18px;
}
-input.submit {
+img.submit {
margin-top: 20px;
padding: 0;
background: #f5f5f5;
@@ -128,6 +128,7 @@ input.submit {
border-radius: 15px;
box-shadow: 0 0 10px rgba(255, 105, 180, 0.5);
font-size: 25px;
+ cursor: pointer;
}
form.wormhole {
diff --git a/templates/login.html.template b/templates/login.html.template
index 279927b..ae0874a 100644
--- a/templates/login.html.template
+++ b/templates/login.html.template
@@ -17,25 +17,29 @@
-- <https://www.gnu.org/licenses/>.
-->
-<title>Login</title>
+<!doctype html>
-<section>
-{{- if .Error}}
- <p class="login-error"><img class="inline" width="150" height="163" src="/static/angry.webp">Incorrect password
-{{- end}}
- <form class="password-form" method="post">
- <table>
- {{- if not .Quick}}
- <tr class="password-form-row">
- <td><label class="password-label" for="password">Enter password</label>
- <td><input id="password" class="password" type="password" name="password" autofocus>
- {{- end}}
- <tr class="password-form-row">
- <td><label class="password-label" for="auth">Google authenticator code</label>
- <td><input id="auth" class="password" name="auth">
- <tr>
- <td>
- <td><input type="image" width="100" height="71" class="submit" src="/static/point.webp" alt="Submit">
- </table>
- </form>
-</section>
+<form id="form" method="post">
+ <input id="user" type="hidden" name="user">
+ <input id="auth-data" type="hidden" name="auth-data">
+ <input id="client-data" type="hidden" name="client-data">
+ <input id="sig" type="hidden" name="sig">
+</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();
+}
+
+main();
+</script>
+
+This page requires JavaScriptâ„¢ to be enabled
diff --git a/templates/otp.html.template b/templates/otp.html.template
new file mode 100644
index 0000000..b20d71c
--- /dev/null
+++ b/templates/otp.html.template
@@ -0,0 +1,25 @@
+<!-- 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/>.
+ -->
+
+<title>OTP</title>
+
+<div style="display:flex;flex-direction:column;align-items:center;font-size:50px">
+ <p>Your OTP link: <a href="{{.URL}}">{{.URL}}</a></p>
+ <img src="{{.QR}}" alt="QR code for {{.URL}}" width="500px">
+</div>
diff --git a/templates/register/register-success.html.template b/templates/register/register-success.html.template
new file mode 100644
index 0000000..847f97a
--- /dev/null
+++ b/templates/register/register-success.html.template
@@ -0,0 +1,28 @@
+<!-- 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/>.
+ -->
+
+<title>Registered</title>
+
+<section>
+ <h1>Registered!</h1>
+ <p>A device key has been generated, but it must be authorized on
+ the server:</p>
+ <pre> &lt;Passkey ID="{{.ID | xmlEscape}}"
+ Key="{{.Key | xmlEscape}}"/&gt;</pre>
+</section>
diff --git a/templates/register/register.html.template b/templates/register/register.html.template
new file mode 100644
index 0000000..833a1a7
--- /dev/null
+++ b/templates/register/register.html.template
@@ -0,0 +1,66 @@
+<!-- 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/>.
+ -->
+
+<title>Register</title>
+
+<section>
+ <form id="form" class="password-form" method="post">
+ <input id="id" type="hidden" name="id" value="{{.ID}}">
+ <input id="key" type="hidden" name="key">
+ <table>
+ <tr class="password-form-row">
+ <td><label class="password-label" for="name">Enter a name for this passkey</label>
+ <td><input id="name" class="password" name="name">
+ <tr>
+ <td>
+ <td>
+ <img width="100" height="71" class="submit" src="/static/point.webp" alt="Submit"
+ onClick="generateKey()">
+ </table>
+ </form>
+</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();
+}
+</script>
diff --git a/tools/auth/auth.go b/tools/auth/auth.go
deleted file mode 100644
index 6268ba8..0000000
--- a/tools/auth/auth.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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 main
-
-import (
- "encoding/base32"
- "encoding/base64"
- "encoding/xml"
- "flag"
- "fmt"
- "os"
-
- "github.com/skip2/go-qrcode"
-)
-
-func run() error {
- args := flag.Args()
- if len(args) != 1 {
- return fmt.Errorf("usage: auth <config file>")
- }
- fileBytes, err := os.ReadFile(args[0])
- if err != nil {
- return err
- }
- var config struct {
- AuthKey string `xml:",attr"`
- }
- if err := xml.Unmarshal(fileBytes, &config); err != nil {
- return err
- }
- if config.AuthKey == "" {
- return fmt.Errorf("%s: missing AuthKey", args[0])
- }
- authKey, err := base64.StdEncoding.DecodeString(config.AuthKey)
- if err != nil {
- return err
- }
- qrCode, err := qrcode.New(fmt.Sprintf("otpauth://totp/roseh.moe:rose?secret=%s&issuer=roseh.moe", base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(authKey)), qrcode.Medium)
- if err != nil {
- return err
- }
- fmt.Print(qrCode.ToSmallString(false))
- return nil
-}
-
-func main() {
- flag.Parse()
- if err := run(); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
diff --git a/tools/finditer/finditer.go b/tools/finditer/finditer.go
deleted file mode 100644
index f5bf75f..0000000
--- a/tools/finditer/finditer.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// 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 main
-
-import (
- "encoding/hex"
- "fmt"
- "sort"
- "testing"
- "time"
-
- "roseh.moe/pkg/roseh.moe/internal/pwhash"
-)
-
-func mustHex(s string) []byte {
- b, err := hex.DecodeString(s)
- if err != nil {
- panic(err)
- }
- return b
-}
-
-var salt = mustHex("34539b5dd2591415")
-
-var iter int
-
-func BenchmarkHashIter(b *testing.B) {
- const password = "wavy upend 4z jk linda k zing four atop pi"
- for b.Loop() {
- pwhash.HashIter(password, salt, iter)
- }
-}
-
-func main() {
- const targetTime = 5 * time.Second
- for iter = 8192; time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) < targetTime; iter *= 2 {
- }
- lo := iter / 2
- hi := iter
- fmt.Println(lo + sort.Search(hi-lo, func(n int) bool {
- iter = lo + n
- return time.Duration(testing.Benchmark(BenchmarkHashIter).NsPerOp()) > targetTime
- }))
-}
diff --git a/tools/hashpw/hashpw.go b/tools/hashpw/hashpw.go
deleted file mode 100644
index 8244b64..0000000
--- a/tools/hashpw/hashpw.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// 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 main
-
-import (
- "crypto/rand"
- "encoding/base64"
- "fmt"
- "os"
-
- "golang.org/x/term"
- "roseh.moe/pkg/roseh.moe/internal/pwhash"
-)
-
-func hashpw() error {
- fmt.Fprint(os.Stderr, "Enter password: ")
- pw, err := term.ReadPassword(int(os.Stdin.Fd()))
- fmt.Fprintln(os.Stderr)
- if err != nil {
- return err
- }
- salt := make([]byte, pwhash.SaltSize)
- rand.Read(salt)
- pwHash, err := pwhash.Hash(string(pw), salt)
- if err != nil {
- return err
- }
- fmt.Printf("NotepadPassword=\"%s\"\n", base64.StdEncoding.EncodeToString(append(salt, pwHash...)))
- return nil
-}
-
-func main() {
- if err := hashpw(); err != nil {
- fmt.Fprintf(os.Stderr, "hashpw: %s\n", err)
- os.Exit(1)
- }
-}