diff options
| author | Rose Hogenson <rosehogenson@posteo.net> | 2026-10-18 16:55:33 -0700 |
|---|---|---|
| committer | Rose Hogenson <rosehogenson@posteo.net> | 2026-10-20 15:35:16 -0700 |
| commit | 6a4c8c1a9424420369ec1560da1c26db7fbff4e4 (patch) | |
| tree | ba6d13b9ff32dfa50cf4eaf0c0f033b43e1d1708 | |
| parent | Allow for the clock to be slightly behind (diff) | |
| download | roseh.moe-6a4c8c1a9424420369ec1560da1c26db7fbff4e4.tar.zst | |
Completely overhaul the auth again
Now we're all in on passkeys (ooh fancy)
| -rw-r--r-- | roseh.moe.go | 420 | ||||
| -rw-r--r-- | static/styles.css | 3 | ||||
| -rw-r--r-- | templates/login.html.template | 46 | ||||
| -rw-r--r-- | templates/register/register-success.html.template | 28 | ||||
| -rw-r--r-- | templates/register/register.html.template | 62 |
5 files changed, 386 insertions, 173 deletions
diff --git a/roseh.moe.go b/roseh.moe.go index 0369740..8e2c999 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -24,12 +24,15 @@ 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" @@ -58,7 +61,6 @@ import ( "github.com/skip2/go-qrcode" "roseh.moe/pkg/oae2" - "roseh.moe/pkg/roseh.moe/internal/pwhash" "roseh.moe/pkg/wordlist" ) @@ -104,7 +106,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 +150,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 +179,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[int64][]byte } var ( @@ -188,6 +219,32 @@ func loadConfig() (*serviceConfiguration, error) { if err := xml.Unmarshal(fileBytes, config); err != nil { return nil, err } + names := make(map[string]bool) + config.passkeys = make(map[int64][]byte) + namesByID := make(map[int64]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 device %q: %s", *configFile, passkey.ID, err) + } + id, n := binary.Varint(keyBytes) + if n <= 0 { + return nil, fmt.Errorf("%s: invalid key for device %q: bad device ID", *configFile, passkey.ID) + } + key := keyBytes[n:] + if _, err := sigVerifier(key); err != nil { + return nil, fmt.Errorf("%s: invalid key for device %q: %s", *configFile, passkey.ID, err) + } + if _, ok := config.passkeys[id]; ok { + return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[id], passkey.ID, id) + } + config.passkeys[id] = key + namesByID[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,87 +747,44 @@ 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:] { - } - 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 -} - -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 unmarshalTime(b []byte) (time.Time, bool) { - u, ok := unmarshalInt(b) - if !ok { - return time.Time{}, 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 -} - -const authVersion = "authv2" - -func makeToken() string { - t := base64.RawURLEncoding.EncodeToString(marshalTime(time.Now())) - return t + mac(t, authVersion) -} - -const authCookieName = "a" +const ( + authVersion = "authv3" + authCookieName = "a" +) -func attachCookie(w http.ResponseWriter) { +func attachCookie(w http.ResponseWriter, expiry time.Time) { + token := signMsg(base64.RawURLEncoding.EncodeToString(binary.AppendVarint(nil, expiry.Unix())), authVersion) http.SetCookie(w, &http.Cookie{ Name: authCookieName, - Value: makeToken(), + Value: token, Path: "/", Domain: selfURL.Hostname(), - Expires: time.Now().Add(longLoginTimeout), + Expires: expiry, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, @@ -773,105 +792,122 @@ func attachCookie(w http.ResponseWriter) { }) } -func cookieAuth(r *http.Request) (time.Duration, bool) { +func cookieAuth(r *http.Request) bool { cookie, err := r.Cookie(authCookieName) if err != nil { - return 0, false + return false } - msg, ok := verify(cookie.Value, authVersion) + msg, ok := verifyHMAC(cookie.Value, authVersion) if !ok { - return 0, false + return false } msgBytes, err := base64.RawURLEncoding.DecodeString(msg) if err != nil { - return 0, false + return false } - t, ok := unmarshalTime(msgBytes) - if !ok { - return 0, false + expiryUnix, n := binary.Varint(msgBytes) + if n <= 0 { + return false } - age := time.Since(t) - if age > longLoginTimeout { - return 0, false + expiry := time.Unix(expiryUnix, 0) + if time.Now().After(expiry) { + return false } - return age, true -} - -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) + return true } -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)) -} - -func checkPassword(password string) int { - hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize]) - if err != nil { - return 0 - } - return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:]) -} +var ( + challengeMu sync.Mutex + challenge []byte + challengePosed time.Time +) var ( //go:embed templates/login.html.template loginTemplateString string - loginTemplate = template.Must(outlinedTemplate(loginTemplateString)) + loginTemplate = template.Must(template.New("login").Parse(loginTemplateString)) ) type loginTemplateArgs struct { - Quick bool - Error bool -} - -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) + Challenge string } -const ( - quickLoginTimeout = 24 * time.Hour - longLoginTimeout = 300 * 24 * time.Hour -) - func serveLogin(w http.ResponseWriter, r *http.Request) { - _, cookieOK := cookieAuth(r) + buf := make([]byte, 32) + rand.Read(buf) + challengeMu.Lock() + challenge = buf + challengePosed = time.Now() + challengeMu.Unlock() if err := loginTemplate.Execute(w, loginTemplateArgs{ - Quick: cookieOK, + Challenge: base64.StdEncoding.EncodeToString(buf), }); err != nil { log.Printf("Warning: login: %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")) + challengeMu.Lock() + myChallenge := challenge + challenge = nil + posed := challengePosed + challengeMu.Unlock() + if len(myChallenge) == 0 { + http.Error(w, "Request already consumed", http.StatusConflict) + return } - if otpMatches&passwordMatches == 0 { - if err := loginTemplate.Execute(w, loginTemplateArgs{ - Quick: cookieOK, - Error: true, - }); err != nil { - log.Printf("Warning: login: %s", err) - } + if time.Since(posed) > 10*time.Minute { + http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) return } - attachCookie(w) - redirect, ok := verify(r.FormValue("redirect"), "redirect") + userBytes, err := base64.StdEncoding.DecodeString(r.FormValue("user")) + if err != nil { + http.Error(w, "Bad user", http.StatusBadRequest) + return + } + user, n := binary.Varint(userBytes) + if n <= 0 { + http.Error(w, "Bad user", http.StatusBadRequest) + return + } + authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data")) + if err != nil { + http.Error(w, "Bad auth data", http.StatusBadRequest) + return + } + clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data")) + if err != nil { + http.Error(w, "Bad client data: invalid base64", http.StatusBadRequest) + return + } + challengeBase64 := base64.RawURLEncoding.EncodeToString(myChallenge) + if !strings.Contains(string(clientData), challengeBase64) { + http.Error(w, "Bad client data: bad challenge", 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[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) + return + } + attachCookie(w, time.Now().Add(30*24*time.Hour)) + redirect, ok := verifyHMAC(r.FormValue("redirect"), "redirect") if !ok { redirect = "/" } @@ -879,6 +915,86 @@ func login(w http.ResponseWriter, r *http.Request) { } var ( + //go:embed templates/register/register.html.template + registerTemplateString string + registerTemplate = template.Must(outlinedTemplate(registerTemplateString)) +) + +type registerTemplateArgs struct { + ID string + Challenge string +} + +func serveRegister(w http.ResponseWriter, r *http.Request) { + serviceConfig, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load service config: %s", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + var maxID int64 + for id := range serviceConfig.passkeys { + maxID = max(maxID, id) + } + myID := maxID + 1 + buf := make([]byte, 32) + rand.Read(buf) + if err := registerTemplate.Execute(w, registerTemplateArgs{ + ID: base64.StdEncoding.EncodeToString(binary.AppendVarint(nil, myID)), + Challenge: base64.StdEncoding.EncodeToString(buf), + }); err != nil { + log.Printf("Warning: serveRegister: %s", err) + } +} + +var ( + //go:embed templates/register/register-success.html.template + registerSuccessTemplateString string + registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString)) +) + +type registerSuccessTemplateArgs struct { + ID string + Key string +} + +func register(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("name") + if username == "" { + http.Error(w, "Passkey name is required", 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 + } + for _, passkey := range config.Passkey { + if passkey.ID == username { + http.Error(w, "Passkey name already used", 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) + } +} + +var ( //go:embed templates/note.html.template notepadString string notepadTemplate = template.Must(outlinedTemplate(notepadString)) @@ -1128,8 +1244,7 @@ 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 { @@ -1139,7 +1254,8 @@ func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 } @@ -1241,6 +1357,8 @@ 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 /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 /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}) 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/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> <Passkey ID="{{.ID | xmlEscape}}" + Key="{{.Key | xmlEscape}}"/></pre> +</section> diff --git a/templates/register/register.html.template b/templates/register/register.html.template new file mode 100644 index 0000000..3b4d959 --- /dev/null +++ b/templates/register/register.html.template @@ -0,0 +1,62 @@ +<!-- 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: { + 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> |
