cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 5a6f018d928808454560e317f79636825e6b49a4
parent 22bcfff6d24cec3007d2563694154328416b0eca
Author: cblgh <cblgh@cblgh.org>
Date:   Fri,  4 Feb 2022 10:21:16 +0100

add password reset functionality

Diffstat:
M.gitignore | 2++
Acmd/pwtool/main.go | 28++++++++++++++++++++++++++++
Acmd/pwtool/main_test.go | 19+++++++++++++++++++
Mcrypto/crypto.go | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mdatabase/database.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Mgo.mod | 1+
Mgo.sum | 9+++++++++
Mhtml/head.html | 1+
Ahtml/password-reset.html | 45+++++++++++++++++++++++++++++++++++++++++++++
Mserver/server.go | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mutil/util.go | 11+++++++++++
11 files changed, 411 insertions(+), 7 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,3 +2,5 @@ *.db data/ data/.gitkeep +pwtool +*.json diff --git a/cmd/pwtool/main.go b/cmd/pwtool/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "cerca/crypto" + "flag" + "fmt" + "os" +) + +func main() { + var kpPath string + var payload string + flag.StringVar(&kpPath, "keypair", "", "path to your account-securing public-keypair (the thing you got during registering and was told to save:)") + flag.StringVar(&payload, "payload", "", "the payload presented on the restore password page") + flag.Parse() + + if kpPath == "" { + fmt.Println(`usage: + tool --keypair <path-to-keypair.json> --payload <payload from website> + tool --help for more information`) + os.Exit(0) + } + kp, _ := crypto.ReadKeypair(kpPath) + proof := crypto.CreateProof(kp, []byte(payload)) + fmt.Println("your proof:") + fmt.Println(fmt.Sprintf("%x", proof)) + fmt.Println("\nplease paste the proof in the proof box of the restore password page, thank you!") +} diff --git a/cmd/pwtool/main_test.go b/cmd/pwtool/main_test.go @@ -0,0 +1,19 @@ +package main_test + +import ( + "cerca/crypto" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMain(t *testing.T) { + a := assert.New(t) + + kp, err := crypto.GenerateKeypair() + a.NoError(err) + msg := []byte("hi") + proof := crypto.CreateProof(kp, msg) + a.NotZero(len(proof), "proof length greater than zero") + proofVerificationCorrect := crypto.VerifyProof(kp.Public, msg, proof) + a.True(proofVerificationCorrect) +} diff --git a/crypto/crypto.go b/crypto/crypto.go @@ -4,16 +4,20 @@ import ( "cerca/util" "crypto/ed25519" crand "crypto/rand" + "encoding/base64" "encoding/binary" "encoding/json" "fmt" "github.com/synacor/argon2id" rand "math/rand" + "os" + "strings" + "time" ) type Keypair struct { - Public string `json:"public"` - Private string `json:"private"` + Public ed25519.PublicKey `json:"public"` + Private ed25519.PrivateKey `json:"private"` } func GenerateKeypair() (Keypair, error) { @@ -22,7 +26,46 @@ func GenerateKeypair() (Keypair, error) { if err != nil { return Keypair{}, ed.Eout(err, "generating key") } - return Keypair{Public: fmt.Sprintf("%x", pub), Private: fmt.Sprintf("%x", priv)}, nil + return Keypair{Public: pub, Private: priv}, nil +} + +func ReadKeypair(kpPath string) (Keypair, error) { + var kp Keypair + ed := util.Describe("read keypair from disk") + b, err := os.ReadFile(kpPath) + if err != nil { + return kp, ed.Eout(err, "read file") + } + err = kp.Unmarshal(b) + if err != nil { + return kp, ed.Eout(err, "unmarshal kp") + } + return kp, nil +} + +func CreateProof(kp Keypair, payload []byte) []byte { + return ed25519.Sign(kp.Private, payload) +} + +func VerifyProof(public ed25519.PublicKey, payload, proof []byte) bool { + return ed25519.Verify(public, payload, proof) +} + +/* kinda cludgy oh well */ +func (kp *Keypair) PublicString() (string, error) { + b, err := json.Marshal(kp.Public) + if err != nil { + return "", util.Eout(err, "marshal public key") + } + return string(b), nil +} + +func (kp *Keypair) PrivateString() (string, error) { + b, err := json.Marshal(kp.Private) + if err != nil { + return "", util.Eout(err, "marshal private key") + } + return string(b), nil } func (kp *Keypair) Marshal() ([]byte, error) { @@ -34,13 +77,61 @@ func (kp *Keypair) Marshal() ([]byte, error) { } func (kp *Keypair) Unmarshal(input []byte) error { - err := json.Unmarshal(input, &kp) + ed := util.Describe("unmarshal keypair") + type stringKp struct { + Public string `json:"public"` + Private string `json:"private"` + } + var m stringKp + err := json.Unmarshal(input, &m) + if err != nil { + return ed.Eout(err, "unmarshal into string struct") + } + + // handle the unfortunate case that the first generated keypairs were all in hex :') + // meaning: convert them from hex to base64 (the format expected by crypto/ed25519) + if len(m.Private) == 128 { + convertedVal, err := util.Hex2Base64(m.Private) + if err != nil { + return ed.Eout(err, "failed to convert privkey hex to base64") + } + m.Private = convertedVal + convertedVal, err = util.Hex2Base64(m.Public) + if err != nil { + return ed.Eout(err, "failed to convert pubkey hex to base64") + } + m.Public = convertedVal + + // marshal the corrected version to a slice of bytes, so that we can pretend this debacle never happened + input, err = json.Marshal(m) + if err != nil { + return ed.Eout(err, "failed to marshal converted hex") + } + } + + err = json.Unmarshal(input, kp) if err != nil { - return util.Eout(err, "unmarshal keypair") + return ed.Eout(err, "unmarshal keypair") } return nil } +func PublicKeyFromString(s string) ed25519.PublicKey { + ed := util.Describe("public key from string") + var err error + // handle legacy case of some pubkeys being stored in wrong column due to faulty query <.< + s = strings.ReplaceAll(s, `"`, "") + // handle legacy case of some pubkeys being stored as hex + if len(s) == 64 { + s, err = util.Hex2Base64(s) + ed.Check(err, "convert hex to base64") + } + b, err := base64.StdEncoding.DecodeString(s) + ed.Check(err, "decode base64 string") + pub := (ed25519.PublicKey)(b) + return pub +} + func HashPassword(s string) (string, error) { ed := util.Describe("hash password") hash, err := argon2id.DefaultHashPassword(s) @@ -64,6 +155,14 @@ func GenerateVerificationCode() int { return rnd.Intn(999999) } +func GenerateNonce() string { + const MaxUint = ^uint(0) + const MaxInt = int(MaxUint >> 1) + var src cryptoSource + rnd := rand.New(src) + return fmt.Sprintf("%d%d", time.Now().Unix(), rnd.Intn(MaxInt)) +} + type cryptoSource struct{} func (s cryptoSource) Seed(seed int64) {} diff --git a/database/database.go b/database/database.go @@ -55,6 +55,12 @@ func createTables(db *sql.DB) { ); `, ` + CREATE TABLE IF NOT EXISTS nonces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nonce TEXT NOT NULL UNIQUE + ); + `, + ` CREATE TABLE IF NOT EXISTS pubkeys ( id INTEGER PRIMARY KEY AUTOINCREMENT, pubkey TEXT NOT NULL UNIQUE, @@ -324,6 +330,16 @@ func (d DB) CreateUser(name, hash string) (int, error) { return userid, nil } +func (d DB) GetUserID(name string) (int, error) { + stmt := `SELECT id FROM users where name = ?` + var userid int + err := d.db.QueryRow(stmt, name).Scan(&userid) + if err != nil { + return -1, util.Eout(err, "get user id") + } + return userid, nil +} + func (d DB) GetPasswordHash(username string) (string, int, error) { stmt := `SELECT passwordhash, id FROM users where name = ?` var hash string @@ -350,6 +366,11 @@ func (d DB) CheckUserExists(userid int) (bool, error) { return d.existsQuery(stmt, userid) } +func (d DB) CheckNonceExists(nonce string) (bool, error) { + stmt := `SELECT 1 FROM nonces WHERE nonce = ?` + return d.existsQuery(stmt, nonce) +} + func (d DB) CheckUsernameExists(username string) (bool, error) { stmt := `SELECT 1 FROM users WHERE name = ?` return d.existsQuery(stmt, username) @@ -375,6 +396,7 @@ func (d DB) DeleteUser(userid int) { func (d DB) AddPubkey(userid int, pubkey string) error { ed := util.Describe("add pubkey") + // TODO (2022-02-03): the insertion order is wrong >.< stmt := `INSERT INTO pubkeys (pubkey, userid) VALUES (?, ?)` _, err := d.Exec(stmt, userid, pubkey) if err = ed.Eout(err, "inserting record"); err != nil { @@ -383,6 +405,29 @@ func (d DB) AddPubkey(userid int, pubkey string) error { return nil } +func (d DB) GetPubkey(userid int) (pubkey string, err error) { + ed := util.Describe("get pubkey") + // due to a mishap in the query in AddPubkey the column `pubkey` contains the userid + // and the column `userid` contains the pubkey + // :')))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))) + // TODO (2022-02-03): when we have migration logic, fix this mishap + stmt := `SELECT userid from pubkeys where pubkey = ?` + err = d.db.QueryRow(stmt, userid).Scan(&pubkey) + err = ed.Eout(err, "query & scan") + return +} + +// TODO (2022-02-04): extend mrv verification code length and reuse nonce scheme to fix registration bug? +func (d DB) AddNonce(nonce string) error { + ed := util.Describe("add nonce") + stmt := `INSERT INTO nonces (nonce) VALUES (?)` + _, err := d.Exec(stmt, nonce) + if err != nil { + return ed.Eout(err, "insert nonce") + } + return nil +} + func (d DB) AddRegistration(userid int, verificationLink string) error { ed := util.Describe("add registration") stmt := `INSERT INTO registrations (userid, host, link, time) VALUES (?, ?, ?, ?)` diff --git a/go.mod b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/sessions v1.2.1 // indirect github.com/mattn/go-sqlite3 v1.14.9 // indirect github.com/microcosm-cc/bluemonday v1.0.17 // indirect + github.com/stretchr/testify v1.7.0 // indirect github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b // indirect golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect diff --git a/go.sum b/go.sum @@ -2,6 +2,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/carlmjohnson/requests v0.22.1 h1:YoifpEbpJW4LPRX/+0dJe3vTLducEE9Ib10k6lElIUM= github.com/carlmjohnson/requests v0.22.1/go.mod h1:Hw4fFOk3xDlHQbNRTGo4oc52TUTpVEq93sNy/H+mrQM= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA= @@ -19,6 +21,11 @@ github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY6 github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b h1:Yu/2y+2iAAcTRfdlMZ3dEdb1aYWXesDDaQjb7xLgy7Y= github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b/go.mod h1:RQlLg9p2W+/d3q6xRWilTA2R4ltKiwEmzoI1urnKm9U= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -47,3 +54,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/html/head.html b/html/head.html @@ -13,6 +13,7 @@ /* end reset */ /* below is the minimum viable css for simply getting the forum out the door; contributions, both in css and design mockups, welcome from the community for v2,v3,...,vn :) */ + br { margin-bottom: unset; } html { max-width: 50rem; font-family: sans-serif; diff --git a/html/password-reset.html b/html/password-reset.html @@ -0,0 +1,45 @@ +{{ template "head" . }} +<p>On this page we'll go through a few steps to securely reset your password—without resorting to any emails!</p> +<p>First up: what was your username?</p> +{{ if eq .Data.Action "/reset/generate" }} +<form method="post" action="{{.Data.Action}}"> + <label type="text" for="username">Username:</label> + <input required id="username" name="username"> + <div> + <input type="submit" value="Generate payload"> + </div> +</form> +{{ end }} + + +{{ if eq .Data.Action "/reset/submit" }} +<input disabled value="{{ .Data.Username }}"> +<p>Now, first copy the snippet (aka <i>proof payload</i>) below:</p> +<pre style="user-select: all;"> +<code>{{ .Data.Payload }}</code> +</pre> +<p>Follow the <b>tool instructions</b> to finalize the password reset.</p> +<details> + <summary>Tool instructions</summary> + <ul> + <li><a href="/">Download the tool</a></li> + <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> + </li> + <li>Copy the generated proof and paste below</li> + <li>(Remember to save your password :)</li> + </ul> +</details> + +<form method="post" action="{{.Data.Action}}"> + <input type="hidden" required id="username" name="username" value="{{ .Data.Username }}"> + <input type="hidden" required id="payload" name="payload" value="{{ .Data.Payload }}"> + <label for="proof">Proof</label> + <input type="text" required id="proof" name="proof"> + <label for="password">New password</label> + <input type="password" minlength="9" required id="password" name="password" aria-describedby="password-help"> + <div> + <input type="submit" value="Change password"> + </div> +</form> +{{ end }} +{{ template "footer" . }} diff --git a/server/server.go b/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/hex" "errors" "fmt" "html/template" @@ -29,6 +30,12 @@ type TemplateData struct { Title string } +type PasswordResetData struct { + Action string + Username string + Payload string +} + type IndexData struct { Threads []database.Thread } @@ -134,6 +141,7 @@ func generateTemplates() (*template.Template, error) { "register", "register-success", "thread", + "password-reset", } rootTemplate := template.New("root") @@ -292,6 +300,136 @@ func hasVerificationCode(link, verification string) bool { return strings.Contains(strings.TrimSpace(linkBody), strings.TrimSpace(verification)) } +func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("password proof route") + loggedIn, _ := h.IsLoggedIn(req) + if loggedIn { + data := GenericMessageData{ + Title: "Reset password", + Message: "You are logged in, log out to reset password using proof", + Link: "/logout", + LinkText: "Logout", + } + h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: "Reset password"}) + return + } + + renderErr := func(errFmt string, args ...interface{}) { + errMessage := fmt.Sprintf(errFmt, args...) + fmt.Println(errMessage) + data := GenericMessageData{ + Title: "Reset password", + Message: errMessage, + Link: "/reset", + LinkText: "Go back", + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + } + + switch req.Method { + case "GET": + switch req.URL.Path { + case "/reset/submit": + params := req.URL.Query() + getParam := func(key string) string { + if q, exists := params[key]; exists { + return q[0] + } + fmt.Println("can't find param", key) + return "" + } + username := getParam("username") + payload := getParam("payload") + h.renderView(res, "password-reset", TemplateData{Data: PasswordResetData{Action: "/reset/submit", Username: username, Payload: payload}}) + default: + h.renderView(res, "password-reset", TemplateData{Data: PasswordResetData{Action: "/reset/generate"}}) + } + case "POST": + username := req.PostFormValue("username") + switch req.URL.Path { + case "/reset/generate": + constructProofPayload := func() string { + return fmt.Sprintf("%s::%s", username, crypto.GenerateNonce()) + } + payload := constructProofPayload() + params := fmt.Sprintf("?payload=%s&username=%s", payload, username) + http.Redirect(res, req, "/reset/submit"+params, http.StatusSeeOther) + case "/reset/submit": + password := req.PostFormValue("password") + proofString := req.PostFormValue("proof") + payload := req.PostFormValue("payload") + + // make sure the user exists + userid, err := h.db.GetUserID(username) + if err != nil { + renderErr("Wrong username, or a non-existent user") + return + } + + // make sure the nonce / payload is not being reused + nonceExisted, err := h.db.CheckNonceExists(payload) + if err != nil { + dump(ed.Eout(err, "check nonce existed")) + return + } + if nonceExisted { + renderErr("This payload has already been used, please generate a new one") + return + } + + // get the pubkey, as it is saved in the database for the corresponding user + pubkeyString, err := h.db.GetPubkey(userid) + if err != nil { + renderErr("No matching pubkey found") + return + } + // convert to ed25519.PublicKey + pubkey := crypto.PublicKeyFromString(pubkeyString) + + proof, err := hex.DecodeString(proofString) + if err != nil { + renderErr("The proof format was incorrect") + return + } + + correct := crypto.VerifyProof(pubkey, []byte(payload), proof) + if !correct { + renderErr("The proof was incorrect") + return + } + // proof was correct! + // save the nonce, so it's not reused + err = h.db.AddNonce(payload) + if err != nil { + dump(ed.Eout(err, "insert nonce into database")) + return + } + // let's set the new password in the database. first, hash it + pwhash, err := crypto.HashPassword(password) + if err != nil { + dump(ed.Eout(err, "hash password during reset")) + return + } + h.db.UpdateUserPasswordHash(userid, pwhash) + // render a success message & show a link to the login page :') + data := GenericMessageData{ + Title: "Reset password—success!", + Message: "You reset your password!", + Link: "/login", + LinkMessage: "Give it a try and", + LinkText: "login", + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + default: + fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) + IndexRedirect(res, req) + } + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } +} + func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request) { ed := util.Describe("register route") loggedIn, _ := h.IsLoggedIn(req) @@ -389,12 +527,17 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request } // generate and pass public keypair keypair, err := crypto.GenerateKeypair() + ed.Check(err, "generate keypair") // record generated pubkey in database for eventual later use - err = h.db.AddPubkey(userID, keypair.Public) + pub, err := keypair.PublicString() + if err = ed.Eout(err, "convert pubkey to string"); err != nil { + dump(err) + } + ed.Check(err, "stringify pubkey") + err = h.db.AddPubkey(userID, pub) if err = ed.Eout(err, "insert pubkey in db"); err != nil { dump(err) } - ed.Check(err, "generate keypair") kpJson, err := keypair.Marshal() ed.Check(err, "marshal keypair") h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, LoggedIn: loggedIn, Title: "registered successfully"}) @@ -532,6 +675,7 @@ func Serve(allowlist []string, sessionKey string, isdev bool) { handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist} /* note: be careful with trailing slashes; go's default handler is a bit sensitive */ // TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes + http.HandleFunc("/reset/", handler.ResetPasswordRoute) http.HandleFunc("/about", handler.AboutRoute) http.HandleFunc("/logout", handler.LogoutRoute) http.HandleFunc("/login", handler.LoginRoute) diff --git a/util/util.go b/util/util.go @@ -1,6 +1,8 @@ package util import ( + "encoding/base64" + "encoding/hex" "fmt" "html/template" "log" @@ -97,6 +99,15 @@ func GetThreadSlug(threadid int, title string, threadLen int) string { return fmt.Sprintf("/thread/%d/%s-%d/", threadid, SanitizeURL(title), threadLen) } +func Hex2Base64(s string) (string, error) { + b, err := hex.DecodeString(s) + if err != nil { + return "", err + } + b64 := base64.StdEncoding.EncodeToString(b) + return b64, nil +} + // make a string be suitable for use as part of a url func SanitizeURL(input string) string { input = strings.ReplaceAll(input, " ", "-")