// Command setpassword sets a user's password. // // It exists because migration 000001 left users.password_hash nullable and // NULL, and the seeded demo user still has no password. Nothing in the seed // fixture, the migrations or this repository contains, generates or defaults a // password: a password enters the system here, typed by a person, and nowhere // else. // // # prompt for the password, twice, with the input hidden // cd go-api && go run ./cmd/setpassword -email demo@krow.app // cd go-api && go run ./cmd/setpassword -id 9a1f...-uuid // // # non-interactive, for a provisioning script — the password arrives on // # stdin, never in argv, so it does not reach `ps` or the shell history // printf '%s' "$NEW_PASSWORD" | go run ./cmd/setpassword -email demo@krow.app -stdin // // There is deliberately no -password flag. A password in argv is visible to // every process on the machine through `ps`, and lands in the shell history // besides. stdin is the only non-interactive route. // // The password, the confirmation and the resulting hash are never printed, // never logged and never written anywhere but the users.password_hash column, // through a bind parameter. package main import ( "context" "errors" "flag" "fmt" "io" "os" "strings" "time" "github.com/jackc/pgx/v5" "golang.org/x/term" "github.com/krow/krow-backend/go-api/internal/auth" "github.com/krow/krow-backend/go-api/internal/config" "github.com/krow/krow-backend/go-api/internal/db" ) func main() { if err := run(); err != nil { // The error strings in this file name rules and identifiers only. No // path here can carry the password into this line. fmt.Fprintf(os.Stderr, "setpassword: %v\n", err) os.Exit(1) } } type target struct { id string email string role string hadHash bool } func run() error { var ( email = flag.String("email", "", "the user's email address") id = flag.String("id", "", "the user's UUID") fromStdin = flag.Bool("stdin", false, "read the password from stdin instead of prompting") ) flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage: setpassword (-email
| -id ) [-stdin]\n\n"+ "Sets one user's password, hashed with argon2id. The password is never\n"+ "echoed, printed or logged, and there is no -password flag by design.\n\n") flag.PrintDefaults() } flag.Parse() if flag.NArg() > 0 { // A bare argument is most likely someone typing the password after the // command. Refuse loudly rather than ignoring it — and say nothing // about what the argument was. return errors.New("unexpected positional argument; pass -email or -id, and supply the password when prompted") } if (*email == "") == (*id == "") { return errors.New("pass exactly one of -email or -id") } cfg, err := config.Load() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() database, err := db.Open(ctx, cfg.DB) if err != nil { return err } defer database.Close() // Resolve and show the target BEFORE asking for a password, so nobody // types a secret at a prompt that turns out to be pointed at the wrong // user, or at no user at all. t, err := resolve(ctx, database, *email, *id) if err != nil { return err } fmt.Fprintf(os.Stderr, "database: %s\nuser: %s <%s>\nrole: %s\npassword: %s\n\n", cfg.DB.Name, t.id, t.email, t.role, existingState(t.hadHash)) password, err := readPassword(*fromStdin) if err != nil { return err } // The plaintext lives in this slice and nowhere else. Wipe it as soon as // the hash exists. Go's garbage collector may still have copied it, so // this is a reduction in exposure rather than a guarantee — worth doing, // not worth trusting. defer wipe(password) if err := auth.ValidatePassword(string(password)); err != nil { return describePolicy(err) } hash, err := auth.HashPassword(string(password)) if err != nil { return err } // Parameterized, and keyed by the UUID resolved above rather than by the // string the operator typed. Neither the hash nor the password is ever // interpolated into SQL. const q = `UPDATE users SET password_hash = $2::text, updated_date = now() WHERE id = $1::uuid` tag, err := database.Pool.Exec(ctx, q, t.id, hash) if err != nil { return fmt.Errorf("update password: %w", err) } if tag.RowsAffected() != 1 { return fmt.Errorf("expected to update exactly one user, updated %d", tag.RowsAffected()) } // Confirms the identity and nothing about the secret: no hash, no length, // no prefix. fmt.Fprintf(os.Stderr, "password set for %s (%s)\n", t.email, t.id) return nil } func existingState(had bool) string { if had { return "already set (it will be replaced)" } return "not set yet" } // resolve finds exactly one user by email or by id. // // Email lookup relies on the citext column, so it is case-insensitive, and on // the global unique index added by migration 000004, so it cannot match two // users in two organizations. func resolve(ctx context.Context, database *db.DB, email, id string) (target, error) { var ( t target err error ) if email != "" { const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL FROM users WHERE email = $1::citext` err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(email)). Scan(&t.id, &t.email, &t.role, &t.hadHash) } else { const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL FROM users WHERE id = $1::uuid` err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(id)). Scan(&t.id, &t.email, &t.role, &t.hadHash) } if errors.Is(err, pgx.ErrNoRows) { return t, errors.New("no such user") } if err != nil { return t, fmt.Errorf("look up user: %w", err) } return t, nil } // readPassword collects the password without echoing it. // // Interactively it asks twice and compares, because a mistyped password that // nobody can see is otherwise only discovered at the next login. With -stdin // it reads the stream verbatim, minus one trailing newline, so // `printf '%s' "$P" | setpassword -stdin` and a here-string both work. func readPassword(fromStdin bool) ([]byte, error) { if fromStdin { raw, err := io.ReadAll(os.Stdin) if err != nil { return nil, fmt.Errorf("read password from stdin: %w", err) } return trimOneNewline(raw), nil } fd := int(os.Stdin.Fd()) if !term.IsTerminal(fd) { // Falling back to an echoing read here would print the password to the // screen and into any transcript. Refuse and name the flag instead. return nil, errors.New("stdin is not a terminal; re-run with -stdin to read the password from the pipe") } fmt.Fprint(os.Stderr, "New password: ") first, err := term.ReadPassword(fd) fmt.Fprintln(os.Stderr) if err != nil { return nil, fmt.Errorf("read password: %w", err) } fmt.Fprint(os.Stderr, "Confirm password: ") second, err := term.ReadPassword(fd) fmt.Fprintln(os.Stderr) if err != nil { wipe(first) return nil, fmt.Errorf("read confirmation: %w", err) } defer wipe(second) if string(first) != string(second) { wipe(first) return nil, errors.New("the two entries do not match") } return first, nil } // describePolicy turns a policy error into advice, still without quoting the // password or revealing its length. func describePolicy(err error) error { switch { case errors.Is(err, auth.ErrEmptyPassword): return errors.New("the password is empty") case errors.Is(err, auth.ErrPasswordTooShort): return fmt.Errorf("the password is too short; it must be at least %d bytes", auth.MinPasswordLength) case errors.Is(err, auth.ErrPasswordTooLong): return fmt.Errorf("the password is too long; the maximum is %d bytes", auth.MaxPasswordLength) } return err } // trimOneNewline removes a single trailing "\n" or "\r\n", and only one: a // password may legitimately end in whitespace, so this strips the line // terminator a shell adds and nothing more. func trimOneNewline(b []byte) []byte { if n := len(b); n > 0 && b[n-1] == '\n' { b = b[:n-1] if n := len(b); n > 0 && b[n-1] == '\r' { b = b[:n-1] } } return b } func wipe(b []byte) { for i := range b { b[i] = 0 } }