From 7f8cb204cd5a58eb143ab20a21bfa32bd8c3c26b Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 13 Aug 2025 12:24:40 +0200 Subject: [PATCH] [feature] 2fa management via CLI (#4368) Adds 2FA management to the admin CLI. Also does some CLI refactoring so the functions we pass around are exported functions instead of changeable global variables. closes https://codeberg.org/superseriousbusiness/gotosocial/issues/4320 Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4368 Co-authored-by: kim Co-committed-by: kim --- .../action/admin/account/account.go | 67 ++++++- cmd/gotosocial/action/admin/media/list.go | 8 +- .../action/admin/media/prune/all.go | 5 +- .../action/admin/media/prune/orphaned.go | 5 +- .../action/admin/media/prune/remote.go | 5 +- cmd/gotosocial/action/admin/trans/export.go | 5 +- cmd/gotosocial/action/admin/trans/import.go | 5 +- cmd/gotosocial/action/debug/config/config.go | 5 +- cmd/gotosocial/action/migration/run.go | 5 +- cmd/gotosocial/action/server/server.go | 8 +- cmd/gotosocial/action/testrig/no_testrig.go | 11 +- cmd/gotosocial/action/testrig/testrig.go | 5 +- cmd/gotosocial/admin.go | 13 ++ cmd/gotosocial/testrig.go | 3 +- internal/processing/user/twofactor.go | 187 +++++++++--------- 15 files changed, 224 insertions(+), 113 deletions(-) diff --git a/cmd/gotosocial/action/admin/account/account.go b/cmd/gotosocial/action/admin/account/account.go index 2c12f90bb..16b8bb807 100644 --- a/cmd/gotosocial/action/admin/account/account.go +++ b/cmd/gotosocial/action/admin/account/account.go @@ -29,12 +29,25 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/db/bundb" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" + userprocessor "code.superseriousbusiness.org/gotosocial/internal/processing/user" "code.superseriousbusiness.org/gotosocial/internal/state" "code.superseriousbusiness.org/gotosocial/internal/util" "code.superseriousbusiness.org/gotosocial/internal/validate" "golang.org/x/crypto/bcrypt" ) +var ( + // check function conformance + _ action.GTSAction = Create + _ action.GTSAction = List + _ action.GTSAction = Confirm + _ action.GTSAction = Promote + _ action.GTSAction = Demote + _ action.GTSAction = Enable + _ action.GTSAction = Disable + _ action.GTSAction = Password +) + func initState(ctx context.Context) (*state.State, error) { var state state.State state.Caches.Init() @@ -61,7 +74,7 @@ func stopState(state *state.State) error { // Create creates a new account and user // in the database using the provided flags. -var Create action.GTSAction = func(ctx context.Context) error { +func Create(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -118,7 +131,7 @@ var Create action.GTSAction = func(ctx context.Context) error { } // List returns all existing local accounts. -var List action.GTSAction = func(ctx context.Context) error { +func List(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -156,7 +169,7 @@ var List action.GTSAction = func(ctx context.Context) error { // Confirm sets a user to Approved, sets Email to the current // UnconfirmedEmail value, and sets ConfirmedAt to now. -var Confirm action.GTSAction = func(ctx context.Context) error { +func Confirm(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -198,7 +211,7 @@ var Confirm action.GTSAction = func(ctx context.Context) error { } // Promote sets admin + moderator flags on a user to true. -var Promote action.GTSAction = func(ctx context.Context) error { +func Promote(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -235,7 +248,7 @@ var Promote action.GTSAction = func(ctx context.Context) error { } // Demote sets admin + moderator flags on a user to false. -var Demote action.GTSAction = func(ctx context.Context) error { +func Demote(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -272,7 +285,7 @@ var Demote action.GTSAction = func(ctx context.Context) error { } // Disable sets Disabled to true on a user. -var Disable action.GTSAction = func(ctx context.Context) error { +func Disable(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -308,7 +321,7 @@ var Disable action.GTSAction = func(ctx context.Context) error { } // Enable sets Disabled to false on a user. -var Enable action.GTSAction = func(ctx context.Context) error { +func Enable(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -344,7 +357,7 @@ var Enable action.GTSAction = func(ctx context.Context) error { } // Password sets the password of target account. -var Password action.GTSAction = func(ctx context.Context) error { +func Password(ctx context.Context) error { state, err := initState(ctx) if err != nil { return err @@ -389,3 +402,41 @@ var Password action.GTSAction = func(ctx context.Context) error { "encrypted_password", ) } + +// Disable2FA disables 2FA for target account. +func Disable2FA(ctx context.Context) error { + state, err := initState(ctx) + if err != nil { + return err + } + + defer func() { + // Ensure state gets stopped on return. + if err := stopState(state); err != nil { + log.Error(ctx, err) + } + }() + + username := config.GetAdminAccountUsername() + if err := validate.Username(username); err != nil { + return err + } + + account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "") + if err != nil { + return err + } + + user, err := state.DB.GetUserByAccountID(ctx, account.ID) + if err != nil { + return err + } + + err = userprocessor.TwoFactorDisable(ctx, state, user) + if err != nil { + return err + } + + fmt.Printf("2fa disabled\n") + return nil +} diff --git a/cmd/gotosocial/action/admin/media/list.go b/cmd/gotosocial/action/admin/media/list.go index a07bf4145..8b8df204b 100644 --- a/cmd/gotosocial/action/admin/media/list.go +++ b/cmd/gotosocial/action/admin/media/list.go @@ -35,6 +35,10 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/state" ) +// check function conformance. +var _ action.GTSAction = ListAttachments +var _ action.GTSAction = ListEmojis + type list struct { dbService db.DB state *state.State @@ -155,7 +159,7 @@ func (l *list) shutdown() error { } // ListAttachments lists local, remote, or all attachment paths. -var ListAttachments action.GTSAction = func(ctx context.Context) error { +func ListAttachments(ctx context.Context) error { list, err := setupList(ctx) if err != nil { return err @@ -214,7 +218,7 @@ var ListAttachments action.GTSAction = func(ctx context.Context) error { } // ListEmojis lists local, remote, or all emoji filepaths. -var ListEmojis action.GTSAction = func(ctx context.Context) error { +func ListEmojis(ctx context.Context) error { list, err := setupList(ctx) if err != nil { return err diff --git a/cmd/gotosocial/action/admin/media/prune/all.go b/cmd/gotosocial/action/admin/media/prune/all.go index 9c2a6a99f..f030517e7 100644 --- a/cmd/gotosocial/action/admin/media/prune/all.go +++ b/cmd/gotosocial/action/admin/media/prune/all.go @@ -26,8 +26,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/log" ) +// check function conformance. +var _ action.GTSAction = All + // All performs all media clean actions -var All action.GTSAction = func(ctx context.Context) error { +func All(ctx context.Context) error { // Setup pruning utilities. prune, err := setupPrune(ctx) if err != nil { diff --git a/cmd/gotosocial/action/admin/media/prune/orphaned.go b/cmd/gotosocial/action/admin/media/prune/orphaned.go index 4894e8900..a58485455 100644 --- a/cmd/gotosocial/action/admin/media/prune/orphaned.go +++ b/cmd/gotosocial/action/admin/media/prune/orphaned.go @@ -26,8 +26,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/log" ) +// check function conformance. +var _ action.GTSAction = Orphaned + // Orphaned prunes orphaned media from storage. -var Orphaned action.GTSAction = func(ctx context.Context) error { +func Orphaned(ctx context.Context) error { // Setup pruning utilities. prune, err := setupPrune(ctx) if err != nil { diff --git a/cmd/gotosocial/action/admin/media/prune/remote.go b/cmd/gotosocial/action/admin/media/prune/remote.go index fdb3a3ce9..270c9baaf 100644 --- a/cmd/gotosocial/action/admin/media/prune/remote.go +++ b/cmd/gotosocial/action/admin/media/prune/remote.go @@ -27,8 +27,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/log" ) +// check function conformance. +var _ action.GTSAction = Remote + // Remote prunes old and/or unused remote media. -var Remote action.GTSAction = func(ctx context.Context) error { +func Remote(ctx context.Context) error { // Setup pruning utilities. prune, err := setupPrune(ctx) if err != nil { diff --git a/cmd/gotosocial/action/admin/trans/export.go b/cmd/gotosocial/action/admin/trans/export.go index 8984a2bab..221a33c70 100644 --- a/cmd/gotosocial/action/admin/trans/export.go +++ b/cmd/gotosocial/action/admin/trans/export.go @@ -29,8 +29,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/trans" ) +// check function conformance. +var _ action.GTSAction = Export + // Export exports info from the database into a file -var Export action.GTSAction = func(ctx context.Context) error { +func Export(ctx context.Context) error { var state state.State // Only set state DB connection. diff --git a/cmd/gotosocial/action/admin/trans/import.go b/cmd/gotosocial/action/admin/trans/import.go index 00762bee3..e3e20105c 100644 --- a/cmd/gotosocial/action/admin/trans/import.go +++ b/cmd/gotosocial/action/admin/trans/import.go @@ -29,8 +29,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/trans" ) +// check function conformance. +var _ action.GTSAction = Import + // Import imports info from a file into the database -var Import action.GTSAction = func(ctx context.Context) error { +func Import(ctx context.Context) error { var state state.State // Only set state DB connection. diff --git a/cmd/gotosocial/action/debug/config/config.go b/cmd/gotosocial/action/debug/config/config.go index f79f5234d..ead019e69 100644 --- a/cmd/gotosocial/action/debug/config/config.go +++ b/cmd/gotosocial/action/debug/config/config.go @@ -26,8 +26,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/config" ) +// check function conformance. +var _ action.GTSAction = Config + // Config just prints the collated config out to stdout as json. -var Config action.GTSAction = func(ctx context.Context) (err error) { +func Config(ctx context.Context) (err error) { var raw map[string]interface{} // Marshal configuration to a raw JSON map diff --git a/cmd/gotosocial/action/migration/run.go b/cmd/gotosocial/action/migration/run.go index 61cec035b..d63160073 100644 --- a/cmd/gotosocial/action/migration/run.go +++ b/cmd/gotosocial/action/migration/run.go @@ -27,8 +27,11 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/state" ) +// check function conformance. +var _ action.GTSAction = Run + // Run will initialize the database, running any available migrations. -var Run action.GTSAction = func(ctx context.Context) error { +func Run(ctx context.Context) error { var state state.State defer func() { diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index afd908304..85f19b9db 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -70,9 +70,13 @@ import ( "go.uber.org/automaxprocs/maxprocs" ) +// check function conformance. +var _ action.GTSAction = Maintenance +var _ action.GTSAction = Start + // Maintenance starts and creates a GoToSocial server // in maintenance mode (returns 503 for most requests). -var Maintenance action.GTSAction = func(ctx context.Context) error { +func Maintenance(ctx context.Context) error { route, err := router.New(ctx) if err != nil { return fmt.Errorf("error creating maintenance router: %w", err) @@ -101,7 +105,7 @@ var Maintenance action.GTSAction = func(ctx context.Context) error { } // Start creates and starts a gotosocial server -var Start action.GTSAction = func(ctx context.Context) error { +func Start(ctx context.Context) error { // Set GOMAXPROCS / GOMEMLIMIT // to match container limits. setLimits(ctx) diff --git a/cmd/gotosocial/action/testrig/no_testrig.go b/cmd/gotosocial/action/testrig/no_testrig.go index 07e9960dc..d5459b200 100644 --- a/cmd/gotosocial/action/testrig/no_testrig.go +++ b/cmd/gotosocial/action/testrig/no_testrig.go @@ -19,8 +19,15 @@ package testrig -import "code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action" +import ( + "context" + + "code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action" +) + +// check function conformance. +var _ action.GTSAction = Start // Start creates and starts a gotosocial testrig server. // This is only enabled in debug builds, else is nil. -var Start action.GTSAction +func Start(context.Context) error { return nil } diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go index 2b70a3447..6399be41c 100644 --- a/cmd/gotosocial/action/testrig/testrig.go +++ b/cmd/gotosocial/action/testrig/testrig.go @@ -50,9 +50,12 @@ import ( "github.com/gin-gonic/gin" ) +// check function conformance. +var _ action.GTSAction = Start + // Start creates and starts a gotosocial testrig server. // This is only enabled in debug builds, else is nil. -var Start action.GTSAction = func(ctx context.Context) error { +func Start(ctx context.Context) error { testrig.InitTestConfig() testrig.InitTestLog() diff --git a/cmd/gotosocial/admin.go b/cmd/gotosocial/admin.go index 63c37a7ce..93fc9c1f8 100644 --- a/cmd/gotosocial/admin.go +++ b/cmd/gotosocial/admin.go @@ -146,6 +146,19 @@ func adminCommands() *cobra.Command { config.AddAdminAccountPassword(adminAccountPasswordCmd) adminAccountCmd.AddCommand(adminAccountPasswordCmd) + adminAccountDisable2FACmd := &cobra.Command{ + Use: "disable-2fa", + Short: "disable 2fa for the given local account", + PreRunE: func(cmd *cobra.Command, args []string) error { + return preRun(preRunArgs{cmd: cmd}) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return run(cmd.Context(), account.Disable2FA) + }, + } + config.AddAdminAccount(adminAccountDisable2FACmd) + adminAccountCmd.AddCommand(adminAccountDisable2FACmd) + adminCmd.AddCommand(adminAccountCmd) /* diff --git a/cmd/gotosocial/testrig.go b/cmd/gotosocial/testrig.go index 55498243b..4c3d75b3a 100644 --- a/cmd/gotosocial/testrig.go +++ b/cmd/gotosocial/testrig.go @@ -20,11 +20,12 @@ package main import ( "code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action/testrig" "code.superseriousbusiness.org/gotosocial/internal/config" + "codeberg.org/gruf/go-debug" "github.com/spf13/cobra" ) func testrigCommands() *cobra.Command { - if testrig.Start != nil { + if debug.DEBUG { testrigCmd := &cobra.Command{ Use: "testrig", Short: "gotosocial testrig-related tasks", diff --git a/internal/processing/user/twofactor.go b/internal/processing/user/twofactor.go index ec0a3edc8..6fdd8898f 100644 --- a/internal/processing/user/twofactor.go +++ b/internal/processing/user/twofactor.go @@ -34,6 +34,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" "code.superseriousbusiness.org/gotosocial/internal/util" "codeberg.org/gruf/go-byteutil" "github.com/pquerna/otp" @@ -43,60 +44,6 @@ import ( var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) -// EncodeQuery is a copy-paste of url.Values.Encode, except it uses -// %20 instead of + to encode spaces. This is necessary to correctly -// render spaces in some authenticator apps, like Google Authenticator. -// -// [Note: this func and the above comment are both taken -// directly from github.com/pquerna/otp/internal/encode.go.] -func encodeQuery(v url.Values) string { - if v == nil { - return "" - } - var buf strings.Builder - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - vs := v[k] - // Changed from url.QueryEscape. - keyEscaped := url.PathEscape(k) - for _, v := range vs { - if buf.Len() > 0 { - buf.WriteByte('&') - } - buf.WriteString(keyEscaped) - buf.WriteByte('=') - // Changed from url.QueryEscape. - buf.WriteString(url.PathEscape(v)) - } - } - return buf.String() -} - -// totpURLForUser reconstructs a TOTP URL for the -// given user, setting the instance host as issuer. -// -// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format -func totpURLForUser(user *gtsmodel.User) *url.URL { - issuer := config.GetHost() + " - GoToSocial" - v := url.Values{} - v.Set("secret", user.TwoFactorSecret) - v.Set("issuer", issuer) - v.Set("period", "30") // 30 seconds totp validity. - v.Set("algorithm", "SHA1") - v.Set("digits", "6") // 6-digit totp. - - return &url.URL{ - Scheme: "otpauth", - Host: "totp", - Path: "/" + issuer + ":" + user.Email, - RawQuery: encodeQuery(v), - } -} - func (p *Processor) TwoFactorQRCodePngGet( ctx context.Context, user *gtsmodel.User, @@ -135,14 +82,32 @@ func (p *Processor) TwoFactorQRCodePngGet( }, nil } +// TwoFactorQRCodeURIGet will generate a new +// 2 factor auth secret for user, and return a +// URI of expected format for generating a QR code +// or inputting into a password manager. +// +// This may be called multiple times without error +// UNTIL the moment the user has finalized enabling +// 2FA. i.e. when user.TwoFactorEnabled() == true. +// Until this point, the URI may be requested for +// both QR code generation, and requesting the URI, +// but once 2FA is confirmed enabled it is not safe +// to re-share the agreed-upon secret. func (p *Processor) TwoFactorQRCodeURIGet( ctx context.Context, user *gtsmodel.User, ) (*url.URL, gtserror.WithCode) { - // Check if we need to lazily - // generate a new 2fa secret. + if user.TwoFactorEnabled() { + const errText = "2fa already enabled; not sharing secret again" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Only generate new 2FA secret + // if not already been generated + // during this enabling process. if user.TwoFactorSecret == "" { - // We do! Read some random crap. + // 32 bytes should be plenty entropy. secret := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, secret); err != nil { @@ -156,41 +121,42 @@ func (p *Processor) TwoFactorQRCodeURIGet( err := gtserror.Newf("db error updating user: %w", err) return nil, gtserror.NewErrorInternalError(err) } - - } else if user.TwoFactorEnabled() { - // If a secret is already set, and 2fa is - // already enabled, we shouldn't share the - // secret via QR code again: Someone may - // have obtained a token for this user and - // is trying to get the 2fa secret so they - // can escalate an attack or something. - const errText = "2fa already enabled; keeping the secret secret" - return nil, gtserror.NewErrorConflict(errors.New(errText), errText) } - // Recreate the totp key. - return totpURLForUser(user), nil + // see: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + issuer := config.GetHost() + " - GoToSocial" + return &url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + issuer + ":" + user.Email, + RawQuery: encodeQuery(url.Values{ + "secret": {user.TwoFactorSecret}, + "issuer": {issuer}, + "period": {"30s"}, // 30s totp validity. + "digits": {"6"}, // 6-digit totp. + "algorithm": {"SHA1"}, + }), + }, nil } +// TwoFactorEnable will enable 2 factor auth for +// account, using given TOTP code to validate the +// user's 2fa secret before continuing. func (p *Processor) TwoFactorEnable( ctx context.Context, user *gtsmodel.User, code string, ) ([]string, gtserror.WithCode) { - if user.TwoFactorSecret == "" { - // User doesn't have a secret set, which - // means they never got the QR code to scan - // into their authenticator app. We can safely - // return an error from this request. - const errText = "no 2fa secret stored yet; read the qr code first" - return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) - } - if user.TwoFactorEnabled() { const errText = "2fa already enabled; disable it first then try again" return nil, gtserror.NewErrorConflict(errors.New(errText), errText) } + if user.TwoFactorSecret == "" { + const errText = "no 2fa secret stored; first read qr code / totp secret" + return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) + } + // Try validating the provided code and give // a helpful error message if it doesn't work. if !totp.Validate(code, user.TwoFactorSecret) { @@ -222,12 +188,11 @@ func (p *Processor) TwoFactorEnable( err := gtserror.Newf("error encrypting backup codes: %w", err) return nil, gtserror.NewErrorInternalError(err) } - user.TwoFactorBackups[i] = string(encryptedBackup) } - if err := p.state.DB.UpdateUser( - ctx, + // Update user in the database. + if err := p.state.DB.UpdateUser(ctx, user, "two_factor_enabled_at", "two_factor_backups", @@ -239,16 +204,12 @@ func (p *Processor) TwoFactorEnable( return backupsClearText, nil } +// TwoFactorDisable: see TwoFactorDisable(). func (p *Processor) TwoFactorDisable( ctx context.Context, user *gtsmodel.User, password string, ) gtserror.WithCode { - if !user.TwoFactorEnabled() { - const errText = "2fa already disabled" - return gtserror.NewErrorConflict(errors.New(errText), errText) - } - // Ensure provided password is correct. if err := bcrypt.CompareHashAndPassword( byteutil.S2B(user.EncryptedPassword), @@ -258,13 +219,29 @@ func (p *Processor) TwoFactorDisable( return gtserror.NewErrorUnauthorized(errors.New(errText), errText) } - // Disable 2fa for this user - // and clear backup codes. + // Disable 2 factor auth for this account. + return TwoFactorDisable(ctx, p.state, user) +} + +// TwoFactorDisable disables 2 factor auth +// for given user account. Note this should +// be gated with password authentication if +// accessed via web. +func TwoFactorDisable( + ctx context.Context, + state *state.State, + user *gtsmodel.User, +) gtserror.WithCode { + if !user.TwoFactorEnabled() { + const errText = "2fa already disabled" + return gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Clear 2FA fields on user account. user.TwoFactorEnabledAt = time.Time{} user.TwoFactorSecret = "" user.TwoFactorBackups = nil - if err := p.state.DB.UpdateUser( - ctx, + if err := state.DB.UpdateUser(ctx, user, "two_factor_enabled_at", "two_factor_secret", @@ -276,3 +253,33 @@ func (p *Processor) TwoFactorDisable( return nil } + +// encodeQuery is a copy-paste of url.Values.Encode, except it uses +// %20 instead of + to encode spaces. This is necessary to correctly +// render spaces in some authenticator apps, like Google Authenticator. +// +// [Note: this func and the above comment are both taken +// directly from github.com/pquerna/otp/internal/encode.go.] +func encodeQuery(v url.Values) string { + var buf strings.Builder + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + vs := v[k] + // Changed from url.QueryEscape. + keyEscaped := url.PathEscape(k) + for _, v := range vs { + if buf.Len() > 0 { + buf.WriteByte('&') + } + buf.WriteString(keyEscaped) + buf.WriteByte('=') + // Changed from url.QueryEscape. + buf.WriteString(url.PathEscape(v)) + } + } + return buf.String() +}